diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..d2c0709
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,103 @@
+# Repository Guidance
+
+## Product and Toolchain
+
+- The public product name is **Lumen Browser**. Keep the historical
+ `MeridianBrowser`, `MeridianCore`, and other `Meridian*` names for Swift
+ packages, targets, modules, and existing internal symbols.
+- This is a SwiftPM-first native macOS app using SwiftUI, AppKit, and WebKit.
+- The supported development baseline is macOS 26, Xcode 26, and Swift 6.2.
+- Launch the foreground app bundle with `./script/build_and_run.sh`. Do not use
+ `swift run MeridianBrowser` as a substitute for app-bundle behavior.
+
+## Repository Layout
+
+- `Sources/MeridianBrowser`: application entry point and native menus.
+- `Sources/MeridianCore`: models, stores, services, security policy,
+ persistence, WebKit integration, and SwiftUI views.
+- `Tests/MeridianBrowserTests`: Swift unit and integration tests.
+- `DebugFixtures`: local-only HTML fixtures for WebKit behavior.
+- `Configuration`: app entitlements and related packaging configuration.
+- `Resources`: first-party application assets.
+- `Docs`: architecture, threat model, test plan, release checklist, and known
+ WebKit limitations.
+- `script/build_and_run.sh`: build, stage, sign, verify, launch, and local-log
+ workflow for `dist/Lumen Browser.app`.
+
+## Validation
+
+Run the repository baseline before handing off code changes:
+
+```sh
+swift build
+swift test
+bash -n script/build_and_run.sh
+git diff --check
+```
+
+- Add or update tests for behavior changes.
+- Run the baseline independently on each affected branch when a cherry-pick,
+ merge, or conflict resolution produces branch-specific code.
+- Use `./script/build_and_run.sh --verify` when app-bundle packaging or signing
+ behavior changes and the local environment can perform the check.
+- Signing, notarization, and full UI smoke tests remain manual unless the
+ repository gains an explicitly configured signing-capable test host.
+
+## Branch and Git Workflow
+
+- Both `dev` and `main` are CI branches.
+- Do not assume `dev` and `main` are byte-identical, fast-forwardable, or based
+ on the same commit hashes. Promotion and squash history can make equivalent
+ work appear as different commits.
+- Before cross-branch work, inspect:
+
+```sh
+git status --short --branch
+git branch -vv
+git log --left-right --graph --oneline dev...main
+```
+
+- To apply one local change to both divergent branches, commit it once and
+ normally cherry-pick that commit onto the other branch. Resolve conflicts in
+ the target branch's native structure; do not merge entire branches merely to
+ copy a single change.
+- Re-run tests on the target branch after any conflict resolution, then return
+ to the user's original branch unless they request otherwise.
+- Use concise, imperative, sentence-case commit subjects consistent with the
+ existing history, for example `Fix website microphone capture`.
+- Keep commits focused and public-history-ready. Do not commit `.build`,
+ `dist`, `.codex`, credentials, signing keys, profiles, or other ignored local
+ state.
+- A request to commit does not authorize a push. Push only when the user asks.
+
+## Security, Privacy, and WebKit
+
+- Keep entitlements minimal. For a capability that requires macOS permission,
+ inspect all relevant surfaces: packaged-app entitlements, local signing
+ entitlements when present, generated Info.plist usage descriptions, tests,
+ and documentation.
+- Treat browser state, URLs, cookies, tokens, credentials, profile identities,
+ and private browsing data as sensitive. Do not add telemetry or remote
+ diagnostics.
+- Persistent state must pass through the existing privacy filtering and repair
+ boundaries. Private-profile state must remain session-only and must not reach
+ disk.
+- Preserve profile isolation across web views, callbacks, cached snapshots,
+ history, permissions, credentials, and website data stores.
+- Route security-sensitive WebKit behavior through explicit policy/store state.
+ If WebKit cannot support a feature safely, document the limitation instead
+ of simulating unsafe compatibility.
+- Security-sensitive changes should update `Docs/ThreatModel.md` or
+ `Docs/TestPlan.md` when the control surface or acceptance criteria change.
+ Update `Docs/WebKitLimitations.md` for platform limitations or capability
+ dependencies.
+
+## Assets and Documentation
+
+- Use first-party assets under `Resources`.
+- Do not add third-party branding, icons, images, fonts, or generated assets
+ without documenting their source and license.
+- Keep user-facing copy branded as Lumen Browser while retaining established
+ internal Meridian names.
+- Keep README, contributor guidance, feature status, tests, and security
+ documentation aligned with implemented behavior.
diff --git a/Configuration/MeridianBrowser.entitlements b/Configuration/MeridianBrowser.entitlements
index ee95ab7..0004bfa 100644
--- a/Configuration/MeridianBrowser.entitlements
+++ b/Configuration/MeridianBrowser.entitlements
@@ -4,6 +4,12 @@
com.apple.security.app-sandbox
+ com.apple.developer.web-browser.public-key-credential
+
+ com.apple.security.device.audio-input
+
+ com.apple.security.device.bluetooth
+
com.apple.security.network.client
diff --git a/Configuration/MeridianBrowserLocal.entitlements b/Configuration/MeridianBrowserLocal.entitlements
new file mode 100644
index 0000000..7c22949
--- /dev/null
+++ b/Configuration/MeridianBrowserLocal.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.device.audio-input
+
+ com.apple.security.device.bluetooth
+
+
+
diff --git a/DebugFixtures/microphone.html b/DebugFixtures/microphone.html
new file mode 100644
index 0000000..c9adc51
--- /dev/null
+++ b/DebugFixtures/microphone.html
@@ -0,0 +1,98 @@
+
+
+
+
+
+ Microphone Capture Fixture
+
+
+
+
+ Microphone Capture Fixture
+ Starts an audio-only WebRTC capture and reports whether the track is live.
+ Start microphone
+ Ready
+
+
+
+
+
diff --git a/DebugFixtures/passkeys.html b/DebugFixtures/passkeys.html
new file mode 100644
index 0000000..2208000
--- /dev/null
+++ b/DebugFixtures/passkeys.html
@@ -0,0 +1,96 @@
+
+
+
+
+
+ Passkey Capability Fixture
+
+
+
+
+ Passkey Capability Fixture
+ Checks WebAuthn support without creating or sending a credential.
+ Checking…
+ Test passkey chooser
+ Ready
+
+
+
+
diff --git a/Docs/FeatureChecklist.md b/Docs/FeatureChecklist.md
index 960e0d4..b228ee1 100644
--- a/Docs/FeatureChecklist.md
+++ b/Docs/FeatureChecklist.md
@@ -18,6 +18,8 @@
- URL and download safety helpers.
- HTTPS-first upgrade attempts for non-local HTTP main-frame navigations, with controlled HTTP fallback warnings.
- Native pending confirmation for external app and local file URL handoff.
+- One-time default-browser onboarding for new and upgraded installations, with
+ macOS HTTP(S) handler registration and external-link delivery.
- WebKit download delegate handling with native destination approval and risky download confirmation.
- Opt-in HTTPS and loopback HTTP password-save prompt backed by local macOS Keychain storage for persistent profiles.
- Native password manager view with profile filtering and account/site search across saved persistent-profile accounts.
diff --git a/Docs/TestPlan.md b/Docs/TestPlan.md
index 9aa9ee5..4fe040b 100644
--- a/Docs/TestPlan.md
+++ b/Docs/TestPlan.md
@@ -28,6 +28,7 @@ Current coverage:
- Profile-scoped local history recording, private-profile exclusion, URL normalization before retention/restore, restored duplicate collapse, scoped querying, command-bar history result activation, active-profile clear, and individual history delete.
- Persistent profile creation and switching, including default profile space/tab seeding, command-bar profile results, public session persistence, active-profile open-tab/history scoping, and site-permission scoping.
- URL scheme security decisions.
+- Versioned one-time default-browser prompt policy for fresh and existing installations.
- Pending confirmation state for external app and local file URLs.
- Insecure HTTP detection, HTTPS-first explicit HTTP opens, controlled HTTP fallback warning publication, and stale insecure-status clearing after successful HTTPS updates.
- Download filename sanitization, risk classification, safe destination selection, and pending confirmation state.
@@ -56,6 +57,13 @@ Run Google account isolation in both directions: University → Personal and Per
Profile-isolation release acceptance additionally requires unique persistent website-store IDs, every tab resolving to its parent space's profile, no accepted stale callback after reassignment, no previous-profile snapshot flash, private-session disposal passing, and a clean full test run.
+Default-browser release acceptance requires checking both a fresh preferences domain
+and an upgraded preferences domain without `DefaultBrowserPromptVersion`. The prompt
+must appear once when Lumen Browser is not already the default, “Not Now” must prevent
+repeat prompting for this rollout, “Set as Default” must register both HTTP and HTTPS,
+and a link opened from another app must arrive in a new Lumen Browser tab. No prompt
+should appear when Lumen Browser already handles both schemes.
+
## Required Future Tests
- UI tests for creating spaces, folders, profiles, opening tabs, switching tabs, restoring sessions, and split view.
diff --git a/Docs/ThreatModel.md b/Docs/ThreatModel.md
index b1c798e..90e8605 100644
--- a/Docs/ThreatModel.md
+++ b/Docs/ThreatModel.md
@@ -21,6 +21,8 @@
## Current Controls
- URL navigation is centralized in `URLSecurityPolicy`.
+- HTTP(S) URLs delivered by macOS when Lumen Browser is the default browser enter
+ through the same `BrowserStore.open` and `URLSecurityPolicy` path as in-app opens.
- Unsafe script/data schemes are blocked.
- External app and `file://` links create a pending native confirmation before any external handoff, retaining the target URL for approval while reducing source page context to a sanitized host or scheme label.
- Non-local HTTP main-frame navigations are HTTPS-first where practical: explicit opens and WebKit main-frame HTTP actions first attempt the HTTPS equivalent, while localhost and loopback URLs remain HTTP for local development. If a tracked HTTPS upgrade attempt falls back to HTTP, Lumen Browser shows the generic insecure-transport status message without embedding the full URL.
@@ -41,7 +43,7 @@
- Password form submissions can create a native save prompt for HTTPS origins and loopback HTTP development origins. Accepted credentials are scoped to the current persistent profile and stored in the local macOS Keychain with this-device-only accessibility; private profiles do not prompt or persist passwords.
- Profile isolation covers website sessions and Lumen Browser-managed Keychain credentials. macOS/WebKit Password AutoFill suggestions are device-wide system behavior and are not claimed as profile-isolated; Lumen Browser does not use fragile webpage-specific suppression to hide them.
- The command bar returns open-tab matches only from the active profile. The intentional all-profile Activity view labels profile ownership and selects a matching space before navigation.
-- App Sandbox entitlement file includes only sandbox and outbound network client entitlement.
+- The local signed development bundle carries only the audio-input entitlement needed for user-approved website microphone capture. The separate App Sandbox entitlement draft includes sandbox, outbound network client, and audio input for future packaged builds.
- A small `WKContentRuleList` blocks common tracker/ad endpoints without request interception hacks.
- Lumen Browser does not collect product analytics, browsing telemetry, page contents, URLs, cookies, tokens, or private browsing data; website passwords are stored only after explicit local confirmation, and developer log modes stream only local OS logs.
diff --git a/Docs/WebKitLimitations.md b/Docs/WebKitLimitations.md
index d5513ab..7df8ffc 100644
--- a/Docs/WebKitLimitations.md
+++ b/Docs/WebKitLimitations.md
@@ -10,6 +10,7 @@ Lumen Browser uses `WKWebView`, not Safari's full browser process or Chromium. K
- Per-profile isolation depends on WebKit support for identified website data stores. Persistent profiles use `WKWebsiteDataStore(forIdentifier:)`; each private profile reuses one `.nonPersistent()` store until that private profile closes.
- macOS Password AutoFill suggestions can be surfaced by WebKit independently of Lumen Browser's profile-scoped Keychain credential store. These device-wide suggestions are outside the profile-isolation guarantee; webpage-specific suppression would be incomplete and fragile, so the UI distinguishes the two instead.
- Fine-grained permission APIs vary by macOS/WebKit version. In the macOS 26.2 SDK used for this slice, `WKUIDelegate` exposes media-capture permission callbacks for camera/microphone; Lumen Browser routes those through `SitePermissionPolicy`.
+- Microphone capture also depends on macOS authorization outside WebKit's per-site decision. The local app bundle includes `NSMicrophoneUsageDescription` and is signed with the audio-input entitlement so a WebKit grant can reach the system device prompt.
- Public-profile allow/deny decisions for supported site permissions can persist in Lumen Browser's session store. Private-profile permission decisions are session-only and filtered before disk writes.
- Lumen Browser's active-site permission menu can manage only WebKit delegate-backed permissions. Unsupported or configuration-only permissions are shown as limited instead of writing ineffective per-site settings.
- Geolocation and notification permission prompts do not have equivalent public macOS `WKUIDelegate` callbacks in this SDK, so Lumen Browser marks them unsupported and denies them conservatively until a safe API is available.
diff --git a/Sources/MeridianBrowser/MeridianBrowserApp.swift b/Sources/MeridianBrowser/MeridianBrowserApp.swift
index a603b67..8282cbc 100644
--- a/Sources/MeridianBrowser/MeridianBrowserApp.swift
+++ b/Sources/MeridianBrowser/MeridianBrowserApp.swift
@@ -11,6 +11,8 @@ struct MeridianBrowserApp: App {
private let sessionPersistence: SQLiteSessionPersistenceStore
private let historyPersistence: SQLiteLocalHistoryPersistenceStore
@StateObject private var store: BrowserStore
+ @StateObject private var passkeyAccessController = BrowserPasskeyAccessController()
+ @StateObject private var defaultBrowserManager = DefaultBrowserManager()
@Environment(\.scenePhase) private var scenePhase
init() {
@@ -44,7 +46,10 @@ struct MeridianBrowserApp: App {
var body: some Scene {
WindowGroup("Lumen Browser") {
- BrowserWindowView(store: store)
+ BrowserWindowView(
+ store: store,
+ initialAlertsCompleted: beginStartupPromptSequence
+ )
.frame(minWidth: 900, minHeight: 620)
.handlesExternalEvents(preferring: ["*"], allowing: ["*"])
.onOpenURL { url in
@@ -55,6 +60,36 @@ struct MeridianBrowserApp: App {
store.flushScheduledSessionPersistence()
}
}
+ .alert(
+ "Make Lumen Browser Your Default?",
+ isPresented: defaultBrowserPromptIsPresented
+ ) {
+ Button("Not Now", role: .cancel) {
+ defaultBrowserManager.dismissPrompt()
+ passkeyAccessController.requestAuthorizationIfNeeded()
+ }
+ Button("Set as Default") {
+ defaultBrowserManager.dismissPrompt()
+ Task {
+ let result = await defaultBrowserManager.setAsDefaultBrowser()
+ switch result {
+ case .succeeded:
+ store.publishStatusMessage(
+ "Lumen Browser is now your default browser."
+ )
+ case .failed:
+ store.publishStatusMessage(
+ "Lumen Browser could not become your default browser. You can choose it in System Settings."
+ )
+ }
+ passkeyAccessController.requestAuthorizationIfNeeded()
+ }
+ }
+ } message: {
+ Text(
+ "Open web links in Lumen Browser by default. You can change this later in System Settings."
+ )
+ }
}
.windowStyle(.hiddenTitleBar)
.defaultWindowPlacement { _, context in
@@ -182,6 +217,23 @@ struct MeridianBrowserApp: App {
private static func saveSidebarRevealEdge(_ edge: SidebarRevealEdge) {
UserDefaults.standard.set(edge.rawValue, forKey: Preferences.sidebarRevealEdgeKey)
}
+
+ private var defaultBrowserPromptIsPresented: Binding {
+ Binding(
+ get: { defaultBrowserManager.isPromptPresented },
+ set: { isPresented in
+ if !isPresented {
+ defaultBrowserManager.dismissPrompt()
+ }
+ }
+ )
+ }
+
+ private func beginStartupPromptSequence() {
+ if !defaultBrowserManager.preparePromptIfNeeded() {
+ passkeyAccessController.requestAuthorizationIfNeeded()
+ }
+ }
}
private struct BrowserNavigationCommandMenu: Commands {
diff --git a/Sources/MeridianCore/Models/BrowserContentPresentationState.swift b/Sources/MeridianCore/Models/BrowserContentPresentationState.swift
index 1f1e25d..fbb62e2 100644
--- a/Sources/MeridianCore/Models/BrowserContentPresentationState.swift
+++ b/Sources/MeridianCore/Models/BrowserContentPresentationState.swift
@@ -142,6 +142,23 @@ public final class BrowserContentPresentationState: ObservableObject {
}
}
+struct BrowserContentPreviewPlaceholder {
+ static func shouldShow(
+ for previewTab: BrowserTab?,
+ selectedTabID: TabID?,
+ snapshotIsAvailable: Bool
+ ) -> Bool {
+ guard let previewTab,
+ previewTab.id != selectedTabID,
+ previewTab.content.isWeb,
+ previewTab.url != nil else {
+ return false
+ }
+
+ return !snapshotIsAvailable
+ }
+}
+
struct BrowserSpaceFocusedTabResolver {
static func focusedTabID(
for space: BrowserSpace,
diff --git a/Sources/MeridianCore/Models/BrowserTab.swift b/Sources/MeridianCore/Models/BrowserTab.swift
index 6ecc944..3160fba 100644
--- a/Sources/MeridianCore/Models/BrowserTab.swift
+++ b/Sources/MeridianCore/Models/BrowserTab.swift
@@ -50,6 +50,18 @@ public enum BrowserTabContent: Hashable, Codable, Sendable {
}
}
+public struct BrowserEssentialReference: Hashable, Codable, Sendable {
+ public var title: String
+ public var url: URL
+ public var faviconURL: URL?
+
+ public init(title: String, url: URL, faviconURL: URL? = nil) {
+ self.title = title
+ self.url = url
+ self.faviconURL = faviconURL
+ }
+}
+
public struct BrowserTab: Identifiable, Hashable, Codable, Sendable {
public var id: TabID
public var title: String
@@ -60,6 +72,7 @@ public struct BrowserTab: Identifiable, Hashable, Codable, Sendable {
public var parentFolderID: FolderID?
public var isPinned: Bool
public var isFavorite: Bool
+ public var essentialReference: BrowserEssentialReference?
public internal(set) var profileID: ProfileID
public var lastActiveDate: Date
public var isLoading: Bool
@@ -77,6 +90,7 @@ public struct BrowserTab: Identifiable, Hashable, Codable, Sendable {
parentFolderID: FolderID? = nil,
isPinned: Bool = false,
isFavorite: Bool = false,
+ essentialReference: BrowserEssentialReference? = nil,
profileID: ProfileID,
lastActiveDate: Date = Date(),
isLoading: Bool = false,
@@ -93,6 +107,11 @@ public struct BrowserTab: Identifiable, Hashable, Codable, Sendable {
self.parentFolderID = parentFolderID
self.isPinned = isPinned
self.isFavorite = isFavorite
+ self.essentialReference = isFavorite
+ ? essentialReference ?? url.map {
+ BrowserEssentialReference(title: title, url: $0, faviconURL: faviconURL)
+ }
+ : nil
self.profileID = profileID
self.lastActiveDate = lastActiveDate
self.isLoading = isLoading
@@ -111,6 +130,7 @@ public struct BrowserTab: Identifiable, Hashable, Codable, Sendable {
case parentFolderID
case isPinned
case isFavorite
+ case essentialReference
case profileID
case lastActiveDate
case isLoading
@@ -131,6 +151,10 @@ public struct BrowserTab: Identifiable, Hashable, Codable, Sendable {
parentFolderID: try container.decodeIfPresent(FolderID.self, forKey: .parentFolderID),
isPinned: try container.decode(Bool.self, forKey: .isPinned),
isFavorite: try container.decode(Bool.self, forKey: .isFavorite),
+ essentialReference: try container.decodeIfPresent(
+ BrowserEssentialReference.self,
+ forKey: .essentialReference
+ ),
profileID: try container.decode(ProfileID.self, forKey: .profileID),
lastActiveDate: try container.decodeIfPresent(Date.self, forKey: .lastActiveDate) ?? Date(),
isLoading: try container.decodeIfPresent(Bool.self, forKey: .isLoading) ?? false,
@@ -142,6 +166,27 @@ public struct BrowserTab: Identifiable, Hashable, Codable, Sendable {
) ?? .init()
)
}
+
+ public var essentialDisplayTitle: String {
+ guard isFavorite, let essentialReference else {
+ return title
+ }
+ return essentialReference.title
+ }
+
+ public var essentialDisplayURL: URL? {
+ guard isFavorite, let essentialReference else {
+ return url
+ }
+ return essentialReference.url
+ }
+
+ public var essentialDisplayFaviconURL: URL? {
+ guard isFavorite, let essentialReference else {
+ return faviconURL
+ }
+ return essentialReference.faviconURL
+ }
}
public enum BrowserTabPlacement: String, Equatable, Sendable {
diff --git a/Sources/MeridianCore/Security/BrowserPasskeyAccessController.swift b/Sources/MeridianCore/Security/BrowserPasskeyAccessController.swift
new file mode 100644
index 0000000..4516009
--- /dev/null
+++ b/Sources/MeridianCore/Security/BrowserPasskeyAccessController.swift
@@ -0,0 +1,102 @@
+import AuthenticationServices
+import Combine
+import OSLog
+import Security
+
+private let browserPasskeyLogger = Logger(
+ subsystem: Bundle.main.bundleIdentifier ?? "MeridianBrowser",
+ category: "Passkeys"
+)
+
+public enum BrowserPasskeyAuthorizationState: Equatable, Sendable {
+ case authorized
+ case denied
+ case notDetermined
+
+ init(_ state: ASAuthorizationWebBrowserPublicKeyCredentialManager.AuthorizationState) {
+ switch state {
+ case .authorized:
+ self = .authorized
+ case .denied:
+ self = .denied
+ case .notDetermined:
+ self = .notDetermined
+ @unknown default:
+ self = .denied
+ }
+ }
+}
+
+public enum BrowserPasskeyAccessPolicy {
+ public static func shouldRequestAuthorization(
+ isBrowserEntitled: Bool,
+ state: BrowserPasskeyAuthorizationState,
+ didRequestThisLaunch: Bool
+ ) -> Bool {
+ isBrowserEntitled
+ && state == .notDetermined
+ && !didRequestThisLaunch
+ }
+}
+
+@MainActor
+public final class BrowserPasskeyAccessController: ObservableObject {
+ public static let browserEntitlement =
+ "com.apple.developer.web-browser.public-key-credential"
+
+ @Published public private(set) var authorizationState: BrowserPasskeyAuthorizationState
+ public let isBrowserEntitled: Bool
+
+ private let credentialManager: ASAuthorizationWebBrowserPublicKeyCredentialManager
+ private var didRequestThisLaunch = false
+
+ public init() {
+ let credentialManager = ASAuthorizationWebBrowserPublicKeyCredentialManager()
+ self.credentialManager = credentialManager
+ self.authorizationState = BrowserPasskeyAuthorizationState(
+ credentialManager.authorizationStateForPlatformCredentials
+ )
+ self.isBrowserEntitled = Self.signedEntitlementIsEnabled(
+ Self.browserEntitlement
+ )
+ }
+
+ public func requestAuthorizationIfNeeded() {
+ guard BrowserPasskeyAccessPolicy.shouldRequestAuthorization(
+ isBrowserEntitled: isBrowserEntitled,
+ state: authorizationState,
+ didRequestThisLaunch: didRequestThisLaunch
+ ) else {
+ if !isBrowserEntitled {
+ browserPasskeyLogger.notice(
+ "browser passkey access unavailable because the signed browser entitlement is missing"
+ )
+ }
+ return
+ }
+
+ didRequestThisLaunch = true
+ credentialManager.requestAuthorizationForPublicKeyCredentials { [weak self] state in
+ let resolvedState = BrowserPasskeyAuthorizationState(state)
+ Task { @MainActor [weak self] in
+ self?.authorizationState = resolvedState
+ browserPasskeyLogger.info(
+ "browser passkey authorization completed state=\(String(describing: resolvedState), privacy: .public)"
+ )
+ }
+ }
+ }
+
+ private static func signedEntitlementIsEnabled(_ entitlement: String) -> Bool {
+ guard let task = SecTaskCreateFromSelf(nil),
+ let value = SecTaskCopyValueForEntitlement(
+ task,
+ entitlement as CFString,
+ nil
+ ) else {
+ return false
+ }
+
+ return value as? Bool == true
+ }
+}
diff --git a/Sources/MeridianCore/Services/DefaultBrowserManager.swift b/Sources/MeridianCore/Services/DefaultBrowserManager.swift
new file mode 100644
index 0000000..e45e0c8
--- /dev/null
+++ b/Sources/MeridianCore/Services/DefaultBrowserManager.swift
@@ -0,0 +1,122 @@
+import AppKit
+import Combine
+import Foundation
+
+public enum DefaultBrowserPromptPolicy {
+ public static let currentPromptVersion = 1
+ public static let promptVersionStorageKey = "DefaultBrowserPromptVersion"
+
+ public static func shouldPresentPrompt(
+ isDefaultBrowser: Bool,
+ defaults: UserDefaults = .standard
+ ) -> Bool {
+ !isDefaultBrowser
+ && defaults.integer(forKey: promptVersionStorageKey) < currentPromptVersion
+ }
+
+ public static func hasHandledCurrentPrompt(
+ defaults: UserDefaults = .standard
+ ) -> Bool {
+ defaults.integer(forKey: promptVersionStorageKey) >= currentPromptVersion
+ }
+
+ public static func markCurrentPromptHandled(
+ defaults: UserDefaults = .standard
+ ) {
+ defaults.set(currentPromptVersion, forKey: promptVersionStorageKey)
+ }
+}
+
+public enum DefaultBrowserRegistrationResult: Equatable, Sendable {
+ case succeeded
+ case failed
+}
+
+@MainActor
+public final class DefaultBrowserManager: ObservableObject {
+ @Published public private(set) var isPromptPresented = false
+
+ private static let webSchemes = ["https", "http"]
+
+ private let workspace: NSWorkspace
+ private let defaults: UserDefaults
+ private let applicationURL: URL
+ private let bundleIdentifier: String?
+
+ public init(
+ workspace: NSWorkspace = .shared,
+ defaults: UserDefaults = .standard,
+ applicationURL: URL = Bundle.main.bundleURL,
+ bundleIdentifier: String? = Bundle.main.bundleIdentifier
+ ) {
+ self.workspace = workspace
+ self.defaults = defaults
+ self.applicationURL = applicationURL
+ self.bundleIdentifier = bundleIdentifier
+ }
+
+ @discardableResult
+ public func preparePromptIfNeeded() -> Bool {
+ guard applicationURL.pathExtension == "app",
+ bundleIdentifier != nil,
+ !DefaultBrowserPromptPolicy.hasHandledCurrentPrompt(defaults: defaults) else {
+ return false
+ }
+
+ let isDefaultBrowser = Self.webSchemes.allSatisfy(isDefaultHandler(for:))
+ let shouldPresent = DefaultBrowserPromptPolicy.shouldPresentPrompt(
+ isDefaultBrowser: isDefaultBrowser,
+ defaults: defaults
+ )
+ DefaultBrowserPromptPolicy.markCurrentPromptHandled(defaults: defaults)
+ isPromptPresented = shouldPresent
+ return shouldPresent
+ }
+
+ public func dismissPrompt() {
+ isPromptPresented = false
+ }
+
+ public func setAsDefaultBrowser() async -> DefaultBrowserRegistrationResult {
+ dismissPrompt()
+
+ for scheme in Self.webSchemes where !isDefaultHandler(for: scheme) {
+ do {
+ try await setAsDefaultHandler(for: scheme)
+ } catch {
+ return .failed
+ }
+ }
+
+ return Self.webSchemes.allSatisfy(isDefaultHandler(for:))
+ ? .succeeded
+ : .failed
+ }
+
+ private func isDefaultHandler(for scheme: String) -> Bool {
+ guard let bundleIdentifier,
+ let testURL = URL(string: "\(scheme)://example.com"),
+ let handlerURL = workspace.urlForApplication(toOpen: testURL),
+ let handlerBundle = Bundle(url: handlerURL) else {
+ return false
+ }
+ return handlerBundle.bundleIdentifier == bundleIdentifier
+ }
+
+ private func setAsDefaultHandler(for scheme: String) async throws {
+ try await withCheckedThrowingContinuation { (
+ continuation: CheckedContinuation
+ ) in
+ workspace.setDefaultApplication(
+ at: applicationURL,
+ toOpenURLsWithScheme: scheme
+ ) { error in
+ if let error {
+ continuation.resume(throwing: error)
+ } else {
+ continuation.resume()
+ }
+ }
+ }
+ }
+}
diff --git a/Sources/MeridianCore/Services/SessionIntegrityRepair.swift b/Sources/MeridianCore/Services/SessionIntegrityRepair.swift
index 00580c5..1ce1fb8 100644
--- a/Sources/MeridianCore/Services/SessionIntegrityRepair.swift
+++ b/Sources/MeridianCore/Services/SessionIntegrityRepair.swift
@@ -60,6 +60,17 @@ enum SessionIntegrityRepair {
tabs[index].isPinned = false
report.ownershipListsRebuilt += 1
}
+ if tabs[index].isFavorite {
+ if tabs[index].essentialReference == nil, let url = tabs[index].url {
+ tabs[index].essentialReference = BrowserEssentialReference(
+ title: tabs[index].title,
+ url: url,
+ faviconURL: tabs[index].faviconURL
+ )
+ }
+ } else {
+ tabs[index].essentialReference = nil
+ }
}
rebuildFolderRelationships(folders: &folders, tabs: tabs, report: &report)
diff --git a/Sources/MeridianCore/Stores/BrowserStore.swift b/Sources/MeridianCore/Stores/BrowserStore.swift
index f9ec551..bc1c8f8 100644
--- a/Sources/MeridianCore/Stores/BrowserStore.swift
+++ b/Sources/MeridianCore/Stores/BrowserStore.swift
@@ -74,6 +74,7 @@ public final class BrowserStore: ObservableObject {
private let profileWebsiteDataStoreDeleter: ProfileWebsiteDataStoreDeleting
private var localHistoryStore: LocalHistoryStore
private var pendingDownloadCompletion: (@MainActor (URL?) -> Void)?
+ private var pendingSitePermissionResolution: (@MainActor (SitePermissionPolicy.Evaluation) -> Void)?
private var downloadCancellationHandlers: [UUID: @MainActor () -> Void]
private var scheduledSessionPersistenceTask: Task?
@@ -134,6 +135,7 @@ public final class BrowserStore: ObservableObject {
self.profileWebsiteDataStoreDeleter = profileWebsiteDataStoreDeleter
self.localHistoryStore = localHistoryStore
self.pendingDownloadCompletion = nil
+ self.pendingSitePermissionResolution = nil
self.downloadCancellationHandlers = [:]
sortDownloads()
@@ -455,7 +457,7 @@ public final class BrowserStore: ObservableObject {
sitePermissionSettings.removeAll { $0.profileID == profileID }
downloads.removeAll { $0.profileID == profileID }
if pendingSitePermissionRequest?.profileID == profileID {
- pendingSitePermissionRequest = nil
+ cancelPendingSitePermissionRequest()
}
if pendingPasswordSaveRequest?.profileID == profileID {
pendingPasswordSaveRequest = nil
@@ -588,7 +590,7 @@ public final class BrowserStore: ObservableObject {
sitePermissionSettings.removeAll { $0.profileID == id }
downloads.removeAll { $0.profileID == id }
if pendingSitePermissionRequest?.profileID == id {
- pendingSitePermissionRequest = nil
+ cancelPendingSitePermissionRequest()
}
if pendingPasswordSaveRequest?.profileID == id {
pendingPasswordSaveRequest = nil
@@ -782,6 +784,22 @@ public final class BrowserStore: ObservableObject {
persistSession()
}
+ @discardableResult
+ public func activateTab(_ id: TabID) -> Bool {
+ guard let tab = tabs.first(where: { $0.id == id }) else {
+ return false
+ }
+
+ let essentialURL = tab.isFavorite ? tab.essentialReference?.url : nil
+ selectTab(id)
+
+ if let essentialURL, tab.url != essentialURL {
+ navigateActiveTab(to: essentialURL)
+ }
+
+ return true
+ }
+
public func closeSelectedTab() {
guard let selectedTabID else {
return
@@ -829,6 +847,19 @@ public final class BrowserStore: ObservableObject {
tab.parentSpaceID = targetSpaceID
tab.parentFolderID = nil
tab.profileID = targetSpace.profileID
+ if placement == .favorite {
+ if !tab.isFavorite || tab.essentialReference == nil {
+ tab.essentialReference = tab.url.map {
+ BrowserEssentialReference(
+ title: tab.title,
+ url: $0,
+ faviconURL: tab.faviconURL
+ )
+ }
+ }
+ } else {
+ tab.essentialReference = nil
+ }
tab.isPinned = placement == .pinned
tab.isFavorite = placement == .favorite
}
@@ -898,6 +929,7 @@ public final class BrowserStore: ObservableObject {
tab.profileID = targetSpace.profileID
tab.isPinned = false
tab.isFavorite = false
+ tab.essentialReference = nil
}
for index in spaces.indices {
@@ -952,6 +984,15 @@ public final class BrowserStore: ObservableObject {
updateTab(tabID) { tab in
tab.parentFolderID = nil
+ tab.essentialReference = placement == .favorite
+ ? tab.url.map {
+ BrowserEssentialReference(
+ title: tab.title,
+ url: $0,
+ faviconURL: tab.faviconURL
+ )
+ }
+ : nil
tab.isPinned = placement == .pinned
tab.isFavorite = placement == .favorite
}
@@ -1337,11 +1378,13 @@ public final class BrowserStore: ObservableObject {
kind: SitePermissionKind,
origin: SitePermissionOrigin?,
profileID: ProfileID? = nil,
- date: Date = Date()
+ date: Date = Date(),
+ resolutionHandler: (@MainActor (SitePermissionPolicy.Evaluation) -> Void)? = nil
) -> SitePermissionPolicy.Evaluation {
+ cancelPendingSitePermissionRequest()
+
guard let origin else {
let reason = "Site permission request was blocked because its origin is unavailable."
- pendingSitePermissionRequest = nil
lastUserMessage = reason
return .deny(reason: reason)
}
@@ -1349,7 +1392,6 @@ public final class BrowserStore: ObservableObject {
let resolvedProfileID = profileID ?? activeProfile?.id
guard let profile = profiles.first(where: { $0.id == resolvedProfileID }) else {
let reason = "Site permission request was blocked because its profile is unavailable."
- pendingSitePermissionRequest = nil
lastUserMessage = reason
return .deny(reason: reason)
}
@@ -1365,13 +1407,12 @@ public final class BrowserStore: ObservableObject {
switch evaluation {
case .allow:
- pendingSitePermissionRequest = nil
lastUserMessage = nil
case .ask:
pendingSitePermissionRequest = request
+ pendingSitePermissionResolution = resolutionHandler
lastUserMessage = request.promptMessage
case .deny(let reason):
- pendingSitePermissionRequest = nil
lastUserMessage = reason
}
@@ -1397,8 +1438,10 @@ public final class BrowserStore: ObservableObject {
shouldPersist = false
}
- pendingSitePermissionRequest = nil
let evaluation = sitePermissionPolicy.evaluation(for: decision, kind: request.kind)
+ let resolutionHandler = pendingSitePermissionResolution
+ pendingSitePermissionRequest = nil
+ pendingSitePermissionResolution = nil
switch evaluation {
case .allow:
lastUserMessage = "\(request.kind.displayName.capitalized) allowed for \(request.origin.displayString)."
@@ -1410,11 +1453,21 @@ public final class BrowserStore: ObservableObject {
if shouldPersist {
persistSession(date: date)
}
+ resolutionHandler?(evaluation)
return evaluation
}
public func cancelPendingSitePermissionRequest() {
+ guard let request = pendingSitePermissionRequest else {
+ pendingSitePermissionResolution = nil
+ return
+ }
+
+ let resolutionHandler = pendingSitePermissionResolution
pendingSitePermissionRequest = nil
+ pendingSitePermissionResolution = nil
+ lastUserMessage = nil
+ resolutionHandler?(sitePermissionPolicy.evaluation(for: .deny, kind: request.kind))
}
public func sitePermissionDecision(
@@ -1982,6 +2035,14 @@ public final class BrowserStore: ObservableObject {
updatedTab.url = url ?? updatedTab.url
updatedTab.isLoading = isLoading
updatedTab.restorationMetadata.lastCommittedURL = url ?? updatedTab.restorationMetadata.lastCommittedURL
+ if updatedTab.isFavorite,
+ var essentialReference = updatedTab.essentialReference,
+ url == essentialReference.url,
+ let title,
+ !title.isEmpty {
+ essentialReference.title = title
+ updatedTab.essentialReference = essentialReference
+ }
if let updatedURL = url {
updateHTTPSUpgradeFallbackMetadata(for: &updatedTab, committedURL: updatedURL)
}
@@ -2025,6 +2086,12 @@ public final class BrowserStore: ObservableObject {
}
tabs[tabIndex].faviconURL = resolvedFaviconURL
+ if tabs[tabIndex].isFavorite,
+ var essentialReference = tabs[tabIndex].essentialReference,
+ tabs[tabIndex].url == essentialReference.url {
+ essentialReference.faviconURL = resolvedFaviconURL
+ tabs[tabIndex].essentialReference = essentialReference
+ }
persistSession()
}
diff --git a/Sources/MeridianCore/Views/Browser/BrowserContentView.swift b/Sources/MeridianCore/Views/Browser/BrowserContentView.swift
index fb7411e..d3b8bc8 100644
--- a/Sources/MeridianCore/Views/Browser/BrowserContentView.swift
+++ b/Sources/MeridianCore/Views/Browser/BrowserContentView.swift
@@ -10,6 +10,7 @@ private let browserContentLogger = Logger(
private let activeTabSnapshotHandoffDelayNanoseconds: UInt64 = 90_000_000
public struct BrowserContentView: View {
+ @Environment(\.colorScheme) private var colorScheme
@ObservedObject private var store: BrowserStore
@ObservedObject private var webViewState: WebViewState
@ObservedObject private var presentationState: BrowserContentPresentationState
@@ -54,29 +55,6 @@ public struct BrowserContentView: View {
.padding(.trailing, 16)
.padding(.bottom, 16)
}
- .overlay(alignment: .topTrailing) {
- if let profile = activeWebProfile,
- activeWebTab != nil,
- !activityPageIsSelected {
- HStack(spacing: 5) {
- Circle()
- .fill(Color(hex: profile.colorHex))
- .frame(width: 7, height: 7)
- Text(profile.name)
- .font(.caption2.weight(.semibold))
- .lineLimit(1)
- }
- .padding(.horizontal, 9)
- .frame(height: 24)
- .background(.ultraThinMaterial, in: Capsule())
- .overlay {
- Capsule().stroke(.separator.opacity(0.35), lineWidth: 0.5)
- }
- .padding(12)
- .help("Website data profile: \(profile.name)")
- .accessibilityLabel("Website data profile \(profile.name)")
- }
- }
.animation(.snappy(duration: 0.18), value: store.lastUserMessage)
.animation(.snappy(duration: 0.18), value: store.primaryActiveDownload?.id)
.animation(.snappy(duration: 0.18), value: store.activeDownloads.count)
@@ -251,14 +229,15 @@ public struct BrowserContentView: View {
return
}
store.failDownload(downloadID, message: message)
- } onSitePermissionRequest: { identity, kind, origin in
+ } onSitePermissionRequest: { identity, kind, origin, resolutionHandler in
guard isSelected(identity: identity) else {
return .deny(reason: "Site permission request was blocked because its profile session is no longer active.")
}
return store.requestSitePermission(
kind: kind,
origin: origin,
- profileID: identity.profileID
+ profileID: identity.profileID,
+ resolutionHandler: resolutionHandler
)
} onPasswordCredentialCaptured: { identity, candidate in
guard isSelected(identity: identity) else {
@@ -295,6 +274,8 @@ public struct BrowserContentView: View {
customizationPreviewSurface
+ unloadedPreviewSurface
+
snapshotOverlay
}
}
@@ -398,6 +379,32 @@ public struct BrowserContentView: View {
return store.profiles.first { $0.id == identity.profileID }
}
+ @ViewBuilder
+ private var unloadedPreviewSurface: some View {
+ if unloadedPreviewSurfaceIsVisible {
+ Color(nsColor: BrowserWebContentAppearance.underPageBackgroundColor(for: colorScheme))
+ .allowsHitTesting(false)
+ .accessibilityHidden(true)
+ }
+ }
+
+ private var unloadedPreviewSurfaceIsVisible: Bool {
+ let previewTab = presentationState.previewTabID.flatMap { previewTabID in
+ store.tabs.first(where: { $0.id == previewTabID })
+ }
+ let snapshotIsAvailable = previewTab.flatMap { tab in
+ presentationState.snapshot(for: store.profileContext(for: tab.id))
+ } != nil
+
+ return !activityPageIsSelected
+ && previewCustomizationContext == nil
+ && BrowserContentPreviewPlaceholder.shouldShow(
+ for: previewTab,
+ selectedTabID: store.selectedTabID,
+ snapshotIsAvailable: snapshotIsAvailable
+ )
+ }
+
@ViewBuilder
private var snapshotOverlay: some View {
if let image = snapshotOverlayImage {
@@ -559,7 +566,7 @@ public struct BrowserContentView: View {
private func selectOverviewTab(_ id: TabID) {
presentationState.beginSnapshotHandoff(to: store.profileContext(for: id))
withTransaction(Transaction(animation: nil)) {
- store.selectTab(id)
+ _ = store.activateTab(id)
}
activityPageIsSelected = false
}
@@ -1213,7 +1220,7 @@ private struct BrowserSpaceContentPreviewTabRow: View {
.frame(width: 16, height: 18)
VStack(alignment: .leading, spacing: 2) {
- Text(item.tab.title)
+ Text(item.tab.essentialDisplayTitle)
.font(.callout)
.lineLimit(1)
@@ -1238,7 +1245,7 @@ private struct BrowserSpaceContentPreviewTabRow: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
- .help(item.tab.title)
+ .help(item.tab.essentialDisplayTitle)
}
private var subtitle: String? {
@@ -1248,7 +1255,7 @@ private struct BrowserSpaceContentPreviewTabRow: View {
case .passwordManager:
return "Saved Passwords"
case .web:
- return item.tab.url?.host(percentEncoded: false)
+ return item.tab.essentialDisplayURL?.host(percentEncoded: false)
}
}
diff --git a/Sources/MeridianCore/Views/BrowserWindowView.swift b/Sources/MeridianCore/Views/BrowserWindowView.swift
index aa66338..69dc055 100644
--- a/Sources/MeridianCore/Views/BrowserWindowView.swift
+++ b/Sources/MeridianCore/Views/BrowserWindowView.swift
@@ -35,6 +35,7 @@ private enum BrowserSidebarSizing {
public struct BrowserWindowView: View {
@ObservedObject private var store: BrowserStore
+ private let initialAlertsCompleted: @MainActor () -> Void
@Environment(\.colorScheme) private var colorScheme
@StateObject private var webViewState = WebViewState()
@StateObject private var webViewRegistry = BrowserWebViewRegistry()
@@ -54,6 +55,7 @@ public struct BrowserWindowView: View {
@State private var activityPageIsSelected = false
@State private var sidebarThemeColorPickerSpaceID: SpaceID?
@State private var showsProfileIsolationRepairAlert = false
+ @State private var didCompleteInitialAlerts = false
@Namespace private var passwordPromptGlassNamespace
private let floatingSidebarInset: CGFloat = 8
private let floatingSidebarCornerRadius: CGFloat = 12
@@ -75,8 +77,12 @@ public struct BrowserWindowView: View {
sidebarWidth
}
- public init(store: BrowserStore) {
+ public init(
+ store: BrowserStore,
+ initialAlertsCompleted: @escaping @MainActor () -> Void = {}
+ ) {
self.store = store
+ self.initialAlertsCompleted = initialAlertsCompleted
}
public var body: some View {
@@ -103,6 +109,9 @@ public struct BrowserWindowView: View {
}
.onAppear {
showsProfileIsolationRepairAlert = store.profileIsolationRepairOccurredThisLaunch
+ if !showsProfileIsolationRepairAlert {
+ completeInitialAlertsIfNeeded()
+ }
}
.alert("Profile Isolation Repaired", isPresented: $showsProfileIsolationRepairAlert) {
Button("Copy Diagnostics") {
@@ -112,8 +121,11 @@ public struct BrowserWindowView: View {
store.profileIsolationDiagnostics().redactedText,
forType: .string
)
+ completeInitialAlertsIfNeeded()
+ }
+ Button("OK", role: .cancel) {
+ completeInitialAlertsIfNeeded()
}
- Button("OK", role: .cancel) {}
} message: {
Text(store.profileIsolationRepairReport.userMessage ?? "Lumen Browser repaired saved profile assignments before browsing began.")
}
@@ -209,6 +221,17 @@ public struct BrowserWindowView: View {
.focusedSceneValue(\.browserNavigationCommandContext, browserNavigationCommandContext)
}
+ private func completeInitialAlertsIfNeeded() {
+ guard !didCompleteInitialAlerts else {
+ return
+ }
+ didCompleteInitialAlerts = true
+ Task { @MainActor in
+ await Task.yield()
+ initialAlertsCompleted()
+ }
+ }
+
private var profileManagementRequestBinding: Binding {
Binding(
get: { store.profileManagementRequest },
diff --git a/Sources/MeridianCore/Views/Sidebar/SidebarFavoriteTabGrid.swift b/Sources/MeridianCore/Views/Sidebar/SidebarFavoriteTabGrid.swift
index 18bd563..b558b22 100644
--- a/Sources/MeridianCore/Views/Sidebar/SidebarFavoriteTabGrid.swift
+++ b/Sources/MeridianCore/Views/Sidebar/SidebarFavoriteTabGrid.swift
@@ -16,7 +16,7 @@ struct SidebarFavoriteTabGrid: View {
var body: some View {
responsiveGrid
.padding(.horizontal, SidebarFavoriteGridLayout.horizontalPadding)
- .padding(.bottom, 2)
+ .padding(.bottom, 4)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onDrop(
@@ -136,9 +136,9 @@ private struct SidebarFavoriteTabDropDelegate: DropDelegate {
struct SidebarFavoriteGridLayout {
static let minColumnCount = 2
static let maxColumnCount = 4
- static let tileSize: CGFloat = 34
- static let spacing: CGFloat = 7
- static let horizontalPadding: CGFloat = 2
+ static let tileSize: CGFloat = 40
+ static let spacing: CGFloat = 8
+ static let horizontalPadding: CGFloat = 0
static func preferredColumnCount(for itemCount: Int) -> Int {
guard itemCount > 0 else {
@@ -198,19 +198,19 @@ private struct SidebarFavoriteTabTile: View {
var body: some View {
Button(action: select) {
- SidebarTabFaviconView(tab: item.tab, size: 18)
+ SidebarTabFaviconView(tab: item.tab, size: 20)
.frame(width: SidebarFavoriteGridLayout.tileSize, height: SidebarFavoriteGridLayout.tileSize)
.frame(maxWidth: .infinity, minHeight: SidebarFavoriteGridLayout.tileSize)
.background(tileBackground)
.overlay {
- RoundedRectangle(cornerRadius: 8, style: .continuous)
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(tileBorderColor, lineWidth: 0.5)
}
- .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+ .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity)
- .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+ .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.onHover { isHovered = $0 }
.onDrag {
dragStarted()
@@ -250,7 +250,7 @@ private struct SidebarFavoriteTabTile: View {
}
}
.help(helpText)
- .accessibilityLabel("Open \(item.tab.title)")
+ .accessibilityLabel("Open \(item.tab.essentialDisplayTitle)")
.accessibilityAddTraits(item.isSelected ? .isSelected : [])
}
@@ -294,16 +294,16 @@ private struct SidebarFavoriteTabTile: View {
}
private var helpText: String {
- guard let url = item.tab.url?.absoluteString else {
- return item.tab.title
+ guard let url = item.tab.essentialDisplayURL?.absoluteString else {
+ return item.tab.essentialDisplayTitle
}
- return "\(item.tab.title)\n\(url)"
+ return "\(item.tab.essentialDisplayTitle)\n\(url)"
}
}
struct SidebarTabFaviconSource {
static func url(for tab: BrowserTab) -> URL? {
- tab.faviconURL ?? rootFaviconURL(for: tab.url)
+ tab.essentialDisplayFaviconURL ?? rootFaviconURL(for: tab.essentialDisplayURL)
}
private static func rootFaviconURL(for url: URL?) -> URL? {
diff --git a/Sources/MeridianCore/Views/Sidebar/SidebarLayoutMetrics.swift b/Sources/MeridianCore/Views/Sidebar/SidebarLayoutMetrics.swift
new file mode 100644
index 0000000..2ea65c5
--- /dev/null
+++ b/Sources/MeridianCore/Views/Sidebar/SidebarLayoutMetrics.swift
@@ -0,0 +1,17 @@
+import SwiftUI
+
+enum SidebarLayoutMetrics {
+ static let contentHorizontalInset: CGFloat = 16
+ static let pageSectionSpacing: CGFloat = 16
+
+ static let rowIconSize: CGFloat = 18
+ static let rowFontSize: CGFloat = 14
+ static let rowLeadingInset: CGFloat = 8
+ static let rowTrailingInset: CGFloat = 6
+ static let rowVerticalInset: CGFloat = 6
+ static let rowCornerRadius: CGFloat = 8
+
+ static let sectionHeaderHorizontalInset: CGFloat = 8
+ static let sectionHeaderTopInset: CGFloat = 4
+ static let sectionHeaderBottomInset: CGFloat = 2
+}
diff --git a/Sources/MeridianCore/Views/Sidebar/SidebarSectionHeader.swift b/Sources/MeridianCore/Views/Sidebar/SidebarSectionHeader.swift
index cf33af8..f15a217 100644
--- a/Sources/MeridianCore/Views/Sidebar/SidebarSectionHeader.swift
+++ b/Sources/MeridianCore/Views/Sidebar/SidebarSectionHeader.swift
@@ -10,19 +10,20 @@ public struct SidebarSectionHeader: View {
}
public var body: some View {
- HStack(spacing: 5) {
+ HStack(spacing: 6) {
Image(systemName: symbolName)
- .font(.caption2)
+ .font(.system(size: 12, weight: .semibold))
.accessibilityHidden(true)
Text(title)
- .font(.caption)
+ .font(.system(size: 12, weight: .semibold))
.textCase(.uppercase)
.lineLimit(1)
.truncationMode(.tail)
Spacer()
}
.foregroundStyle(.secondary)
- .padding(.top, 2)
- .padding(.horizontal, 4)
+ .padding(.top, SidebarLayoutMetrics.sectionHeaderTopInset)
+ .padding(.bottom, SidebarLayoutMetrics.sectionHeaderBottomInset)
+ .padding(.horizontal, SidebarLayoutMetrics.sectionHeaderHorizontalInset)
}
}
diff --git a/Sources/MeridianCore/Views/Sidebar/SidebarTabReorderInteraction.swift b/Sources/MeridianCore/Views/Sidebar/SidebarTabReorderInteraction.swift
index a148688..57b8f85 100644
--- a/Sources/MeridianCore/Views/Sidebar/SidebarTabReorderInteraction.swift
+++ b/Sources/MeridianCore/Views/Sidebar/SidebarTabReorderInteraction.swift
@@ -4,9 +4,9 @@ import UniformTypeIdentifiers
enum SidebarTabReorderInteractionMetrics {
static let animation = Animation.smooth(duration: 0.18, extraBounce: 0)
static let indicatorAnimation = Animation.smooth(duration: 0.12, extraBounce: 0)
- static let rowDropMidlineY: CGFloat = 14
+ static let rowDropMidlineY: CGFloat = 17
static let dropSlotHitHeight: CGFloat = 10
- static let emptySectionDropSlotHitHeight: CGFloat = 34
+ static let emptySectionDropSlotHitHeight: CGFloat = 40
}
struct SidebarTabDropState: Equatable {
diff --git a/Sources/MeridianCore/Views/Sidebar/SidebarTabRow.swift b/Sources/MeridianCore/Views/Sidebar/SidebarTabRow.swift
index 8a703a8..1a69a28 100644
--- a/Sources/MeridianCore/Views/Sidebar/SidebarTabRow.swift
+++ b/Sources/MeridianCore/Views/Sidebar/SidebarTabRow.swift
@@ -43,13 +43,17 @@ public struct SidebarTabRow: View {
}
public var body: some View {
- HStack(spacing: 8) {
- SidebarTabFaviconView(tab: tab, size: 16, fallbackSymbolName: iconName)
- .frame(width: 16)
+ HStack(spacing: 9) {
+ SidebarTabFaviconView(
+ tab: tab,
+ size: SidebarLayoutMetrics.rowIconSize,
+ fallbackSymbolName: iconName
+ )
+ .frame(width: SidebarLayoutMetrics.rowIconSize)
.accessibilityHidden(true)
Text(tab.title)
- .font(.system(size: 13))
+ .font(.system(size: SidebarLayoutMetrics.rowFontSize))
.lineLimit(1)
Spacer(minLength: 4)
@@ -63,8 +67,8 @@ public struct SidebarTabRow: View {
if canClose {
Button(action: close) {
Image(systemName: "xmark")
- .font(.system(size: 10, weight: .semibold))
- .frame(width: 18, height: 18)
+ .font(.system(size: 11, weight: .semibold))
+ .frame(width: 22, height: 22)
}
.buttonStyle(.plain)
.opacity(isHovered || isSelected ? 1 : 0)
@@ -72,11 +76,11 @@ public struct SidebarTabRow: View {
.accessibilityLabel("Close \(tab.title)")
}
}
- .padding(.leading, 6)
- .padding(.trailing, 4)
- .padding(.vertical, 5)
+ .padding(.leading, SidebarLayoutMetrics.rowLeadingInset)
+ .padding(.trailing, SidebarLayoutMetrics.rowTrailingInset)
+ .padding(.vertical, SidebarLayoutMetrics.rowVerticalInset)
.background(selectionBackground)
- .clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
+ .clipShape(RoundedRectangle(cornerRadius: SidebarLayoutMetrics.rowCornerRadius, style: .continuous))
.contentShape(Rectangle())
.onTapGesture(perform: select)
.onHover { isHovered = $0 }
@@ -124,21 +128,25 @@ public struct SidebarTabRow: View {
}
private var dragPreview: some View {
- HStack(spacing: 8) {
- SidebarTabFaviconView(tab: tab, size: 16, fallbackSymbolName: iconName)
- .frame(width: 16)
+ HStack(spacing: 9) {
+ SidebarTabFaviconView(
+ tab: tab,
+ size: SidebarLayoutMetrics.rowIconSize,
+ fallbackSymbolName: iconName
+ )
+ .frame(width: SidebarLayoutMetrics.rowIconSize)
Text(tab.title)
- .font(.system(size: 13, weight: .medium))
+ .font(.system(size: SidebarLayoutMetrics.rowFontSize, weight: .medium))
.lineLimit(1)
Spacer(minLength: 0)
}
.padding(.horizontal, 10)
- .frame(width: 220, height: 34, alignment: .leading)
- .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
+ .frame(width: 236, height: 38, alignment: .leading)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 9, style: .continuous))
.overlay {
- RoundedRectangle(cornerRadius: 8, style: .continuous)
+ RoundedRectangle(cornerRadius: 9, style: .continuous)
.stroke(Color.primary.opacity(0.10), lineWidth: 1)
}
.shadow(color: .black.opacity(0.16), radius: 16, y: 8)
diff --git a/Sources/MeridianCore/Views/Sidebar/SidebarView.swift b/Sources/MeridianCore/Views/Sidebar/SidebarView.swift
index 45ba29a..5967e7b 100644
--- a/Sources/MeridianCore/Views/Sidebar/SidebarView.swift
+++ b/Sources/MeridianCore/Views/Sidebar/SidebarView.swift
@@ -120,32 +120,21 @@ private struct SidebarFixedChromeForeground: View {
}
}
-private struct SidebarThemeSeparator: View {
- @Environment(\.colorScheme) private var colorScheme
- @Environment(\.sidebarForegroundWhiteAmount) private var foregroundWhiteAmount
-
- var body: some View {
- Divider()
- .opacity(foregroundWhiteAmount * (colorScheme == .dark ? 0.12 : 1))
- .accessibilityHidden(true)
- }
-}
-
private let sidebarLockControlAnimation = Animation.smooth(duration: 0.24, extraBounce: 0)
private enum SidebarHeaderMetrics {
- static let trafficLightEdgeInset: CGFloat = 12
- static let controlRowHeight: CGFloat = 24
- static let controlRowBottomInset: CGFloat = 7
- static let compactAddressControlsHeight: CGFloat = 34
+ static let trafficLightEdgeInset: CGFloat = 14
+ static let controlRowHeight: CGFloat = 28
+ static let controlRowBottomInset: CGFloat = 10
+ static let compactAddressControlsHeight: CGFloat = 40
static let pageContentTopInset: CGFloat = 20
- static let pageContentBottomInset: CGFloat = 12
- static let inlineControlHeight: CGFloat = 14
- static let spaceSwitcherButtonSize: CGFloat = 26
- static let spaceSwitcherGlyphSize: CGFloat = 26
- static let spaceSwitcherIconFrameSize: CGFloat = 20
- static let spaceSwitcherSpacing: CGFloat = 6
- static let spaceSwitcherPlusSymbolSize: CGFloat = 12
+ static let pageContentBottomInset: CGFloat = 16
+ static let inlineControlHeight: CGFloat = 24
+ static let spaceSwitcherButtonSize: CGFloat = 30
+ static let spaceSwitcherGlyphSize: CGFloat = 30
+ static let spaceSwitcherIconFrameSize: CGFloat = 22
+ static let spaceSwitcherSpacing: CGFloat = 8
+ static let spaceSwitcherPlusSymbolSize: CGFloat = 13
static var spaceSwitcherVerticalInset: CGFloat {
max(
@@ -322,7 +311,6 @@ public struct SidebarView: View {
VStack(spacing: 0) {
browserControlsHeader
compactAddressButton
- sidebarSeparator
}
}
spacePager
@@ -332,10 +320,7 @@ public struct SidebarView: View {
fallbackStyle: settledChromeLiveStyle,
isPinned: store.sidebarIsLockedOpen
) {
- VStack(spacing: 0) {
- sidebarSeparator
- spaceSwitcher
- }
+ spaceSwitcher
}
}
.accessibilityLabel("Sidebar")
@@ -348,7 +333,7 @@ public struct SidebarView: View {
}
private var browserControlsHeader: some View {
- HStack(alignment: .top, spacing: 8) {
+ HStack(alignment: .center, spacing: 8) {
WindowTrafficLightGroup(window: window)
SidebarPinButton(isLockedOpen: store.sidebarIsLockedOpen) {
@@ -411,10 +396,6 @@ public struct SidebarView: View {
)
}
- private var sidebarSeparator: some View {
- SidebarThemeSeparator()
- }
-
private var commandTargetTabID: TabID? {
guard !activityPageIsSelected else {
return nil
@@ -501,7 +482,7 @@ public struct SidebarView: View {
)
}
.contentShape(Rectangle())
- .padding(.horizontal, 12)
+ .padding(.horizontal, SidebarHeaderMetrics.trafficLightEdgeInset)
.padding(.vertical, SidebarHeaderMetrics.spaceSwitcherVerticalInset)
}
}
@@ -584,7 +565,7 @@ public struct SidebarView: View {
SidebarSpacePagerView(
snapshot: makeSpacePagerSnapshot(),
navigationRequest: pagerNavigationRequest,
- selectTab: { store.selectTab($0) },
+ selectTab: { store.activateTab($0) },
closeTab: { store.closeTab($0.id) },
setTabPlacement: { tabID, placement in store.setTabPlacement(placement, for: tabID) },
moveTab: { tabID, direction in
@@ -1061,22 +1042,22 @@ private struct SidebarAddressControls: View {
}
var body: some View {
- HStack(spacing: 6) {
- HStack(spacing: 6) {
+ HStack(spacing: 8) {
+ HStack(spacing: 7) {
Button {
store.showCommandBar()
} label: {
- HStack(spacing: 6) {
+ HStack(spacing: 7) {
Image(systemName: siteSymbolName)
- .font(.system(size: 10, weight: .semibold))
+ .font(.system(size: 11, weight: .semibold))
.foregroundStyle(.secondary)
- .frame(width: 12)
+ .frame(width: 14)
SidebarAddressMorphingText(
settledText: addressText,
controller: addressMorphController
)
- .font(.caption)
+ .font(.system(size: 13))
.foregroundStyle(sidebarForegroundColor)
.lineLimit(1)
.truncationMode(.middle)
@@ -1093,22 +1074,22 @@ private struct SidebarAddressControls: View {
copyCurrentURLToPasteboard()
} label: {
Image(systemName: didCopyCurrentURL ? "checkmark" : "doc.on.doc")
- .font(.system(size: 10, weight: .semibold))
+ .font(.system(size: 11, weight: .semibold))
.foregroundStyle(currentURLForCopy == nil ? .tertiary : .secondary)
- .frame(width: 22, height: 22)
- .contentShape(RoundedRectangle(cornerRadius: 5, style: .continuous))
+ .frame(width: 26, height: 26)
+ .contentShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
}
.buttonStyle(.plain)
.disabled(currentURLForCopy == nil)
.help("Copy current URL")
.accessibilityLabel("Copy current URL")
}
- .padding(.leading, 8)
+ .padding(.leading, 10)
.padding(.trailing, 3)
- .frame(height: 26)
- .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 7, style: .continuous))
+ .frame(height: 30)
+ .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay {
- RoundedRectangle(cornerRadius: 7, style: .continuous)
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(
.separator.opacity(0.35 * sidebarForegroundWhiteAmount),
lineWidth: 0.5
@@ -1117,8 +1098,8 @@ private struct SidebarAddressControls: View {
sitePermissionsMenu
}
- .padding(.horizontal, 12)
- .padding(.bottom, 8)
+ .padding(.horizontal, SidebarHeaderMetrics.trafficLightEdgeInset)
+ .padding(.bottom, 10)
.frame(height: SidebarHeaderMetrics.compactAddressControlsHeight, alignment: .top)
}
@@ -1216,14 +1197,14 @@ private struct SidebarAddressControls: View {
}
} label: {
Image(systemName: sitePermissionMenuSymbolName)
- .font(.system(size: 11, weight: .semibold))
+ .font(.system(size: 12, weight: .semibold))
.symbolRenderingMode(.monochrome)
.foregroundStyle(sidebarForegroundColor)
.foregroundColor(sidebarForegroundColor)
- .frame(width: 26, height: 26)
- .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 7, style: .continuous))
+ .frame(width: 30, height: 30)
+ .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay {
- RoundedRectangle(cornerRadius: 7, style: .continuous)
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(
.separator.opacity(0.35 * sidebarForegroundWhiteAmount),
lineWidth: 0.5
@@ -1577,7 +1558,7 @@ private struct ActivitySwitcherButtonLabel: View {
var body: some View {
Image(systemName: "clock.arrow.circlepath")
- .font(.system(size: 14, weight: .semibold))
+ .font(.system(size: 15, weight: .semibold))
.foregroundStyle(sidebarForegroundColor)
.frame(
width: SidebarHeaderMetrics.spaceSwitcherIconFrameSize,
@@ -2883,17 +2864,17 @@ private struct SidebarPinButton: View {
isLockedOpen ? "Use auto-hide sidebar" : "Pin sidebar open"
}
- var body: some View {
- Button(action: action) {
- Image(systemName: "sidebar.leading")
- .font(.system(size: 11, weight: .semibold))
- .foregroundStyle(.secondary)
- .frame(width: 26, height: SidebarHeaderMetrics.inlineControlHeight)
- .contentShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
- .background {
- RoundedRectangle(cornerRadius: 6, style: .continuous)
- .fill(isHovered ? sidebarForegroundColor.opacity(0.08) : .clear)
- }
+ var body: some View {
+ Button(action: action) {
+ Image(systemName: "sidebar.leading")
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(.secondary)
+ .frame(width: 28, height: SidebarHeaderMetrics.inlineControlHeight)
+ .contentShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
+ .background {
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .fill(isHovered ? sidebarForegroundColor.opacity(0.08) : .clear)
+ }
}
.buttonStyle(.plain)
.help(label)
@@ -2912,8 +2893,8 @@ private struct SidebarNavigationButton: View {
var body: some View {
Button(action: action) {
Image(systemName: systemName)
- .font(.system(size: 11, weight: .semibold))
- .frame(width: 22, height: SidebarHeaderMetrics.inlineControlHeight)
+ .font(.system(size: 12, weight: .semibold))
+ .frame(width: 24, height: SidebarHeaderMetrics.inlineControlHeight)
}
.buttonStyle(.plain)
.disabled(isDisabled)
@@ -3406,12 +3387,12 @@ private struct SidebarActivityPageView: View, Equatable {
var body: some View {
GeometryReader { geometry in
ScrollView(.vertical, showsIndicators: false) {
- VStack(alignment: .leading, spacing: 14) {
+ VStack(alignment: .leading, spacing: SidebarLayoutMetrics.pageSectionSpacing) {
header
modeButtons
selectedSection
}
- .padding(.horizontal, 10)
+ .padding(.horizontal, SidebarLayoutMetrics.contentHorizontalInset)
.padding(.top, SidebarHeaderMetrics.pageContentTopInset)
.padding(.bottom, SidebarHeaderMetrics.pageContentBottomInset)
.frame(width: max(geometry.size.width, 1), alignment: .topLeading)
@@ -3442,7 +3423,7 @@ private struct SidebarActivityPageView: View, Equatable {
profileFilterMenu
}
.frame(maxWidth: .infinity, alignment: .leading)
- .padding(.horizontal, 4)
+ .padding(.horizontal, SidebarLayoutMetrics.sectionHeaderHorizontalInset)
}
private var modeButtons: some View {
@@ -3451,7 +3432,6 @@ private struct SidebarActivityPageView: View, Equatable {
modeButton(mode)
}
}
- .padding(.horizontal, 2)
}
private func modeButton(_ mode: SidebarActivityMode) -> some View {
@@ -3829,7 +3809,7 @@ private struct SidebarSpacePageView: View, Equatable {
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
- LazyVStack(alignment: .leading, spacing: 12) {
+ LazyVStack(alignment: .leading, spacing: SidebarLayoutMetrics.pageSectionSpacing) {
favoriteTabGrid
emptyFavoriteTabDropTarget
tabSection(
@@ -3851,7 +3831,7 @@ private struct SidebarSpacePageView: View, Equatable {
)
)
}
- .padding(.horizontal, 10)
+ .padding(.horizontal, SidebarLayoutMetrics.contentHorizontalInset)
.padding(.top, SidebarHeaderMetrics.pageContentTopInset)
.padding(.bottom, SidebarHeaderMetrics.pageContentBottomInset)
.frame(maxWidth: .infinity, minHeight: 1, alignment: .topLeading)
@@ -4345,18 +4325,18 @@ private struct SidebarFolderNodeView: View {
}
}
}
- .padding(.horizontal, 8)
- .padding(.vertical, 4)
+ .padding(.horizontal, SidebarLayoutMetrics.rowLeadingInset)
+ .padding(.vertical, 5)
}
private var folderLabel: some View {
HStack(spacing: 8) {
Image(systemName: "folder")
- .font(.system(size: 13, weight: .medium))
+ .font(.system(size: SidebarLayoutMetrics.rowFontSize, weight: .medium))
.foregroundStyle(.secondary)
- .frame(width: 16)
+ .frame(width: SidebarLayoutMetrics.rowIconSize)
Text(folderItem.folder.name)
- .font(.system(size: 13, weight: .medium))
+ .font(.system(size: SidebarLayoutMetrics.rowFontSize, weight: .medium))
.lineLimit(1)
}
.contentShape(Rectangle())
@@ -4408,11 +4388,11 @@ private struct SidebarFolderNodeView: View {
}
private var folderLabelIndent: CGFloat {
- CGFloat(nestingLevel) * 16
+ CGFloat(nestingLevel) * 18
}
private var folderContentIndent: CGFloat {
- CGFloat(nestingLevel + 1) * 16
+ CGFloat(nestingLevel + 1) * 18
}
private func tabDropResetToken(for tabs: [SidebarTabItemSnapshot]) -> String {
diff --git a/Sources/MeridianCore/WebKit/WebViewHost.swift b/Sources/MeridianCore/WebKit/WebViewHost.swift
index 883a3b3..e4a33ba 100644
--- a/Sources/MeridianCore/WebKit/WebViewHost.swift
+++ b/Sources/MeridianCore/WebKit/WebViewHost.swift
@@ -971,7 +971,11 @@ struct BrowserWebViewCallbacks {
var onDownloadProgress: @MainActor (UUID, Double?) -> Void
var onDownloadFinished: @MainActor (UUID, URL?, Bool) -> Void
var onDownloadFailed: @MainActor (UUID, String) -> Void
- var onSitePermissionRequest: @MainActor (SitePermissionKind, SitePermissionOrigin?) -> SitePermissionPolicy.Evaluation
+ var onSitePermissionRequest: @MainActor (
+ SitePermissionKind,
+ SitePermissionOrigin?,
+ (@MainActor (SitePermissionPolicy.Evaluation) -> Void)?
+ ) -> SitePermissionPolicy.Evaluation
var onPasswordCredentialCaptured: @MainActor (PasswordCredentialCandidate) -> Void
var onPasswordCredentialsRequested: @MainActor (URL) -> [SavedPasswordCredential]
var onSnapshot: @MainActor (NSImage) -> Void
@@ -986,7 +990,11 @@ struct BrowserWebViewCallbacks {
onDownloadProgress: @escaping @MainActor (UUID, Double?) -> Void = { _, _ in },
onDownloadFinished: @escaping @MainActor (UUID, URL?, Bool) -> Void = { _, _, _ in },
onDownloadFailed: @escaping @MainActor (UUID, String) -> Void = { _, _ in },
- onSitePermissionRequest: @escaping @MainActor (SitePermissionKind, SitePermissionOrigin?) -> SitePermissionPolicy.Evaluation,
+ onSitePermissionRequest: @escaping @MainActor (
+ SitePermissionKind,
+ SitePermissionOrigin?,
+ (@MainActor (SitePermissionPolicy.Evaluation) -> Void)?
+ ) -> SitePermissionPolicy.Evaluation,
onPasswordCredentialCaptured: @escaping @MainActor (PasswordCredentialCandidate) -> Void = { _ in },
onPasswordCredentialsRequested: @escaping @MainActor (URL) -> [SavedPasswordCredential] = { _ in [] },
onSnapshot: @escaping @MainActor (NSImage) -> Void = { _ in }
@@ -1775,7 +1783,12 @@ public struct WebViewHost: NSViewRepresentable {
private let onDownloadProgress: @MainActor (WebContentSessionIdentity, UUID, Double?) -> Void
private let onDownloadFinished: @MainActor (WebContentSessionIdentity, UUID, URL?, Bool) -> Void
private let onDownloadFailed: @MainActor (WebContentSessionIdentity, UUID, String) -> Void
- private let onSitePermissionRequest: @MainActor (WebContentSessionIdentity, SitePermissionKind, SitePermissionOrigin?) -> SitePermissionPolicy.Evaluation
+ private let onSitePermissionRequest: @MainActor (
+ WebContentSessionIdentity,
+ SitePermissionKind,
+ SitePermissionOrigin?,
+ (@MainActor (SitePermissionPolicy.Evaluation) -> Void)?
+ ) -> SitePermissionPolicy.Evaluation
private let onPasswordCredentialCaptured: @MainActor (WebContentSessionIdentity, PasswordCredentialCandidate) -> Void
private let onPasswordCredentialsRequested: @MainActor (WebContentSessionIdentity, URL) -> [SavedPasswordCredential]
private let onSnapshotCaptured: @MainActor (WebContentSessionIdentity, NSImage) -> Void
@@ -1802,7 +1815,12 @@ public struct WebViewHost: NSViewRepresentable {
onDownloadProgress: @escaping @MainActor (WebContentSessionIdentity, UUID, Double?) -> Void = { _, _, _ in },
onDownloadFinished: @escaping @MainActor (WebContentSessionIdentity, UUID, URL?, Bool) -> Void = { _, _, _, _ in },
onDownloadFailed: @escaping @MainActor (WebContentSessionIdentity, UUID, String) -> Void = { _, _, _ in },
- onSitePermissionRequest: @escaping @MainActor (WebContentSessionIdentity, SitePermissionKind, SitePermissionOrigin?) -> SitePermissionPolicy.Evaluation = { _, _, _ in
+ onSitePermissionRequest: @escaping @MainActor (
+ WebContentSessionIdentity,
+ SitePermissionKind,
+ SitePermissionOrigin?,
+ (@MainActor (SitePermissionPolicy.Evaluation) -> Void)?
+ ) -> SitePermissionPolicy.Evaluation = { _, _, _, _ in
.deny(reason: "Site permission request was blocked because no permission handler is installed.")
},
onPasswordCredentialCaptured: @escaping @MainActor (WebContentSessionIdentity, PasswordCredentialCandidate) -> Void = { _, _ in },
@@ -1857,7 +1875,7 @@ public struct WebViewHost: NSViewRepresentable {
onDownloadProgress: { _, _ in },
onDownloadFinished: { _, _, _ in },
onDownloadFailed: { _, _ in },
- onSitePermissionRequest: { _, _ in
+ onSitePermissionRequest: { _, _, _ in
.deny(reason: "Site permission request was blocked because no active tab is attached.")
},
onPasswordCredentialCaptured: { _ in },
@@ -1922,8 +1940,8 @@ public struct WebViewHost: NSViewRepresentable {
onDownloadFailed: { downloadID, message in
onDownloadFailed(identity, downloadID, message)
},
- onSitePermissionRequest: { kind, origin in
- onSitePermissionRequest(identity, kind, origin)
+ onSitePermissionRequest: { kind, origin, resolutionHandler in
+ onSitePermissionRequest(identity, kind, origin, resolutionHandler)
},
onPasswordCredentialCaptured: { candidate in
onPasswordCredentialCaptured(identity, candidate)
@@ -2325,13 +2343,27 @@ public struct WebViewHost: NSViewRepresentable {
} else {
let origin = SitePermissionOrigin(securityOrigin: navigationAction.sourceFrame.securityOrigin)
?? SitePermissionOrigin(url: webView.url ?? url)
- switch callbacks.onSitePermissionRequest(.popupWindow, origin) {
- case .allow:
- routeWebContentNavigation(to: url, in: webView)
- case .ask:
- publishSecurityMessage("Pop-up windows require permission for this site.")
- case .deny(let reason):
- publishSecurityMessage(reason)
+ let applyDecision: @MainActor (SitePermissionPolicy.Evaluation) -> Void = {
+ [weak self, weak webView] evaluation in
+ guard let self, let webView, self.isActive else {
+ return
+ }
+ switch evaluation {
+ case .allow:
+ self.routeWebContentNavigation(to: url, in: webView)
+ case .ask:
+ self.publishSecurityMessage("Pop-up windows require permission for this site.")
+ case .deny(let reason):
+ self.publishSecurityMessage(reason)
+ }
+ }
+ let evaluation = callbacks.onSitePermissionRequest(
+ .popupWindow,
+ origin,
+ applyDecision
+ )
+ if evaluation != .ask {
+ applyDecision(evaluation)
}
}
}
@@ -2352,8 +2384,22 @@ public struct WebViewHost: NSViewRepresentable {
let permissionOrigin = SitePermissionOrigin(securityOrigin: origin)
?? frame.request.url.flatMap(SitePermissionOrigin.init(url:))
- let evaluation = callbacks.onSitePermissionRequest(Self.permissionKind(for: type), permissionOrigin)
- decisionHandler(Self.webKitPermissionDecision(for: evaluation))
+ let applyDecision: @MainActor (SitePermissionPolicy.Evaluation) -> Void = {
+ [weak self] evaluation in
+ guard let self, self.isActive else {
+ decisionHandler(.deny)
+ return
+ }
+ decisionHandler(Self.webKitPermissionDecision(for: evaluation))
+ }
+ let evaluation = callbacks.onSitePermissionRequest(
+ Self.permissionKind(for: type),
+ permissionOrigin,
+ applyDecision
+ )
+ if evaluation != .ask {
+ applyDecision(evaluation)
+ }
}
public func userContentController(
diff --git a/Tests/MeridianBrowserTests/BrowserPasskeyAccessTests.swift b/Tests/MeridianBrowserTests/BrowserPasskeyAccessTests.swift
new file mode 100644
index 0000000..cf86933
--- /dev/null
+++ b/Tests/MeridianBrowserTests/BrowserPasskeyAccessTests.swift
@@ -0,0 +1,48 @@
+import XCTest
+@testable import MeridianCore
+
+final class BrowserPasskeyAccessTests: XCTestCase {
+ func testRequestsAuthorizationOnlyForEntitledUndeterminedBrowser() {
+ XCTAssertTrue(
+ BrowserPasskeyAccessPolicy.shouldRequestAuthorization(
+ isBrowserEntitled: true,
+ state: .notDetermined,
+ didRequestThisLaunch: false
+ )
+ )
+ }
+
+ func testDoesNotRequestAuthorizationWithoutBrowserEntitlement() {
+ XCTAssertFalse(
+ BrowserPasskeyAccessPolicy.shouldRequestAuthorization(
+ isBrowserEntitled: false,
+ state: .notDetermined,
+ didRequestThisLaunch: false
+ )
+ )
+ }
+
+ func testDoesNotRepeatOrOverridePasskeyAuthorizationDecision() {
+ XCTAssertFalse(
+ BrowserPasskeyAccessPolicy.shouldRequestAuthorization(
+ isBrowserEntitled: true,
+ state: .notDetermined,
+ didRequestThisLaunch: true
+ )
+ )
+ XCTAssertFalse(
+ BrowserPasskeyAccessPolicy.shouldRequestAuthorization(
+ isBrowserEntitled: true,
+ state: .authorized,
+ didRequestThisLaunch: false
+ )
+ )
+ XCTAssertFalse(
+ BrowserPasskeyAccessPolicy.shouldRequestAuthorization(
+ isBrowserEntitled: true,
+ state: .denied,
+ didRequestThisLaunch: false
+ )
+ )
+ }
+}
diff --git a/Tests/MeridianBrowserTests/BrowserStoreTests.swift b/Tests/MeridianBrowserTests/BrowserStoreTests.swift
index 6c81982..87938a5 100644
--- a/Tests/MeridianBrowserTests/BrowserStoreTests.swift
+++ b/Tests/MeridianBrowserTests/BrowserStoreTests.swift
@@ -749,6 +749,7 @@ final class BrowserStoreTests: XCTestCase {
XCTAssertFalse(store.spaces.first(where: { $0.id == spaceID })?.regularTabIDs.contains(tab.id) ?? true)
XCTAssertEqual(store.tabs.first(where: { $0.id == tab.id })?.isPinned, true)
XCTAssertEqual(store.tabs.first(where: { $0.id == tab.id })?.isFavorite, false)
+ XCTAssertNil(store.tabs.first(where: { $0.id == tab.id })?.essentialReference)
XCTAssertTrue(store.setTabPlacement(.favorite, for: tab.id))
XCTAssertEqual(store.selectedTabID, tab.id)
@@ -757,6 +758,13 @@ final class BrowserStoreTests: XCTestCase {
XCTAssertFalse(store.spaces.first(where: { $0.id == spaceID })?.pinnedTabIDs.contains(tab.id) ?? true)
XCTAssertEqual(store.tabs.first(where: { $0.id == tab.id })?.isPinned, false)
XCTAssertEqual(store.tabs.first(where: { $0.id == tab.id })?.isFavorite, true)
+ XCTAssertEqual(
+ store.tabs.first(where: { $0.id == tab.id })?.essentialReference,
+ BrowserEssentialReference(
+ title: "Docs",
+ url: URL(string: "https://docs.example.com")!
+ )
+ )
XCTAssertTrue(store.setTabPlacement(.regular, for: tab.id))
XCTAssertEqual(store.selectedTabID, tab.id)
@@ -764,6 +772,79 @@ final class BrowserStoreTests: XCTestCase {
XCTAssertFalse(store.spaces.first(where: { $0.id == spaceID })?.favoriteTabIDs.contains(tab.id) ?? true)
XCTAssertEqual(store.tabs.first(where: { $0.id == tab.id })?.isPinned, false)
XCTAssertEqual(store.tabs.first(where: { $0.id == tab.id })?.isFavorite, false)
+ XCTAssertNil(store.tabs.first(where: { $0.id == tab.id })?.essentialReference)
+ }
+
+ func testActivatingEssentialRestoresCapturedPageAfterTabNavigatesAway() throws {
+ let store = BrowserStore()
+ let originalURL = try XCTUnwrap(URL(string: "https://original.example.com/article"))
+ let originalFaviconURL = try XCTUnwrap(URL(string: "https://original.example.com/icon.png"))
+ let otherURL = try XCTUnwrap(URL(string: "https://other.example.com/dashboard"))
+ let otherFaviconURL = try XCTUnwrap(URL(string: "https://other.example.com/icon.png"))
+ let tab = try XCTUnwrap(store.createTab(title: "Original Article", url: originalURL))
+
+ store.updateTabFavicon(originalFaviconURL, for: tab.id)
+ XCTAssertTrue(store.setTabPlacement(.favorite, for: tab.id))
+
+ store.updateTabFromWebView(
+ tabID: tab.id,
+ title: "Other Dashboard",
+ url: otherURL,
+ isLoading: false
+ )
+ store.updateTabFavicon(otherFaviconURL, for: tab.id)
+
+ let navigatedTab = try XCTUnwrap(store.tabs.first { $0.id == tab.id })
+ XCTAssertEqual(navigatedTab.url, otherURL)
+ XCTAssertEqual(navigatedTab.title, "Other Dashboard")
+ XCTAssertEqual(
+ navigatedTab.essentialReference,
+ BrowserEssentialReference(
+ title: "Original Article",
+ url: originalURL,
+ faviconURL: originalFaviconURL
+ )
+ )
+
+ XCTAssertTrue(store.activateTab(tab.id))
+
+ let restoredTab = try XCTUnwrap(store.tabs.first { $0.id == tab.id })
+ XCTAssertEqual(store.selectedTabID, tab.id)
+ XCTAssertEqual(restoredTab.url, originalURL)
+ XCTAssertNil(restoredTab.faviconURL)
+ XCTAssertEqual(
+ restoredTab.essentialReference,
+ BrowserEssentialReference(
+ title: "Original Article",
+ url: originalURL,
+ faviconURL: originalFaviconURL
+ )
+ )
+ }
+
+ func testLegacyFavoriteTabInfersEssentialReferenceWhenDecoded() throws {
+ let tabID = UUID()
+ let spaceID = UUID()
+ let profileID = UUID()
+ let legacyData = try JSONSerialization.data(withJSONObject: [
+ "id": tabID.uuidString,
+ "title": "Legacy Essential",
+ "url": "https://legacy.example.com/saved",
+ "parentSpaceID": spaceID.uuidString,
+ "isPinned": false,
+ "isFavorite": true,
+ "profileID": profileID.uuidString
+ ])
+
+ let tab = try JSONDecoder().decode(BrowserTab.self, from: legacyData)
+
+ XCTAssertEqual(
+ tab.essentialReference,
+ BrowserEssentialReference(
+ title: "Legacy Essential",
+ url: try XCTUnwrap(URL(string: "https://legacy.example.com/saved"))
+ )
+ )
}
func testCloseInactiveTabPreservesSelectedTab() throws {
@@ -859,6 +940,33 @@ final class BrowserStoreTests: XCTestCase {
XCTAssertFalse(restoredSpace.regularTabIDs.contains(tab.id))
}
+ func testEssentialReferenceRoundTripsAfterCurrentPageChanges() throws {
+ let store = BrowserStore()
+ let originalURL = try XCTUnwrap(URL(string: "https://saved.example.com/original"))
+ let currentURL = try XCTUnwrap(URL(string: "https://current.example.com/elsewhere"))
+ let tab = try XCTUnwrap(store.createTab(title: "Saved Page", url: originalURL))
+ XCTAssertTrue(store.setTabPlacement(.favorite, for: tab.id))
+ store.updateTabFromWebView(
+ tabID: tab.id,
+ title: "Current Page",
+ url: currentURL,
+ isLoading: false
+ )
+
+ let data = try JSONEncoder().encode(
+ store.persistentSnapshot(date: Date(timeIntervalSince1970: 18))
+ )
+ let decoded = try JSONDecoder().decode(BrowserSessionSnapshot.self, from: data)
+ let restored = BrowserStore(snapshot: decoded)
+ let restoredTab = try XCTUnwrap(restored.tabs.first { $0.id == tab.id })
+
+ XCTAssertEqual(restoredTab.url, currentURL)
+ XCTAssertEqual(
+ restoredTab.essentialReference,
+ BrowserEssentialReference(title: "Saved Page", url: originalURL)
+ )
+ }
+
func testTabReorderMovesWithinSidebarSectionsAndPreservesSelection() throws {
let store = BrowserStore()
let spaceID = try XCTUnwrap(store.selectedSpaceID)
@@ -1770,6 +1878,48 @@ final class BrowserStoreTests: XCTestCase {
)
}
+ func testResolvingSitePermissionContinuesThePendingWebKitRequest() throws {
+ let store = BrowserStore()
+ let profileID = try XCTUnwrap(store.activeProfile?.id)
+ let origin = try XCTUnwrap(SitePermissionOrigin(url: URL(string: "https://microphone.example")!))
+ var resolutions: [SitePermissionPolicy.Evaluation] = []
+
+ let result = store.requestSitePermission(
+ kind: .microphone,
+ origin: origin,
+ profileID: profileID,
+ resolutionHandler: { resolutions.append($0) }
+ )
+
+ XCTAssertEqual(result, .ask)
+ XCTAssertTrue(resolutions.isEmpty)
+
+ _ = store.resolvePendingSitePermission(
+ .allow,
+ requestID: try XCTUnwrap(store.pendingSitePermissionRequest?.id)
+ )
+
+ XCTAssertEqual(resolutions, [.allow])
+ }
+
+ func testDismissingSitePermissionDeniesThePendingWebKitRequest() throws {
+ let store = BrowserStore()
+ let profileID = try XCTUnwrap(store.activeProfile?.id)
+ let origin = try XCTUnwrap(SitePermissionOrigin(url: URL(string: "https://microphone.example")!))
+ var resolutions: [SitePermissionPolicy.Evaluation] = []
+ _ = store.requestSitePermission(
+ kind: .microphone,
+ origin: origin,
+ profileID: profileID,
+ resolutionHandler: { resolutions.append($0) }
+ )
+
+ store.cancelPendingSitePermissionRequest()
+
+ XCTAssertNil(store.pendingSitePermissionRequest)
+ XCTAssertEqual(resolutions, [.deny(reason: "Microphone is blocked for this site.")])
+ }
+
func testRestoredSitePermissionSettingsAreLoadedFromSnapshot() throws {
var snapshot = SessionSnapshotFactory.initial(date: Date(timeIntervalSince1970: 11))
let profileID = try XCTUnwrap(snapshot.profiles.first?.id)
diff --git a/Tests/MeridianBrowserTests/BrowserWebViewRegistryTests.swift b/Tests/MeridianBrowserTests/BrowserWebViewRegistryTests.swift
index 4df86de..061a5c2 100644
--- a/Tests/MeridianBrowserTests/BrowserWebViewRegistryTests.swift
+++ b/Tests/MeridianBrowserTests/BrowserWebViewRegistryTests.swift
@@ -382,7 +382,7 @@ private struct RegistryFixture {
onSecurityMessage: { _ in },
onURLConfirmationRequired: { _, _, _ in },
onDownloadConfirmationRequired: { _, completion in completion(nil) },
- onSitePermissionRequest: { _, _ in
+ onSitePermissionRequest: { _, _, _ in
.deny(reason: "Test denies site permission requests.")
}
)
diff --git a/Tests/MeridianBrowserTests/DefaultBrowserPromptPolicyTests.swift b/Tests/MeridianBrowserTests/DefaultBrowserPromptPolicyTests.swift
new file mode 100644
index 0000000..9306c09
--- /dev/null
+++ b/Tests/MeridianBrowserTests/DefaultBrowserPromptPolicyTests.swift
@@ -0,0 +1,82 @@
+import Foundation
+import MeridianCore
+import XCTest
+
+final class DefaultBrowserPromptPolicyTests: XCTestCase {
+ func testFreshInstallationPromptsWhenLumenIsNotDefault() throws {
+ try withDefaults { defaults in
+ XCTAssertTrue(
+ DefaultBrowserPromptPolicy.shouldPresentPrompt(
+ isDefaultBrowser: false,
+ defaults: defaults
+ )
+ )
+ }
+ }
+
+ func testExistingInstallationWithoutPromptStateReceivesCurrentPrompt() throws {
+ try withDefaults { defaults in
+ defaults.set("existing preference", forKey: "UnrelatedBrowserPreference")
+
+ XCTAssertTrue(
+ DefaultBrowserPromptPolicy.shouldPresentPrompt(
+ isDefaultBrowser: false,
+ defaults: defaults
+ )
+ )
+ }
+ }
+
+ func testHandledPromptDoesNotRepeat() throws {
+ try withDefaults { defaults in
+ DefaultBrowserPromptPolicy.markCurrentPromptHandled(defaults: defaults)
+
+ XCTAssertTrue(
+ DefaultBrowserPromptPolicy.hasHandledCurrentPrompt(defaults: defaults)
+ )
+ XCTAssertFalse(
+ DefaultBrowserPromptPolicy.shouldPresentPrompt(
+ isDefaultBrowser: false,
+ defaults: defaults
+ )
+ )
+ }
+ }
+
+ func testOlderPromptVersionReceivesCurrentPrompt() throws {
+ try withDefaults { defaults in
+ defaults.set(
+ DefaultBrowserPromptPolicy.currentPromptVersion - 1,
+ forKey: DefaultBrowserPromptPolicy.promptVersionStorageKey
+ )
+
+ XCTAssertTrue(
+ DefaultBrowserPromptPolicy.shouldPresentPrompt(
+ isDefaultBrowser: false,
+ defaults: defaults
+ )
+ )
+ }
+ }
+
+ func testDefaultBrowserDoesNotReceiveUnnecessaryPrompt() throws {
+ try withDefaults { defaults in
+ XCTAssertFalse(
+ DefaultBrowserPromptPolicy.shouldPresentPrompt(
+ isDefaultBrowser: true,
+ defaults: defaults
+ )
+ )
+ }
+ }
+
+ private func withDefaults(
+ _ body: (UserDefaults) throws -> Void
+ ) throws {
+ let suiteName = "DefaultBrowserPromptPolicyTests.\(UUID().uuidString)"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ try body(defaults)
+ }
+}
diff --git a/Tests/MeridianBrowserTests/ProfileIsolationWebKitIntegrationTests.swift b/Tests/MeridianBrowserTests/ProfileIsolationWebKitIntegrationTests.swift
index 9604af3..eadc000 100644
--- a/Tests/MeridianBrowserTests/ProfileIsolationWebKitIntegrationTests.swift
+++ b/Tests/MeridianBrowserTests/ProfileIsolationWebKitIntegrationTests.swift
@@ -239,7 +239,7 @@ final class ProfileIsolationWebKitIntegrationTests: XCTestCase {
onSecurityMessage: { _ in },
onURLConfirmationRequired: { _, _, _ in },
onDownloadConfirmationRequired: { _, completion in completion(nil) },
- onSitePermissionRequest: { _, _ in
+ onSitePermissionRequest: { _, _, _ in
.deny(reason: "Profile isolation fixture denies permission requests.")
}
)
diff --git a/Tests/MeridianBrowserTests/SidebarSpacePagerSelectionTests.swift b/Tests/MeridianBrowserTests/SidebarSpacePagerSelectionTests.swift
index 351b17b..d892080 100644
--- a/Tests/MeridianBrowserTests/SidebarSpacePagerSelectionTests.swift
+++ b/Tests/MeridianBrowserTests/SidebarSpacePagerSelectionTests.swift
@@ -54,15 +54,15 @@ final class SidebarSpacePagerSelectionTests: XCTestCase {
.before(firstID)
)
XCTAssertEqual(
- SidebarSpaceSwitcherLayout.target(for: 50, spaceIDs: spaceIDs),
+ SidebarSpaceSwitcherLayout.target(for: 70, spaceIDs: spaceIDs),
.before(secondID)
)
XCTAssertEqual(
- SidebarSpaceSwitcherLayout.target(for: 90, spaceIDs: spaceIDs),
+ SidebarSpaceSwitcherLayout.target(for: 110, spaceIDs: spaceIDs),
.before(thirdID)
)
XCTAssertEqual(
- SidebarSpaceSwitcherLayout.target(for: 130, spaceIDs: spaceIDs),
+ SidebarSpaceSwitcherLayout.target(for: 150, spaceIDs: spaceIDs),
.tail
)
}
@@ -73,12 +73,12 @@ final class SidebarSpacePagerSelectionTests: XCTestCase {
let thirdID = UUID()
let spaceIDs = [firstID, secondID, thirdID]
- XCTAssertNil(SidebarSpaceSwitcherLayout.spaceID(at: CGPoint(x: 12, y: 13), spaceIDs: spaceIDs))
- XCTAssertEqual(SidebarSpaceSwitcherLayout.spaceID(at: CGPoint(x: 32, y: 13), spaceIDs: spaceIDs), firstID)
- XCTAssertEqual(SidebarSpaceSwitcherLayout.spaceID(at: CGPoint(x: 64, y: 13), spaceIDs: spaceIDs), secondID)
- XCTAssertEqual(SidebarSpaceSwitcherLayout.spaceID(at: CGPoint(x: 96, y: 13), spaceIDs: spaceIDs), thirdID)
- XCTAssertNil(SidebarSpaceSwitcherLayout.spaceID(at: CGPoint(x: 128, y: 13), spaceIDs: spaceIDs))
- XCTAssertNil(SidebarSpaceSwitcherLayout.spaceID(at: CGPoint(x: 64, y: -1), spaceIDs: spaceIDs))
+ XCTAssertNil(SidebarSpaceSwitcherLayout.spaceID(at: CGPoint(x: 15, y: 15), spaceIDs: spaceIDs))
+ XCTAssertEqual(SidebarSpaceSwitcherLayout.spaceID(at: CGPoint(x: 53, y: 15), spaceIDs: spaceIDs), firstID)
+ XCTAssertEqual(SidebarSpaceSwitcherLayout.spaceID(at: CGPoint(x: 91, y: 15), spaceIDs: spaceIDs), secondID)
+ XCTAssertEqual(SidebarSpaceSwitcherLayout.spaceID(at: CGPoint(x: 129, y: 15), spaceIDs: spaceIDs), thirdID)
+ XCTAssertNil(SidebarSpaceSwitcherLayout.spaceID(at: CGPoint(x: 152, y: 15), spaceIDs: spaceIDs))
+ XCTAssertNil(SidebarSpaceSwitcherLayout.spaceID(at: CGPoint(x: 91, y: -1), spaceIDs: spaceIDs))
}
func testSpaceSwitcherLayoutIgnoresEmptyOrUnknownTargets() {
@@ -2119,6 +2119,29 @@ final class SidebarSpacePagerSelectionTests: XCTestCase {
)
}
+ func testFavoriteGridKeepsSavedFaviconAfterEssentialNavigatesAway() throws {
+ let savedURL = try XCTUnwrap(URL(string: "https://saved.example.com/article"))
+ let savedFaviconURL = try XCTUnwrap(URL(string: "https://saved.example.com/icon.png"))
+ let currentFaviconURL = try XCTUnwrap(URL(string: "https://current.example.com/icon.png"))
+ let tab = BrowserTab(
+ title: "Current Page",
+ url: try XCTUnwrap(URL(string: "https://current.example.com/dashboard")),
+ faviconURL: currentFaviconURL,
+ parentSpaceID: UUID(),
+ isFavorite: true,
+ essentialReference: BrowserEssentialReference(
+ title: "Saved Page",
+ url: savedURL,
+ faviconURL: savedFaviconURL
+ ),
+ profileID: UUID()
+ )
+
+ XCTAssertEqual(tab.essentialDisplayTitle, "Saved Page")
+ XCTAssertEqual(tab.essentialDisplayURL, savedURL)
+ XCTAssertEqual(SidebarTabFaviconSource.url(for: tab), savedFaviconURL)
+ }
+
func testFavoriteGridDoesNotResolveNonWebFaviconURL() throws {
let tab = BrowserTab(
title: "Local File",
@@ -2188,6 +2211,60 @@ final class SidebarSpacePagerSelectionTests: XCTestCase {
XCTAssertNil(state.previewStartPageSpaceID)
}
+ func testUnloadedWebPreviewUsesPlaceholderUntilSnapshotExists() throws {
+ let selectedTabID = UUID()
+ let previewTab = BrowserTab(
+ title: "Target",
+ url: try XCTUnwrap(URL(string: "https://example.com")),
+ parentSpaceID: UUID(),
+ profileID: UUID()
+ )
+
+ XCTAssertTrue(
+ BrowserContentPreviewPlaceholder.shouldShow(
+ for: previewTab,
+ selectedTabID: selectedTabID,
+ snapshotIsAvailable: false
+ )
+ )
+ XCTAssertFalse(
+ BrowserContentPreviewPlaceholder.shouldShow(
+ for: previewTab,
+ selectedTabID: selectedTabID,
+ snapshotIsAvailable: true
+ )
+ )
+ }
+
+ func testPreviewPlaceholderIgnoresCurrentAndStartPageTabs() throws {
+ let currentTab = BrowserTab(
+ title: "Current",
+ url: try XCTUnwrap(URL(string: "https://example.com")),
+ parentSpaceID: UUID(),
+ profileID: UUID()
+ )
+ let startPageTab = BrowserTab(
+ title: "New Tab",
+ parentSpaceID: UUID(),
+ profileID: UUID()
+ )
+
+ XCTAssertFalse(
+ BrowserContentPreviewPlaceholder.shouldShow(
+ for: currentTab,
+ selectedTabID: currentTab.id,
+ snapshotIsAvailable: false
+ )
+ )
+ XCTAssertFalse(
+ BrowserContentPreviewPlaceholder.shouldShow(
+ for: startPageTab,
+ selectedTabID: currentTab.id,
+ snapshotIsAvailable: false
+ )
+ )
+ }
+
@MainActor
func testPresentationStateStoresAndPrunesSnapshots() {
let keptTabID = UUID()
diff --git a/Tests/MeridianBrowserTests/WebViewHostHTTPFallbackTests.swift b/Tests/MeridianBrowserTests/WebViewHostHTTPFallbackTests.swift
index 4f60839..0d78cd8 100644
--- a/Tests/MeridianBrowserTests/WebViewHostHTTPFallbackTests.swift
+++ b/Tests/MeridianBrowserTests/WebViewHostHTTPFallbackTests.swift
@@ -27,7 +27,7 @@ final class WebViewHostHTTPFallbackTests: XCTestCase {
onSecurityMessage: { _ in },
onURLConfirmationRequired: { _, _, _ in },
onDownloadConfirmationRequired: { _, completion in completion(nil) },
- onSitePermissionRequest: { _, _ in .deny(reason: "Test denies site permission requests.") }
+ onSitePermissionRequest: { _, _, _ in .deny(reason: "Test denies site permission requests.") }
),
requestedURL: httpsURL,
pendingHTTPFallbackURL: httpURL,
@@ -63,7 +63,7 @@ final class WebViewHostHTTPFallbackTests: XCTestCase {
onSecurityMessage: { _ in },
onURLConfirmationRequired: { _, _, _ in },
onDownloadConfirmationRequired: { _, completion in completion(nil) },
- onSitePermissionRequest: { _, _ in .deny(reason: "Test denies site permission requests.") }
+ onSitePermissionRequest: { _, _, _ in .deny(reason: "Test denies site permission requests.") }
),
requestedURL: nil,
pendingHTTPFallbackURL: nil,
diff --git a/script/build_and_run.sh b/script/build_and_run.sh
index 5b99306..851bae5 100755
--- a/script/build_and_run.sh
+++ b/script/build_and_run.sh
@@ -28,6 +28,7 @@ LEGACY_BUILD_STAMP="$DIST_DIR/.BareBrowserBuildConfiguration"
APP_ICON_SOURCE="$ROOT_DIR/Resources/$APP_ICON_FILE"
APP_ICON_PARTIAL_PLIST="$DIST_DIR/AppIconPartialInfo.plist"
APP_ICON_ACTOOL_OUTPUT="$DIST_DIR/AppIconActoolOutput.plist"
+LOCAL_ENTITLEMENTS="$ROOT_DIR/Configuration/MeridianBrowserLocal.entitlements"
# Keep the established local certificate for the same continuity guarantees as
# the stable bundle identifier. This is a development implementation detail.
DEV_SIGNING_IDENTITY="Bare Browser Local Development"
@@ -48,7 +49,13 @@ build_stamp_value() {
}
app_bundle_is_current() {
- local source_paths=("$ROOT_DIR/Package.swift" "$ROOT_DIR/Sources" "$ROOT_DIR/Resources" "$ROOT_DIR/script/build_and_run.sh")
+ local source_paths=(
+ "$ROOT_DIR/Package.swift"
+ "$ROOT_DIR/Sources"
+ "$ROOT_DIR/Resources"
+ "$ROOT_DIR/script/build_and_run.sh"
+ "$LOCAL_ENTITLEMENTS"
+ )
local newest_source
[[ "${LUMEN_BROWSER_FORCE_BUILD:-0}" != "1" ]] || return 1
@@ -165,6 +172,10 @@ cat >"$INFO_PLIST" <$MIN_SYSTEM_VERSION
NSPrincipalClass
NSApplication
+ NSMicrophoneUsageDescription
+ Lumen Browser uses the microphone only when you allow a website to capture audio.
+ NSBluetoothAlwaysUsageDescription
+ Lumen Browser uses Bluetooth only when a website asks to connect to a nearby device, such as using a passkey from your phone.
UTExportedTypeDeclarations
@@ -331,6 +342,7 @@ sign_with_local_identity() {
--keychain "$DEV_SIGNING_KEYCHAIN" \
--sign "$identity" \
--identifier "$BUNDLE_ID" \
+ --entitlements "$LOCAL_ENTITLEMENTS" \
"$APP_BUNDLE"
status=$?
set -e
@@ -350,7 +362,11 @@ sign_app() {
fi
if [[ -n "$identity" && "$identity" != "-" ]]; then
- codesign --force --sign "$identity" --identifier "$BUNDLE_ID" "$APP_BUNDLE"
+ codesign --force \
+ --sign "$identity" \
+ --identifier "$BUNDLE_ID" \
+ --entitlements "$LOCAL_ENTITLEMENTS" \
+ "$APP_BUNDLE"
return
fi
@@ -360,7 +376,11 @@ sign_app() {
return
fi
- codesign --force --sign - --identifier "$BUNDLE_ID" "$APP_BUNDLE"
+ codesign --force \
+ --sign - \
+ --identifier "$BUNDLE_ID" \
+ --entitlements "$LOCAL_ENTITLEMENTS" \
+ "$APP_BUNDLE"
echo "warning: no valid code-signing identity found and local signing identity creation failed; using ad-hoc signing. Keychain Always Allow may not persist across rebuilt app binaries." >&2
}