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.

+ + 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… + + 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 }