Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions Configuration/MeridianBrowser.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.developer.web-browser.public-key-credential</key>
<true/>
<key>com.apple.security.device.bluetooth</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
Expand Down
96 changes: 96 additions & 0 deletions DebugFixtures/passkeys.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Passkey Capability Fixture</title>
<style>
:root {
color-scheme: light dark;
font: 16px/1.5 system-ui, sans-serif;
}

body {
box-sizing: border-box;
display: grid;
min-height: 100vh;
margin: 0;
padding: 2rem;
place-items: center;
}

main {
width: min(38rem, 100%);
}

button {
font: inherit;
padding: 0.65rem 1rem;
}

output {
display: block;
margin: 1rem 0;
white-space: pre-wrap;
}
</style>
</head>
<body>
<main>
<h1>Passkey Capability Fixture</h1>
<p>Checks WebAuthn support without creating or sending a credential.</p>
<output id="capabilities">Checking…</output>
<button id="start" type="button">Test passkey chooser</button>
<output id="result">Ready</output>
</main>
<script>
const capabilities = document.querySelector("#capabilities");
const result = document.querySelector("#result");
const startButton = document.querySelector("#start");

const reportCapabilities = async () => {
if (!window.PublicKeyCredential) {
capabilities.textContent = "PublicKeyCredential: unavailable";
return;
}

const platform = await PublicKeyCredential
.isUserVerifyingPlatformAuthenticatorAvailable();
const conditional = typeof PublicKeyCredential.isConditionalMediationAvailable === "function"
? await PublicKeyCredential.isConditionalMediationAvailable()
: false;
capabilities.textContent = [
"PublicKeyCredential: available",
`Platform authenticator: ${platform}`,
`Conditional mediation: ${conditional}`
].join("\n");
};

startButton.addEventListener("click", async () => {
startButton.disabled = true;
result.textContent = "Opening the system passkey chooser…";

try {
await navigator.credentials.get({
publicKey: {
challenge: crypto.getRandomValues(new Uint8Array(32)),
rpId: location.hostname,
allowCredentials: [],
userVerification: "preferred",
timeout: 60_000
}
});
result.textContent = "PASS: WebAuthn returned a credential to this local-only fixture.";
} catch (error) {
result.textContent = `RESULT: ${error.name}: ${error.message}`;
} finally {
startButton.disabled = false;
}
});

reportCapabilities().catch(error => {
capabilities.textContent = `Capability check failed: ${error.name}: ${error.message}`;
});
</script>
</body>
</html>
2 changes: 2 additions & 0 deletions Docs/FeatureChecklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions Docs/TestPlan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions Docs/ThreatModel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, Bare Browser shows the generic insecure-transport status message without embedding the full URL.
Expand Down
54 changes: 53 additions & 1 deletion Sources/MeridianBrowser/MeridianBrowserApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -44,7 +46,10 @@ struct MeridianBrowserApp: App {

var body: some Scene {
WindowGroup("Bare Browser") {
BrowserWindowView(store: store)
BrowserWindowView(
store: store,
initialAlertsCompleted: beginStartupPromptSequence
)
.frame(minWidth: 900, minHeight: 620)
.handlesExternalEvents(preferring: ["*"], allowing: ["*"])
.onOpenURL { url in
Expand All @@ -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
Expand Down Expand Up @@ -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<Bool> {
Binding(
get: { defaultBrowserManager.isPromptPresented },
set: { isPresented in
if !isPresented {
defaultBrowserManager.dismissPrompt()
}
}
)
}

private func beginStartupPromptSequence() {
if !defaultBrowserManager.preparePromptIfNeeded() {
passkeyAccessController.requestAuthorizationIfNeeded()
}
}
}

private struct BrowserNavigationCommandMenu: Commands {
Expand Down
Loading