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.
6 changes: 6 additions & 0 deletions Configuration/MeridianBrowser.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
<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.audio-input</key>
<true/>
<key>com.apple.security.device.bluetooth</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
Expand Down
10 changes: 10 additions & 0 deletions Configuration/MeridianBrowserLocal.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.bluetooth</key>
<true/>
</dict>
</plist>
98 changes: 98 additions & 0 deletions DebugFixtures/microphone.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Microphone Capture 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(34rem, 100%);
}

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

output {
display: block;
margin-top: 1rem;
white-space: pre-wrap;
}

meter {
display: block;
width: 100%;
margin-top: 1rem;
}
</style>
</head>
<body>
<main>
<h1>Microphone Capture Fixture</h1>
<p>Starts an audio-only WebRTC capture and reports whether the track is live.</p>
<button id="start" type="button">Start microphone</button>
<output id="status">Ready</output>
<meter id="level" min="0" max="1" value="0" aria-label="Microphone level"></meter>
</main>
<script>
const startButton = document.querySelector("#start");
const status = document.querySelector("#status");
const level = document.querySelector("#level");

const report = (state, message) => {
document.documentElement.dataset.microphoneState = state;
status.textContent = message;
};

startButton.addEventListener("click", async () => {
startButton.disabled = true;
report("pending", "Requesting microphone access…");

try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const track = stream.getAudioTracks()[0];
if (!track || track.readyState !== "live") {
throw new Error("The microphone stream did not contain a live audio track.");
}

const audioContext = new AudioContext();
const source = audioContext.createMediaStreamSource(stream);
const analyser = audioContext.createAnalyser();
const samples = new Uint8Array(analyser.fftSize);
source.connect(analyser);

const updateLevel = () => {
analyser.getByteTimeDomainData(samples);
const peak = samples.reduce(
(maximum, sample) => Math.max(maximum, Math.abs(sample - 128) / 128),
0
);
level.value = peak;
requestAnimationFrame(updateLevel);
};

updateLevel();
report("granted", `PASS: live audio track (${track.label || "default microphone"})`);
} catch (error) {
report("denied", `FAIL: ${error.name}: ${error.message}`);
startButton.disabled = false;
}
});
</script>
</body>
</html>
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
4 changes: 3 additions & 1 deletion 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, Lumen Browser shows the generic insecure-transport status message without embedding the full URL.
Expand All @@ -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.

Expand Down
1 change: 1 addition & 0 deletions Docs/WebKitLimitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading