Skip to content

Latest commit

 

History

History
239 lines (197 loc) · 12.9 KB

ARCHITECTURE.md

File metadata and controls

239 lines (197 loc) · 12.9 KB

Architecture

Linen is a macOS SwiftUI app around WebKit. Swift 6 strict concurrency and Main Actor default isolation are enabled for the app target.

Source layout

Folder Contents
Linen/App Launch, the application, menus, install and updates. App/Coordinator holds AppCoordinator and its extensions.
Linen/Core Code with no browser or feature knowledge: persistence, runtime helpers, profiles and stage mode.
Linen/Browser The browser engine: models, tabs, splits, search, history, downloads, pages and privacy. It contains no SwiftUI views.
Linen/Interface The window shell and the shared visual system: shell, toolbar, sidebar, start page, command palette and content area.
Linen/Features Self-contained features. Each feature folder holds its model, its engine and its views.
Linen/Settings The settings model, the settings components, navigation and one folder per settings group.
Linen/Resources Assets, the app icon, string catalogs, Info.plist, entitlements and generated data.

LinenTests uses the same folders. A test goes in the folder of the code it tests. LinenTests/Support holds shared helpers and fixtures. LinenTests/Evals holds the evals that Tools/eval runs. Kit holds the runner and the checks, and Suites holds the tasks. See EVALS.md.

Put a new file in the folder of the feature or layer that owns it. If a feature has views, put them in a UI subfolder of that feature. If a folder holds more than about 15 files, divide it by responsibility.

Runtime structure

flowchart LR
    App["AppDelegate"] --> Application["BrowserApplication"]
    Application --> Coordinator["AppCoordinator per window"]
    Coordinator --> Profile["Shared BrowserProfileContext"]
    Coordinator --> Browser["BrowserModel"]
    Coordinator --> Turns["AgentTurnModel"]
    Turns --> Agent["AgentRunner"]
    Coordinator --> Input["Voice and keyboard input"]
    Browser --> Tabs["BrowserTab"]
    Tabs --> Process["TabProcessState"]
    Tabs --> WebKit["WKWebView"]
    Agent --> Toolkit["AgentToolkit"]
    Tabs --> Access["TabAssistantAccessCenter"]
    Access --> Permissions["SitePermissions"]
    Toolkit --> Access
    Access --> Driver["PageDriver"]
    Driver --> WebKit
    Browser --> Stores["Profile stores"]
    Turns --> Log["ConversationLog"]
    Stores --> Database["AppDatabase / GRDB"]
    Log --> Database
    Coordinator --> Views["SwiftUI views"]
Loading

BrowserApplication owns the native window registry, active-window routing, updates, and one MCP server. Each window has an AppCoordinator with its own tabs, sidebar, selection, voice input, and assistant task. Views observe their coordinator and its focused models. They should not contain persistence, networking or WebKit policy. New work should prefer a narrow model or protocol over another coordinator responsibility.

VoiceInputModel owns microphone and transcription state. AgentTurnModel owns one turn’s task, reply, tab activity and durable completion state. Both models expose small observable values while their service dependencies stay outside observation.

Browser state

BrowserModel owns tab and folder order, activation, session restoration and history integration. BrowserTab owns one page’s observable state and WebKit lifecycle. TabProcessState owns process-protection signals, unload status and unexpected-termination throttling. WebViewPool prepares reusable views without owning tab state.

A restored tab has no WKWebView until you open it. BrowserTab.webView builds one on first use and isMaterialised reports whether it exists, so code that iterates over tabs must check it before accessing the view. A tab unloaded under memory pressure retains its view but replaces the loaded page. In both cases the title, address, favicon and WebKit interaction state remain, so activation loads the page again. Code that adds a new kind of in-progress page work must decide whether that work prevents discarding.

BrowserProfileContext owns a profile's database, website data store, settings, permissions, history, downloads, conversation log, web view pool, and extension manager. Persistent windows using the same profile share this context. Tabs keep an explicit context even before their web view is materialized. Changing focus or another window's profile cannot retarget their data stores.

Each private window gets a distinct context with an in-memory database and a nonpersistent website data store. Closing it cancels its tasks, unloads its extensions, clears its conversations, and removes its website data and temporary compiled content rules. Shutdown awaits pending private cleanup, including cleanup started by an already closed window. Private windows never enter persistent session restoration or window-frame autosave.

Session rows include a stable window ID. Saves, close records, folders, and split layouts are scoped to that ID. The migration assigns the old session to one window, and the application gives migrated windows unique IDs across profiles. Revisions reject stale queued saves after a close or synchronous final save. Remapped IDs keep a hidden revision cutoff so delayed saves cannot recreate the old window record. A later profile switch may reuse the ID with a newer revision. A tab transfer moves the existing tab and web view, rebinds callbacks, cancels source-window assistant work, and commits both windows together. A live tab may move only between windows sharing the same context.

A space groups tabs and folders inside one profile. SpaceStore owns the list of spaces, and each Space names its profile. Tabs and folders store a spaceID. A switch between spaces of the same profile changes only the visible space. A space with its own data uses its own profile.

Each window keeps one BrowserModel for each profile it has shown. AppCoordinator.parkedModels holds the models that are not visible. A parked model restores its session lazily and builds no web views. A profile switch closes the outgoing saved session, pauses its media and parks the model. It then registers the incoming model with the next context's extension manager and reopens that model's session. A private model is closed, not parked. Code that keeps a BrowserModel reference must follow AppCoordinator.browser, because a switch replaces it. Other windows keep their existing contexts and tasks. Visible and dismissing Peek pages detach immediately during a profile switch or window teardown, so their callbacks cannot reach the next profile. Extension window and tab adapters expire when their window unregisters. Toolbar actions and delayed popup work must check the current registration before reading the browser, since the same browser model can now belong to another profile.

Agent trust boundaries

The model is not a security boundary. Page text is untrusted, even when the model describes it as an instruction.

  • AgentToolkit exposes the supported browser actions.
  • TabAssistantAccessCenter authorizes visible-page reads and controls by origin before AgentToolkit reaches the page driver.
  • PageDriver resolves actual page elements and enforces sensitive-field and consequential-action rules.
  • AgentActionConsent asks from trusted app UI. The model cannot suppress it.
  • AgentActionPolicy stores narrow, revocable category-and-host grants.
  • ConversationLog persists the activity trail inside the active profile.

Background research uses a non-persistent WebKit data store for each task. It does not inherit cookies, logins or website storage from the active profile. Loading the chosen result into a tab does not grant the assistant access to it.

Add enforcement below the model prompt. Prompts can improve behavior but cannot authorize access, protect credentials or confirm an irreversible action.

External MCP clients run the assistant's own tools. MCPBrowserSession gives each connection an AgentToolkit with an external AgentDriver and connection-local consequential-action approvals, so site access, tab scope and page safety are the assistant's. The only added gate is a one-time connection approval that names the client's verified developer. External work is logged as prompt-less tasks in the tab's conversation; it does not enter AgentTurnModel or the model's context. Each connection binds to one regular window when it connects. Focus changes do not retarget it. Closing or switching that window revokes its connections before replacing stores. Other windows keep their connections. The bundled --mcp process relays stdio to a user-only Unix socket without opening a second browser session. See MCP.md for the tool contract and boundaries.

MCP enablement is an app-level preference. Runtime shutdown clears connections without changing that preference. The listener belongs to the application. It refuses new connections when the focused window is private. Existing connections to regular windows stay bound to their original window. The approval sheet attaches to that window.

MCPClientInstaller handles optional client setup on its own actor. It merges standard JSON configs and uses the installed Codex CLI on a staged TOML copy. MCPConfigurationFile owns private backups and checked atomic replacement. These services have no browser, profile, assistant, or sharing-grant dependency.

Assistant execution and context

Providers propose actions. Linen checks permissions, runs each action once, and saves its result in a checkpoint. Resuming an interrupted task preserves user answers and requires verification before retrying an action with an unknown outcome. Repeated failures or unchanged results eventually pause the task.

Conversation messages, attachments, and checkpoints belong to the active profile. They are private conversation data, not diagnostic exports. Deleting a turn also invalidates checkpoints that may contain it. AgentRunDiagnostics exports only approved event names, counts, timings, and status values. It excludes prompts, answers, page content, URLs, tool arguments, credentials, and raw provider errors.

Automatic and manual compaction use the selected provider. The portable compactor summarizes chronological evidence in bounded slices. It preserves the latest user request, exact answers to questions, and complete recent tool exchanges where they fit. Page content and tool results remain untrusted input. A failed or cancelled compaction leaves the original checkpoint intact. On-device compaction stays on device. The OpenAI adapter also preserves native Responses state.

The context indicator estimates occupancy, including tool definitions. It does not report measured provider token usage. Progress updates remain in private chat history. Diagnostics record only their event type and status.

See OpenAI integration for provider configuration and validation, and Browser autofill for form and credential handling.

State and SwiftUI

Shared mutable models use Observation. View-local state is private. A distinct screen or independently changing section should be a real View type with narrow inputs. A computed some View property does not create an observation boundary.

Use the components and metrics in Linen/Interface/DesignSystem and Linen/Settings/Components. Theme owns shared visual tokens. User-facing strings stay localizable. Protocol values, URLs, model IDs and third-party error text stay verbatim.

Persistence

GRDB stores structured browser and agent data. Small preferences use UserDefaults. Provider secrets use Keychain. File-backed models use atomic writes through the support layer. These are profiles, website permissions, page zoom and the download list. A write needed for quit or profile teardown must be awaited or flushed synchronously before its owner is released.

Tests

Tests use Swift Testing, with XCTest for the two things it cannot express: performance baselines (XCTMetric) and a test that drives the main run loop. Prefer pure parsing and policy functions, injected stores, temporary databases and local WebKit fixtures. HTTPFixtureServer serves deterministic loopback pages for navigation and origin-boundary tests. A test should assert a user-observable result or an enforced invariant. Live services and fixed sleeps do not belong in the default suite.

Tests use a per-process temporary directory from AppDatabase.supportDirectory. Profiles, permissions, zoom state and the download list do not access an installed copy’s support directory.

WebViewGate limits how many cases have a live WKWebView at once, to half the machine’s cores. The .boundedWebViews trait takes a slot. Apply it to the tests that build a view, not to a whole suite, so the other tests do not wait for a resource they never use.

Linen.xctestplan turns on per-test timeouts: 120 seconds by default, 300 at most. A stalled test times out and reports its name without blocking the full run.

CI runs the full suite without code coverage. See CONTRIBUTING.md for the change checklist.