From df10a1bb3959f3108cc90ffb79026d8eea0ce40f Mon Sep 17 00:00:00 2001 From: "L. Ramos" Date: Sun, 25 Oct 2026 10:55:48 +0100 Subject: [PATCH 01/29] chore: configure fork signing, bundle ID and update feed --- ora/Info/ora-dev.entitlements | 33 +++++++++++++++++++++++++++ project.yml | 42 +++++++++-------------------------- 2 files changed, 44 insertions(+), 31 deletions(-) create mode 100644 ora/Info/ora-dev.entitlements diff --git a/ora/Info/ora-dev.entitlements b/ora/Info/ora-dev.entitlements new file mode 100644 index 00000000..3c755624 --- /dev/null +++ b/ora/Info/ora-dev.entitlements @@ -0,0 +1,33 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.assets.movies.read-only + + com.apple.security.assets.music.read-only + + com.apple.security.assets.pictures.read-only + + com.apple.security.cs.allow-jit + + com.apple.security.device.audio-input + + com.apple.security.device.camera + + com.apple.security.files.downloads.read-write + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.security.print + + com.apple.security.temporary-exception.mach-lookup.global-name + + $(PRODUCT_BUNDLE_IDENTIFIER)-spks + $(PRODUCT_BUNDLE_IDENTIFIER)-spki + + + diff --git a/project.yml b/project.yml index c5297fc0..eb4187ff 100644 --- a/project.yml +++ b/project.yml @@ -1,6 +1,6 @@ name: Ora options: - bundleIdPrefix: com.orabrowser + bundleIdPrefix: com.barto95100 packages: Sparkle: @@ -61,9 +61,9 @@ targets: properties: NSCameraUsageDescription: "Allows websites to use the camera, for example during video calls." NSMicrophoneUsageDescription: "Allows websites to use the microphone, for example during calls or dictation." - SUFeedURL: "https://the-ora.github.io/browser/appcast.xml" + SUFeedURL: "https://barto95100.github.io/ora-browser/appcast.xml" SUPublicEDKey: "Ozj+rezzbJAD76RfajtfQ7rFojJbpFSCl/0DcFSBCTI=" - SUEnableAutomaticChecks: YES + SUEnableAutomaticChecks: NO SUEnableInstallerLauncherService: YES CFBundleURLTypes: - CFBundleURLName: Ora Browser @@ -93,26 +93,6 @@ targets: LSMinimumSystemVersion: "15.0" LSApplicationCategoryType: public.app-category.web-browser - entitlements: - path: ora/Info/ora.entitlements - properties: - com.apple.security.app-sandbox: true - com.apple.security.assets.movies.read-only: true - com.apple.security.assets.music.read-only: true - com.apple.security.assets.pictures.read-only: true - com.apple.security.cs.allow-jit: true - com.apple.security.device.audio-input: true - com.apple.security.device.camera: true - com.apple.security.files.downloads.read-write: true - com.apple.security.files.user-selected.read-write: true - com.apple.security.network.client: true - com.apple.security.print: true - com.apple.security.temporary-exception.mach-lookup.global-name: - - "$(PRODUCT_BUNDLE_IDENTIFIER)-spks" - - "$(PRODUCT_BUNDLE_IDENTIFIER)-spki" - com.apple.developer.web-browser: true - com.apple.developer.web-browser.public-key-credential: true - settings: base: SWIFT_VERSION: 5.9 @@ -120,24 +100,24 @@ targets: MARKETING_VERSION: 0.2.14 CURRENT_PROJECT_VERSION: 105 PRODUCT_NAME: Ora - PRODUCT_BUNDLE_IDENTIFIER: com.orabrowser.app + PRODUCT_BUNDLE_IDENTIFIER: com.barto95100.ora GENERATE_INFOPLIST_FILE: YES ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor - SUFeedURL: "https://the-ora.github.io/browser/appcast.xml" + SUFeedURL: "https://barto95100.github.io/ora-browser/appcast.xml" SUPublicEDKey: "Ozj+rezzbJAD76RfajtfQ7rFojJbpFSCl/0DcFSBCTI=" - SUEnableAutomaticChecks: YES + SUEnableAutomaticChecks: NO SUEnableInstallerLauncherService: YES configs: Debug: ASSETCATALOG_COMPILER_APPICON_NAME: OraIconDev - CODE_SIGN_ENTITLEMENTS: ora/Info/ora.entitlements - CODE_SIGN_STYLE: Manual - CODE_SIGN_IDENTITY: "Developer ID Application" + PRODUCT_BUNDLE_IDENTIFIER: com.barto95100.ora.dev + CODE_SIGN_ENTITLEMENTS: ora/Info/ora-dev.entitlements + CODE_SIGN_STYLE: Automatic + CODE_SIGN_IDENTITY: "Apple Development" EMIT_FRONTEND_COMMAND_LINES: YES OTHER_LDFLAGS: "-Xlinker -interposable" ENABLE_HARDENED_RUNTIME: NO - DEVELOPMENT_TEAM: 3Y566D2A4G - PROVISIONING_PROFILE_SPECIFIER: ora-profile-working + DEVELOPMENT_TEAM: Q76T6LDBBZ Release: ASSETCATALOG_COMPILER_APPICON_NAME: OraIcon CODE_SIGN_ENTITLEMENTS: ora/Info/ora.entitlements From 97f870face224136616e664bf72fe0e1c51a09f8 Mon Sep 17 00:00:00 2001 From: "L. Ramos" Date: Sun, 25 Oct 2026 10:59:23 +0100 Subject: [PATCH 02/29] ci: add daily upstream sync workflow --- .github/workflows/upstream-sync.yml | 66 +++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .github/workflows/upstream-sync.yml diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml new file mode 100644 index 00000000..3937cde4 --- /dev/null +++ b/.github/workflows/upstream-sync.yml @@ -0,0 +1,66 @@ +# Vérifie chaque jour s'il y a du nouveau sur the-ora/browser et ouvre une PR +# de synchronisation dans ce fork (branche `upstream-sync` -> `main`). +name: Upstream Sync + +on: + schedule: + - cron: "17 5 * * *" # tous les jours vers 7h (heure de Paris) + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: upstream-sync + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + env: + UPSTREAM_REPO: the-ora/browser + UPSTREAM_BRANCH: main + SYNC_BRANCH: upstream-sync + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch upstream + run: | + git remote add upstream "https://github.com/${UPSTREAM_REPO}.git" + git fetch upstream "${UPSTREAM_BRANCH}" --tags + + - name: Check for new upstream commits + id: check + run: | + count=$(git rev-list --count "HEAD..upstream/${UPSTREAM_BRANCH}") + echo "count=${count}" >> "$GITHUB_OUTPUT" + echo "Nouveaux commits upstream : ${count}" + + - name: Push sync branch + if: steps.check.outputs.count != '0' + run: | + git push --force origin "upstream/${UPSTREAM_BRANCH}:refs/heads/${SYNC_BRANCH}" + + - name: Open or update pull request + if: steps.check.outputs.count != '0' + run: | + log=$(git log --oneline --no-merges "HEAD..upstream/${UPSTREAM_BRANCH}" | head -50) + body=$(printf '%s\n\n%s\n\n```\n%s\n```\n' \ + "Synchronisation automatique depuis [${UPSTREAM_REPO}](https://github.com/${UPSTREAM_REPO})." \ + "${{ steps.check.outputs.count }} nouveau(x) commit(s) upstream :" \ + "$log") + existing=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$SYNC_BRANCH" --state open --json number --jq '.[0].number') + if [ -n "$existing" ]; then + gh pr edit "$existing" --repo "$GITHUB_REPOSITORY" --body "$body" + echo "PR #$existing mise à jour" + else + gh pr create --repo "$GITHUB_REPOSITORY" \ + --base main --head "$SYNC_BRANCH" \ + --title "Sync upstream (${UPSTREAM_REPO})" \ + --body "$body" + fi From f660a73a5efe39491167e305ffb48c4d06559313 Mon Sep 17 00:00:00 2001 From: "L. Ramos" Date: Sun, 25 Oct 2026 11:08:55 +0100 Subject: [PATCH 03/29] chore: pin SwiftFormat rules to upstream style and apply update feed to Info.plist --- .swiftformat | 8 ++++++++ ora/Features/Tabs/State/TabManager.swift | 2 +- ora/Info/Info.plist | 4 ++-- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.swiftformat b/.swiftformat index 61b9b6b5..077e0ec5 100644 --- a/.swiftformat +++ b/.swiftformat @@ -20,6 +20,14 @@ --disable sortedImports --disable consecutiveSpaces --disable redundantReturn +# Règles apparues dans les versions récentes de SwiftFormat : désactivées pour +# garder le formatage d'origine (et éviter les conflits avec the-ora/browser) +--disable wrapIfStatementBodies +--disable nestedIf +--disable repeatedGuard +--disable redundantSwiftUIGroup +--disable wrapIfExpressionBodies +--disable redundantEmptyView --exclude Pods --exclude Carthage --exclude .build diff --git a/ora/Features/Tabs/State/TabManager.swift b/ora/Features/Tabs/State/TabManager.swift index ff32741d..2a556c5a 100644 --- a/ora/Features/Tabs/State/TabManager.swift +++ b/ora/Features/Tabs/State/TabManager.swift @@ -222,7 +222,7 @@ class TabManager: ObservableObject { // Set the most recently accessed tab in the container if let lastAccessedTab = container.tabs .sorted(by: { $0.lastAccessedAt ?? Date() > $1.lastAccessedAt ?? Date() }).first, - lastAccessedTab.isWebViewReady + lastAccessedTab.isWebViewReady { activeTab?.maybeIsActive = false activeTab = lastAccessedTab diff --git a/ora/Info/Info.plist b/ora/Info/Info.plist index 67060411..acdc524f 100644 --- a/ora/Info/Info.plist +++ b/ora/Info/Info.plist @@ -86,11 +86,11 @@ NSUserActivityTypeBrowsingWeb SUEnableAutomaticChecks - + SUEnableInstallerLauncherService SUFeedURL - https://the-ora.github.io/browser/appcast.xml + https://barto95100.github.io/ora-browser/appcast.xml SUPublicEDKey Ozj+rezzbJAD76RfajtfQ7rFojJbpFSCl/0DcFSBCTI= From ee0816ebafa72997a250d5715f59d1f9ca6a8d80 Mon Sep 17 00:00:00 2001 From: "L. Ramos" Date: Sun, 25 Oct 2026 11:11:53 +0100 Subject: [PATCH 04/29] ci: pin SwiftFormat version to 0.64.1 --- .github/workflows/build-and-test.yml | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index d5312146..35258152 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -40,11 +40,22 @@ jobs: - name: Run SwiftLint run: swiftlint lint - - name: Install SwiftFormat - run: brew install swiftformat + - name: Install SwiftFormat (version figée) + env: + SWIFTFORMAT_VERSION: "0.64.1" + run: | + curl -fsSL -o /tmp/swiftformat.zip \ + "https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat.zip" + mkdir -p "$RUNNER_TEMP/swiftformat-bin" + unzip -o -q /tmp/swiftformat.zip -d "$RUNNER_TEMP/swiftformat-bin" + chmod +x "$RUNNER_TEMP/swiftformat-bin/swiftformat" + echo "$RUNNER_TEMP/swiftformat-bin" >> "$GITHUB_PATH" - name: Check code formatting - run: swiftformat --lint . + run: | + which swiftformat + swiftformat --version + swiftformat --lint . - name: Install Xcbeautify run: brew install xcbeautify From 0ec86639702687721cfd9939c752000b77623fda Mon Sep 17 00:00:00 2001 From: VanRoove Date: Fri, 17 Jul 2026 15:36:28 -0700 Subject: [PATCH 05/29] fix: stop link preview flicker on hover within nested anchors --- .../BrowserEngine/Scripts/OraBrowserScripts.swift | 13 ++++++++----- .../Browser/Views/BrowserWebContentView.swift | 1 + 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/ora/Core/BrowserEngine/Scripts/OraBrowserScripts.swift b/ora/Core/BrowserEngine/Scripts/OraBrowserScripts.swift index 35b5690f..2bc9dc82 100644 --- a/ora/Core/BrowserEngine/Scripts/OraBrowserScripts.swift +++ b/ora/Core/BrowserEngine/Scripts/OraBrowserScripts.swift @@ -93,16 +93,19 @@ enum OraBrowserScripts { post('linkHover', url || ""); } + function currentAnchor(node) { + return node && node.closest ? node.closest('a[href]') : null; + } + function onMouseOver(event) { - const anchor = event.target.closest && event.target.closest('a[href]'); + const anchor = currentAnchor(event.target); postHover(anchor ? anchor.href : ''); } function onMouseOut(event) { - const related = event.relatedTarget; - if (!related || !event.currentTarget.contains(related)) { - postHover(""); - } + const from = currentAnchor(event.target); + const to = currentAnchor(event.relatedTarget); + if (from !== to) postHover(to ? to.href : ''); } document.addEventListener('mouseover', onMouseOver, true); diff --git a/ora/Features/Browser/Views/BrowserWebContentView.swift b/ora/Features/Browser/Views/BrowserWebContentView.swift index 3ea5c6e8..61049842 100644 --- a/ora/Features/Browser/Views/BrowserWebContentView.swift +++ b/ora/Features/Browser/Views/BrowserWebContentView.swift @@ -88,6 +88,7 @@ struct BrowserWebContentView: View { LinkPreview(text: hovered) } } + .animation(.easeOut(duration: 0.12), value: tab.hoveredLinkURL) } else { ZStack { Rectangle().fill(theme.background) From 0a6b7c2dcdbe361c3abafd6e43c2dc6affd55785 Mon Sep 17 00:00:00 2001 From: "L. Ramos" Date: Sun, 25 Oct 2026 11:41:53 +0100 Subject: [PATCH 06/29] fix: only publish link hover updates when the hovered link changes Moving across child elements of the same anchor re-sent the same URL on every mouseover and re-assigned the @Published hoveredLinkURL, re-rendering the link preview. Deduplicate in the injected script and skip identical assignments in Swift. Complements the-ora/browser#246. --- ora/Core/BrowserEngine/Scripts/OraBrowserScripts.swift | 9 ++++++++- ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift | 7 ++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/ora/Core/BrowserEngine/Scripts/OraBrowserScripts.swift b/ora/Core/BrowserEngine/Scripts/OraBrowserScripts.swift index 2bc9dc82..72e1a8e7 100644 --- a/ora/Core/BrowserEngine/Scripts/OraBrowserScripts.swift +++ b/ora/Core/BrowserEngine/Scripts/OraBrowserScripts.swift @@ -89,8 +89,15 @@ enum OraBrowserScripts { window.addEventListener('popstate', () => notifyChange(true)); notifyChange(true); + // Only notify the app when the hovered link actually changes, so that + // moving across child elements of the same anchor does not re-render + // the link preview on every mouseover. + let lastHoverURL = ''; function postHover(url) { - post('linkHover', url || ""); + const value = url || ''; + if (value === lastHoverURL) return; + lastHoverURL = value; + post('linkHover', value); } function currentAnchor(node) { diff --git a/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift b/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift index 0a40c258..0220a5a5 100644 --- a/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift +++ b/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift @@ -114,7 +114,12 @@ final class TabBrowserPageDelegate: BrowserPageDelegate { handleURLUpdateMessage(message.body, for: tab) case "linkHover": let hovered = (message.body as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - tab.hoveredLinkURL = hovered.isEmpty ? nil : hovered + let newValue: String? = hovered.isEmpty ? nil : hovered + // Avoid publishing an identical value, which would needlessly + // invalidate the views observing the tab. + if tab.hoveredLinkURL != newValue { + tab.hoveredLinkURL = newValue + } case "mediaEvent": handleMediaEventMessage(message.body, for: tab) case "passwordManager": From 41b81b6a772bd01e1ab1c162b764fb045b4523f5 Mon Sep 17 00:00:00 2001 From: "L. Ramos" Date: Sun, 25 Oct 2026 11:51:18 +0100 Subject: [PATCH 07/29] fix(tabs): keep urlString in sync with the tab URL on navigation urlString, used by open-tab search, was only set when the tab was created, so a tab kept matching the URL it was first opened with. Route navigation updates through Tab.updateURL(_:) which updates both. --- ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift | 8 +++++--- ora/Features/Tabs/Models/Tab.swift | 9 +++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift b/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift index 0220a5a5..70a0a4bc 100644 --- a/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift +++ b/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift @@ -63,7 +63,7 @@ final class TabBrowserPageDelegate: BrowserPageDelegate { tab.isLoading = event.isLoading tab.loadingProgress = event.progress if let url = event.url { - tab.url = url + tab.updateURL(url) } case .committed: @@ -86,7 +86,7 @@ final class TabBrowserPageDelegate: BrowserPageDelegate { } } if let url = event.url { - tab.url = url + tab.updateURL(url) if tab.favicon == nil { tab.setFavicon() } @@ -241,7 +241,9 @@ final class TabBrowserPageDelegate: BrowserPageDelegate { let oldTitle = tab.title tab.title = update.title - tab.url = URL(string: update.href) ?? tab.url + if let url = URL(string: update.href) { + tab.updateURL(url) + } tab.setFavicon() tab.updateHistory() diff --git a/ora/Features/Tabs/Models/Tab.swift b/ora/Features/Tabs/Models/Tab.swift index db0c7bbf..e6a6f7ad 100644 --- a/ora/Features/Tabs/Models/Tab.swift +++ b/ora/Features/Tabs/Models/Tab.swift @@ -265,6 +265,15 @@ class Tab: ObservableObject, Identifiable { } } + /// Updates the tab's URL and keeps the persisted `urlString` in sync. + /// + /// `urlString` is what open-tab search queries match against, so it must + /// follow navigation instead of keeping the URL the tab was created with. + func updateURL(_ newURL: URL) { + url = newURL + urlString = newURL.absoluteString + } + func loadURL(_ urlString: String) { lastAccessedAt = Date() let input = urlString.trimmingCharacters(in: .whitespacesAndNewlines) From f30a8a48e10ce68651ab782d43c53941af7fbe33 Mon Sep 17 00:00:00 2001 From: "L. Ramos" Date: Sun, 25 Oct 2026 11:51:19 +0100 Subject: [PATCH 08/29] fix(launcher): make Enter open a typed or pasted URL The first suggestion could be the active tab itself, so Enter switched to the current tab and nothing happened. Never suggest the active tab, and rank the typed URL before open tabs when the input is a URL. --- .../Launcher/State/LauncherViewModel.swift | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/ora/Features/Launcher/State/LauncherViewModel.swift b/ora/Features/Launcher/State/LauncherViewModel.swift index c9c6fb8f..ae226be9 100644 --- a/ora/Features/Launcher/State/LauncherViewModel.swift +++ b/ora/Features/Launcher/State/LauncherViewModel.swift @@ -61,13 +61,23 @@ class LauncherViewModel: ObservableObject { text, activeContainerId: tabManager.activeContainer?.id ?? UUID() ) - let tabs = tabManager.search(text) + // Switching to the tab the user is already on is a no-op, so never + // offer it (it used to swallow Enter in the URL bar). + let activeTabId = tabManager.activeTab?.id + let tabs = tabManager.search(text).filter { $0.id != activeTabId } suggestions = [] var itemsCount = 0 - appendOpenTabs(tabs, itemsCount: &itemsCount) - appendOpenURLSuggestionIfNeeded(text) + // A URL typed or pasted by the user is the most likely intent, so it + // goes first and is what Enter opens (as in Safari or Chrome). + if explicitURL(from: text) != nil { + appendOpenURLSuggestionIfNeeded(text) + appendOpenTabs(tabs, itemsCount: &itemsCount) + } else { + appendOpenTabs(tabs, itemsCount: &itemsCount) + appendOpenURLSuggestionIfNeeded(text) + } appendSearchWithDefaultEngineSuggestion(text) let insertIndex = suggestions.count @@ -192,20 +202,23 @@ class LauncherViewModel: ObservableObject { } } + /// The URL the user is explicitly entering, if the text is one + /// (`https://example.com/page` or a bare `example.com`). + private func explicitURL(from text: String) -> URL? { + if let candidateURL = URL(string: text), candidateURL.scheme != nil, candidateURL.host != nil { + return candidateURL + } + if isValidURL(text) { + return constructURL(from: text) + } + return nil + } + private func appendOpenURLSuggestionIfNeeded(_ text: String) { guard let tabManager, let historyManager, let privacyMode else { return } - let finalURL: URL? = if let candidateURL = URL(string: text), candidateURL.scheme != nil, - candidateURL.host != nil - { - candidateURL - } else if isValidURL(text) { - constructURL(from: text) - } else { - nil - } - guard let url = finalURL else { return } + guard let url = explicitURL(from: text) else { return } let navigateCurrent = self.navigateInCurrentTab suggestions.append( LauncherSuggestion( From 8f031e053378b48d4603404e1738dbc5e9992ab2 Mon Sep 17 00:00:00 2001 From: "L. Ramos" Date: Sun, 25 Oct 2026 12:04:12 +0100 Subject: [PATCH 09/29] fix(urlbar): end editing on any click outside the field and let clicks through Clicking the sidebar or toolbar left the URL bar suggestions open, while the dimming overlay over the page swallowed clicks, so links and the context menu did not work while editing. Watch mouse-downs during editing (without consuming them) and dismiss when they land outside the field and its suggestions; keep the overlay purely visual. --- ora/Features/Browser/URLBar/URLBar.swift | 43 +++++++++++++++++++ .../Browser/Views/BrowserWebContentView.swift | 10 ++--- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/ora/Features/Browser/URLBar/URLBar.swift b/ora/Features/Browser/URLBar/URLBar.swift index 97ed308e..b9a9cb20 100644 --- a/ora/Features/Browser/URLBar/URLBar.swift +++ b/ora/Features/Browser/URLBar/URLBar.swift @@ -25,6 +25,10 @@ struct URLBar: View { @FocusState private var isLauncherFocused: Bool @State private var mouseHasMoved = false @State private var mouseMonitor: Any? + @State private var mouseDownMonitor: Any? + @State private var editingFieldFrame: CGRect = .zero + @State private var suggestionsFrame: CGRect = .zero + @Environment(\.window) private var window @State private var suppressInitialSearch = false let onSidebarToggle: () -> Void @@ -116,6 +120,34 @@ struct URLBar: View { return event } } + + // End editing when the user clicks anywhere else in the window. The + // event is passed through untouched, so the click still reaches its + // target (a sidebar tab, the page...), as in Safari. + if mouseDownMonitor == nil { + mouseDownMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown] + ) { event in + if isEditing, isOutsideEditingArea(event) { + dismissEditing() + } + return event + } + } + } + + /// Whether a mouse-down landed in this window but outside both the URL + /// field and its suggestions list. + private func isOutsideEditingArea(_ event: NSEvent) -> Bool { + guard let window, event.window === window, let contentView = window.contentView else { + return false + } + // SwiftUI's global space is top-left based, AppKit's may not be. + var point = contentView.convert(event.locationInWindow, from: nil) + if !contentView.isFlipped { + point.y = contentView.bounds.height - point.y + } + return !editingFieldFrame.contains(point) && !suggestionsFrame.contains(point) } private func cleanupInlineLauncher() { @@ -123,6 +155,10 @@ struct URLBar: View { NSEvent.removeMonitor(monitor) mouseMonitor = nil } + if let monitor = mouseDownMonitor { + NSEvent.removeMonitor(monitor) + mouseDownMonitor = nil + } DispatchQueue.main.async { guard !appState.isURLBarEditing else { return } isLauncherFocused = false @@ -220,6 +256,9 @@ struct URLBar: View { .transition(.blurReplace) } } + .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame in + editingFieldFrame = frame + } .overlay(alignment: .top) { if isEditing { suggestionsOverlay() @@ -441,6 +480,10 @@ struct URLBar: View { .padding(0.25) ) .shadow(color: .black.opacity(0.1), radius: 20, x: 0, y: 10) + .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame in + suggestionsFrame = frame + } + .onDisappear { suggestionsFrame = .zero } .environment(\.launcherMouseHasMoved, mouseHasMoved) } } diff --git a/ora/Features/Browser/Views/BrowserWebContentView.swift b/ora/Features/Browser/Views/BrowserWebContentView.swift index 61049842..547551eb 100644 --- a/ora/Features/Browser/Views/BrowserWebContentView.swift +++ b/ora/Features/Browser/Views/BrowserWebContentView.swift @@ -31,14 +31,12 @@ struct BrowserWebContentView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) if appState.isURLBarEditing { + // Visual dimming only: clicks go through to the page (links, + // context menu...). The URL bar ends editing by itself on any + // click outside of it. Color.black.opacity(0.15) .frame(maxWidth: .infinity, maxHeight: .infinity) - .contentShape(Rectangle()) - .onTapGesture { - DispatchQueue.main.async { - appState.isURLBarEditing = false - } - } + .allowsHitTesting(false) .transition(.opacity) } } From ff4c235fb70f2487265163d08681630c8e75d8ad Mon Sep 17 00:00:00 2001 From: "L. Ramos" Date: Sun, 25 Oct 2026 12:14:25 +0100 Subject: [PATCH 10/29] fix(permissions): wire up the media capture delegate and stop granting it blindly The WKUIDelegate method was missing the type parameter, so WebKit never called it (compiler: 'nearly matches optional requirement'). Fix the signature, forward whether camera, microphone or both are requested, and answer with WebKit's own prompt instead of an unconditional grant. --- ora/Core/BrowserEngine/BrowserEngineTypes.swift | 6 +++++- ora/Core/BrowserEngine/BrowserPage.swift | 15 +++++++++++++-- .../Tabs/Browser/TabBrowserPageDelegate.swift | 4 +++- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/ora/Core/BrowserEngine/BrowserEngineTypes.swift b/ora/Core/BrowserEngine/BrowserEngineTypes.swift index 17b918fb..dd439377 100644 --- a/ora/Core/BrowserEngine/BrowserEngineTypes.swift +++ b/ora/Core/BrowserEngine/BrowserEngineTypes.swift @@ -30,12 +30,16 @@ struct BrowserOpenPanelOptions { } enum BrowserPermissionKind { - case mediaCapture + case camera + case microphone + case cameraAndMicrophone } enum BrowserPermissionDecision { case grant case deny + /// Let WebKit ask the user with its own built-in prompt. + case prompt } struct BrowserNavigationAction { diff --git a/ora/Core/BrowserEngine/BrowserPage.swift b/ora/Core/BrowserEngine/BrowserPage.swift index af2fe1d4..a042d11a 100644 --- a/ora/Core/BrowserEngine/BrowserPage.swift +++ b/ora/Core/BrowserEngine/BrowserPage.swift @@ -399,11 +399,22 @@ final class BrowserPage: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptM _ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, initiatedByFrame frame: WKFrameInfo, + type: WKMediaCaptureType, decisionHandler: @escaping (WKPermissionDecision) -> Void ) { + let permission: BrowserPermissionKind = switch type { + case .camera: .camera + case .microphone: .microphone + case .cameraAndMicrophone: .cameraAndMicrophone + @unknown default: .cameraAndMicrophone + } let pageURL = URL(string: "\(origin.protocol)://\(origin.host):\(origin.port)") - delegate?.browserPage(self, requestPermission: .mediaCapture, origin: pageURL) { decision in - decisionHandler(decision == .grant ? .grant : .deny) + delegate?.browserPage(self, requestPermission: permission, origin: pageURL) { decision in + switch decision { + case .grant: decisionHandler(.grant) + case .deny: decisionHandler(.deny) + case .prompt: decisionHandler(.prompt) + } } } diff --git a/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift b/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift index 70a0a4bc..be3f565c 100644 --- a/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift +++ b/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift @@ -137,7 +137,9 @@ final class TabBrowserPageDelegate: BrowserPageDelegate { origin: URL?, decisionHandler: @escaping (BrowserPermissionDecision) -> Void ) { - decisionHandler(.grant) + // Never grant capture devices silently: until per-site permissions + // are implemented, defer to WebKit's own prompt. + decisionHandler(.prompt) } func browserPage( From 76ca4d7145b7329518f0db667f8daae640d27b8a Mon Sep 17 00:00:00 2001 From: "L. Ramos" Date: Sun, 25 Oct 2026 12:24:23 +0100 Subject: [PATCH 11/29] build: make the unit test target signable oraTests had no Info.plist and no signing team, so tests could not run locally. Generate the Info.plist and sign with the development team. --- project.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/project.yml b/project.yml index eb4187ff..fb8baf12 100644 --- a/project.yml +++ b/project.yml @@ -139,6 +139,9 @@ targets: base: TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Ora.app/Contents/MacOS/Ora" BUNDLE_LOADER: "$(TEST_HOST)" + GENERATE_INFOPLIST_FILE: YES + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: Q76T6LDBBZ schemes: ora: From 4d7607cdf7eb55180f0ec6f6d5189b9279f44c5d Mon Sep 17 00:00:00 2001 From: "L. Ramos" Date: Sun, 25 Oct 2026 12:24:23 +0100 Subject: [PATCH 12/29] feat(permissions): add per-space site permission model and store Introduce SiteOrigin (scheme, host and port, with default ports normalized), a tri-state ask/allow/deny per capability, and SitePermissionStore scoped per space like website data. Private browsing is kept in memory only. Permissions are cleared when a space is deleted. Replaces the unused SitePermissionSettings. --- ora/Core/Utilities/SettingsStore.swift | 35 +--- .../Permissions/Models/SitePermission.swift | 143 ++++++++++++++++ .../Services/SitePermissionStore.swift | 117 +++++++++++++ oraTests/SitePermissionTests.swift | 155 ++++++++++++++++++ 4 files changed, 416 insertions(+), 34 deletions(-) create mode 100644 ora/Features/Permissions/Models/SitePermission.swift create mode 100644 ora/Features/Permissions/Services/SitePermissionStore.swift create mode 100644 oraTests/SitePermissionTests.swift diff --git a/ora/Core/Utilities/SettingsStore.swift b/ora/Core/Utilities/SettingsStore.swift index 992f013b..2d5874f7 100644 --- a/ora/Core/Utilities/SettingsStore.swift +++ b/ora/Core/Utilities/SettingsStore.swift @@ -2,18 +2,6 @@ import AppKit import Foundation import SwiftUI -struct SitePermissionSettings: Codable, Hashable, Identifiable { - var id: String { - host - } - - let host: String - var camera: Bool - var microphone: Bool - var location: Bool - var notifications: Bool -} - enum AutoClearTabsAfter: String, CaseIterable, Identifiable, Codable { case never = "Never" case oneHour = "1 Hour" @@ -148,7 +136,6 @@ class SettingsStore: ObservableObject { private let adBlockingKey = "settings.tracking.adBlocking" private let cookiesPolicyKey = "settings.cookies.policy" private let adBlockFilterListsKey = "settings.adBlock.filterLists" - private let sitePermissionsKey = "settings.permissions.sitePermissions" private let customSearchEnginesKey = "settings.customSearchEngines" private let globalDefaultSearchEngineKey = "settings.globalDefaultSearchEngine" private let customKeyboardShortcutsKey = "settings.customKeyboardShortcuts" @@ -201,10 +188,6 @@ class SettingsStore: ObservableObject { didSet { defaults.set(cookiesPolicy.rawValue, forKey: cookiesPolicyKey) } } - @Published var sitePermissions: [String: SitePermissionSettings] { - didSet { saveCodable(sitePermissions, forKey: sitePermissionsKey) } - } - @Published private(set) var adBlockFilterLists: [FilterListRecord] { didSet { saveCodable(adBlockFilterLists, forKey: adBlockFilterListsKey) } } @@ -277,9 +260,6 @@ class SettingsStore: ObservableObject { cookiesPolicy = .allowAll } - sitePermissions = - Self.loadCodable([String: SitePermissionSettings].self, key: sitePermissionsKey) ?? [:] - adBlockFilterLists = FilterListCatalogService.shared.normalizedRecords( from: Self.loadCodable([FilterListRecord].self, key: adBlockFilterListsKey) ?? [] ) @@ -394,23 +374,10 @@ class SettingsStore: ObservableObject { defaults.removeObject(forKey: keyForDefaultAI(for: containerId)) defaults.removeObject(forKey: keyForAutoClear(for: containerId)) defaults.removeObject(forKey: keyForPrivacySettings(for: containerId)) + SitePermissionStore.shared.removeAll(in: containerId) objectWillChange.send() } - // MARK: - Permissions - - func upsertSitePermission(_ permission: SitePermissionSettings) { - var copy = sitePermissions - copy[permission.host] = permission - sitePermissions = copy - } - - func removeSitePermission(host: String) { - var copy = sitePermissions - copy.removeValue(forKey: host) - sitePermissions = copy - } - // MARK: - Custom Search Engines func addCustomSearchEngine(_ engine: CustomSearchEngine) { diff --git a/ora/Features/Permissions/Models/SitePermission.swift b/ora/Features/Permissions/Models/SitePermission.swift new file mode 100644 index 00000000..3eab2f20 --- /dev/null +++ b/ora/Features/Permissions/Models/SitePermission.swift @@ -0,0 +1,143 @@ +import Foundation + +/// A capability a website can ask the user for. +enum SitePermissionType: String, Codable, CaseIterable, Identifiable { + case camera + case microphone + + var id: String { + rawValue + } +} + +/// The user's choice for a capability on a given site. +enum SitePermissionState: String, Codable, CaseIterable { + /// Ask the user the next time the site requests it. + case ask + case allow + case deny +} + +// MARK: - Site Origin + +/// A web origin (scheme, host and port), the unit browsers scope permissions to. +/// +/// `http://example.com` and `https://example.com` are distinct origins. A +/// default port (80 for http, 443 for https, or 0 as reported by WebKit) is +/// stored as `nil`, so `https://example.com` and `https://example.com:443` +/// are the same origin. +struct SiteOrigin: Hashable, Codable, Identifiable, CustomStringConvertible { + let scheme: String + let host: String + let port: Int? + + init?(scheme: String, host: String, port: Int?) { + let scheme = scheme.lowercased() + let host = host.lowercased() + guard !scheme.isEmpty, !host.isEmpty else { return nil } + + self.scheme = scheme + self.host = host + if let port, port > 0, port != Self.defaultPort(for: scheme) { + self.port = port + } else { + self.port = nil + } + } + + init?(url: URL) { + guard let scheme = url.scheme, let host = url.host(percentEncoded: false) else { return nil } + self.init(scheme: scheme, host: host, port: url.port) + } + + /// Parses the canonical string produced by `description`. + init?(string: String) { + guard let url = URL(string: string) else { return nil } + self.init(url: url) + } + + var id: String { + description + } + + var description: String { + if let port { + return "\(scheme)://\(host):\(port)" + } + return "\(scheme)://\(host)" + } + + private static func defaultPort(for scheme: String) -> Int? { + switch scheme { + case "http": 80 + case "https": 443 + default: nil + } + } +} + +// MARK: - Site Permissions + +/// The permissions granted to one site within one space. +struct SitePermissions: Codable, Hashable { + var camera: SitePermissionState = .ask + var microphone: SitePermissionState = .ask + + init(camera: SitePermissionState = .ask, microphone: SitePermissionState = .ask) { + self.camera = camera + self.microphone = microphone + } + + /// Tolerate missing keys so that capabilities added later decode as `.ask`. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + camera = try container.decodeIfPresent(SitePermissionState.self, forKey: .camera) ?? .ask + microphone = try container.decodeIfPresent(SitePermissionState.self, forKey: .microphone) ?? .ask + } + + subscript(type: SitePermissionType) -> SitePermissionState { + get { + switch type { + case .camera: camera + case .microphone: microphone + } + } + set { + switch type { + case .camera: camera = newValue + case .microphone: microphone = newValue + } + } + } + + /// Whether nothing has been decided yet for this site. + var isUndecided: Bool { + SitePermissionType.allCases.allSatisfy { self[$0] == .ask } + } + + /// The combined decision for a request covering several capabilities: + /// denied if any is denied, allowed only if all are allowed, otherwise ask. + func decision(for types: Set) -> SitePermissionState { + let states = types.map { self[$0] } + if states.contains(.deny) { + return .deny + } + if !states.isEmpty, states.allSatisfy({ $0 == .allow }) { + return .allow + } + return .ask + } +} + +// MARK: - Engine Mapping + +extension BrowserPermissionKind { + /// The site permissions a WebKit capture request covers. + var sitePermissionTypes: Set { + switch self { + case .camera: [.camera] + case .microphone: [.microphone] + case .cameraAndMicrophone: [.camera, .microphone] + } + } +} diff --git a/ora/Features/Permissions/Services/SitePermissionStore.swift b/ora/Features/Permissions/Services/SitePermissionStore.swift new file mode 100644 index 00000000..6376be91 --- /dev/null +++ b/ora/Features/Permissions/Services/SitePermissionStore.swift @@ -0,0 +1,117 @@ +import Combine +import Foundation + +/// Site permissions, scoped per space and per origin. +/// +/// Spaces have isolated website data, so permissions follow the same +/// boundary: allowing the camera for a site in one space does not allow it in +/// another. Regular spaces are persisted in user defaults; private browsing +/// keeps its choices in memory only, so they are forgotten when the app quits. +/// +/// Like `SettingsStore`, this type is meant to be used from the main thread. +final class SitePermissionStore: ObservableObject { + static let shared = SitePermissionStore() + + typealias SpacePermissions = [SiteOrigin: SitePermissions] + + /// Persisted permissions of regular spaces, keyed by space identifier. + @Published private(set) var permissionsBySpace: [UUID: SpacePermissions] + + private var privatePermissionsBySpace: [UUID: SpacePermissions] = [:] + private let defaults: UserDefaults + private let storageKey: String + + init(defaults: UserDefaults = .standard, storageKey: String = "settings.permissions.sites.v1") { + self.defaults = defaults + self.storageKey = storageKey + self.permissionsBySpace = Self.load(from: defaults, key: storageKey) + } + + // MARK: - Reading + + func permissions(for origin: SiteOrigin, in spaceId: UUID, isPrivate: Bool) -> SitePermissions { + let spaces = isPrivate ? privatePermissionsBySpace : permissionsBySpace + return spaces[spaceId]?[origin] ?? SitePermissions() + } + + // MARK: - Writing + + func setState( + _ state: SitePermissionState, + for types: Set, + origin: SiteOrigin, + in spaceId: UUID, + isPrivate: Bool + ) { + var permissions = permissions(for: origin, in: spaceId, isPrivate: isPrivate) + for type in types { + permissions[type] = state + } + setPermissions(permissions, for: origin, in: spaceId, isPrivate: isPrivate) + } + + func setPermissions(_ permissions: SitePermissions, for origin: SiteOrigin, in spaceId: UUID, isPrivate: Bool) { + // A site with nothing decided is the same as an unknown site: don't keep it. + let value: SitePermissions? = permissions.isUndecided ? nil : permissions + if isPrivate { + privatePermissionsBySpace[spaceId, default: [:]][origin] = value + } else { + permissionsBySpace[spaceId, default: [:]][origin] = value + if permissionsBySpace[spaceId]?.isEmpty == true { + permissionsBySpace[spaceId] = nil + } + save() + } + } + + func removePermissions(for origin: SiteOrigin, in spaceId: UUID, isPrivate: Bool) { + setPermissions(SitePermissions(), for: origin, in: spaceId, isPrivate: isPrivate) + } + + /// Forgets everything about a space, e.g. when it is deleted. + func removeAll(in spaceId: UUID) { + privatePermissionsBySpace[spaceId] = nil + guard permissionsBySpace[spaceId] != nil else { return } + permissionsBySpace[spaceId] = nil + save() + } + + // MARK: - Persistence + + /// On-disk shape: `[space UUID string: [origin string: permissions]]`, + /// using string keys so the JSON stays readable and stable. + private typealias Stored = [String: [String: SitePermissions]] + + private func save() { + var stored: Stored = [:] + for (spaceId, origins) in permissionsBySpace { + stored[spaceId.uuidString] = Dictionary( + uniqueKeysWithValues: origins.map { ($0.key.description, $0.value) } + ) + } + if let data = try? JSONEncoder().encode(stored) { + defaults.set(data, forKey: storageKey) + } + } + + private static func load(from defaults: UserDefaults, key: String) -> [UUID: SpacePermissions] { + guard let data = defaults.data(forKey: key), + let stored = try? JSONDecoder().decode(Stored.self, from: data) + else { return [:] } + + var result: [UUID: SpacePermissions] = [:] + for (spaceKey, origins) in stored { + guard let spaceId = UUID(uuidString: spaceKey) else { continue } + var spacePermissions: SpacePermissions = [:] + for (originKey, permissions) in origins { + if let origin = SiteOrigin(string: originKey) { + spacePermissions[origin] = permissions + } + } + if !spacePermissions.isEmpty { + result[spaceId] = spacePermissions + } + } + return result + } +} diff --git a/oraTests/SitePermissionTests.swift b/oraTests/SitePermissionTests.swift new file mode 100644 index 00000000..a3b992cf --- /dev/null +++ b/oraTests/SitePermissionTests.swift @@ -0,0 +1,155 @@ +import Foundation +@testable import Ora +import Testing + +// MARK: - SiteOrigin + +struct SiteOriginTests { + @Test func defaultPortsAreNormalizedAway() throws { + let explicit = try #require(SiteOrigin(string: "https://meet.example.com:443")) + let implicit = try #require(SiteOrigin(string: "https://meet.example.com")) + let fromWebKit = try #require(SiteOrigin(scheme: "https", host: "meet.example.com", port: 0)) + + #expect(explicit == implicit) + #expect(fromWebKit == implicit) + #expect(implicit.description == "https://meet.example.com") + } + + @Test func customPortIsKept() throws { + let origin = try #require(SiteOrigin(string: "http://localhost:8080/some/path?q=1")) + + #expect(origin.port == 8080) + #expect(origin.description == "http://localhost:8080") + } + + @Test func schemeAndHostAreCaseInsensitive() throws { + let upper = try #require(SiteOrigin(string: "HTTPS://Meet.Example.COM/")) + let lower = try #require(SiteOrigin(string: "https://meet.example.com")) + + #expect(upper == lower) + } + + @Test func httpAndHttpsAreDifferentOrigins() throws { + let http = try #require(SiteOrigin(string: "http://example.com")) + let https = try #require(SiteOrigin(string: "https://example.com")) + + #expect(http != https) + } + + @Test func descriptionRoundTrips() throws { + let origin = try #require(SiteOrigin(string: "https://example.com:8443")) + + #expect(SiteOrigin(string: origin.description) == origin) + } + + @Test func urlsWithoutHostAreRejected() { + #expect(SiteOrigin(string: "about:blank") == nil) + #expect(SiteOrigin(scheme: "https", host: "", port: nil) == nil) + } +} + +// MARK: - SitePermissions + +struct SitePermissionsTests { + @Test func combinedDecisionDeniesIfAnyCapabilityIsDenied() { + let permissions = SitePermissions(camera: .allow, microphone: .deny) + + #expect(permissions.decision(for: [.camera, .microphone]) == .deny) + #expect(permissions.decision(for: [.camera]) == .allow) + } + + @Test func combinedDecisionAllowsOnlyIfEveryCapabilityIsAllowed() { + let partial = SitePermissions(camera: .allow, microphone: .ask) + let full = SitePermissions(camera: .allow, microphone: .allow) + + #expect(partial.decision(for: [.camera, .microphone]) == .ask) + #expect(full.decision(for: [.camera, .microphone]) == .allow) + } + + @Test func emptyRequestAsks() { + #expect(SitePermissions(camera: .allow, microphone: .allow).decision(for: []) == .ask) + } + + @Test func missingKeysDecodeAsAsk() throws { + let data = Data(#"{"camera":"allow"}"#.utf8) + let permissions = try JSONDecoder().decode(SitePermissions.self, from: data) + + #expect(permissions.camera == .allow) + #expect(permissions.microphone == .ask) + } +} + +// MARK: - SitePermissionStore + +struct SitePermissionStoreTests { + private let space = UUID() + private let otherSpace = UUID() + private let origin = SiteOrigin(scheme: "https", host: "meet.example.com", port: nil)! + + /// A store backed by its own throwaway defaults suite. + private func makeStore(suite: String = "SitePermissionStoreTests.\(UUID().uuidString)") + -> (SitePermissionStore, UserDefaults) + { + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return (SitePermissionStore(defaults: defaults), defaults) + } + + @Test func unknownSiteAsks() { + let (store, _) = makeStore() + + #expect(store.permissions(for: origin, in: space, isPrivate: false).isUndecided) + } + + @Test func permissionsAreScopedPerSpace() { + let (store, _) = makeStore() + + store.setState(.allow, for: [.camera], origin: origin, in: space, isPrivate: false) + + #expect(store.permissions(for: origin, in: space, isPrivate: false).camera == .allow) + #expect(store.permissions(for: origin, in: otherSpace, isPrivate: false).camera == .ask) + } + + @Test func permissionsArePersisted() { + let suite = "SitePermissionStoreTests.\(UUID().uuidString)" + let (store, defaults) = makeStore(suite: suite) + + store.setState(.deny, for: [.camera, .microphone], origin: origin, in: space, isPrivate: false) + let reloaded = SitePermissionStore(defaults: defaults) + + #expect(reloaded.permissions(for: origin, in: space, isPrivate: false) == + SitePermissions(camera: .deny, microphone: .deny)) + } + + @Test func privateBrowsingIsNeverPersisted() { + let suite = "SitePermissionStoreTests.\(UUID().uuidString)" + let (store, defaults) = makeStore(suite: suite) + + store.setState(.allow, for: [.camera], origin: origin, in: space, isPrivate: true) + + #expect(store.permissions(for: origin, in: space, isPrivate: true).camera == .allow) + #expect(store.permissions(for: origin, in: space, isPrivate: false).camera == .ask) + #expect(SitePermissionStore(defaults: defaults).permissions(for: origin, in: space, isPrivate: true) + .isUndecided) + } + + @Test func resettingASiteRemovesIt() { + let (store, _) = makeStore() + + store.setState(.allow, for: [.camera], origin: origin, in: space, isPrivate: false) + store.removePermissions(for: origin, in: space, isPrivate: false) + + #expect(store.permissionsBySpace[space] == nil) + } + + @Test func removingASpaceForgetsItsPermissions() { + let (store, _) = makeStore() + + store.setState(.allow, for: [.camera], origin: origin, in: space, isPrivate: false) + store.setState(.allow, for: [.camera], origin: origin, in: otherSpace, isPrivate: false) + store.removeAll(in: space) + + #expect(store.permissions(for: origin, in: space, isPrivate: false).isUndecided) + #expect(store.permissions(for: origin, in: otherSpace, isPrivate: false).camera == .allow) + } +} From e983e0d48969d1e2f46ce757c67518b39aa8b4cb Mon Sep 17 00:00:00 2001 From: "L. Ramos" Date: Sun, 25 Oct 2026 15:51:21 +0100 Subject: [PATCH 13/29] feat(permissions): ask per site before granting camera and microphone Capture requests are answered from the saved per-space permissions; when nothing is saved, an in-page prompt asks the user, optionally remembering the choice. Unanswered prompts are denied when the page navigates away, the tab is closed or another request replaces them. Allow has no keyboard shortcut on purpose. --- .../Browser/Views/BrowserWebContentView.swift | 10 +++ .../Models/SitePermissionRequest.swift | 34 ++++++++ .../Services/Tab+SitePermissions.swift | 34 ++++++++ .../Views/SitePermissionPromptView.swift | 87 +++++++++++++++++++ .../Tabs/Browser/TabBrowserPageDelegate.swift | 28 +++++- ora/Features/Tabs/Models/Tab.swift | 3 + 6 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 ora/Features/Permissions/Models/SitePermissionRequest.swift create mode 100644 ora/Features/Permissions/Services/Tab+SitePermissions.swift create mode 100644 ora/Features/Permissions/Views/SitePermissionPromptView.swift diff --git a/ora/Features/Browser/Views/BrowserWebContentView.swift b/ora/Features/Browser/Views/BrowserWebContentView.swift index 547551eb..932a296b 100644 --- a/ora/Features/Browser/Views/BrowserWebContentView.swift +++ b/ora/Features/Browser/Views/BrowserWebContentView.swift @@ -86,7 +86,17 @@ struct BrowserWebContentView: View { LinkPreview(text: hovered) } } + .overlay(alignment: .top) { + if let request = tab.permissionRequest { + SitePermissionPromptView(request: request, isPrivate: tab.isPrivate) { granted, remember in + tab.answerPermissionRequest(granted: granted, remember: remember) + } + .padding(.top, 12) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } .animation(.easeOut(duration: 0.12), value: tab.hoveredLinkURL) + .animation(.easeOut(duration: 0.2), value: tab.permissionRequest) } else { ZStack { Rectangle().fill(theme.background) diff --git a/ora/Features/Permissions/Models/SitePermissionRequest.swift b/ora/Features/Permissions/Models/SitePermissionRequest.swift new file mode 100644 index 00000000..c2a02354 --- /dev/null +++ b/ora/Features/Permissions/Models/SitePermissionRequest.swift @@ -0,0 +1,34 @@ +import Foundation + +/// A permission request from a page, waiting for the user's answer. +/// +/// WebKit keeps the page's request pending until it gets a decision, so the +/// request is resolved exactly once: by the user, or with a denial when it is +/// dropped unanswered (another request replaces it, the page navigates away, +/// the tab is closed). +final class SitePermissionRequest: Identifiable, Equatable { + let id = UUID() + let origin: SiteOrigin + let types: Set + private var completion: ((Bool) -> Void)? + + init(origin: SiteOrigin, types: Set, completion: @escaping (Bool) -> Void) { + self.origin = origin + self.types = types + self.completion = completion + } + + deinit { + resolve(granted: false) + } + + func resolve(granted: Bool) { + guard let completion else { return } + self.completion = nil + completion(granted) + } + + static func == (lhs: SitePermissionRequest, rhs: SitePermissionRequest) -> Bool { + lhs.id == rhs.id + } +} diff --git a/ora/Features/Permissions/Services/Tab+SitePermissions.swift b/ora/Features/Permissions/Services/Tab+SitePermissions.swift new file mode 100644 index 00000000..28699f4b --- /dev/null +++ b/ora/Features/Permissions/Services/Tab+SitePermissions.swift @@ -0,0 +1,34 @@ +import Foundation + +extension Tab { + /// Shows a permission prompt for this tab. A request still waiting for an + /// answer is denied first, so only one prompt is ever visible. + func presentPermissionRequest(_ request: SitePermissionRequest) { + permissionRequest?.resolve(granted: false) + permissionRequest = request + } + + /// Answers the visible prompt and, if asked to, remembers the choice for + /// the site in this tab's space. + func answerPermissionRequest(granted: Bool, remember: Bool) { + guard let request = permissionRequest else { return } + permissionRequest = nil + if remember { + SitePermissionStore.shared.setState( + granted ? .allow : .deny, + for: request.types, + origin: request.origin, + in: container.id, + isPrivate: isPrivate + ) + } + request.resolve(granted: granted) + } + + /// Denies and hides the visible prompt, if any. + func cancelPermissionRequest() { + guard let request = permissionRequest else { return } + permissionRequest = nil + request.resolve(granted: false) + } +} diff --git a/ora/Features/Permissions/Views/SitePermissionPromptView.swift b/ora/Features/Permissions/Views/SitePermissionPromptView.swift new file mode 100644 index 00000000..f59eab29 --- /dev/null +++ b/ora/Features/Permissions/Views/SitePermissionPromptView.swift @@ -0,0 +1,87 @@ +import SwiftUI + +/// Asks whether the current site may use the camera and/or the microphone. +/// +/// There is deliberately no keyboard shortcut for "Allow": a reflex press of +/// Return must never hand a capture device to a website. +struct SitePermissionPromptView: View { + let request: SitePermissionRequest + let isPrivate: Bool + let onAnswer: (_ granted: Bool, _ remember: Bool) -> Void + + @State private var remember = true + + private let width: CGFloat = 340 + private let cornerRadius: CGFloat = 18 + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: symbolName) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(Color(nsColor: .labelColor)) + .frame(width: 32, height: 32) + .background(Circle().fill(Color(nsColor: .labelColor).opacity(0.08))) + + VStack(alignment: .leading, spacing: 2) { + Text(siteName) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color(nsColor: .labelColor)) + .lineLimit(1) + .truncationMode(.middle) + Text("wants to use your \(capabilityDescription)") + .font(.subheadline) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + } + } + + Toggle(isPrivate ? "Remember until Ora quits" : "Remember for this site", isOn: $remember) + .toggleStyle(.checkbox) + .font(.caption) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + + HStack(spacing: 8) { + Spacer() + OraButton(label: "Don't Allow", variant: .secondary, size: .sm) { + onAnswer(false, remember) + } + OraButton(label: "Allow", size: .sm) { + onAnswer(true, remember) + } + } + } + .padding(14) + .frame(width: width) + .background { + ZStack { + BlurEffectView(material: .popover, blendingMode: .withinWindow) + Color(nsColor: .windowBackgroundColor).opacity(0.5) + } + } + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(Color(nsColor: .separatorColor).opacity(0.7), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.12), radius: 20, x: 0, y: 8) + } + + /// The host alone for regular HTTPS sites; the full origin otherwise, so an + /// insecure scheme or an unusual port is visible before granting access. + private var siteName: String { + let origin = request.origin + return origin.scheme == "https" && origin.port == nil ? origin.host : origin.description + } + + private var capabilityDescription: String { + switch (request.types.contains(.camera), request.types.contains(.microphone)) { + case (true, true): "camera and microphone" + case (true, false): "camera" + default: "microphone" + } + } + + private var symbolName: String { + request.types.contains(.camera) ? "video.fill" : "mic.fill" + } +} diff --git a/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift b/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift index be3f565c..50255a30 100644 --- a/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift +++ b/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift @@ -57,6 +57,8 @@ final class TabBrowserPageDelegate: BrowserPageDelegate { switch event.phase { case .started: progressResetWorkItem?.cancel() + // A prompt belongs to the page that asked; leaving it denies. + tab.cancelPermissionRequest() tab.clearNavigationError() tab.maintainSnapShots() passwordCoordinator?.clearAutofillState() @@ -137,9 +139,29 @@ final class TabBrowserPageDelegate: BrowserPageDelegate { origin: URL?, decisionHandler: @escaping (BrowserPermissionDecision) -> Void ) { - // Never grant capture devices silently: until per-site permissions - // are implemented, defer to WebKit's own prompt. - decisionHandler(.prompt) + guard let tab, let origin = origin.flatMap(SiteOrigin.init(url:)) else { + decisionHandler(.deny) + return + } + + let types = permission.sitePermissionTypes + let saved = SitePermissionStore.shared.permissions( + for: origin, + in: tab.container.id, + isPrivate: tab.isPrivate + ) + + switch saved.decision(for: types) { + case .allow: + decisionHandler(.grant) + case .deny: + decisionHandler(.deny) + case .ask: + let request = SitePermissionRequest(origin: origin, types: types) { granted in + decisionHandler(granted ? .grant : .deny) + } + tab.presentPermissionRequest(request) + } } func browserPage( diff --git a/ora/Features/Tabs/Models/Tab.swift b/ora/Features/Tabs/Models/Tab.swift index e6a6f7ad..42cb4202 100644 --- a/ora/Features/Tabs/Models/Tab.swift +++ b/ora/Features/Tabs/Models/Tab.swift @@ -53,6 +53,8 @@ class Tab: ObservableObject, Identifiable { @Transient var passwordCoordinator: PasswordAutofillCoordinator? @Transient @Published var passwordOverlayState: PasswordAutofillOverlayState? @Transient @Published var passwordTriggerOverlayState: PasswordAutofillOverlayState? + /// Permission prompt currently shown for this tab, see `Tab+SitePermissions`. + @Transient @Published var permissionRequest: SitePermissionRequest? @Relationship(inverse: \TabContainer.tabs) var container: TabContainer @@ -302,6 +304,7 @@ class Tab: ObservableObject, Identifiable { } func destroyWebView() { + cancelPermissionRequest() browserPage?.teardown() browserPage = nil pageDelegate = nil From 2a2f4b7ca40759046cc53b43413fd3c8b3a3fa7b Mon Sep 17 00:00:00 2001 From: "L. Ramos" Date: Sun, 25 Oct 2026 15:51:21 +0100 Subject: [PATCH 14/29] test: avoid force unwraps in site permission tests --- oraTests/SitePermissionTests.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/oraTests/SitePermissionTests.swift b/oraTests/SitePermissionTests.swift index a3b992cf..6ff4e9e6 100644 --- a/oraTests/SitePermissionTests.swift +++ b/oraTests/SitePermissionTests.swift @@ -84,13 +84,17 @@ struct SitePermissionsTests { struct SitePermissionStoreTests { private let space = UUID() private let otherSpace = UUID() - private let origin = SiteOrigin(scheme: "https", host: "meet.example.com", port: nil)! + private let origin = SiteOrigin(scheme: "https", host: "meet.example.com", port: nil) ?? { + preconditionFailure("valid test origin") + }() /// A store backed by its own throwaway defaults suite. private func makeStore(suite: String = "SitePermissionStoreTests.\(UUID().uuidString)") -> (SitePermissionStore, UserDefaults) { - let defaults = UserDefaults(suiteName: suite)! + guard let defaults = UserDefaults(suiteName: suite) else { + preconditionFailure("could not create defaults suite \(suite)") + } defaults.removePersistentDomain(forName: suite) return (SitePermissionStore(defaults: defaults), defaults) } From 254b69d47fc8c7118b23d57862f96cebda3d8cac Mon Sep 17 00:00:00 2001 From: "L. Ramos" Date: Sun, 25 Oct 2026 15:55:50 +0100 Subject: [PATCH 15/29] feat(permissions): review and revoke site permissions per space in settings Add a Site Permissions card to each space in Settings > Spaces to change or forget saved camera and microphone choices. Share the site display rule (host for HTTPS, full origin otherwise) between the prompt and settings, with tests. --- .../Permissions/Models/SitePermission.swift | 28 +++++++ .../Views/SitePermissionPromptView.swift | 9 +-- .../Views/SitePermissionsSettingsCard.swift | 73 +++++++++++++++++++ .../Sections/SpacesSettingsView.swift | 1 + oraTests/SitePermissionTests.swift | 10 +++ 5 files changed, 113 insertions(+), 8 deletions(-) create mode 100644 ora/Features/Permissions/Views/SitePermissionsSettingsCard.swift diff --git a/ora/Features/Permissions/Models/SitePermission.swift b/ora/Features/Permissions/Models/SitePermission.swift index 3eab2f20..18128c29 100644 --- a/ora/Features/Permissions/Models/SitePermission.swift +++ b/ora/Features/Permissions/Models/SitePermission.swift @@ -67,6 +67,13 @@ struct SiteOrigin: Hashable, Codable, Identifiable, CustomStringConvertible { return "\(scheme)://\(host)" } + /// How to show the site to the user: the host alone for regular HTTPS + /// sites, the full origin otherwise, so an insecure scheme or an unusual + /// port is visible before granting anything. + var displayName: String { + scheme == "https" && port == nil ? host : description + } + private static func defaultPort(for scheme: String) -> Int? { switch scheme { case "http": 80 @@ -129,6 +136,27 @@ struct SitePermissions: Codable, Hashable { } } +// MARK: - Display + +extension SitePermissionType { + var title: String { + switch self { + case .camera: "Camera" + case .microphone: "Microphone" + } + } +} + +extension SitePermissionState { + var title: String { + switch self { + case .ask: "Ask" + case .allow: "Allow" + case .deny: "Don't Allow" + } + } +} + // MARK: - Engine Mapping extension BrowserPermissionKind { diff --git a/ora/Features/Permissions/Views/SitePermissionPromptView.swift b/ora/Features/Permissions/Views/SitePermissionPromptView.swift index f59eab29..4d1ad191 100644 --- a/ora/Features/Permissions/Views/SitePermissionPromptView.swift +++ b/ora/Features/Permissions/Views/SitePermissionPromptView.swift @@ -24,7 +24,7 @@ struct SitePermissionPromptView: View { .background(Circle().fill(Color(nsColor: .labelColor).opacity(0.08))) VStack(alignment: .leading, spacing: 2) { - Text(siteName) + Text(request.origin.displayName) .font(.subheadline.weight(.semibold)) .foregroundStyle(Color(nsColor: .labelColor)) .lineLimit(1) @@ -66,13 +66,6 @@ struct SitePermissionPromptView: View { .shadow(color: .black.opacity(0.12), radius: 20, x: 0, y: 8) } - /// The host alone for regular HTTPS sites; the full origin otherwise, so an - /// insecure scheme or an unusual port is visible before granting access. - private var siteName: String { - let origin = request.origin - return origin.scheme == "https" && origin.port == nil ? origin.host : origin.description - } - private var capabilityDescription: String { switch (request.types.contains(.camera), request.types.contains(.microphone)) { case (true, true): "camera and microphone" diff --git a/ora/Features/Permissions/Views/SitePermissionsSettingsCard.swift b/ora/Features/Permissions/Views/SitePermissionsSettingsCard.swift new file mode 100644 index 00000000..6c10029f --- /dev/null +++ b/ora/Features/Permissions/Views/SitePermissionsSettingsCard.swift @@ -0,0 +1,73 @@ +import SwiftUI + +/// Settings card listing the sites with saved permissions in one space, so the +/// user can review, change or forget them. +struct SitePermissionsSettingsCard: View { + let container: TabContainer + @ObservedObject private var store = SitePermissionStore.shared + + private var entries: [(origin: SiteOrigin, permissions: SitePermissions)] { + (store.permissionsBySpace[container.id] ?? [:]) + .map { (origin: $0.key, permissions: $0.value) } + .sorted { lhs, rhs in + lhs.origin.host == rhs.origin.host + ? lhs.origin.description < rhs.origin.description + : lhs.origin.host < rhs.origin.host + } + } + + var body: some View { + SettingsCard( + header: "Site Permissions", + description: "Sites that asked to use your camera or microphone in \(container.name). " + + "Changes apply to the next request; reload a page to stop a camera already in use." + ) { + if entries.isEmpty { + Text("No site has asked for permissions in this space yet.") + .font(.subheadline) + .foregroundStyle(.secondary) + } else { + ForEach(Array(entries.enumerated()), id: \.element.origin) { index, entry in + if index > 0 { + Divider() + } + row(for: entry.origin) + } + } + } + } + + private func row(for origin: SiteOrigin) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(origin.displayName) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Button { + store.removePermissions(for: origin, in: container.id, isPrivate: false) + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .help("Forget this site") + } + + ForEach(SitePermissionType.allCases) { type in + Picker(type.title, selection: binding(for: type, origin: origin)) { + ForEach(SitePermissionState.allCases, id: \.self) { state in + Text(state.title).tag(state) + } + } + } + } + } + + private func binding(for type: SitePermissionType, origin: SiteOrigin) -> Binding { + Binding( + get: { store.permissions(for: origin, in: container.id, isPrivate: false)[type] }, + set: { store.setState($0, for: [type], origin: origin, in: container.id, isPrivate: false) } + ) + } +} diff --git a/ora/Features/Settings/Sections/SpacesSettingsView.swift b/ora/Features/Settings/Sections/SpacesSettingsView.swift index 1df8f7b8..8b884470 100644 --- a/ora/Features/Settings/Sections/SpacesSettingsView.swift +++ b/ora/Features/Settings/Sections/SpacesSettingsView.swift @@ -119,6 +119,7 @@ struct SpacesSettingsView: View { } privacySettingsCard(for: container) + SitePermissionsSettingsCard(container: container) adBlockingSettingsCard(for: container) SettingsCard(header: "Clear Data") { diff --git a/oraTests/SitePermissionTests.swift b/oraTests/SitePermissionTests.swift index 6ff4e9e6..def625ae 100644 --- a/oraTests/SitePermissionTests.swift +++ b/oraTests/SitePermissionTests.swift @@ -42,6 +42,16 @@ struct SiteOriginTests { #expect(SiteOrigin(string: origin.description) == origin) } + @Test func displayNameRevealsInsecureSchemesAndCustomPorts() throws { + let secure = try #require(SiteOrigin(string: "https://meet.example.com")) + let insecure = try #require(SiteOrigin(string: "http://meet.example.com")) + let customPort = try #require(SiteOrigin(string: "https://meet.example.com:8443")) + + #expect(secure.displayName == "meet.example.com") + #expect(insecure.displayName == "http://meet.example.com") + #expect(customPort.displayName == "https://meet.example.com:8443") + } + @Test func urlsWithoutHostAreRejected() { #expect(SiteOrigin(string: "about:blank") == nil) #expect(SiteOrigin(scheme: "https", host: "", port: nil) == nil) From 4da3f77cae8ef1f4ec86640d7f5b1c315f96c983 Mon Sep 17 00:00:00 2001 From: "L. Ramos" Date: Sun, 25 Oct 2026 16:02:20 +0100 Subject: [PATCH 16/29] ci: build the app and run unit tests on every push and pull request The workflow only checked lint and formatting. Build the app and run the unit tests (ad-hoc signing on CI), pin SwiftLint and SwiftFormat to the local versions, cache Swift packages and upload the test results on failure. Upstream Sync now dispatches the CI on its branch, since pull requests opened with GITHUB_TOKEN do not trigger workflows. --- .github/workflows/build-and-test.yml | 165 ++++++++++++++++----------- .github/workflows/upstream-sync.yml | 8 ++ 2 files changed, 107 insertions(+), 66 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 35258152..2adae8ba 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -1,77 +1,110 @@ -name: Lint and Formatting +# Lint, format check, build and unit tests on every push and pull request. +# Also runnable on demand (used by Upstream Sync, whose PRs cannot trigger +# workflows by themselves). +name: Build and Test on: push: branches: [main, develop] pull_request: branches: [main, develop] + workflow_dispatch: + +concurrency: + group: build-and-test-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + # Same versions as the ones used locally, so lint results match. + SWIFTLINT_VERSION: "0.65.1" + SWIFTFORMAT_VERSION: "0.64.1" jobs: build-and-test: runs-on: macos-latest - + timeout-minutes: 45 + steps: - - name: Check architecture - run: uname -m - - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Xcode - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: latest-stable - - - name: Show Xcode version - run: xcodebuild -version - - - name: Show Swift version - run: swift --version - - - name: Install Xcodegen - run: brew install xcodegen - - - name: Generate Xcode project - run: xcodegen - - - name: Install SwiftLint - run: brew install swiftlint - - - name: Run SwiftLint - run: swiftlint lint - - - name: Install SwiftFormat (version figée) - env: - SWIFTFORMAT_VERSION: "0.64.1" - run: | - curl -fsSL -o /tmp/swiftformat.zip \ - "https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat.zip" - mkdir -p "$RUNNER_TEMP/swiftformat-bin" - unzip -o -q /tmp/swiftformat.zip -d "$RUNNER_TEMP/swiftformat-bin" - chmod +x "$RUNNER_TEMP/swiftformat-bin/swiftformat" - echo "$RUNNER_TEMP/swiftformat-bin" >> "$GITHUB_PATH" - - - name: Check code formatting - run: | - which swiftformat - swiftformat --version - swiftformat --lint . - - - name: Install Xcbeautify - run: brew install xcbeautify - - - name: Clean build folder - run: xcodebuild clean -scheme ora -project Ora.xcodeproj - - - name: Cache SPM dependencies - uses: actions/cache@v3 - with: - path: | - ~/Library/Caches/org.swift.swiftpm/ - ~/.swiftpm/ - key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.swift') }} - restore-keys: | - ${{ runner.os }}-spm- - - - name: Resolve dependencies - run: xcodebuild -resolvePackageDependencies + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Show toolchain + run: | + uname -m + xcodebuild -version + swift --version + + - name: Install pinned SwiftLint and SwiftFormat + run: | + bin="$RUNNER_TEMP/tools-bin" + mkdir -p "$bin" + # Download a release archive and install the named binary from it, + # wherever it sits inside the archive. + install_tool() { + local name="$1" url="$2" dir="$RUNNER_TEMP/$1" + curl -fsSL -o "$dir.zip" "$url" + unzip -o -q "$dir.zip" -d "$dir" + install -m 755 "$(find "$dir" -type f -name "$name" | head -n 1)" "$bin/$name" + } + install_tool swiftlint \ + "https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/portable_swiftlint.zip" + install_tool swiftformat \ + "https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat.zip" + echo "$bin" >> "$GITHUB_PATH" + + - name: Install XcodeGen and xcbeautify + run: brew install xcodegen xcbeautify + + - name: Lint + run: | + swiftlint version + swiftlint lint --quiet + + - name: Check formatting + run: | + swiftformat --version + swiftformat --lint . + + - name: Generate Xcode project + run: xcodegen + + - name: Cache Swift packages + uses: actions/cache@v4 + with: + path: .spm + key: ${{ runner.os }}-spm-${{ hashFiles('project.yml') }} + restore-keys: | + ${{ runner.os }}-spm- + + # The CI has no signing identity: sign ad hoc ("-") instead, which is + # enough to build, run the host app and load the test bundle. + - name: Build and run unit tests + run: | + set -o pipefail + xcodebuild test \ + -project Ora.xcodeproj \ + -scheme ora \ + -destination "platform=macOS,arch=arm64" \ + -clonedSourcePackagesDirPath .spm \ + -resultBundlePath "$RUNNER_TEMP/TestResults.xcresult" \ + CODE_SIGN_IDENTITY="-" \ + CODE_SIGN_STYLE=Manual \ + DEVELOPMENT_TEAM="" \ + PROVISIONING_PROFILE_SPECIFIER="" \ + | xcbeautify --renderer github-actions + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: TestResults + path: ${{ runner.temp }}/TestResults.xcresult + retention-days: 7 diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml index 3937cde4..abb00fa9 100644 --- a/.github/workflows/upstream-sync.yml +++ b/.github/workflows/upstream-sync.yml @@ -10,6 +10,7 @@ on: permissions: contents: write pull-requests: write + actions: write concurrency: group: upstream-sync @@ -64,3 +65,10 @@ jobs: --title "Sync upstream (${UPSTREAM_REPO})" \ --body "$body" fi + + # Pull requests opened with GITHUB_TOKEN do not trigger other workflows, + # but workflow_dispatch does: run the CI on the sync branch explicitly so + # the pull request gets its checks. + - name: Run CI on the sync branch + if: steps.check.outputs.count != '0' + run: gh workflow run build-and-test.yml --repo "$GITHUB_REPOSITORY" --ref "$SYNC_BRANCH" From 6618f35a08afb8006e811527652de81250d1b377 Mon Sep 17 00:00:00 2001 From: "L. Ramos" Date: Sun, 25 Oct 2026 16:32:16 +0100 Subject: [PATCH 17/29] fix: keep full screen when Escape is pressed in a web page Escape not handled by the page travels up the responder chain as cancelOperation(_:) and reached the window, which exits full screen. Stop it at the page host view, after WebKit, so pages still get the key. Like Safari, it now stops a page that is still loading. Fixes the-ora/browser#179. --- ora/Core/BrowserEngine/BrowserPageView.swift | 21 +++++++++++++++++++ oraTests/BrowserPageHostViewTests.swift | 22 ++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/ora/Core/BrowserEngine/BrowserPageView.swift b/ora/Core/BrowserEngine/BrowserPageView.swift index cbc4e36d..2e337721 100644 --- a/ora/Core/BrowserEngine/BrowserPageView.swift +++ b/ora/Core/BrowserEngine/BrowserPageView.swift @@ -4,6 +4,9 @@ import SwiftUI final class BrowserPageHostView: NSView { private(set) weak var hostedContentView: NSView? + /// Called when Escape reaches the host without being handled by the page. + var cancelOperationHandler: (() -> Void)? + override init(frame frameRect: NSRect) { super.init(frame: frameRect) wantsLayer = true @@ -71,6 +74,14 @@ final class BrowserPageHostView: NSView { hostedContentView?.frame = bounds } + /// Escape that the page does not handle travels up the responder chain as + /// `cancelOperation(_:)`. Left alone it reaches the window, which exits full + /// screen (the-ora/browser#179), so it stops here. Pages still receive the + /// key first (closing their own dialogs, leaving video full screen...). + override func cancelOperation(_ sender: Any?) { + cancelOperationHandler?() + } + private func configure(contentView: NSView) { contentView.wantsLayer = true contentView.autoresizingMask = [.width, .height] @@ -135,6 +146,7 @@ struct BrowserPageView: NSViewRepresentable { let contentView = page.contentView context.coordinator.update(page: page, contentView: contentView) hostView.host(contentView: contentView) + hostView.cancelOperationHandler = Self.cancelOperationHandler(for: page) return hostView } @@ -142,6 +154,15 @@ struct BrowserPageView: NSViewRepresentable { let contentView = page.contentView context.coordinator.update(page: page, contentView: contentView) nsView.host(contentView: contentView) + nsView.cancelOperationHandler = Self.cancelOperationHandler(for: page) + } + + /// Like Safari, Escape stops a page that is still loading. + private static func cancelOperationHandler(for page: BrowserPage) -> () -> Void { + { [weak page] in + guard let page, page.isLoading else { return } + page.stopLoading() + } } final class Coordinator: NSObject { diff --git a/oraTests/BrowserPageHostViewTests.swift b/oraTests/BrowserPageHostViewTests.swift index 6b7bc433..5fc02e17 100644 --- a/oraTests/BrowserPageHostViewTests.swift +++ b/oraTests/BrowserPageHostViewTests.swift @@ -95,6 +95,19 @@ struct BrowserPageHostViewTests { #expect(staleContentView.removeFromSuperviewCalls == 0) } + @Test func cancelOperationStopsAtTheHostInsteadOfReachingTheWindow() { + let host = makeHost() + let nextResponder = CancelOperationSpy() + host.nextResponder = nextResponder + var handlerCalls = 0 + host.cancelOperationHandler = { handlerCalls += 1 } + + host.cancelOperation(nil) + + #expect(handlerCalls == 1) + #expect(nextResponder.cancelOperationCalls == 0) + } + private func makeHost() -> BrowserPageHostView { BrowserPageHostView(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) } @@ -114,3 +127,12 @@ private final class TrackingContentView: NSView { super.viewWillMove(toSuperview: newSuperview) } } + +/// Records `cancelOperation(_:)` calls that reach it through the responder chain. +private final class CancelOperationSpy: NSResponder { + private(set) var cancelOperationCalls = 0 + + override func cancelOperation(_ sender: Any?) { + cancelOperationCalls += 1 + } +} From a9cd8e9e37967bdad428e61b5dafa7d3563acbb2 Mon Sep 17 00:00:00 2001 From: "L. Ramos" Date: Sun, 25 Oct 2026 16:39:52 +0100 Subject: [PATCH 18/29] fix: make the Fullscreen keyboard shortcut work The Fullscreen shortcut (Control-Command-F by default) was defined and listed in Settings but never handled, and macOS only offers Globe-F in the View menu, which keyboards without a Globe key cannot type. Handle Ora's customizable shortcut with the other global key handlers, without adding a duplicate menu item. --- ora/App/OraRoot.swift | 11 +++++++++ oraTests/KeyboardShortcutTests.swift | 35 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 oraTests/KeyboardShortcutTests.swift diff --git a/ora/App/OraRoot.swift b/ora/App/OraRoot.swift index 8f8f7840..a9e2dcf6 100644 --- a/ora/App/OraRoot.swift +++ b/ora/App/OraRoot.swift @@ -148,6 +148,17 @@ struct OraRoot: View { return false } + // Full screen: macOS only offers Globe+F in the View menu, which + // keyboards without a Globe key cannot type. Honour Ora's own, + // customizable shortcut (⌃⌘F by default) as well. + keyModifierListener.registerKeyDownHandler { event in + guard KeyChord(fromEvent: event) == KeyboardShortcuts.Window.fullscreen.currentChord, + let targetWindow = event.window ?? NSApp.keyWindow + else { return false } + targetWindow.toggleFullScreen(nil) + return true + } + // Cmd+Q quit confirmation NotificationCenter.default.addObserver(forName: .quitRequested, object: nil, queue: .main) { note in guard note.object as? NSWindow === window ?? NSApp.keyWindow else { return } diff --git a/oraTests/KeyboardShortcutTests.swift b/oraTests/KeyboardShortcutTests.swift new file mode 100644 index 00000000..70c666f9 --- /dev/null +++ b/oraTests/KeyboardShortcutTests.swift @@ -0,0 +1,35 @@ +import AppKit +@testable import Ora +import SwiftUI +import Testing + +struct KeyboardShortcutTests { + private func keyDown(_ characters: String, keyCode: UInt16, modifiers: NSEvent.ModifierFlags) throws -> NSEvent { + try #require( + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifiers, + timestamp: 0, + windowNumber: 0, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode + ) + ) + } + + @Test func controlCommandFMatchesTheDefaultFullScreenShortcut() throws { + let event = try keyDown("f", keyCode: 3, modifiers: [.control, .command]) + + #expect(KeyChord(fromEvent: event) == KeyboardShortcuts.Window.fullscreen.defaultChord) + } + + @Test func commandFAloneIsNotTheFullScreenShortcut() throws { + let event = try keyDown("f", keyCode: 3, modifiers: [.command]) + + #expect(KeyChord(fromEvent: event) != KeyboardShortcuts.Window.fullscreen.defaultChord) + } +} From 619cbf6e047a582dd2bf7d3912382a5c74c57a42 Mon Sep 17 00:00:00 2001 From: "L. Ramos" Date: Sun, 25 Oct 2026 17:31:27 +0100 Subject: [PATCH 19/29] fix: open links from other apps reliably, including on a cold launch The app delegate received links before any window was ready, picked an arbitrary window or built one by hand without its environment, and posted a notification the window might not be listening to yet, so links were lost. Let SwiftUI route external events: regular windows accept and prefer them and get them through onOpenURL once ready; settings and private windows refuse them. Remove the now unused manual window factory. Fixes the-ora/browser#178. --- ora/App/OraApp.swift | 31 +++++-------------- ora/App/OraRoot.swift | 17 ++++++++++ ora/Core/Utilities/WindowFactory.swift | 23 -------------- .../PasswordAutofillCoordinator.swift | 4 --- 4 files changed, 24 insertions(+), 51 deletions(-) delete mode 100644 ora/Core/Utilities/WindowFactory.swift diff --git a/ora/App/OraApp.swift b/ora/App/OraApp.swift index 76dee4ef..93e8cb0c 100644 --- a/ora/App/OraApp.swift +++ b/ora/App/OraApp.swift @@ -21,29 +21,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { return .terminateLater } - func application(_ application: NSApplication, open urls: [URL]) { - handleIncomingURLs(urls) - } - - func getWindow() -> NSWindow? { - if let key = NSApp.keyWindow { return key } - if let visible = NSApp.windows.first(where: { $0.isVisible }) { return visible } - if let any = NSApp.windows.first { - any.makeKeyAndOrderFront(nil) - return any - } - return WindowFactory.makeMainWindow(rootView: OraRoot()) - } - - func handleIncomingURLs(_ urls: [URL]) { - let window = getWindow()! - for url in urls { - let userInfo: [AnyHashable: Any] = ["url": url] - DispatchQueue.main.async { - NotificationCenter.default.post(name: .openURL, object: window, userInfo: userInfo) - } - } - } + // Links opened from other apps are routed by SwiftUI to a browser window + // (see `handlesExternalEvents` on the scenes and in `OraRoot`), so the app + // delegate deliberately does not implement `application(_:open:)`. } func deleteSwiftDataStore(_ loc: String) { @@ -82,7 +62,9 @@ struct OraApp: App { .defaultSize(width: 1440, height: 900) .windowStyle(.hiddenTitleBar) .windowResizability(.contentMinSize) - .handlesExternalEvents(matching: []) + // Links opened from other apps (Ora as default browser) go to a regular + // window; SwiftUI creates one if none is open, e.g. on a cold launch. + .handlesExternalEvents(matching: ["*"]) WindowGroup("Private", id: "private") { OraRoot(isPrivate: true) @@ -114,6 +96,7 @@ struct OraApp: App { .windowToolbarStyle(UnifiedCompactWindowToolbarStyle()) .windowResizability(.contentSize) .defaultSize(width: 980, height: 640) + .handlesExternalEvents(matching: []) .commands { OraCommands() } } } diff --git a/ora/App/OraRoot.swift b/ora/App/OraRoot.swift index a9e2dcf6..5c487fad 100644 --- a/ora/App/OraRoot.swift +++ b/ora/App/OraRoot.swift @@ -107,6 +107,23 @@ struct OraRoot: View { .modelContext(downloadContext) .withTheme() .enableInjection() + // External links: reuse this window instead of opening a new one + // per link. `onOpenURL` only runs once the view is ready, so links + // that launch Ora are not lost. Private windows never take them. + .handlesExternalEvents( + preferring: privacyMode.isPrivate ? [] : ["*"], + allowing: privacyMode.isPrivate ? [] : ["*"] + ) + .onOpenURL { url in + guard !privacyMode.isPrivate else { return } + tabManager.openTab( + url: url, + historyManager: historyManager, + downloadManager: downloadManager, + focusAfterOpening: true, + isPrivate: false + ) + } .onAppear { downloadManager.toastManager = toastManager Task { diff --git a/ora/Core/Utilities/WindowFactory.swift b/ora/Core/Utilities/WindowFactory.swift deleted file mode 100644 index 15ee5251..00000000 --- a/ora/Core/Utilities/WindowFactory.swift +++ /dev/null @@ -1,23 +0,0 @@ -import AppKit -import SwiftUI - -enum WindowFactory { - static func makeMainWindow(rootView: some View, size: CGSize = CGSize(width: 1440, height: 900)) -> NSWindow { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: size.width, height: size.height), - styleMask: [.titled, .closable, .miniaturizable, .resizable], - backing: .buffered, - defer: false - ) - window.titleVisibility = .hidden - window.titlebarAppearsTransparent = true - window.isReleasedWhenClosed = false - - let hostingController = NSHostingController(rootView: rootView) - window.contentViewController = hostingController - window.center() - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - return window - } -} diff --git a/ora/Features/Passwords/Services/PasswordAutofillCoordinator.swift b/ora/Features/Passwords/Services/PasswordAutofillCoordinator.swift index abf2129d..90b064a2 100644 --- a/ora/Features/Passwords/Services/PasswordAutofillCoordinator.swift +++ b/ora/Features/Passwords/Services/PasswordAutofillCoordinator.swift @@ -440,10 +440,6 @@ final class PasswordAutofillCoordinator { return window } - if let appDelegate = NSApp.delegate as? AppDelegate { - return appDelegate.getWindow() - } - return NSApp.keyWindow ?? NSApp.windows.first(where: { $0.isVisible }) ?? NSApp.windows.first From 06047adb5846a671104e811d8e401e1d05fc4a4b Mon Sep 17 00:00:00 2001 From: "L. Ramos" Date: Sun, 25 Oct 2026 17:31:27 +0100 Subject: [PATCH 20/29] fix: open local HTML files openTab required a host, which file URLs do not have, so no tab was created; and the sandbox only lets WebKit read a local file loaded with loadFileURL(_:allowingReadAccessTo:). Use the file name as the initial title and load file URLs with that API. --- ora/Core/BrowserEngine/BrowserPage.swift | 15 ++++++++++++-- ora/Features/Tabs/State/TabManager.swift | 22 ++++++++++++++------ oraTests/TabInitialTitleTests.swift | 26 ++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 8 deletions(-) create mode 100644 oraTests/TabInitialTitleTests.swift diff --git a/ora/Core/BrowserEngine/BrowserPage.swift b/ora/Core/BrowserEngine/BrowserPage.swift index a042d11a..aa12774c 100644 --- a/ora/Core/BrowserEngine/BrowserPage.swift +++ b/ora/Core/BrowserEngine/BrowserPage.swift @@ -126,7 +126,18 @@ final class BrowserPage: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptM return } - webView.load(request) + perform(request) + } + + /// The app is sandboxed: WebKit may only read a local file loaded through + /// `loadFileURL(_:allowingReadAccessTo:)`, scoped to that file (the one the + /// user opened), not with a plain request. + private func perform(_ request: URLRequest) { + if let url = request.url, url.isFileURL { + webView.loadFileURL(url, allowingReadAccessTo: url) + } else { + webView.load(request) + } } func reload() { @@ -190,7 +201,7 @@ final class BrowserPage: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptM private func flushPendingNavigationIfNeeded() { if let pendingLoadRequest { self.pendingLoadRequest = nil - webView.load(pendingLoadRequest) + perform(pendingLoadRequest) return } diff --git a/ora/Features/Tabs/State/TabManager.swift b/ora/Features/Tabs/State/TabManager.swift index 2a556c5a..235b0699 100644 --- a/ora/Features/Tabs/State/TabManager.swift +++ b/ora/Features/Tabs/State/TabManager.swift @@ -300,14 +300,10 @@ class TabManager: ObservableObject { loadSilently: Bool = false ) -> Tab? { if let container = activeContainer { - if let host = url.host { - let faviconURL = FaviconService.shared.faviconURL(for: host) - - let cleanHost = host.hasPrefix("www.") ? String(host.dropFirst(4)) : host - + if let (title, faviconURL) = Self.initialTitleAndFavicon(for: url) { let newTab = Tab( url: url, - title: cleanHost, + title: title, favicon: faviconURL, container: container, type: .normal, @@ -345,6 +341,20 @@ class TabManager: ObservableObject { return nil } + /// Placeholder title and favicon shown until the page reports its own: + /// the host for web pages, the file name for local files (which have no + /// host). Returns nil for URLs a tab cannot show. + static func initialTitleAndFavicon(for url: URL) -> (title: String, favicon: URL?)? { + if let host = url.host, !host.isEmpty { + let cleanHost = host.hasPrefix("www.") ? String(host.dropFirst(4)) : host + return (cleanHost, FaviconService.shared.faviconURL(for: host)) + } + if url.isFileURL { + return (url.lastPathComponent, nil) + } + return nil + } + func reorderTabs(from: Tab, toTab: Tab) { from.container.reorderTabs(from: from, to: toTab) try? modelContext.save() diff --git a/oraTests/TabInitialTitleTests.swift b/oraTests/TabInitialTitleTests.swift new file mode 100644 index 00000000..e4f5a834 --- /dev/null +++ b/oraTests/TabInitialTitleTests.swift @@ -0,0 +1,26 @@ +import Foundation +@testable import Ora +import Testing + +struct TabInitialTitleTests { + @Test func webPagesUseTheHostWithoutWww() throws { + let url = try #require(URL(string: "https://www.example.com/some/page")) + let initial = try #require(TabManager.initialTitleAndFavicon(for: url)) + + #expect(initial.title == "example.com") + } + + @Test func localFilesUseTheFileNameAndNoFavicon() throws { + let url = URL(fileURLWithPath: "/tmp/ora-test.html") + let initial = try #require(TabManager.initialTitleAndFavicon(for: url)) + + #expect(initial.title == "ora-test.html") + #expect(initial.favicon == nil) + } + + @Test func urlsWithoutHostThatAreNotFilesAreRejected() throws { + let url = try #require(URL(string: "about:blank")) + + #expect(TabManager.initialTitleAndFavicon(for: url) == nil) + } +} From 3b63de4409f939283087d3e9bdd8a096cfb83dbd Mon Sep 17 00:00:00 2001 From: "L. Ramos" Date: Sun, 25 Oct 2026 17:31:28 +0100 Subject: [PATCH 21/29] fix: show the host or file name for pages without a title Pages without a report an empty one, which blanked the tab title. Fall back to the host, or the file name for local files, like Safari. --- .../Tabs/Browser/TabBrowserPageDelegate.swift | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift b/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift index 50255a30..51ff4797 100644 --- a/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift +++ b/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift @@ -264,9 +264,19 @@ final class TabBrowserPageDelegate: BrowserPageDelegate { } let oldTitle = tab.title - tab.title = update.title - if let url = URL(string: update.href) { - tab.updateURL(url) + let newURL = URL(string: update.href) + // Pages without a <title> report an empty one: show the host or the + // file name instead (like Safari) rather than a blank tab. + if update.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let fallbackTitle = MainActor.assumeIsolated { + newURL.flatMap { TabManager.initialTitleAndFavicon(for: $0)?.title } + } + tab.title = fallbackTitle ?? oldTitle + } else { + tab.title = update.title + } + if let newURL { + tab.updateURL(newURL) } tab.setFavicon() tab.updateHistory() From 0a2196e48b4e6eca6480451595be86c206e5f7e5 Mon Sep 17 00:00:00 2001 From: "L. Ramos" <l.ramos@sct-telecom.fr> Date: Sun, 25 Oct 2026 17:37:27 +0100 Subject: [PATCH 22/29] test: run tab title tests on the main actor --- oraTests/TabInitialTitleTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/oraTests/TabInitialTitleTests.swift b/oraTests/TabInitialTitleTests.swift index e4f5a834..2d4a64be 100644 --- a/oraTests/TabInitialTitleTests.swift +++ b/oraTests/TabInitialTitleTests.swift @@ -2,6 +2,7 @@ import Foundation @testable import Ora import Testing +@MainActor struct TabInitialTitleTests { @Test func webPagesUseTheHostWithoutWww() throws { let url = try #require(URL(string: "https://www.example.com/some/page")) From 54ae5b70d58600cf561c045e90f802c05fbc9bd4 Mon Sep 17 00:00:00 2001 From: "L. Ramos" <l.ramos@sct-telecom.fr> Date: Sun, 25 Oct 2026 17:50:34 +0100 Subject: [PATCH 23/29] fix(media): don't show the mini player for silent or muted media Muted autoplay videos (page backgrounds, previews, ads) were marked as played and brought up the mini player, and one code path computed its visibility from all sessions instead of played ones. Only audible media count as played, including a muted video the user unmutes. Ported from the upstream branch tembo/fix-mini-player-trigger-no-media, which no longer applies after upstream's file reorganisation. Fixes the-ora/browser#187. --- .../Scripts/OraBrowserScripts.swift | 25 +++++++++++++++---- .../Player/State/MediaController.swift | 2 +- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/ora/Core/BrowserEngine/Scripts/OraBrowserScripts.swift b/ora/Core/BrowserEngine/Scripts/OraBrowserScripts.swift index 72e1a8e7..5ca292c1 100644 --- a/ora/Core/BrowserEngine/Scripts/OraBrowserScripts.swift +++ b/ora/Core/BrowserEngine/Scripts/OraBrowserScripts.swift @@ -191,21 +191,36 @@ enum OraBrowserScripts { observer.observe(document.body, { childList: true, subtree: true }); } + // Only audible media count as "played": muted autoplay videos (page + // backgrounds, previews, ads) must not bring up the mini player. + function isAudible(element) { + return !element.muted && element.volume > 0; + } + function attach(element) { if (!element || element.__oraAttached) return; element.__oraAttached = true; const update = () => post(stateFrom(element)); element.addEventListener('play', () => { + if (isAudible(element)) { + element.__oraWasPlayed = true; + } update(); - element.__oraWasPlayed = true; }); element.addEventListener('pause', update); element.addEventListener('ended', () => post({ type: 'ended' })); - element.addEventListener('volumechange', () => - post({ type: 'volume', volume: element.muted ? 0 : element.volume }) - ); + element.addEventListener('volumechange', () => { + // A muted video the user unmutes while it plays now counts. + if (!element.paused && isAudible(element) && !element.__oraWasPlayed) { + element.__oraWasPlayed = true; + update(); + } + post({ type: 'volume', volume: element.muted ? 0 : element.volume }); + }); if (!element.paused) { - element.__oraWasPlayed = true; + if (isAudible(element)) { + element.__oraWasPlayed = true; + } update(); } watchRemoval(element, () => post({ type: 'removed' })); diff --git a/ora/Features/Player/State/MediaController.swift b/ora/Features/Player/State/MediaController.swift index 4917956d..b9fc595c 100644 --- a/ora/Features/Player/State/MediaController.swift +++ b/ora/Features/Player/State/MediaController.swift @@ -122,7 +122,7 @@ final class MediaController: ObservableObject { break } - isVisible = !sessions.isEmpty + isVisible = !visibleSessions.isEmpty } // MARK: - Controls (per session, default to primary) From 64c040fa125021e927ced01a12aa0e4ad4b628e8 Mon Sep 17 00:00:00 2001 From: "L. Ramos" <l.ramos@sct-telecom.fr> Date: Sun, 25 Oct 2026 18:59:20 +0100 Subject: [PATCH 24/29] fix(media): make Picture in Picture reliable and stop flooding the app with media events - Coalesce DOM scans (at most one every 250 ms) and only report next/previous button changes: busy pages like YouTube sent hundreds of events per minute, each re-rendering the sidebar. - Don't republish unchanged media state. - Automatic Picture in Picture uses the video playing with sound, not the first video of the page, and leaving a tab whose video is already in Picture in Picture keeps it there. - 'Back to tab' from the Picture in Picture window now works: the page of a tab in Picture in Picture stays attached to the window, hidden, so WebKit can bring the video back, and Ora then shows that tab like Safari. --- .../Scripts/OraBrowserScripts.swift | 47 ++++++++++--- .../Browser/Views/BrowserWebContentView.swift | 11 +++ .../Player/State/MediaController.swift | 68 +++++++++++++++++-- .../Player/Views/ParkedBrowserPageView.swift | 35 ++++++++++ ora/Features/Tabs/State/TabManager.swift | 1 + 5 files changed, 148 insertions(+), 14 deletions(-) create mode 100644 ora/Features/Player/Views/ParkedBrowserPageView.swift diff --git a/ora/Core/BrowserEngine/Scripts/OraBrowserScripts.swift b/ora/Core/BrowserEngine/Scripts/OraBrowserScripts.swift index 5ca292c1..551189c9 100644 --- a/ora/Core/BrowserEngine/Scripts/OraBrowserScripts.swift +++ b/ora/Core/BrowserEngine/Scripts/OraBrowserScripts.swift @@ -159,12 +159,14 @@ enum OraBrowserScripts { return null; } + // Only tell the app when the next/previous buttons appear or disappear. + let lastCaps = null; function caps() { - post({ - type: 'caps', - hasNext: !!findNextButton(), - hasPrevious: !!findPrevButton() - }); + const hasNext = !!findNextButton(); + const hasPrevious = !!findPrevButton(); + if (lastCaps && lastCaps.hasNext === hasNext && lastCaps.hasPrevious === hasPrevious) return; + lastCaps = { hasNext, hasPrevious }; + post({ type: 'caps', hasNext, hasPrevious }); } const stateFrom = (element) => ({ @@ -209,6 +211,16 @@ enum OraBrowserScripts { }); element.addEventListener('pause', update); element.addEventListener('ended', () => post({ type: 'ended' })); + // Leaving Picture in Picture with the video still playing means the + // user chose "back to tab" (closing the window pauses it): ask the + // app to show this tab, as Safari does. + element.addEventListener('enterpictureinpicture', () => post({ type: 'pip', state: 'entered' })); + element.addEventListener('leavepictureinpicture', () => { + post({ type: 'pip', state: 'left' }); + setTimeout(() => { + if (!element.paused) post({ type: 'pipReturn' }); + }, 0); + }); element.addEventListener('volumechange', () => { // A muted video the user unmutes while it plays now counts. if (!element.paused && isAudible(element) && !element.__oraWasPlayed) { @@ -231,7 +243,19 @@ enum OraBrowserScripts { caps(); } - const observer = new MutationObserver(scan); + // Busy pages (YouTube...) mutate their DOM constantly: coalesce mutations + // into at most one scan every 250 ms instead of one per mutation, to + // keep CPU and energy use low. Timers of hidden pages are throttled + // further by WebKit, as they should be. + let scanScheduled = false; + const observer = new MutationObserver(() => { + if (scanScheduled) return; + scanScheduled = true; + setTimeout(() => { + scanScheduled = false; + scan(); + }, 250); + }); observer.observe(document.documentElement, { childList: true, subtree: true }); scan(); @@ -295,8 +319,6 @@ enum OraBrowserScripts { }; window.__oraTriggerPiP = function(isActive = false) { - const video = document.querySelector('video'); - function hasAudio(target) { if (!target) return false; if (target.audioTracks && target.audioTracks.length > 0) return true; @@ -304,6 +326,11 @@ enum OraBrowserScripts { return false; } + // Pages can hold several videos (hover previews, ads...): use the + // one actually playing with sound, not merely the first one. + const video = Array.from(document.querySelectorAll('video')) + .find((candidate) => !candidate.paused && hasAudio(candidate)) || null; + if ( video && video.tagName === 'VIDEO' && @@ -313,7 +340,9 @@ enum OraBrowserScripts { hasAudio(video) ) { video.requestPictureInPicture().catch(() => {}); - } else if (document.pictureInPictureElement) { + } else if (isActive && document.pictureInPictureElement) { + // Only bring the video back when its tab is shown again; leaving + // a tab whose video is already in Picture in Picture keeps it. document.exitPictureInPicture().catch(() => {}); } }; diff --git a/ora/Features/Browser/Views/BrowserWebContentView.swift b/ora/Features/Browser/Views/BrowserWebContentView.swift index 932a296b..55db451a 100644 --- a/ora/Features/Browser/Views/BrowserWebContentView.swift +++ b/ora/Features/Browser/Views/BrowserWebContentView.swift @@ -5,6 +5,7 @@ struct BrowserWebContentView: View { @EnvironmentObject var tabManager: TabManager @EnvironmentObject private var appState: AppState @EnvironmentObject private var toolbarManager: ToolbarManager + @EnvironmentObject private var mediaController: MediaController let tab: Tab var body: some View { @@ -27,6 +28,16 @@ struct BrowserWebContentView: View { } ZStack { + // Pages of tabs whose video is in Picture in Picture stay in the + // window, hidden behind the current page, so "back to tab" works. + ForEach(mediaController.pictureInPictureTabs(excluding: tab.id), id: \.id) { parkedTab in + if let page = parkedTab.browserPage { + ParkedBrowserPageView(page: page) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + webContent .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/ora/Features/Player/State/MediaController.swift b/ora/Features/Player/State/MediaController.swift index b9fc595c..e099b075 100644 --- a/ora/Features/Player/State/MediaController.swift +++ b/ora/Features/Player/State/MediaController.swift @@ -32,7 +32,15 @@ final class MediaController: ObservableObject { } private var tabRefs: [UUID: WeakTab] = [:] + + /// Tabs whose video is in Picture in Picture. Their page must stay attached + /// to the window (see `ParkedBrowserPageView`), otherwise WebKit cannot + /// bring the video back when the user clicks "back to tab". + @Published private(set) var pictureInPictureTabIDs: Set<UUID> = [] + private var pictureInPictureTabRefs: [UUID: WeakTab] = [:] private var titleSyncTimer: Timer? + /// The tab manager of the window this controller belongs to (set by it). + weak var tabManager: TabManager? init() { startPeriodicTitleSync() @@ -92,6 +100,18 @@ final class MediaController: ObservableObject { // case "ready": // Session is already ensured in other cases + case "pip": + if event.state == "entered" { + pictureInPictureTabRefs[id] = WeakTab(tab) + pictureInPictureTabIDs.insert(id) + } else { + pictureInPictureTabRefs[id] = nil + pictureInPictureTabIDs.remove(id) + } + + case "pipReturn": + returnToTab(tab) + case "volume": if let idx = sessions.firstIndex(where: { $0.tabID == id }), let vol = event.volume { sessions[idx].volume = clamp(vol) @@ -99,8 +119,14 @@ final class MediaController: ObservableObject { case "caps": if let idx = sessions.firstIndex(where: { $0.tabID == id }) { - sessions[idx].canGoNext = event.hasNext ?? sessions[idx].canGoNext - sessions[idx].canGoPrevious = event.hasPrevious ?? sessions[idx].canGoPrevious + let canGoNext = event.hasNext ?? sessions[idx].canGoNext + let canGoPrevious = event.hasPrevious ?? sessions[idx].canGoPrevious + // Don't republish identical values: every write to `sessions` + // re-renders the sidebar. + if sessions[idx].canGoNext != canGoNext || sessions[idx].canGoPrevious != canGoPrevious { + sessions[idx].canGoNext = canGoNext + sessions[idx].canGoPrevious = canGoPrevious + } } case "ended": @@ -122,7 +148,7 @@ final class MediaController: ObservableObject { break } - isVisible = !visibleSessions.isEmpty + updateVisibility() } // MARK: - Controls (per session, default to primary) @@ -171,17 +197,19 @@ final class MediaController: ObservableObject { // Update tab's isPlayingMedia property tabRefs[id]?.value?.isPlayingMedia = false tabRefs[id] = nil - isVisible = !visibleSessions.isEmpty + updateVisibility() } func removeSession(for tabID: UUID) { if let idx = sessions.firstIndex(where: { $0.tabID == tabID }) { sessions.remove(at: idx) } + pictureInPictureTabRefs[tabID] = nil + pictureInPictureTabIDs.remove(tabID) // Update tab's isPlayingMedia property tabRefs[tabID]?.value?.isPlayingMedia = false tabRefs[tabID] = nil - isVisible = !visibleSessions.isEmpty + updateVisibility() } /// Helpers @@ -209,6 +237,36 @@ final class MediaController: ObservableObject { tabRefs[tabID]?.value?.evaluateJavaScript(javaScript) } + /// Tabs in Picture in Picture whose page is loaded, except `excludedTabID` + /// (the tab currently displayed, whose page is already in the window). + func pictureInPictureTabs(excluding excludedTabID: UUID?) -> [Tab] { + pictureInPictureTabIDs + .filter { $0 != excludedTabID } + .compactMap { pictureInPictureTabRefs[$0]?.value } + .filter { $0.browserPage != nil } + } + + /// "Back to tab" in the Picture in Picture window: like Safari, show the + /// tab the video belongs to. Its page is not displayed otherwise, so the + /// video would come back into a hidden page. + private func returnToTab(_ tab: Tab) { + // Same path as the mini player's "go to tab" button: it also switches + // to the tab's space. + guard let tabManager else { return } + tabManager.activateTab(id: tab.id) + NSApp.activate() + DispatchQueue.main.async { + tab.pageWindow?.makeKeyAndOrderFront(nil) + } + } + + private func updateVisibility() { + let visible = !visibleSessions.isEmpty + if isVisible != visible { + isVisible = visible + } + } + private func clamp(_ value: Double) -> Double { max(0, min(1, value)) } diff --git a/ora/Features/Player/Views/ParkedBrowserPageView.swift b/ora/Features/Player/Views/ParkedBrowserPageView.swift new file mode 100644 index 00000000..1e0056d4 --- /dev/null +++ b/ora/Features/Player/Views/ParkedBrowserPageView.swift @@ -0,0 +1,35 @@ +import AppKit +import SwiftUI + +/// Keeps a background tab's page attached to the window, hidden behind the +/// displayed page, while its video is in Picture in Picture. +/// +/// Pages of background tabs are normally detached from the window to save +/// resources, but WebKit can only bring a video back from Picture in Picture +/// ("back to tab") into a page that is in a window. Only tabs in Picture in +/// Picture are parked, and only for that duration. +struct ParkedBrowserPageView: NSViewRepresentable { + let page: BrowserPage + + func makeNSView(context: Context) -> BrowserPageHostView { + let hostView = BrowserPageHostView(frame: .zero) + park(page, in: hostView) + return hostView + } + + func updateNSView(_ hostView: BrowserPageHostView, context: Context) { + park(page, in: hostView) + } + + /// Hosting is deferred to the next run loop pass so the visible host has + /// released the page first when the user switches away from its tab; a + /// page still shown elsewhere is never taken. + private func park(_ page: BrowserPage, in hostView: BrowserPageHostView) { + DispatchQueue.main.async { [weak hostView, weak page] in + guard let hostView, let page else { return } + let contentView = page.contentView + guard hostView.hostedContentView !== contentView, contentView.superview == nil else { return } + hostView.host(contentView: contentView) + } + } +} diff --git a/ora/Features/Tabs/State/TabManager.swift b/ora/Features/Tabs/State/TabManager.swift index 235b0699..85b74527 100644 --- a/ora/Features/Tabs/State/TabManager.swift +++ b/ora/Features/Tabs/State/TabManager.swift @@ -71,6 +71,7 @@ class TabManager: ObservableObject { self.modelContext = modelContext self.mediaController = mediaController self.tabSearchingService = tabSearchingService + mediaController.tabManager = self self.modelContext.undoManager = UndoManager() initializeActiveContainerAndTab() From 6b9e0a44bfc312e6a8b31008faa4453d70c7453b Mon Sep 17 00:00:00 2001 From: "L. Ramos" <l.ramos@sct-telecom.fr> Date: Sun, 25 Oct 2026 19:22:03 +0100 Subject: [PATCH 25/29] fix(privacy): stop overriding navigator and screen properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebKit already reports them like Safari (core count capped at 8, no deviceMemory, real language and screen). Overriding them made Ora stand out from Safari users and look inconsistent — language differing from the Accept-Language header, a screen smaller than the window — which YouTube's bot detection flagged, stopping playback after a few minutes. Keep canvas, WebGL, audio and media device protections; tests guard against reintroducing the overrides. --- .../Services/BrowserPrivacyService.swift | 74 ++----------------- oraTests/oraTests.swift | 21 ++++-- 2 files changed, 21 insertions(+), 74 deletions(-) diff --git a/ora/Features/Privacy/Services/BrowserPrivacyService.swift b/ora/Features/Privacy/Services/BrowserPrivacyService.swift index ddaf67cd..2cde1a50 100644 --- a/ora/Features/Privacy/Services/BrowserPrivacyService.swift +++ b/ora/Features/Privacy/Services/BrowserPrivacyService.swift @@ -2,22 +2,15 @@ import Foundation @preconcurrency import WebKit // swiftlint:disable type_body_length function_body_length +/// What the fingerprinting protection randomizes or normalizes. +/// +/// Navigator and screen properties are deliberately left alone: WebKit already +/// reports them like Safari does (capped core count, no `deviceMemory`, real +/// language and screen). Overriding them only made Ora stand out from Safari +/// users and look inconsistent (language differing from the Accept-Language +/// header, a screen smaller than the window), which bot detection flags; it +/// broke YouTube playback after a few minutes. struct FingerprintingProtectionProfile: Equatable { - let hardwareConcurrency: Int - let deviceMemory: Int - let maxTouchPoints: Int - let webdriver: Bool - let platform: String - let vendor: String - let language: String - let languages: [String] - let screenWidth: Int - let screenHeight: Int - let availWidth: Int - let availHeight: Int - let colorDepth: Int - let pixelDepth: Int - let devicePixelRatio: Double let webGLVendor: String let webGLRenderer: String let webGLRendererName: String @@ -29,21 +22,6 @@ struct FingerprintingProtectionProfile: Equatable { let mediaDeviceKinds: [String] static let balanced = FingerprintingProtectionProfile( - hardwareConcurrency: 8, - deviceMemory: 8, - maxTouchPoints: 0, - webdriver: false, - platform: "MacIntel", - vendor: "Apple Computer, Inc.", - language: "en-US", - languages: ["en-US", "en"], - screenWidth: 1440, - screenHeight: 900, - availWidth: 1440, - availHeight: 877, - colorDepth: 24, - pixelDepth: 24, - devicePixelRatio: 2, webGLVendor: "Apple Inc.", webGLRenderer: "Apple GPU", webGLRendererName: "WebKit WebGL", @@ -59,23 +37,8 @@ struct FingerprintingProtectionProfile: Equatable { let profileObject: [String: Any] = [ "audioBucketSize": audioBucketSize, "audioNoise": audioNoise, - "availHeight": availHeight, - "availWidth": availWidth, "canvasShift": canvasShift, - "colorDepth": colorDepth, - "deviceMemory": deviceMemory, - "devicePixelRatio": devicePixelRatio, - "hardwareConcurrency": hardwareConcurrency, - "language": language, - "languages": languages, - "maxTouchPoints": maxTouchPoints, "mediaDeviceKinds": mediaDeviceKinds, - "pixelDepth": pixelDepth, - "platform": platform, - "screenHeight": screenHeight, - "screenWidth": screenWidth, - "vendor": vendor, - "webdriver": webdriver, "webGLRenderer": webGLRenderer, "webGLRendererName": webGLRendererName, "webGLShadingLanguageVersion": webGLShadingLanguageVersion, @@ -125,10 +88,6 @@ struct FingerprintingProtectionProfile: Equatable { } catch (error) {} } - function cloneLanguages() { - return profile.languages.slice(); - } - function makeDevice(kind, index) { const suffix = String(index + 1); const device = { @@ -241,23 +200,6 @@ struct FingerprintingProtectionProfile: Equatable { return values; } - defineValue(Navigator.prototype, 'hardwareConcurrency', profile.hardwareConcurrency); - defineValue(Navigator.prototype, 'deviceMemory', profile.deviceMemory); - defineValue(Navigator.prototype, 'maxTouchPoints', profile.maxTouchPoints); - defineValue(Navigator.prototype, 'webdriver', profile.webdriver); - defineValue(Navigator.prototype, 'platform', profile.platform); - defineValue(Navigator.prototype, 'vendor', profile.vendor); - defineValue(Navigator.prototype, 'language', profile.language); - defineGetter(Navigator.prototype, 'languages', cloneLanguages); - - defineValue(Screen.prototype, 'width', profile.screenWidth); - defineValue(Screen.prototype, 'height', profile.screenHeight); - defineValue(Screen.prototype, 'availWidth', profile.availWidth); - defineValue(Screen.prototype, 'availHeight', profile.availHeight); - defineValue(Screen.prototype, 'colorDepth', profile.colorDepth); - defineValue(Screen.prototype, 'pixelDepth', profile.pixelDepth); - defineValue(window, 'devicePixelRatio', profile.devicePixelRatio); - if (navigator.permissions && typeof navigator.permissions.query === 'function') { const originalQuery = navigator.permissions.query.bind(navigator.permissions); navigator.permissions.query = function (parameters) { diff --git a/oraTests/oraTests.swift b/oraTests/oraTests.swift index 3a522313..aee05c24 100644 --- a/oraTests/oraTests.swift +++ b/oraTests/oraTests.swift @@ -304,26 +304,31 @@ struct OraTests { @Test func balancedFingerprintingProfileIsInternallyCoherent() { let profile = FingerprintingProtectionProfile.balanced - #expect(profile.language == profile.languages.first) - #expect(profile.availWidth <= profile.screenWidth) - #expect(profile.availHeight <= profile.screenHeight) - #expect(profile.devicePixelRatio > 0) - #expect(profile.platform == "MacIntel") - #expect(profile.vendor == "Apple Computer, Inc.") + #expect(profile.webGLVendor == "Apple Inc.") #expect(profile.mediaDeviceKinds == ["audioinput", "audiooutput", "videoinput"]) } @Test func fingerprintingScriptIncludesBalancedSurfaceNormalization() { let script = BrowserPrivacyService.fingerprintingProtectionScriptSource() - #expect(script.contains("hardwareConcurrency")) - #expect(script.contains("devicePixelRatio")) #expect(script.contains("enumerateDevices")) #expect(script.contains("toDataURL")) #expect(script.contains("OfflineAudioContext")) #expect(script.contains("WebGLRenderingContext")) } + /// Overriding navigator or screen properties made Ora inconsistent with + /// Safari (and with its own HTTP headers) and broke YouTube playback. + @Test func fingerprintingScriptLeavesNavigatorAndScreenAlone() { + let script = BrowserPrivacyService.fingerprintingProtectionScriptSource() + + #expect(!script.contains("Navigator.prototype")) + #expect(!script.contains("Screen.prototype")) + #expect(!script.contains("devicePixelRatio")) + #expect(!script.contains("deviceMemory")) + #expect(!script.contains("en-US")) + } + @Test func trackerRegexMatchesRootAndSubdomains() throws { let pattern = BrowserPrivacyService.regexForDomain("hotjar.com") let regex = try NSRegularExpression(pattern: pattern) From bd8836a18e951496059b8ba9192a8545fc7e41e3 Mon Sep 17 00:00:00 2001 From: "L. Ramos" <l.ramos@sct-telecom.fr> Date: Sun, 25 Oct 2026 19:22:03 +0100 Subject: [PATCH 26/29] fix: send Safari's exact user agent instead of a duplicated one applicationNameForUserAgent is appended by WebKit to its own Mozilla/AppleWebKit prefix but was given a full user agent, so the prefix appeared twice, with a hard-coded, outdated Safari version. Pass only Safari's suffix, using the installed Safari's version (validated, with a fallback). --- ora/Core/BrowserEngine/BrowserEngine.swift | 5 ++- ora/Core/BrowserEngine/BrowserPage.swift | 2 +- ora/Core/BrowserEngine/SafariUserAgent.swift | 41 ++++++++++++++++++++ oraTests/SafariUserAgentTests.swift | 27 +++++++++++++ 4 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 ora/Core/BrowserEngine/SafariUserAgent.swift create mode 100644 oraTests/SafariUserAgentTests.swift diff --git a/ora/Core/BrowserEngine/BrowserEngine.swift b/ora/Core/BrowserEngine/BrowserEngine.swift index 37c61ff6..807f9a4f 100644 --- a/ora/Core/BrowserEngine/BrowserEngine.swift +++ b/ora/Core/BrowserEngine/BrowserEngine.swift @@ -1,7 +1,8 @@ import Foundation struct BrowserPageConfiguration { - let userAgent: String? + /// Appended by WebKit to its own user agent prefix: see `SafariUserAgent`. + let applicationNameForUserAgent: String? let allowsPictureInPicture: Bool let allowsJavaScript: Bool let allowsJavaScriptWindowsAutomatically: Bool @@ -18,7 +19,7 @@ struct BrowserPageConfiguration { privacySettings: SpacePrivacySettings ) -> BrowserPageConfiguration { BrowserPageConfiguration( - userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15", + applicationNameForUserAgent: SafariUserAgent.current, allowsPictureInPicture: true, allowsJavaScript: true, allowsJavaScriptWindowsAutomatically: false, diff --git a/ora/Core/BrowserEngine/BrowserPage.swift b/ora/Core/BrowserEngine/BrowserPage.swift index aa12774c..5ff3ed55 100644 --- a/ora/Core/BrowserEngine/BrowserPage.swift +++ b/ora/Core/BrowserEngine/BrowserPage.swift @@ -21,7 +21,7 @@ final class BrowserPage: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptM delegate: BrowserPageDelegate? ) { let webConfiguration = WKWebViewConfiguration() - webConfiguration.applicationNameForUserAgent = configuration.userAgent + webConfiguration.applicationNameForUserAgent = configuration.applicationNameForUserAgent webConfiguration.websiteDataStore = profile.dataStore webConfiguration.allowsAirPlayForMediaPlayback = configuration.allowsAirPlayForMediaPlayback webConfiguration.preferences.setValue( diff --git a/ora/Core/BrowserEngine/SafariUserAgent.swift b/ora/Core/BrowserEngine/SafariUserAgent.swift new file mode 100644 index 00000000..6dac06d6 --- /dev/null +++ b/ora/Core/BrowserEngine/SafariUserAgent.swift @@ -0,0 +1,41 @@ +import AppKit +import Foundation + +/// The part of the user agent Ora hands to WebKit. +/// +/// WebKit appends `WKWebViewConfiguration.applicationNameForUserAgent` to its +/// own "Mozilla/5.0 (Macintosh; …) AppleWebKit/605.1.15 (KHTML, like Gecko)" +/// prefix, so it must only hold Safari's suffix; a full user agent there ends +/// up duplicated. Using the installed Safari's version keeps Ora's user agent +/// identical to Safari's — the crowd it should blend into — across updates. +enum SafariUserAgent { + static let safariToken = "Safari/605.1.15" + + /// Used when the installed Safari's version cannot be read. + static let fallbackVersion = "26.0" + + /// Computed once: Safari's version only changes with a macOS update. + static let current = applicationName(safariVersion: installedSafariVersion) + + static func applicationName(safariVersion: String?) -> String { + let version = safariVersion.flatMap(sanitizedVersion) ?? fallbackVersion + return "Version/\(version) \(safariToken)" + } + + static var installedSafariVersion: String? { + guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.Safari") else { + return nil + } + return Bundle(url: url)?.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + } + + /// Accepts plain dotted versions only ("26.6.2"), so nothing unexpected + /// can ever end up in the user agent. + static func sanitizedVersion(_ version: String) -> String? { + let parts = version.split(separator: ".", omittingEmptySubsequences: false) + guard (1 ... 4).contains(parts.count), + parts.allSatisfy({ part in !part.isEmpty && part.allSatisfy { $0.isASCII && $0.isNumber } }) + else { return nil } + return version + } +} diff --git a/oraTests/SafariUserAgentTests.swift b/oraTests/SafariUserAgentTests.swift new file mode 100644 index 00000000..cc8f3a85 --- /dev/null +++ b/oraTests/SafariUserAgentTests.swift @@ -0,0 +1,27 @@ +@testable import Ora +import Testing + +struct SafariUserAgentTests { + @Test func applicationNameIsOnlySafarisSuffix() { + let name = SafariUserAgent.applicationName(safariVersion: "26.6.2") + + #expect(name == "Version/26.6.2 Safari/605.1.15") + // WebKit adds the "Mozilla/5.0 … AppleWebKit …" prefix itself. + #expect(!name.contains("Mozilla")) + #expect(!name.contains("AppleWebKit")) + } + + @Test func unreadableOrUnexpectedVersionsFallBack() { + let fallback = "Version/\(SafariUserAgent.fallbackVersion) Safari/605.1.15" + + #expect(SafariUserAgent.applicationName(safariVersion: nil) == fallback) + #expect(SafariUserAgent.applicationName(safariVersion: "") == fallback) + #expect(SafariUserAgent.applicationName(safariVersion: "26.6 beta") == fallback) + #expect(SafariUserAgent.applicationName(safariVersion: "26..1") == fallback) + } + + @Test func currentUserAgentIsWellFormed() { + #expect(SafariUserAgent.current.hasPrefix("Version/")) + #expect(SafariUserAgent.current.hasSuffix(" Safari/605.1.15")) + } +} From 5faa6c6a886534ad1c72a944b3289f43104b186a Mon Sep 17 00:00:00 2001 From: "L. Ramos" <l.ramos@sct-telecom.fr> Date: Sun, 25 Oct 2026 19:29:47 +0100 Subject: [PATCH 27/29] fix(privacy): vary fingerprinting noise per site and per session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canvas and audio noise was a fixed offset on fixed samples, so Ora produced the same unique signature on every site and every session — an identifier in itself. Derive the noise from a secret drawn at each launch, mixed with the space, the browsing mode and, in the page, the top-level site and frame origin. A site reads stable values within a session; sites and sessions cannot be linked. --- .../Services/BrowserPrivacyService.swift | 103 ++++++++++++++++-- ora/Features/Tabs/Models/Tab.swift | 6 +- oraTests/FingerprintingNoiseSeedTests.swift | 44 ++++++++ 3 files changed, 142 insertions(+), 11 deletions(-) create mode 100644 oraTests/FingerprintingNoiseSeedTests.swift diff --git a/ora/Features/Privacy/Services/BrowserPrivacyService.swift b/ora/Features/Privacy/Services/BrowserPrivacyService.swift index 2cde1a50..cadf67e2 100644 --- a/ora/Features/Privacy/Services/BrowserPrivacyService.swift +++ b/ora/Features/Privacy/Services/BrowserPrivacyService.swift @@ -33,8 +33,11 @@ struct FingerprintingProtectionProfile: Equatable { mediaDeviceKinds: ["audioinput", "audiooutput", "videoinput"] ) - func scriptSource() -> String { + /// - Parameter seed: secret mixed with each site's origin in the page to + /// derive its noise (see `FingerprintingNoiseSeed`). + func scriptSource(seed: UInt32) -> String { let profileObject: [String: Any] = [ + "seed": seed, "audioBucketSize": audioBucketSize, "audioNoise": audioNoise, "canvasShift": canvasShift, @@ -62,6 +65,47 @@ struct FingerprintingProtectionProfile: Equatable { const profile = \(profileJSON); + // Noise varies per session and per site ("farbling"): a secret + // seed, new at every launch and different per space, is mixed with + // the top-level site and this frame's origin. Within a session a + // site always reads the same values (nothing breaks), but two sites + // — or the same tracker embedded on two sites — never get the same + // noise, so readings cannot be linked across sites or sessions. + function hashString(value, seed) { + let hash = (0x811c9dc5 ^ seed) >>> 0; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash >>> 0; + } + + function mix(seed, value) { + let hash = Math.imul((seed ^ value) >>> 0, 0x9e3779b1) >>> 0; + hash ^= hash >>> 16; + hash = Math.imul(hash, 0x85ebca6b) >>> 0; + hash ^= hash >>> 13; + return hash >>> 0; + } + + const ancestorOrigins = location.ancestorOrigins; + const topOrigin = ancestorOrigins && ancestorOrigins.length + ? ancestorOrigins[ancestorOrigins.length - 1] + : location.origin; + const siteSeed = hashString(topOrigin + '|' + location.origin, profile.seed); + + function noiseSign(index) { + return (mix(siteSeed, index) & 2) ? 1 : -1; + } + + function touches(index) { + return (mix(siteSeed, index) & 1) === 0; + } + + function clampByte(value) { + return Math.max(0, Math.min(255, value)); + } + function defineGetter(target, property, getter) { if (!target) return; try { @@ -133,9 +177,11 @@ struct FingerprintingProtectionProfile: Equatable { if (!data || !data.length) return; const stride = Math.max(8, step * 16); for (let index = 0; index < data.length; index += stride) { - data[index] = (data[index] + profile.canvasShift) & 255; + if (!touches(index)) continue; + const delta = noiseSign(index) * profile.canvasShift; + data[index] = clampByte(data[index] + delta); if (index + 1 < data.length) { - data[index + 1] = (data[index + 1] + profile.canvasShift) & 255; + data[index + 1] = clampByte(data[index + 1] + delta); } } } @@ -180,7 +226,7 @@ struct FingerprintingProtectionProfile: Equatable { const stride = Math.max(profile.audioBucketSize, 32); for (let index = 0; index < samples.length; index += stride) { const sample = samples[index] || 0; - samples[index] = Math.fround(sample + profile.audioNoise); + samples[index] = Math.fround(sample + noiseSign(channel * 1000003 + index) * profile.audioNoise); } } @@ -192,9 +238,9 @@ struct FingerprintingProtectionProfile: Equatable { const stride = Math.max(profile.audioBucketSize, 16); for (let index = 0; index < values.length; index += stride) { if (mode === 'float') { - values[index] = Math.fround((values[index] || 0) + profile.audioNoise); + values[index] = Math.fround((values[index] || 0) + noiseSign(index) * profile.audioNoise); } else { - values[index] = Math.max(0, Math.min(255, (values[index] || 0) + 1)); + values[index] = clampByte((values[index] || 0) + noiseSign(index)); } } return values; @@ -313,6 +359,35 @@ struct FingerprintingProtectionProfile: Equatable { // swiftlint:enable type_body_length function_body_length +/// Secret behind the fingerprinting noise of a space. +/// +/// A new random secret is drawn at every launch, so noise differs from one +/// session to the next, and it is mixed with the space and browsing mode, so +/// spaces (which have separate website data) and private browsing do not share +/// noise either. The page then mixes it with each site's origin. +enum FingerprintingNoiseSeed { + private static let sessionSecret = UInt64.random(in: .min ... .max) + + static func seed(spaceID: UUID?, isPrivate: Bool) -> UInt32 { + seed(spaceID: spaceID, isPrivate: isPrivate, secret: sessionSecret) + } + + /// Deterministic for a given secret (FNV-1a), so it is testable. + static func seed(spaceID: UUID?, isPrivate: Bool, secret: UInt64) -> UInt32 { + var hash: UInt64 = 0xCBF29CE484222325 + func combine(_ bytes: some Sequence<UInt8>) { + for byte in bytes { + hash ^= UInt64(byte) + hash = hash &* 0x00000100000001B3 + } + } + withUnsafeBytes(of: secret.littleEndian) { combine($0) } + combine((spaceID?.uuidString ?? "global").utf8) + combine([isPrivate ? 1 : 0]) + return UInt32(truncatingIfNeeded: hash ^ (hash >> 32)) + } +} + final class BrowserPrivacyService { private enum StaticRuleListIdentifier: String { case trackers = "com.orabrowser.privacy.trackers.v1" @@ -369,21 +444,29 @@ final class BrowserPrivacyService { group.notify(queue: .main, execute: completion) } - static func privacyScripts(for privacySettings: SpacePrivacySettings) -> [BrowserUserScript] { + static func privacyScripts( + for privacySettings: SpacePrivacySettings, + spaceID: UUID? = nil, + isPrivate: Bool = false + ) -> [BrowserUserScript] { guard privacySettings.blockFingerprinting else { return [] } return [ BrowserUserScript( name: "ora-fingerprinting-protection", - source: fingerprintingProtectionScriptSource(), + source: fingerprintingProtectionScriptSource( + seed: FingerprintingNoiseSeed.seed(spaceID: spaceID, isPrivate: isPrivate) + ), injectionTime: .atDocumentStart, forMainFrameOnly: false ) ] } - static func fingerprintingProtectionScriptSource() -> String { - FingerprintingProtectionProfile.balanced.scriptSource() + static func fingerprintingProtectionScriptSource( + seed: UInt32 = FingerprintingNoiseSeed.seed(spaceID: nil, isPrivate: false) + ) -> String { + FingerprintingProtectionProfile.balanced.scriptSource(seed: seed) } private func enabledRuleLists(for spaceID: UUID, privacySettings: SpacePrivacySettings) -> [String] { diff --git a/ora/Features/Tabs/Models/Tab.swift b/ora/Features/Tabs/Models/Tab.swift index 42cb4202..2e2f3f33 100644 --- a/ora/Features/Tabs/Models/Tab.swift +++ b/ora/Features/Tabs/Models/Tab.swift @@ -215,7 +215,11 @@ class Tab: ObservableObject, Identifiable { let engine = BrowserEngine.shared let profile = engine.makeProfile(identifier: container.id, isPrivate: isPrivate) let privacySettings = SettingsStore.shared.privacySettings(for: container.id) - let userScripts = OraBrowserScripts.userScripts() + BrowserPrivacyService.privacyScripts(for: privacySettings) + let userScripts = OraBrowserScripts.userScripts() + BrowserPrivacyService.privacyScripts( + for: privacySettings, + spaceID: container.id, + isPrivate: isPrivate + ) let page = engine.makePage( profile: profile, configuration: BrowserPageConfiguration.oraDefault( diff --git a/oraTests/FingerprintingNoiseSeedTests.swift b/oraTests/FingerprintingNoiseSeedTests.swift new file mode 100644 index 00000000..ed0eb34b --- /dev/null +++ b/oraTests/FingerprintingNoiseSeedTests.swift @@ -0,0 +1,44 @@ +import Foundation +@testable import Ora +import Testing + +struct FingerprintingNoiseSeedTests { + private let space = UUID() + private let otherSpace = UUID() + + @Test func seedIsStableForTheSameSpaceAndSession() { + let first = FingerprintingNoiseSeed.seed(spaceID: space, isPrivate: false, secret: 42) + let second = FingerprintingNoiseSeed.seed(spaceID: space, isPrivate: false, secret: 42) + + #expect(first == second) + } + + @Test func spacesDoNotShareNoise() { + let first = FingerprintingNoiseSeed.seed(spaceID: space, isPrivate: false, secret: 42) + let second = FingerprintingNoiseSeed.seed(spaceID: otherSpace, isPrivate: false, secret: 42) + + #expect(first != second) + } + + @Test func privateBrowsingDoesNotShareNoiseWithItsSpace() { + let regular = FingerprintingNoiseSeed.seed(spaceID: space, isPrivate: false, secret: 42) + let privateBrowsing = FingerprintingNoiseSeed.seed(spaceID: space, isPrivate: true, secret: 42) + + #expect(regular != privateBrowsing) + } + + @Test func noiseChangesFromOneSessionToTheNext() { + let session1 = FingerprintingNoiseSeed.seed(spaceID: space, isPrivate: false, secret: 1) + let session2 = FingerprintingNoiseSeed.seed(spaceID: space, isPrivate: false, secret: 2) + + #expect(session1 != session2) + } + + @Test func scriptMixesTheSeedWithTheSite() { + let script = BrowserPrivacyService.fingerprintingProtectionScriptSource(seed: 123_456_789) + + #expect(script.contains("123456789")) + #expect(script.contains("ancestorOrigins")) + #expect(script.contains("siteSeed")) + } +} From a8c106faa2326cb36b79d57acb13433e1b3ce33d Mon Sep 17 00:00:00 2001 From: "L. Ramos" <l.ramos@sct-telecom.fr> Date: Sun, 25 Oct 2026 21:12:29 +0100 Subject: [PATCH 28/29] feat(tabs): recover from web content process crashes like Safari WebKit reports the end of a page's process (crash or memory reclaim), but Ora ignored it and left the tab blank and unresponsive. The visible page now reloads once with a notice, a background page reloads only when its tab is shown, and a page that ends again within 30 seconds of a reload shows a crash page instead of looping. Media sessions, Picture in Picture, permission prompts and autofill of the dead page are cleared. --- ora/Core/BrowserEngine/BrowserPage.swift | 17 ++++ .../BrowserEngine/BrowserPageDelegate.swift | 5 ++ .../Browser/Views/BrowserWebContentView.swift | 12 +++ .../Browser/Views/PageCrashNoticeView.swift | 45 +++++++++++ .../Browser/Views/StatusPageView.swift | 11 +++ .../Tabs/Browser/TabBrowserPageDelegate.swift | 13 +++ .../Tabs/Models/PageCrashRecovery.swift | 63 +++++++++++++++ .../Tabs/Models/Tab+CrashRecovery.swift | 56 +++++++++++++ ora/Features/Tabs/Models/Tab.swift | 14 ++++ oraTests/PageCrashRecoveryPolicyTests.swift | 79 +++++++++++++++++++ 10 files changed, 315 insertions(+) create mode 100644 ora/Features/Browser/Views/PageCrashNoticeView.swift create mode 100644 ora/Features/Tabs/Models/PageCrashRecovery.swift create mode 100644 ora/Features/Tabs/Models/Tab+CrashRecovery.swift create mode 100644 oraTests/PageCrashRecoveryPolicyTests.swift diff --git a/ora/Core/BrowserEngine/BrowserPage.swift b/ora/Core/BrowserEngine/BrowserPage.swift index 5ff3ed55..e1dd207d 100644 --- a/ora/Core/BrowserEngine/BrowserPage.swift +++ b/ora/Core/BrowserEngine/BrowserPage.swift @@ -235,6 +235,14 @@ final class BrowserPage: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptM return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled } + /// Navigation failure caused by the end of the web content process, which is + /// reported separately by `webViewWebContentProcessDidTerminate(_:)`. + private func isWebContentProcessTerminationError(_ error: Error) -> Bool { + let nsError = error as NSError + return nsError.domain == WKError.errorDomain + && nsError.code == WKError.Code.webContentProcessTerminated.rawValue + } + private func mapInjectionTime(_ injectionTime: BrowserUserScriptInjectionTime) -> WKUserScriptInjectionTime { switch injectionTime { case .atDocumentStart: @@ -314,11 +322,18 @@ final class BrowserPage: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptM } } + func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + originalURL = nil + delegate?.browserPageWebContentProcessDidTerminate(self) + } + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { guard !isDownloadNavigation else { originalURL = nil return } + // Handled as a whole by webViewWebContentProcessDidTerminate(_:). + guard !isWebContentProcessTerminationError(error) else { return } emitNavigationEvent( phase: .finished, @@ -339,6 +354,8 @@ final class BrowserPage: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptM originalURL = nil return } + // Handled as a whole by webViewWebContentProcessDidTerminate(_:). + guard !isWebContentProcessTerminationError(error) else { return } emitNavigationEvent( phase: .finished, diff --git a/ora/Core/BrowserEngine/BrowserPageDelegate.swift b/ora/Core/BrowserEngine/BrowserPageDelegate.swift index ccfe558c..2e39dd3a 100644 --- a/ora/Core/BrowserEngine/BrowserPageDelegate.swift +++ b/ora/Core/BrowserEngine/BrowserPageDelegate.swift @@ -30,6 +30,9 @@ protocol BrowserPageDelegate: AnyObject { completion: @escaping (String?) -> Void ) func browserPage(_ page: BrowserPage, didStartDownload download: BrowserDownloadTask) + /// The process rendering the page ended (crash, or macOS reclaiming memory). + /// The page is blank until it is reloaded. + func browserPageWebContentProcessDidTerminate(_ page: BrowserPage) } extension BrowserPageDelegate { @@ -81,4 +84,6 @@ extension BrowserPageDelegate { } func browserPage(_ page: BrowserPage, didStartDownload download: BrowserDownloadTask) {} + + func browserPageWebContentProcessDidTerminate(_ page: BrowserPage) {} } diff --git a/ora/Features/Browser/Views/BrowserWebContentView.swift b/ora/Features/Browser/Views/BrowserWebContentView.swift index 55db451a..b3e82079 100644 --- a/ora/Features/Browser/Views/BrowserWebContentView.swift +++ b/ora/Features/Browser/Views/BrowserWebContentView.swift @@ -53,6 +53,10 @@ struct BrowserWebContentView: View { } } .animation(.easeOut(duration: 0.2), value: appState.isURLBarEditing) + // A page left unloaded by a crash while in the background reloads now. + .onChange(of: tab.id, initial: true) { + tab.recoverFromCrashIfNeeded() + } } @ViewBuilder @@ -106,8 +110,16 @@ struct BrowserWebContentView: View { .transition(.move(edge: .top).combined(with: .opacity)) } } + .overlay(alignment: .top) { + if tab.showsCrashRecoveryNotice { + PageCrashNoticeView { tab.dismissCrashRecoveryNotice() } + .padding(.top, 12) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } .animation(.easeOut(duration: 0.12), value: tab.hoveredLinkURL) .animation(.easeOut(duration: 0.2), value: tab.permissionRequest) + .animation(.easeOut(duration: 0.2), value: tab.showsCrashRecoveryNotice) } else { ZStack { Rectangle().fill(theme.background) diff --git a/ora/Features/Browser/Views/PageCrashNoticeView.swift b/ora/Features/Browser/Views/PageCrashNoticeView.swift new file mode 100644 index 00000000..36606753 --- /dev/null +++ b/ora/Features/Browser/Views/PageCrashNoticeView.swift @@ -0,0 +1,45 @@ +import SwiftUI + +/// Tells the user why the page they were looking at just reloaded by itself. +struct PageCrashNoticeView: View { + let onDismiss: () -> Void + + private let cornerRadius: CGFloat = 14 + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + + Text("This webpage was reloaded because a problem occurred.") + .font(.subheadline) + .foregroundStyle(Color(nsColor: .labelColor)) + .lineLimit(2) + + Button(action: onDismiss) { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .frame(width: 18, height: 18) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Dismiss") + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background { + ZStack { + BlurEffectView(material: .popover, blendingMode: .withinWindow) + Color(nsColor: .windowBackgroundColor).opacity(0.5) + } + } + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(Color(nsColor: .separatorColor).opacity(0.7), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.12), radius: 20, x: 0, y: 8) + } +} diff --git a/ora/Features/Browser/Views/StatusPageView.swift b/ora/Features/Browser/Views/StatusPageView.swift index 5b6c2b09..218d184e 100644 --- a/ora/Features/Browser/Views/StatusPageView.swift +++ b/ora/Features/Browser/Views/StatusPageView.swift @@ -86,6 +86,8 @@ struct StatusPageView: View { return "lock.slash" case .timeout: return "clock.badge.exclamationmark" + case .crashed: + return "exclamationmark.octagon" case .unknown: return "exclamationmark.triangle" } @@ -101,6 +103,8 @@ struct StatusPageView: View { return "Connection Not Secure" case .timeout: return "Request Timed Out" + case .crashed: + return "A Problem Repeatedly Occurred" case .unknown: return "Something Went Wrong" } @@ -116,12 +120,18 @@ struct StatusPageView: View { return "The connection to this site is not secure." case .timeout: return "The page took too long to load. Try again or check your connection." + case .crashed: + return "This webpage stopped working several times in a row, so Ora didn't reload it again." case .unknown: return "An unexpected error occurred while loading this page." } } private var errorType: ErrorType { + if error is PageCrashError { + return .crashed + } + let nsError = error as NSError // Network-related errors @@ -158,6 +168,7 @@ struct StatusPageView: View { case notFound case security case timeout + case crashed case unknown } } diff --git a/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift b/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift index 51ff4797..ae06b2df 100644 --- a/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift +++ b/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift @@ -60,6 +60,7 @@ final class TabBrowserPageDelegate: BrowserPageDelegate { // A prompt belongs to the page that asked; leaving it denies. tab.cancelPermissionRequest() tab.clearNavigationError() + tab.crashRecoveryNavigationDidStart() tab.maintainSnapShots() passwordCoordinator?.clearAutofillState() tab.isLoading = event.isLoading @@ -79,6 +80,7 @@ final class TabBrowserPageDelegate: BrowserPageDelegate { } case .finished: + tab.crashRecoveryNavigationDidFinish() tab.isLoading = event.isLoading tab.loadingProgress = event.progress if let title = event.title, !title.isEmpty { @@ -104,6 +106,17 @@ final class TabBrowserPageDelegate: BrowserPageDelegate { } } + func browserPageWebContentProcessDidTerminate(_ page: BrowserPage) { + guard let tab else { return } + + progressResetWorkItem?.cancel() + MainActor.assumeIsolated { + // The media (and Picture in Picture) of the dead page is gone. + mediaController?.removeSession(for: tab.id) + tab.handleWebContentProcessTermination() + } + } + func browserPage(_ page: BrowserPage, didFailNavigationWith error: Error, failingURL: URL?) { tab?.setNavigationError(error, for: failingURL) } diff --git a/ora/Features/Tabs/Models/PageCrashRecovery.swift b/ora/Features/Tabs/Models/PageCrashRecovery.swift new file mode 100644 index 00000000..6016f468 --- /dev/null +++ b/ora/Features/Tabs/Models/PageCrashRecovery.swift @@ -0,0 +1,63 @@ +import Foundation + +/// The process rendering a page ended unexpectedly (a crash, or macOS reclaiming +/// memory) and Ora stopped reloading it automatically. +struct PageCrashError: LocalizedError { + var errorDescription: String? { + "The webpage stopped working because a problem occurred." + } +} + +/// Decides how a tab recovers when the process rendering its page ends, the way +/// Safari does: +/// - the page the user is looking at reloads once, right away; +/// - a page in the background stays unloaded until its tab is shown again, so a +/// crash never wakes up pages nobody is looking at; +/// - a page that ends again shortly after being reloaded is not reloaded again, +/// to avoid a crash loop: the user decides. +struct PageCrashRecoveryPolicy { + enum Action: Equatable { + /// Reload the page right away and tell the user why. + case reloadNow + /// Leave the page unloaded until its tab is shown again. + case reloadWhenShown + /// Stop reloading and show the crash page. + case showCrashPage + } + + /// A crash this soon after a reload counts as a repeated crash. + static let repeatedCrashInterval: TimeInterval = 30 + + /// Whether the page is waiting for its tab to be shown to reload. + private(set) var isPendingReload = false + private var lastReloadDate: Date? + + /// Called when the process rendering the page has ended. + mutating func processDidTerminate(isVisible: Bool, now: Date = Date()) -> Action { + if let lastReloadDate, now.timeIntervalSince(lastReloadDate) < Self.repeatedCrashInterval { + isPendingReload = false + return .showCrashPage + } + + guard isVisible else { + isPendingReload = true + return .reloadWhenShown + } + + recordReload(at: now) + return .reloadNow + } + + /// Called when the tab is shown. Returns whether its page must reload now. + mutating func tabDidBecomeVisible(now: Date = Date()) -> Bool { + guard isPendingReload else { return false } + recordReload(at: now) + return true + } + + /// Records a reload of the crashed page, automatic or asked by the user. + mutating func recordReload(at now: Date = Date()) { + isPendingReload = false + lastReloadDate = now + } +} diff --git a/ora/Features/Tabs/Models/Tab+CrashRecovery.swift b/ora/Features/Tabs/Models/Tab+CrashRecovery.swift new file mode 100644 index 00000000..88bea75a --- /dev/null +++ b/ora/Features/Tabs/Models/Tab+CrashRecovery.swift @@ -0,0 +1,56 @@ +import Foundation + +extension Tab { + /// Whether this tab is the one shown in its window. + @MainActor var isShownInWindow: Bool { + tabManager?.activeTab?.id == id + } + + /// The process rendering the page ended: the page is blank until reloaded. + @MainActor func handleWebContentProcessTermination() { + // Whatever the dead page was showing or asking for is gone. + cancelPermissionRequest() + passwordCoordinator?.clearAutofillState() + if hoveredLinkURL != nil { hoveredLinkURL = nil } + isLoading = false + loadingProgress = 0 + isReloadingAfterCrash = false + if showsCrashRecoveryNotice { showsCrashRecoveryNotice = false } + + switch crashRecovery.processDidTerminate(isVisible: isShownInWindow) { + case .reloadNow: + reloadAfterCrash() + case .reloadWhenShown: + break + case .showCrashPage: + setNavigationError(PageCrashError(), for: url) + } + } + + /// Reloads a page left unloaded by a crash, now that its tab is shown. + @MainActor func recoverFromCrashIfNeeded() { + if crashRecovery.tabDidBecomeVisible() { + reloadAfterCrash() + } + } + + func dismissCrashRecoveryNotice() { + if showsCrashRecoveryNotice { showsCrashRecoveryNotice = false } + } + + /// Called when a navigation starts: the notice explains the automatic reload + /// only, so it goes away with the next page the user opens. + func crashRecoveryNavigationDidStart() { + if !isReloadingAfterCrash { dismissCrashRecoveryNotice() } + } + + func crashRecoveryNavigationDidFinish() { + isReloadingAfterCrash = false + } + + private func reloadAfterCrash() { + isReloadingAfterCrash = true + showsCrashRecoveryNotice = true + browserPage?.reload() + } +} diff --git a/ora/Features/Tabs/Models/Tab.swift b/ora/Features/Tabs/Models/Tab.swift index 2e2f3f33..ee7e4d7b 100644 --- a/ora/Features/Tabs/Models/Tab.swift +++ b/ora/Features/Tabs/Models/Tab.swift @@ -55,6 +55,9 @@ class Tab: ObservableObject, Identifiable { @Transient @Published var passwordTriggerOverlayState: PasswordAutofillOverlayState? /// Permission prompt currently shown for this tab, see `Tab+SitePermissions`. @Transient @Published var permissionRequest: SitePermissionRequest? + @Transient var crashRecovery = PageCrashRecoveryPolicy() + @Transient var isReloadingAfterCrash = false + @Transient @Published var showsCrashRecoveryNotice = false @Relationship(inverse: \TabContainer.tabs) var container: TabContainer @@ -309,6 +312,10 @@ class Tab: ObservableObject, Identifiable { func destroyWebView() { cancelPermissionRequest() + // A new page loads from scratch: nothing left to recover. + crashRecovery = PageCrashRecoveryPolicy() + isReloadingAfterCrash = false + showsCrashRecoveryNotice = false browserPage?.teardown() browserPage = nil pageDelegate = nil @@ -332,6 +339,13 @@ class Tab: ObservableObject, Identifiable { } func retryNavigation() { + // A crashed page reloads in place, keeping its back/forward history. + if navigationError is PageCrashError { + crashRecovery.recordReload() + browserPage?.reload() + return + } + // Don't clear error state immediately - let onStart callback handle it // This prevents showing white background before navigation begins if let url = failedURL { diff --git a/oraTests/PageCrashRecoveryPolicyTests.swift b/oraTests/PageCrashRecoveryPolicyTests.swift new file mode 100644 index 00000000..ed65e4bc --- /dev/null +++ b/oraTests/PageCrashRecoveryPolicyTests.swift @@ -0,0 +1,79 @@ +import Foundation +@testable import Ora +import Testing + +struct PageCrashRecoveryPolicyTests { + private let start = Date(timeIntervalSinceReferenceDate: 0) + + @Test func visiblePageReloadsRightAway() { + var policy = PageCrashRecoveryPolicy() + + let action = policy.processDidTerminate(isVisible: true, now: start) + + #expect(action == .reloadNow) + #expect(!policy.isPendingReload) + } + + @Test func backgroundPageReloadsOnlyOnceShown() { + var policy = PageCrashRecoveryPolicy() + + let action = policy.processDidTerminate(isVisible: false, now: start) + #expect(action == .reloadWhenShown) + #expect(policy.isPendingReload) + + let reloadsWhenShown = policy.tabDidBecomeVisible(now: start.addingTimeInterval(600)) + #expect(reloadsWhenShown) + #expect(!policy.isPendingReload) + + let reloadsAgain = policy.tabDidBecomeVisible(now: start.addingTimeInterval(601)) + #expect(!reloadsAgain) + } + + @Test func showingATabThatDidNotCrashDoesNothing() { + var policy = PageCrashRecoveryPolicy() + + let reloads = policy.tabDidBecomeVisible(now: start) + + #expect(!reloads) + } + + @Test func crashSoonAfterAnAutomaticReloadStopsReloading() { + var policy = PageCrashRecoveryPolicy() + _ = policy.processDidTerminate(isVisible: true, now: start) + + let action = policy.processDidTerminate(isVisible: true, now: start.addingTimeInterval(5)) + + #expect(action == .showCrashPage) + } + + @Test func crashLongAfterAReloadReloadsAgain() { + var policy = PageCrashRecoveryPolicy() + _ = policy.processDidTerminate(isVisible: true, now: start) + + let later = start.addingTimeInterval(PageCrashRecoveryPolicy.repeatedCrashInterval + 1) + + let action = policy.processDidTerminate(isVisible: true, now: later) + + #expect(action == .reloadNow) + } + + @Test func backgroundCrashSoonAfterAReloadShowsTheCrashPage() { + var policy = PageCrashRecoveryPolicy() + _ = policy.processDidTerminate(isVisible: false, now: start) + _ = policy.tabDidBecomeVisible(now: start) + + let action = policy.processDidTerminate(isVisible: false, now: start.addingTimeInterval(10)) + + #expect(action == .showCrashPage) + #expect(!policy.isPendingReload) + } + + @Test func crashSoonAfterTryAgainShowsTheCrashPageAgain() { + var policy = PageCrashRecoveryPolicy() + policy.recordReload(at: start) + + let action = policy.processDidTerminate(isVisible: true, now: start.addingTimeInterval(3)) + + #expect(action == .showCrashPage) + } +} From ccc42f392099a598f00fb5e7c137aac544f7bb9d Mon Sep 17 00:00:00 2001 From: "L. Ramos" <l.ramos@sct-telecom.fr> Date: Sun, 25 Oct 2026 21:12:30 +0100 Subject: [PATCH 29/29] chore(debug): add a command to simulate a page crash Debug builds only. The sandbox forbids Ora to kill a web content process, so the command copies the kill -9 command for the space's pages to the clipboard, to run in Terminal. --- ora/App/OraCommands.swift | 8 +++++++ ora/App/OraRoot.swift | 24 +++++++++++++++++++ .../BrowserEngine/BrowserPage+Debug.swift | 20 ++++++++++++++++ ora/Core/Constants/AppEvents.swift | 1 + 4 files changed, 53 insertions(+) create mode 100644 ora/Core/BrowserEngine/BrowserPage+Debug.swift diff --git a/ora/App/OraCommands.swift b/ora/App/OraCommands.swift index 2ec663d5..58668168 100644 --- a/ora/App/OraCommands.swift +++ b/ora/App/OraCommands.swift @@ -127,6 +127,14 @@ struct OraCommands: Commands { NotificationCenter.default.post(name: .goForward, object: NSApp.keyWindow) } .keyboardShortcut(KeyboardShortcuts.Navigation.forward.keyboardShortcut) + + #if DEBUG + Divider() + + Button("Copy Page Crash Command (All Tabs in Space)") { + NotificationCenter.default.post(name: .simulatePageCrash, object: NSApp.keyWindow) + } + #endif } CommandMenu("Tabs") { diff --git a/ora/App/OraRoot.swift b/ora/App/OraRoot.swift index 5c487fad..1af3c21f 100644 --- a/ora/App/OraRoot.swift +++ b/ora/App/OraRoot.swift @@ -241,6 +241,30 @@ struct OraRoot: View { tabManager.activeTab?.reload() } } + #if DEBUG + NotificationCenter.default.addObserver( + forName: .simulatePageCrash, + object: nil, + queue: .main + ) { note in + Task { @MainActor in + guard note.object as? NSWindow === window ?? NSApp.keyWindow else { return } + // The sandbox forbids Ora to kill a web content process + // itself: copy the command that crashes them, to run in + // Terminal (SIGKILL ends a process exactly like a crash). + let tabs = tabManager.activeContainer?.tabs ?? [] + let pids = Set(tabs.compactMap { $0.browserPage?.webContentProcessIdentifier }) + guard !pids.isEmpty else { + print("[Simulate crash] No web content process found") + return + } + let command = "kill -9 " + pids.sorted().map(String.init).joined(separator: " ") + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(command, forType: .string) + print("[Simulate crash] Copied to the clipboard, run it in Terminal: \(command)") + } + } + #endif NotificationCenter.default.addObserver(forName: .goBack, object: nil, queue: .main) { note in Task { @MainActor in guard note.object as? NSWindow === window ?? NSApp.keyWindow else { return } diff --git a/ora/Core/BrowserEngine/BrowserPage+Debug.swift b/ora/Core/BrowserEngine/BrowserPage+Debug.swift new file mode 100644 index 00000000..d65420ac --- /dev/null +++ b/ora/Core/BrowserEngine/BrowserPage+Debug.swift @@ -0,0 +1,20 @@ +#if DEBUG + import Foundation + import WebKit + + extension BrowserPage { + /// Identifier of the process rendering the page, to simulate a crash by + /// killing it. Debug builds only: it reads a private WebKit property. + var webContentProcessIdentifier: pid_t? { + let key = "_webProcessIdentifier" + guard let webView = contentView as? WKWebView, + webView.responds(to: NSSelectorFromString(key)), + let pid = (webView.value(forKey: key) as? NSNumber)?.int32Value, + pid > 0 + else { + return nil + } + return pid + } + } +#endif diff --git a/ora/Core/Constants/AppEvents.swift b/ora/Core/Constants/AppEvents.swift index 49077f2f..5a0ac032 100644 --- a/ora/Core/Constants/AppEvents.swift +++ b/ora/Core/Constants/AppEvents.swift @@ -11,6 +11,7 @@ extension Notification.Name { static let findInPage = Notification.Name("FindInPage") static let toggleFullURL = Notification.Name("ToggleFullURL") static let reloadPage = Notification.Name("ReloadPage") + static let simulatePageCrash = Notification.Name("SimulatePageCrash") // Debug builds only static let goBack = Notification.Name("GoBack") static let goForward = Notification.Name("GoForward") static let togglePinTab = Notification.Name("TogglePinTab")