diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index d5312146..2adae8ba 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -1,66 +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 - run: brew install swiftformat - - - name: Check code formatting - run: 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 new file mode 100644 index 00000000..abb00fa9 --- /dev/null +++ b/.github/workflows/upstream-sync.yml @@ -0,0 +1,74 @@ +# 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 + actions: 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 + + # 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" 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/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/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 8f8f7840..1215f82c 100644 --- a/ora/App/OraRoot.swift +++ b/ora/App/OraRoot.swift @@ -59,18 +59,15 @@ struct OraRoot: View { let media = MediaController() _mediaController = StateObject(wrappedValue: media) + let downloads = DownloadManager(modelContainer: container, modelContext: modelContext) + _downloadManager = StateObject(wrappedValue: downloads) + _tabManager = StateObject( wrappedValue: TabManager( modelContainer: container, modelContext: modelContext, - mediaController: media - ) - ) - - _downloadManager = StateObject( - wrappedValue: DownloadManager( - modelContainer: container, - modelContext: modelContext + mediaController: media, + downloadManager: downloads ) ) } @@ -107,6 +104,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 { @@ -148,6 +162,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 } @@ -213,6 +238,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/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/BrowserEngineTypes.swift b/ora/Core/BrowserEngine/BrowserEngineTypes.swift index 17b918fb..e68f7c1c 100644 --- a/ora/Core/BrowserEngine/BrowserEngineTypes.swift +++ b/ora/Core/BrowserEngine/BrowserEngineTypes.swift @@ -30,12 +30,55 @@ 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 +} + +enum BrowserCaptureDevice: CaseIterable { + case camera + case microphone +} + +/// Whether a page is using a capture device. +enum BrowserCaptureState: Equatable { + case none + case active + /// Still granted, but paused by the user: the page receives no image/sound. + case muted +} + +struct BrowserMediaCaptureState: Equatable { + var camera: BrowserCaptureState = .none + var microphone: BrowserCaptureState = .none + + var isCapturing: Bool { + camera != .none || microphone != .none + } + + subscript(device: BrowserCaptureDevice) -> BrowserCaptureState { + switch device { + case .camera: camera + case .microphone: microphone + } + } + + /// Whether the device must be muted (true) or resumed (false) to toggle it, + /// or nil when the page is not using it. + func mutedAfterToggling(_ device: BrowserCaptureDevice) -> Bool? { + switch self[device] { + case .none: nil + case .active: true + case .muted: false + } + } } struct BrowserNavigationAction { diff --git a/ora/Core/BrowserEngine/BrowserPage+Debug.swift b/ora/Core/BrowserEngine/BrowserPage+Debug.swift new file mode 100644 index 00000000..7306df53 --- /dev/null +++ b/ora/Core/BrowserEngine/BrowserPage+Debug.swift @@ -0,0 +1,19 @@ +#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 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/BrowserEngine/BrowserPage+MediaCapture.swift b/ora/Core/BrowserEngine/BrowserPage+MediaCapture.swift new file mode 100644 index 00000000..19ecf024 --- /dev/null +++ b/ora/Core/BrowserEngine/BrowserPage+MediaCapture.swift @@ -0,0 +1,50 @@ +import Foundation +@preconcurrency import WebKit + +extension BrowserPage { + /// Pauses every media element of the page at once, in WebKit itself (does + /// not depend on the page's scripts). + func pauseAllMedia(completion: @escaping () -> Void) { + webView.pauseAllMediaPlayback(completionHandler: completion) + } + + var mediaCaptureState: BrowserMediaCaptureState { + BrowserMediaCaptureState( + camera: BrowserCaptureState(webView.cameraCaptureState), + microphone: BrowserCaptureState(webView.microphoneCaptureState) + ) + } + + /// Pauses (muted) or resumes a capture device the page is using, like the + /// camera and microphone buttons of Safari's address bar. + func setCaptureMuted(_ muted: Bool, for device: BrowserCaptureDevice) { + let state: WKMediaCaptureState = muted ? .muted : .active + switch device { + case .camera: + guard webView.cameraCaptureState != .none else { return } + webView.setCameraCaptureState(state, completionHandler: nil) + case .microphone: + guard webView.microphoneCaptureState != .none else { return } + webView.setMicrophoneCaptureState(state, completionHandler: nil) + } + } + + /// Called by the capture state observers set up in `init`. + func mediaCaptureStateDidChange() { + // KVO may notify on any thread; the delegate updates the interface. + DispatchQueue.main.async { [weak self] in + guard let self else { return } + delegate?.browserPage(self, didChangeMediaCaptureState: mediaCaptureState) + } + } +} + +private extension BrowserCaptureState { + init(_ state: WKMediaCaptureState) { + switch state { + case .active: self = .active + case .muted: self = .muted + default: self = .none + } + } +} diff --git a/ora/Core/BrowserEngine/BrowserPage+NavigationDelegate.swift b/ora/Core/BrowserEngine/BrowserPage+NavigationDelegate.swift new file mode 100644 index 00000000..34496edc --- /dev/null +++ b/ora/Core/BrowserEngine/BrowserPage+NavigationDelegate.swift @@ -0,0 +1,225 @@ +import AppKit +import Foundation +@preconcurrency import WebKit + +extension BrowserPage: WKNavigationDelegate { + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + // Links the site marks as downloads (``): download instead + // of navigating. Continues in webView(_:navigationAction:didBecome:). + if navigationAction.shouldPerformDownload { + decisionHandler(.download) + return + } + + let action = BrowserNavigationAction( + request: navigationAction.request, + modifierFlags: navigationAction.modifierFlags + ) + + switch delegate?.browserPage(self, decidePolicyFor: action) ?? .allow { + case .allow: + decisionHandler(.allow) + case .cancel: + decisionHandler(.cancel) + case .openInNewTab: + if let url = navigationAction.request.url { + delegate?.browserPage(self, didRequestOpenInNewTab: url) + } + decisionHandler(.cancel) + } + } + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + if !isDownloadNavigation { + originalURL = lastCommittedURL + emitNavigationEvent( + phase: .started, + url: webView.url, + title: webView.title, + progress: 10.0, + isLoading: true + ) + } + } + + func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { + if !isDownloadNavigation { + lastCommittedURL = webView.url + emitNavigationEvent( + phase: .committed, + url: webView.url, + title: webView.title, + progress: webView.estimatedProgress * 100.0, + isLoading: true + ) + } + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + if !isDownloadNavigation { + lastCommittedURL = webView.url + emitNavigationEvent( + phase: .finished, + url: webView.url, + title: webView.title, + progress: webView.estimatedProgress * 100.0, + isLoading: false + ) + originalURL = nil + } + } + + 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, + url: webView.url, + title: webView.title, + progress: 100.0, + isLoading: false + ) + + if !handleCancelledNavigationError(error) { + delegate?.browserPage(self, didFailNavigationWith: error, failingURL: webView.url) + } + originalURL = nil + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation 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, + url: webView.url, + title: webView.title, + progress: 100.0, + isLoading: false + ) + + if handleCancelledNavigationError(error) { + return + } + + let nsError = error as NSError + let failingURL = nsError.userInfo[NSURLErrorFailingURLErrorKey] as? URL ?? webView.url + delegate?.browserPage(self, didFailNavigationWith: error, failingURL: failingURL) + originalURL = nil + } + + func webView( + _ webView: WKWebView, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let serverTrust = challenge.protectionSpace.serverTrust, + sslBypassedHosts.contains(challenge.protectionSpace.host) + { + completionHandler(.useCredential, URLCredential(trust: serverTrust)) + } else { + completionHandler(.performDefaultHandling, nil) + } + } + + @available(macOS 11.3, *) + func webView( + _ webView: WKWebView, + decidePolicyFor navigationResponse: WKNavigationResponse, + decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void + ) { + if navigationResponse.canShowMIMEType { + isDownloadNavigation = false + originalURL = nil + decisionHandler(.allow) + return + } + + isDownloadNavigation = true + emitNavigationEvent( + phase: .finished, + url: originalURL, + title: webView.title, + progress: 0, + isLoading: false + ) + decisionHandler(.download) + } + + func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { + guard let downloadURL = navigationAction.request.url else { return } + delegate?.browserPage(self, didStartDownload: BrowserDownloadTask(download: download, originalURL: downloadURL)) + } + + /// "Download Image", "Download Linked File" and "Download Video" from the + /// page's context menu. Private WebKit delegate method + /// (WKNavigationDelegatePrivate, macOS 12+): without it, WebKit creates the + /// download but nothing takes care of it, so it never starts. + @objc(_webView:contextMenuDidCreateDownload:) + func webView(_ webView: WKWebView, contextMenuDidCreate download: WKDownload) { + guard let downloadURL = download.originalRequest?.url else { return } + delegate?.browserPage(self, didStartDownload: BrowserDownloadTask(download: download, originalURL: downloadURL)) + } + + @available(macOS 11.3, *) + func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { + guard let downloadURL = navigationResponse.response.url else { return } + let task = BrowserDownloadTask(download: download, originalURL: downloadURL) + delegate?.browserPage(self, didStartDownload: task) + isDownloadNavigation = false + originalURL = nil + } + + // MARK: - Helpers + + private func emitNavigationEvent( + phase: BrowserNavigationPhase, + url: URL?, + title: String?, + progress: Double, + isLoading: Bool + ) { + delegate?.browserPage( + self, + didUpdateNavigation: BrowserNavigationEvent( + phase: phase, + url: url, + title: title, + progress: progress, + isLoading: isLoading + ) + ) + } + + private func handleCancelledNavigationError(_ error: Error) -> Bool { + let nsError = error as NSError + 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 + } +} diff --git a/ora/Core/BrowserEngine/BrowserPage+UIDelegate.swift b/ora/Core/BrowserEngine/BrowserPage+UIDelegate.swift new file mode 100644 index 00000000..e9de63e3 --- /dev/null +++ b/ora/Core/BrowserEngine/BrowserPage+UIDelegate.swift @@ -0,0 +1,123 @@ +import AppKit +import Foundation +@preconcurrency import WebKit + +extension BrowserPage: WKUIDelegate { + func webView( + _ 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 + } + delegate?.browserPage(self, requestPermission: permission, origin: url(of: origin)) { decision in + decisionHandler(Self.webKitDecision(decision)) + } + } + + /// Answers `navigator.permissions.query()`. Private WebKit delegate method + /// (WKUIDelegatePrivate, macOS 13+), the one Safari implements: without it, + /// WebKit's answer ignores the user's per-site decisions. WebKit then adjusts + /// the answer like in Safari (e.g. a denial reads as "prompt" until the page + /// itself was denied). + @objc(_webView:queryPermission:forOrigin:completionHandler:) + func webView( + _ webView: WKWebView, + queryPermission name: String, + forOrigin origin: WKSecurityOrigin, + completionHandler: @escaping (WKPermissionDecision) -> Void + ) { + let permission: BrowserPermissionKind? = switch name { + case "camera": .camera + case "microphone": .microphone + default: nil + } + guard let permission, let delegate else { + // Other permissions: WebKit's own default answer. + completionHandler(.prompt) + return + } + let decision = delegate.browserPage(self, queryPermission: permission, origin: url(of: origin)) + completionHandler(Self.webKitDecision(decision)) + } + + private func url(of origin: WKSecurityOrigin) -> URL? { + URL(string: "\(origin.protocol)://\(origin.host):\(origin.port)") + } + + private static func webKitDecision(_ decision: BrowserPermissionDecision) -> WKPermissionDecision { + switch decision { + case .grant: .grant + case .deny: .deny + case .prompt: .prompt + } + } + + func webView( + _ webView: WKWebView, + runOpenPanelWith parameters: WKOpenPanelParameters, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping ([URL]?) -> Void + ) { + delegate?.browserPage( + self, + runOpenPanelWith: BrowserOpenPanelOptions( + allowsDirectories: parameters.allowsDirectories, + allowsMultipleSelection: parameters.allowsMultipleSelection + ), + completion: completionHandler + ) + } + + func webView( + _ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + if let url = navigationAction.request.url { + delegate?.browserPage(self, didRequestOpenInNewTab: url) + } + return nil + } + + func webView( + _ webView: WKWebView, + runJavaScriptAlertPanelWithMessage message: String, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping () -> Void + ) { + delegate?.browserPage(self, runJavaScriptAlert: message) + completionHandler() + } + + func webView( + _ webView: WKWebView, + runJavaScriptConfirmPanelWithMessage message: String, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping (Bool) -> Void + ) { + delegate?.browserPage(self, runJavaScriptConfirm: message, completion: completionHandler) + } + + func webView( + _ webView: WKWebView, + runJavaScriptTextInputPanelWithPrompt prompt: String, + defaultText: String?, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping (String?) -> Void + ) { + delegate?.browserPage( + self, + runJavaScriptPrompt: prompt, + defaultText: defaultText, + completion: completionHandler + ) + } +} diff --git a/ora/Core/BrowserEngine/BrowserPage.swift b/ora/Core/BrowserEngine/BrowserPage.swift index af2fe1d4..d3ed6af9 100644 --- a/ora/Core/BrowserEngine/BrowserPage.swift +++ b/ora/Core/BrowserEngine/BrowserPage.swift @@ -2,18 +2,25 @@ import AppKit import Foundation @preconcurrency import WebKit -final class BrowserPage: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler { +/// A web page backed by WebKit. The page API lives here; the WebKit delegate +/// conformances are in `BrowserPage+NavigationDelegate.swift`, +/// `BrowserPage+UIDelegate.swift` and `BrowserPage+MediaCapture.swift`. +final class BrowserPage: NSObject, WKScriptMessageHandler { weak var delegate: BrowserPageDelegate? - private let webView: WKWebView + /// Engine-internal: used by the delegate extensions. Outside the browser + /// engine, go through the page API (`contentView`, `load`, …). + let webView: WKWebView private let messageNames: [String] - private var originalURL: URL? - private(set) var lastCommittedURL: URL? - private(set) var isDownloadNavigation = false + // Navigation tracking, updated by the navigation delegate extension only. + var originalURL: URL? + var lastCommittedURL: URL? + var isDownloadNavigation = false private(set) var sslBypassedHosts: Set = [] private var isReadyForNavigation = false private var pendingLoadRequest: URLRequest? private var pendingReload = false + private var captureStateObservations: [NSKeyValueObservation] = [] init( profile: BrowserEngineProfile, @@ -21,7 +28,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( @@ -55,6 +62,16 @@ final class BrowserPage: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptM super.init() + // Camera/microphone use, to show it and let the user pause it. + captureStateObservations = [ + webView.observe(\.cameraCaptureState) { [weak self] _, _ in + self?.mediaCaptureStateDidChange() + }, + webView.observe(\.microphoneCaptureState) { [weak self] _, _ in + self?.mediaCaptureStateDidChange() + } + ] + for messageName in configuration.scriptMessageNames { contentController.add(self, name: messageName) } @@ -126,7 +143,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() { @@ -172,6 +200,16 @@ final class BrowserPage: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptM } func teardown() { + captureStateObservations.forEach { $0.invalidate() } + captureStateObservations = [] + // Release the camera and microphone right away, not when WebKit frees + // the page later (the camera light would stay on after closing a tab). + if webView.cameraCaptureState != .none { + webView.setCameraCaptureState(.none, completionHandler: nil) + } + if webView.microphoneCaptureState != .none { + webView.setMicrophoneCaptureState(.none, completionHandler: nil) + } webView.stopLoading() webView.navigationDelegate = nil webView.uiDelegate = nil @@ -190,7 +228,7 @@ final class BrowserPage: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptM private func flushPendingNavigationIfNeeded() { if let pendingLoadRequest { self.pendingLoadRequest = nil - webView.load(pendingLoadRequest) + perform(pendingLoadRequest) return } @@ -200,30 +238,6 @@ final class BrowserPage: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptM } } - private func emitNavigationEvent( - phase: BrowserNavigationPhase, - url: URL?, - title: String?, - progress: Double, - isLoading: Bool - ) { - delegate?.browserPage( - self, - didUpdateNavigation: BrowserNavigationEvent( - phase: phase, - url: url, - title: title, - progress: progress, - isLoading: isLoading - ) - ) - } - - private func handleCancelledNavigationError(_ error: Error) -> Bool { - let nsError = error as NSError - return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled - } - private func mapInjectionTime(_ injectionTime: BrowserUserScriptInjectionTime) -> WKUserScriptInjectionTime { switch injectionTime { case .atDocumentStart: @@ -239,233 +253,4 @@ final class BrowserPage: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptM didReceiveScriptMessage: BrowserScriptMessage(name: message.name, body: message.body) ) } - - func webView( - _ webView: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction, - decisionHandler: @escaping (WKNavigationActionPolicy) -> Void - ) { - let action = BrowserNavigationAction( - request: navigationAction.request, - modifierFlags: navigationAction.modifierFlags - ) - - switch delegate?.browserPage(self, decidePolicyFor: action) ?? .allow { - case .allow: - decisionHandler(.allow) - case .cancel: - decisionHandler(.cancel) - case .openInNewTab: - if let url = navigationAction.request.url { - delegate?.browserPage(self, didRequestOpenInNewTab: url) - } - decisionHandler(.cancel) - } - } - - func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { - if !isDownloadNavigation { - originalURL = lastCommittedURL - emitNavigationEvent( - phase: .started, - url: webView.url, - title: webView.title, - progress: 10.0, - isLoading: true - ) - } - } - - func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { - if !isDownloadNavigation { - lastCommittedURL = webView.url - emitNavigationEvent( - phase: .committed, - url: webView.url, - title: webView.title, - progress: webView.estimatedProgress * 100.0, - isLoading: true - ) - } - } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - if !isDownloadNavigation { - lastCommittedURL = webView.url - emitNavigationEvent( - phase: .finished, - url: webView.url, - title: webView.title, - progress: webView.estimatedProgress * 100.0, - isLoading: false - ) - originalURL = nil - } - } - - func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { - guard !isDownloadNavigation else { - originalURL = nil - return - } - - emitNavigationEvent( - phase: .finished, - url: webView.url, - title: webView.title, - progress: 100.0, - isLoading: false - ) - - if !handleCancelledNavigationError(error) { - delegate?.browserPage(self, didFailNavigationWith: error, failingURL: webView.url) - } - originalURL = nil - } - - func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { - guard !isDownloadNavigation else { - originalURL = nil - return - } - - emitNavigationEvent( - phase: .finished, - url: webView.url, - title: webView.title, - progress: 100.0, - isLoading: false - ) - - if handleCancelledNavigationError(error) { - return - } - - let nsError = error as NSError - let failingURL = nsError.userInfo[NSURLErrorFailingURLErrorKey] as? URL ?? webView.url - delegate?.browserPage(self, didFailNavigationWith: error, failingURL: failingURL) - originalURL = nil - } - - func webView( - _ webView: WKWebView, - didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void - ) { - if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, - let serverTrust = challenge.protectionSpace.serverTrust, - sslBypassedHosts.contains(challenge.protectionSpace.host) - { - completionHandler(.useCredential, URLCredential(trust: serverTrust)) - } else { - completionHandler(.performDefaultHandling, nil) - } - } - - @available(macOS 11.3, *) - func webView( - _ webView: WKWebView, - decidePolicyFor navigationResponse: WKNavigationResponse, - decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void - ) { - if navigationResponse.canShowMIMEType { - isDownloadNavigation = false - originalURL = nil - decisionHandler(.allow) - return - } - - isDownloadNavigation = true - emitNavigationEvent( - phase: .finished, - url: originalURL, - title: webView.title, - progress: 0, - isLoading: false - ) - decisionHandler(.download) - } - - @available(macOS 11.3, *) - func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { - guard let downloadURL = navigationResponse.response.url else { return } - let task = BrowserDownloadTask(download: download, originalURL: downloadURL) - delegate?.browserPage(self, didStartDownload: task) - isDownloadNavigation = false - originalURL = nil - } - - func webView( - _ webView: WKWebView, - requestMediaCapturePermissionFor origin: WKSecurityOrigin, - initiatedByFrame frame: WKFrameInfo, - decisionHandler: @escaping (WKPermissionDecision) -> Void - ) { - let pageURL = URL(string: "\(origin.protocol)://\(origin.host):\(origin.port)") - delegate?.browserPage(self, requestPermission: .mediaCapture, origin: pageURL) { decision in - decisionHandler(decision == .grant ? .grant : .deny) - } - } - - func webView( - _ webView: WKWebView, - runOpenPanelWith parameters: WKOpenPanelParameters, - initiatedByFrame frame: WKFrameInfo, - completionHandler: @escaping ([URL]?) -> Void - ) { - delegate?.browserPage( - self, - runOpenPanelWith: BrowserOpenPanelOptions( - allowsDirectories: parameters.allowsDirectories, - allowsMultipleSelection: parameters.allowsMultipleSelection - ), - completion: completionHandler - ) - } - - func webView( - _ webView: WKWebView, - createWebViewWith configuration: WKWebViewConfiguration, - for navigationAction: WKNavigationAction, - windowFeatures: WKWindowFeatures - ) -> WKWebView? { - if let url = navigationAction.request.url { - delegate?.browserPage(self, didRequestOpenInNewTab: url) - } - return nil - } - - func webView( - _ webView: WKWebView, - runJavaScriptAlertPanelWithMessage message: String, - initiatedByFrame frame: WKFrameInfo, - completionHandler: @escaping () -> Void - ) { - delegate?.browserPage(self, runJavaScriptAlert: message) - completionHandler() - } - - func webView( - _ webView: WKWebView, - runJavaScriptConfirmPanelWithMessage message: String, - initiatedByFrame frame: WKFrameInfo, - completionHandler: @escaping (Bool) -> Void - ) { - delegate?.browserPage(self, runJavaScriptConfirm: message, completion: completionHandler) - } - - func webView( - _ webView: WKWebView, - runJavaScriptTextInputPanelWithPrompt prompt: String, - defaultText: String?, - initiatedByFrame frame: WKFrameInfo, - completionHandler: @escaping (String?) -> Void - ) { - delegate?.browserPage( - self, - runJavaScriptPrompt: prompt, - defaultText: defaultText, - completion: completionHandler - ) - } } diff --git a/ora/Core/BrowserEngine/BrowserPageDelegate.swift b/ora/Core/BrowserEngine/BrowserPageDelegate.swift index ccfe558c..2d69a3cd 100644 --- a/ora/Core/BrowserEngine/BrowserPageDelegate.swift +++ b/ora/Core/BrowserEngine/BrowserPageDelegate.swift @@ -16,6 +16,13 @@ protocol BrowserPageDelegate: AnyObject { origin: URL?, decisionHandler: @escaping (BrowserPermissionDecision) -> Void ) + /// Answer to `navigator.permissions.query()` for a capture permission, + /// without asking the user. + func browserPage( + _ page: BrowserPage, + queryPermission permission: BrowserPermissionKind, + origin: URL? + ) -> BrowserPermissionDecision func browserPage( _ page: BrowserPage, runOpenPanelWith options: BrowserOpenPanelOptions, @@ -30,6 +37,10 @@ protocol BrowserPageDelegate: AnyObject { completion: @escaping (String?) -> Void ) func browserPage(_ page: BrowserPage, didStartDownload download: BrowserDownloadTask) + func browserPage(_ page: BrowserPage, didChangeMediaCaptureState state: BrowserMediaCaptureState) + /// 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 { @@ -57,6 +68,14 @@ extension BrowserPageDelegate { decisionHandler(.deny) } + func browserPage( + _ page: BrowserPage, + queryPermission permission: BrowserPermissionKind, + origin: URL? + ) -> BrowserPermissionDecision { + .prompt + } + func browserPage( _ page: BrowserPage, runOpenPanelWith options: BrowserOpenPanelOptions, @@ -81,4 +100,8 @@ extension BrowserPageDelegate { } func browserPage(_ page: BrowserPage, didStartDownload download: BrowserDownloadTask) {} + + func browserPage(_ page: BrowserPage, didChangeMediaCaptureState state: BrowserMediaCaptureState) {} + + func browserPageWebContentProcessDidTerminate(_ page: BrowserPage) {} } 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/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/ora/Core/BrowserEngine/Scripts/OraBrowserScripts.swift b/ora/Core/BrowserEngine/Scripts/OraBrowserScripts.swift index 35b5690f..551189c9 100644 --- a/ora/Core/BrowserEngine/Scripts/OraBrowserScripts.swift +++ b/ora/Core/BrowserEngine/Scripts/OraBrowserScripts.swift @@ -89,20 +89,30 @@ 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) { + 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); @@ -149,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) => ({ @@ -181,21 +193,46 @@ 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 }) - ); + // 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) { + 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' })); @@ -206,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(); @@ -270,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; @@ -279,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' && @@ -288,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/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") 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/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/Browser/URLBar/MediaCaptureIndicator.swift b/ora/Features/Browser/URLBar/MediaCaptureIndicator.swift new file mode 100644 index 00000000..5c73d495 --- /dev/null +++ b/ora/Features/Browser/URLBar/MediaCaptureIndicator.swift @@ -0,0 +1,51 @@ +import SwiftUI + +/// Camera and microphone buttons shown in the address bar while the page uses +/// them, like Safari: red while capturing, slashed while paused. Clicking one +/// pauses or resumes that device without reloading the page. +struct MediaCaptureIndicator: View { + let state: BrowserMediaCaptureState + let foregroundColor: Color + let onToggle: (BrowserCaptureDevice) -> Void + + var body: some View { + HStack(spacing: 6) { + ForEach(BrowserCaptureDevice.allCases, id: \.self) { device in + if state[device] != .none { + button(for: device, isPaused: state[device] == .muted) + } + } + } + } + + private func button(for device: BrowserCaptureDevice, isPaused: Bool) -> some View { + Button { + onToggle(device) + } label: { + Image(systemName: device.symbolName(isPaused: isPaused)) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(isPaused ? foregroundColor.opacity(0.6) : Color.red) + .frame(width: 16, height: 16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(device.toggleTitle(isPaused: isPaused)) + .accessibilityLabel(Text(device.toggleTitle(isPaused: isPaused))) + } +} + +extension BrowserCaptureDevice { + func symbolName(isPaused: Bool) -> String { + switch self { + case .camera: isPaused ? "video.slash.fill" : "video.fill" + case .microphone: isPaused ? "mic.slash.fill" : "mic.fill" + } + } + + func toggleTitle(isPaused: Bool) -> String { + switch self { + case .camera: isPaused ? "Resume Camera" : "Pause Camera" + case .microphone: isPaused ? "Resume Microphone" : "Pause Microphone" + } + } +} diff --git a/ora/Features/Browser/URLBar/URLBar.swift b/ora/Features/Browser/URLBar/URLBar.swift index 97ed308e..062b182c 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() @@ -332,6 +371,10 @@ struct URLBar: View { .foregroundColor(buttonForegroundColor) .frame(maxWidth: .infinity, alignment: .leading) + MediaCaptureIndicator(state: tab.mediaCaptureState, foregroundColor: buttonForegroundColor) { device in + tab.toggleCapture(device) + } + Button { if let activeTab = tabManager.activeTab { triggerCopy(activeTab.url.absoluteString) @@ -441,6 +484,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 3ea5c6e8..b3e82079 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,23 +28,35 @@ 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) 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) } } } .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 @@ -88,6 +101,25 @@ 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)) + } + } + .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/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( 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 diff --git a/ora/Features/Permissions/Models/SitePermission.swift b/ora/Features/Permissions/Models/SitePermission.swift new file mode 100644 index 00000000..a25704c0 --- /dev/null +++ b/ora/Features/Permissions/Models/SitePermission.swift @@ -0,0 +1,182 @@ +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)" + } + + /// 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 + 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: - 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 { + /// The site permissions a WebKit capture request covers. + var sitePermissionTypes: Set { + switch self { + case .camera: [.camera] + case .microphone: [.microphone] + case .cameraAndMicrophone: [.camera, .microphone] + } + } +} + +extension SitePermissionState { + /// Answer to `navigator.permissions.query()` for this saved decision. + var permissionQueryDecision: BrowserPermissionDecision { + switch self { + case .allow: .grant + case .deny: .deny + case .ask: .prompt + } + } +} 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/SitePermissionStore.swift b/ora/Features/Permissions/Services/SitePermissionStore.swift new file mode 100644 index 00000000..485cb2a3 --- /dev/null +++ b/ora/Features/Permissions/Services/SitePermissionStore.swift @@ -0,0 +1,122 @@ +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) + } + + /// Saves the permissions of a site. A site set back to "Ask" stays listed in + /// the settings, so the user can change it again; only + /// `removePermissions(for:in:isPrivate:)` forgets it, like in Safari. + func setPermissions(_ permissions: SitePermissions, for origin: SiteOrigin, in spaceId: UUID, isPrivate: Bool) { + update(permissions, for: origin, in: spaceId, isPrivate: isPrivate) + } + + func removePermissions(for origin: SiteOrigin, in spaceId: UUID, isPrivate: Bool) { + update(nil, for: origin, in: spaceId, isPrivate: isPrivate) + } + + private func update(_ value: SitePermissions?, for origin: SiteOrigin, in spaceId: UUID, isPrivate: Bool) { + if isPrivate { + privatePermissionsBySpace[spaceId, default: [:]][origin] = value + } else { + permissionsBySpace[spaceId, default: [:]][origin] = value + if permissionsBySpace[spaceId]?.isEmpty == true { + permissionsBySpace[spaceId] = nil + } + save() + } + } + + /// 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/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..4d1ad191 --- /dev/null +++ b/ora/Features/Permissions/Views/SitePermissionPromptView.swift @@ -0,0 +1,80 @@ +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(request.origin.displayName) + .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) + } + + 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/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/Player/State/MediaController.swift b/ora/Features/Player/State/MediaController.swift index 4917956d..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 = [] + 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 = !sessions.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/Privacy/Services/BrowserPrivacyService.swift b/ora/Features/Privacy/Services/BrowserPrivacyService.swift index ddaf67cd..7899bd39 100644 --- a/ora/Features/Privacy/Services/BrowserPrivacyService.swift +++ b/ora/Features/Privacy/Services/BrowserPrivacyService.swift @@ -2,85 +2,37 @@ 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. +/// +/// WebGL is left alone for the same reason: WebKit already hides the GPU like +/// Safari ("Apple GPU"), and fixed values contradicted it ("Apple Inc." vendor, +/// WebGL 1.0 reported for WebGL 2 contexts). 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 - let webGLVersion: String - let webGLShadingLanguageVersion: String let canvasShift: Int let audioNoise: Double let audioBucketSize: Int - 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", - webGLVersion: "WebGL 1.0", - webGLShadingLanguageVersion: "WebGL GLSL ES 1.0", canvasShift: 1, audioNoise: 0.000_015_258_789_062_5, - audioBucketSize: 64, - mediaDeviceKinds: ["audioinput", "audiooutput", "videoinput"] + audioBucketSize: 64 ) - 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, - "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, - "webGLVendor": webGLVendor, - "webGLVersion": webGLVersion + "canvasShift": canvasShift ] guard JSONSerialization.isValidJSONObject(profileObject), @@ -99,19 +51,45 @@ struct FingerprintingProtectionProfile: Equatable { const profile = \(profileJSON); - function defineGetter(target, property, getter) { - if (!target) return; - try { - Object.defineProperty(target, property, { - configurable: true, - enumerable: false, - get: getter - }); - } catch (error) {} + // 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 defineValue(target, property, value) { - defineGetter(target, property, function () { return value; }); + 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 wrapMethod(target, property, wrapper) { @@ -125,43 +103,6 @@ struct FingerprintingProtectionProfile: Equatable { } catch (error) {} } - function cloneLanguages() { - return profile.languages.slice(); - } - - function makeDevice(kind, index) { - const suffix = String(index + 1); - const device = { - deviceId: 'ora-' + kind + '-' + suffix, - groupId: 'ora-group-' + kind, - kind: kind, - label: '', - toJSON: function () { - return { - deviceId: this.deviceId, - groupId: this.groupId, - kind: this.kind, - label: this.label - }; - } - }; - - return Object.freeze(device); - } - - function normalizeMediaDevices(devices) { - const hasLabels = Array.isArray(devices) && devices.some(function (device) { - return !!(device && device.label); - }); - if (hasLabels) { - return devices; - } - - return profile.mediaDeviceKinds.map(function (kind, index) { - return makeDevice(kind, index); - }); - } - function shouldSanitizeCanvas(canvas) { if (!canvas) return false; const width = canvas.width || 0; @@ -174,9 +115,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); } } } @@ -221,7 +164,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); } } @@ -233,52 +176,14 @@ 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; } - 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) { - const permissionName = parameters && parameters.name; - if (permissionName === 'camera' || - permissionName === 'microphone' || - permissionName === 'geolocation' || - permissionName === 'notifications') { - return Promise.resolve({ state: 'prompt', onchange: null }); - } - return originalQuery(parameters); - }; - } - - if (navigator.mediaDevices && typeof navigator.mediaDevices.enumerateDevices === 'function') { - const originalEnumerateDevices = navigator.mediaDevices.enumerateDevices.bind(navigator.mediaDevices); - navigator.mediaDevices.enumerateDevices = function () { - return originalEnumerateDevices().then(normalizeMediaDevices); - }; - } - wrapMethod(HTMLCanvasElement.prototype, 'toDataURL', function (original) { return function () { const sanitizedCanvas = cloneAndSanitizeCanvas(this); @@ -303,25 +208,6 @@ struct FingerprintingProtectionProfile: Equatable { }; }); - function wrapWebGLContext(contextType) { - if (!contextType || !contextType.prototype) return; - - wrapMethod(contextType.prototype, 'getParameter', function (original) { - return function (parameter) { - if (parameter === 37445) return profile.webGLVendor; - if (parameter === 37446) return profile.webGLRenderer; - if (parameter === 7936) return profile.webGLVendor; - if (parameter === 7937) return profile.webGLRendererName; - if (parameter === 7938) return profile.webGLVersion; - if (parameter === 35724) return profile.webGLShadingLanguageVersion; - return original.apply(this, arguments); - }; - }); - } - - wrapWebGLContext(window.WebGLRenderingContext); - wrapWebGLContext(window.WebGL2RenderingContext); - if (window.OfflineAudioContext && window.OfflineAudioContext.prototype) { wrapMethod(window.OfflineAudioContext.prototype, 'startRendering', function (original) { return function () { @@ -371,6 +257,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) { + 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" @@ -427,21 +342,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/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/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift b/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift index 0a40c258..37ffee81 100644 --- a/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift +++ b/ora/Features/Tabs/Browser/TabBrowserPageDelegate.swift @@ -57,13 +57,16 @@ 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.crashRecoveryNavigationDidStart() tab.maintainSnapShots() passwordCoordinator?.clearAutofillState() tab.isLoading = event.isLoading tab.loadingProgress = event.progress if let url = event.url { - tab.url = url + tab.updateURL(url) } case .committed: @@ -77,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 { @@ -86,7 +90,7 @@ final class TabBrowserPageDelegate: BrowserPageDelegate { } } if let url = event.url { - tab.url = url + tab.updateURL(url) if tab.favicon == nil { tab.setFavicon() } @@ -102,6 +106,21 @@ final class TabBrowserPageDelegate: BrowserPageDelegate { } } + func browserPage(_ page: BrowserPage, didChangeMediaCaptureState state: BrowserMediaCaptureState) { + tab?.updateMediaCaptureState(state) + } + + 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) } @@ -114,7 +133,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": @@ -132,7 +156,44 @@ final class TabBrowserPageDelegate: BrowserPageDelegate { origin: URL?, decisionHandler: @escaping (BrowserPermissionDecision) -> Void ) { - decisionHandler(.grant) + 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( + _ page: BrowserPage, + queryPermission permission: BrowserPermissionKind, + origin: URL? + ) -> BrowserPermissionDecision { + guard let tab, let origin = origin.flatMap(SiteOrigin.init(url:)) else { + return .prompt + } + + return SitePermissionStore.shared + .permissions(for: origin, in: tab.container.id, isPrivate: tab.isPrivate) + .decision(for: permission.sitePermissionTypes) + .permissionQueryDecision } func browserPage( @@ -235,8 +296,20 @@ final class TabBrowserPageDelegate: BrowserPageDelegate { } let oldTitle = tab.title - tab.title = update.title - tab.url = URL(string: update.href) ?? tab.url + let newURL = URL(string: update.href) + // Pages without a 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() 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..4ff37268 --- /dev/null +++ b/ora/Features/Tabs/Models/Tab+CrashRecovery.swift @@ -0,0 +1,57 @@ +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 } + if mediaCaptureState.isCapturing { mediaCaptureState = BrowserMediaCaptureState() } + + 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+MediaCapture.swift b/ora/Features/Tabs/Models/Tab+MediaCapture.swift new file mode 100644 index 00000000..0f642942 --- /dev/null +++ b/ora/Features/Tabs/Models/Tab+MediaCapture.swift @@ -0,0 +1,15 @@ +import Foundation + +extension Tab { + /// Pauses the device if the page is using it, resumes it if it is paused. + func toggleCapture(_ device: BrowserCaptureDevice) { + guard let muted = mediaCaptureState.mutedAfterToggling(device) else { return } + browserPage?.setCaptureMuted(muted, for: device) + } + + func updateMediaCaptureState(_ state: BrowserMediaCaptureState) { + if mediaCaptureState != state { + mediaCaptureState = state + } + } +} diff --git a/ora/Features/Tabs/Models/Tab.swift b/ora/Features/Tabs/Models/Tab.swift index db0c7bbf..bd876f37 100644 --- a/ora/Features/Tabs/Models/Tab.swift +++ b/ora/Features/Tabs/Models/Tab.swift @@ -53,6 +53,12 @@ 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? + @Transient var crashRecovery = PageCrashRecoveryPolicy() + @Transient var isReloadingAfterCrash = false + @Transient @Published var showsCrashRecoveryNotice = false + @Transient @Published var mediaCaptureState = BrowserMediaCaptureState() @Relationship(inverse: \TabContainer.tabs) var container: TabContainer @@ -213,7 +219,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( @@ -253,18 +263,31 @@ class Tab: ObservableObject, Identifiable { } catch (e) {} }); """ - page.evaluateJavaScript(js) { [weak self] _, _ in - page.closeMediaPresentations { - page.teardown() - if self?.browserPage === page { - self?.browserPage = nil - self?.pageDelegate = nil + // Silence first: closing Picture in Picture is animated and takes a + // moment, during which the sound kept playing. + page.pauseAllMedia { + page.evaluateJavaScript(js) { [weak self] _, _ in + page.closeMediaPresentations { + page.teardown() + if self?.browserPage === page { + self?.browserPage = nil + self?.pageDelegate = nil + } + completed() } - completed() } } } + /// 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) @@ -293,6 +316,12 @@ class Tab: ObservableObject, Identifiable { } func destroyWebView() { + cancelPermissionRequest() + // A new page loads from scratch: nothing left to recover. + crashRecovery = PageCrashRecoveryPolicy() + isReloadingAfterCrash = false + showsCrashRecoveryNotice = false + if mediaCaptureState.isCapturing { mediaCaptureState = BrowserMediaCaptureState() } browserPage?.teardown() browserPage = nil pageDelegate = nil @@ -316,6 +345,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/ora/Features/Tabs/State/TabManager.swift b/ora/Features/Tabs/State/TabManager.swift index ff32741d..aad882bb 100644 --- a/ora/Features/Tabs/State/TabManager.swift +++ b/ora/Features/Tabs/State/TabManager.swift @@ -43,6 +43,9 @@ class TabManager: ObservableObject { let modelContainer: ModelContainer let modelContext: ModelContext let mediaController: MediaController + /// The window's download manager, shown by its Downloads panel. Tabs must + /// all use it: a separate instance records downloads the panel never sees. + let downloadManager: DownloadManager var recentTabs: [Tab] { guard let container = activeContainer else { return [] } @@ -65,12 +68,15 @@ class TabManager: ObservableObject { modelContainer: ModelContainer, modelContext: ModelContext, mediaController: MediaController, + downloadManager: DownloadManager, tabSearchingService: TabSearchingProviding = TabSearchingService() ) { self.modelContainer = modelContainer self.modelContext = modelContext self.mediaController = mediaController + self.downloadManager = downloadManager self.tabSearchingService = tabSearchingService + mediaController.tabManager = self self.modelContext.undoManager = UndoManager() initializeActiveContainerAndTab() @@ -222,7 +228,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 @@ -278,10 +284,7 @@ class TabManager: ObservableObject { modelContainer: modelContainer, modelContext: modelContext ), - downloadManager: downloadManager ?? DownloadManager( - modelContainer: modelContainer, - modelContext: modelContext - ), + downloadManager: downloadManager ?? self.downloadManager, tabManager: self, isPrivate: isPrivate ) @@ -300,14 +303,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, @@ -328,10 +327,7 @@ class TabManager: ObservableObject { // Initialize the WebView for the new active tab newTab.restoreTransientState( historyManager: historyManager, - downloadManager: downloadManager ?? DownloadManager( - modelContainer: modelContainer, - modelContext: modelContext - ), + downloadManager: downloadManager ?? self.downloadManager, tabManager: self, isPrivate: isPrivate ) @@ -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() @@ -468,10 +478,7 @@ class TabManager: ObservableObject { modelContainer: modelContainer, modelContext: modelContext ), - downloadManager: tab.downloadManager ?? DownloadManager( - modelContainer: modelContainer, - modelContext: modelContext - ), + downloadManager: tab.downloadManager ?? self.downloadManager, tabManager: self, isPrivate: tab.isPrivate ) @@ -489,7 +496,10 @@ class TabManager: ObservableObject { let allContainers = fetchContainers() for container in allContainers { for tab in container.tabs { - if !tab.isAlive, tab.isWebViewReady, tab.id != activeTab?.id, !tab.isPlayingMedia, tab.type == .normal { + // A tab in a call keeps its page, like one playing media. + if !tab.isAlive, tab.isWebViewReady, tab.id != activeTab?.id, !tab.isPlayingMedia, + !tab.mediaCaptureState.isCapturing, tab.type == .normal + { tab.destroyWebView() } } @@ -507,6 +517,7 @@ class TabManager: ObservableObject { lastAccessed < cutoffDate, tab.id != activeTab?.id, !tab.isPlayingMedia, + !tab.mediaCaptureState.isCapturing, tab.type == .normal { closeTab(tab: tab, shouldTrackForRestore: false) @@ -530,6 +541,7 @@ class TabManager: ObservableObject { lastAccessed < cutoffDate, tab.id != activeTab?.id, !tab.isPlayingMedia, + !tab.mediaCaptureState.isCapturing, tab.type == .normal { closeTab(tab: tab) diff --git a/ora/Features/Tabs/Views/TabItem.swift b/ora/Features/Tabs/Views/TabItem.swift index 2f77eb6e..31500a24 100644 --- a/ora/Features/Tabs/Views/TabItem.swift +++ b/ora/Features/Tabs/Views/TabItem.swift @@ -46,6 +46,16 @@ struct FavIcon: View { let faviconLocalFile: URL? let textColor: Color var isPlayingMedia: Bool = false + var mediaCaptureState = BrowserMediaCaptureState() + + /// Camera first: it matters more than the microphone. + private var captureDevice: BrowserCaptureDevice? { + BrowserCaptureDevice.allCases.first { mediaCaptureState[$0] != .none } + } + + private var hasIndicator: Bool { + isPlayingMedia || captureDevice != nil + } var body: some View { HStack(spacing: 4) { @@ -70,7 +80,15 @@ struct FavIcon: View { ) } - if isPlayingMedia { + if let captureDevice { + let isPaused = mediaCaptureState[captureDevice] == .muted + Image(systemName: captureDevice.symbolName(isPaused: isPaused)) + .resizable() + .scaledToFit() + .frame(width: 8, height: 8) + .foregroundColor(isPaused ? textColor.opacity(0.8) : .red) + .help(isPaused ? "Paused" : "In use") + } else if isPlayingMedia { Image(systemName: "speaker.wave.2.fill") .resizable() .scaledToFit() @@ -78,7 +96,7 @@ struct FavIcon: View { .foregroundColor(textColor.opacity(0.8)) } } - .frame(width: isPlayingMedia ? 28 : 16, height: 16) + .frame(width: hasIndicator ? 28 : 16, height: 16) } } @@ -109,7 +127,8 @@ struct TabItem: View { favicon: tab.favicon, faviconLocalFile: tab.faviconLocalFile, textColor: textColor, - isPlayingMedia: tab.isPlayingMedia + isPlayingMedia: tab.isPlayingMedia, + mediaCaptureState: tab.mediaCaptureState ) tabTitle Spacer() 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 @@ <string>NSUserActivityTypeBrowsingWeb</string> </array> <key>SUEnableAutomaticChecks</key> - <true/> + <false/> <key>SUEnableInstallerLauncherService</key> <true/> <key>SUFeedURL</key> - <string>https://the-ora.github.io/browser/appcast.xml</string> + <string>https://barto95100.github.io/ora-browser/appcast.xml</string> <key>SUPublicEDKey</key> <string>Ozj+rezzbJAD76RfajtfQ7rFojJbpFSCl/0DcFSBCTI=</string> </dict> 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 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>com.apple.security.app-sandbox</key> + <true/> + <key>com.apple.security.assets.movies.read-only</key> + <true/> + <key>com.apple.security.assets.music.read-only</key> + <true/> + <key>com.apple.security.assets.pictures.read-only</key> + <true/> + <key>com.apple.security.cs.allow-jit</key> + <true/> + <key>com.apple.security.device.audio-input</key> + <true/> + <key>com.apple.security.device.camera</key> + <true/> + <key>com.apple.security.files.downloads.read-write</key> + <true/> + <key>com.apple.security.files.user-selected.read-write</key> + <true/> + <key>com.apple.security.network.client</key> + <true/> + <key>com.apple.security.print</key> + <true/> + <key>com.apple.security.temporary-exception.mach-lookup.global-name</key> + <array> + <string>$(PRODUCT_BUNDLE_IDENTIFIER)-spks</string> + <string>$(PRODUCT_BUNDLE_IDENTIFIER)-spki</string> + </array> +</dict> +</plist> 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 + } +} 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")) + } +} 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) + } +} diff --git a/oraTests/MediaCaptureStateTests.swift b/oraTests/MediaCaptureStateTests.swift new file mode 100644 index 00000000..79190e4f --- /dev/null +++ b/oraTests/MediaCaptureStateTests.swift @@ -0,0 +1,33 @@ +@testable import Ora +import Testing + +struct MediaCaptureStateTests { + @Test func nothingCapturedByDefault() { + let state = BrowserMediaCaptureState() + + #expect(!state.isCapturing) + #expect(state.mutedAfterToggling(.camera) == nil) + #expect(state.mutedAfterToggling(.microphone) == nil) + } + + @Test func togglingAnActiveDevicePausesIt() { + let state = BrowserMediaCaptureState(camera: .active, microphone: .none) + + #expect(state.isCapturing) + #expect(state.mutedAfterToggling(.camera) == true) + #expect(state.mutedAfterToggling(.microphone) == nil) + } + + @Test func togglingAPausedDeviceResumesIt() { + let state = BrowserMediaCaptureState(camera: .active, microphone: .muted) + + #expect(state.isCapturing) + #expect(state.mutedAfterToggling(.microphone) == false) + } + + @Test func aPausedDeviceStillCountsAsCapturing() { + let state = BrowserMediaCaptureState(camera: .muted, microphone: .none) + + #expect(state.isCapturing) + } +} 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) + } +} 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")) + } +} diff --git a/oraTests/SitePermissionTests.swift b/oraTests/SitePermissionTests.swift new file mode 100644 index 00000000..ecb6ea2c --- /dev/null +++ b/oraTests/SitePermissionTests.swift @@ -0,0 +1,185 @@ +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 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) + } +} + +// MARK: - SitePermissions + +struct SitePermissionsTests { + /// What `navigator.permissions.query()` tells the site. + @Test func permissionQueryReflectsTheSavedDecision() { + #expect(SitePermissionState.allow.permissionQueryDecision == .grant) + #expect(SitePermissionState.deny.permissionQueryDecision == .deny) + #expect(SitePermissionState.ask.permissionQueryDecision == .prompt) + } + + @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) ?? { + preconditionFailure("valid test origin") + }() + + /// A store backed by its own throwaway defaults suite. + private func makeStore(suite: String = "SitePermissionStoreTests.\(UUID().uuidString)") + -> (SitePermissionStore, UserDefaults) + { + guard let defaults = UserDefaults(suiteName: suite) else { + preconditionFailure("could not create defaults suite \(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 siteSetBackToAskStaysListed() { + let (store, _) = makeStore() + + store.setState(.allow, for: [.camera], origin: origin, in: space, isPrivate: false) + store.setState(.ask, for: [.camera], origin: origin, in: space, isPrivate: false) + + #expect(store.permissionsBySpace[space]?[origin]?.isUndecided == true) + } + + @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) + } +} diff --git a/oraTests/TabInitialTitleTests.swift b/oraTests/TabInitialTitleTests.swift new file mode 100644 index 00000000..2d4a64be --- /dev/null +++ b/oraTests/TabInitialTitleTests.swift @@ -0,0 +1,27 @@ +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")) + 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) + } +} diff --git a/oraTests/oraTests.swift b/oraTests/oraTests.swift index 3a522313..ddb2eceb 100644 --- a/oraTests/oraTests.swift +++ b/oraTests/oraTests.swift @@ -304,24 +304,54 @@ 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.mediaDeviceKinds == ["audioinput", "audiooutput", "videoinput"]) + #expect(profile.canvasShift > 0) + #expect(profile.audioNoise > 0) } @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")) + } + + /// The fake answer was always "prompt", even for a site the user allowed, + /// and a plain object without addEventListener broke sites watching the + /// permission state. WebKit answers like Safari. + /// The fake devices were named "ora-…", identifying Ora; WebKit already + /// hides device names and identifiers until the site is allowed, like Safari. + @Test func fingerprintingScriptLeavesMediaDevicesAlone() { + let script = BrowserPrivacyService.fingerprintingProtectionScriptSource() + + #expect(!script.contains("enumerateDevices")) + #expect(!script.contains("'ora-")) + } + + /// WebKit already hides the GPU like Safari; the fixed values contradicted + /// it (vendor "Apple Inc." instead of "WebKit", WebGL 1.0 for WebGL 2). + @Test func fingerprintingScriptLeavesWebGLAlone() { + let script = BrowserPrivacyService.fingerprintingProtectionScriptSource() + + #expect(!script.contains("WebGL")) + #expect(!script.contains("getParameter")) + } + + @Test func fingerprintingScriptLeavesPermissionsQueryAlone() { + let script = BrowserPrivacyService.fingerprintingProtectionScriptSource() + + #expect(!script.contains("permissions.query")) } @Test func trackerRegexMatchesRootAndSubdomains() throws { diff --git a/project.yml b/project.yml index c5297fc0..fb8baf12 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 @@ -159,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: diff --git a/scripts/xcbuild-debug.sh b/scripts/xcbuild-debug.sh index 82ac7336..b1c10a96 100755 --- a/scripts/xcbuild-debug.sh +++ b/scripts/xcbuild-debug.sh @@ -1,7 +1,11 @@ #!/bin/bash +# Pre-push check: build without signing, in its own DerivedData so the unsigned +# products never mix with the signed ones of Xcode and `xcodebuild test` +# (which then failed with "code object is not signed at all"). set -o pipefail && xcodebuild build \ -scheme ora \ -destination "platform=macOS" \ -configuration Debug \ + -derivedDataPath build/DerivedData \ CODE_SIGN_IDENTITY="" \ CODE_SIGNING_REQUIRED=NO | xcbeautify