Architecture
Litr is a single Rust binary. GPUI (Zed's UI framework,
drawing with Metal) draws the interface. WebKit's WKWebView, reached through
the objc2 bindings, renders pages.
NSWindow (transparent, full-size content view)
└── content view
├── NSVisualEffectView glass blur, when enabled
├── GPUI's Metal view sidebar, toolbar, panels
├── container NSView rounded clip, one per attached tab
│ └── LitrWebView WKWebView subclass
└── NSImageView icon in the web app and About panels
Source layout
| File | Role |
|---|---|
main.rs |
Startup, key bindings, menus. Switches to --compile-blocklist and web app mode. |
browser.rs |
The Browser view: tabs, rooms, memory policy, sidebar, toolbar, panels, persistence, schedules, web app creation, responsive view. |
webview.rs |
The bridge to WebKit: LitrWebView, WebView, NativeHost, NativeImage, WebEvent, and scroll sync's isolated-world message handler. |
text_input.rs |
Single-line text field (from GPUI's input example), used by the address bar and panels. |
address.rs |
Address bar input to URL or search. Search engines. |
storage.rs |
SQLite: history, tabs, rooms, schedules, web apps, developer sites, settings. |
history_view.rs |
The history panel. |
palette.rs |
The command palette window and fuzzy matching. |
dev.rs |
Developer tabs: local origins, server checks, project folder detection (lsof), FSEvents watching. |
downloads.rs |
WKDownload handling. |
schedule.rs |
Scheduled tab types and timing. |
webapp.rs |
Detecting app mode, building web app bundles, icons. |
adblock.rs |
Content blocker compilation and lookup, the YouTube script. |
privacy.rs |
Privacy report: blocked hosts per page and per day, estimates. Fed by the _webView:contentRuleListWithIdentifier:performedAction:forURL: navigation delegate SPI. |
assistant.rs |
The page assistant: runs helpers/litr-ai (Swift: FoundationModels and Translation) per request, streams its answer. |
tidy.rs |
Tab tidying: cleaning the model's groups, grouping by site, duplicates. |
links.rs |
Default browser (NSWorkspace) and link rules for links from other apps, delivered through GPUI's on_open_urls. |
share.rs |
Handoff (NSUserActivity), receiving handed-off pages (a method added to GPUI's app delegate), Markdown links. |
importer.rs |
Reading other browsers' bookmarks, history and Arc's sidebar (Chromium JSON and SQLite, Safari plist and SQLite, Firefox places). |
vault.rs |
Password vault: keychain internet passwords (Security framework), Touch ID (LocalAuthentication), Chromium password decryption (CommonCrypto), CSV import. The page side is resources/passwords.js, in the isolated client world. |
reader.rs |
Reader mode: Readability scripts, the reader page template. |
update.rs |
Update feed, download, signature check, bundle swap, relaunch. |
memory.rs |
Per-process memory (proc_pid_rusage), the memory limit's choice of tab. |
memory_pressure.rs |
libdispatch memory pressure source. |
time.rs |
Local time helpers (localtime_r). |
actions.rs |
GPUI actions. |
GPUI and WebKit in one window
GPUI can't host native views, so web views are siblings of GPUI's Metal view in the window's content view, and always sit above GPUI content. The consequences:
- Layout: GPUI lays out an empty area where the page belongs. A
canvaselement's prepaint callback reports that area's bounds, andWebView::set_frameconverts them from GPUI's top-left coordinates to AppKit's and moves the web view. Frames are only set when the bounds change. - Overlays: nothing GPUI draws can appear over a page. Litr avoids needing
to:
- The find bar is a row above the page, not over it.
- History, settings, schedules and the web app panel replace the page. The web view is detached while they are open.
- The command palette is a separate borderless GPUI window, attached to the main window as a native child window so it moves with it.
- Keyboard focus: AppKit's first responder is either GPUI's view or a web
view. When Litr focuses a GPUI element (address bar, panel), it makes GPUI's
view first responder first (
NativeHost::focus_gpui). When the user clicks into a page,LitrWebViewoverridesbecomeFirstResponderand reports it, so the address bar can give up focus. - Shortcuts go through the menu bar, so they work whichever view has
focus. Edit menu items use
MenuItem::os_action, which maps to the nativecopy:/paste:selectors, so they reach the web view through the responder chain. - Rounded corners:
WKWebViewmanages its own layers, so each web view sits in a layer-backed container view that clips to rounded corners.
WebKit events
LitrWebView is a WKWebView subclass (define_class!) and is its own
navigation delegate, UI delegate and KVO observer. It observes title, URL,
loading, estimatedProgress, canGoBack, canGoForward, themeColor and
underPageBackgroundColor.
Everything becomes a WebEvent on an async_channel that the browser drains
on GPUI's main-thread executor: page changes, focus, new windows, close
requests, crashed content processes, find results, downloads, icon lookups and
the ad block list. Litr never polls.
New windows (webView:createWebViewWithConfiguration:…) return a new
LitrWebView built with the configuration WebKit passes in. That keeps
window.opener working for sign-in pop-ups.
Memory
- Lazy web views. A
TabholdsOption<WebView>, so new and restored tabs have none until shown. - One attached view. Only the active tab's web view is in the window.
Out-of-window pages drop their tile backing stores, and with
WKInactiveSchedulingPolicy::Suspend(macOS 14+) they are suspended. - Live limit. At most
max_live_tabsweb views exist. The least recently used background tabs are unloaded first. - Memory limit. Every 5 seconds each loaded tab's web content process
(
_webProcessIdentifier, SPI, checked withrespondsToSelector:) is measured withproc_pid_rusage(physical footprint, as in Activity Monitor). Over the limit, the heaviest background tab that isn't busy with media is unloaded, one per sample. "Busy" means_isPlayingAudio(SPI) or a camera or microphone capture state; busy tabs are exempt from every kind of automatic unloading. - Snapshots instead of blank reloads. Leaving a tab calls
takeSnapshotWithConfiguration:while the view is still in the window; the image is redrawn at 1x and stored as JPEG (quality 0.6, about 100 KB) in the tab and the session (tabs.snapshot, schema v4). The old view stays attached under the new one until the snapshot arrives (at most 600 ms), so nothing flickers. Reloading an unloaded tab puts the JPEG over the page in anNSImageViewand fades it out once the page has loaded (at most 4 s). - Idle unloading and memory pressure. A timer unloads idle tabs, and a libdispatch memory pressure source unloads all background tabs.
- Assistant. The on-device model and translation run in
litr-ai, a Swift helper started per request and killed when the request is replaced or the pane closes. The model's memory belongs to the system's model service, not to Litr. - Forget tabs. A forget tab holds its own
WKWebViewConfigurationwith anonPersistentDataStore, sharing the preferences and user content controller (ad blocking, scripts) with the shared configuration. The tab keeps the configuration while unloaded, so its cookies survive until it closes. Views WebKit creates for its pop-ups inherit it. - Rooms. Only the current room's tabs are in
Browser::tabs, so every index-based tab operation works on one room. The other rooms' tabs wait inRoom::tabs, unloaded apart from tabs that play sound or are in a call; the memory sampler counts those and unloads each once it goes quiet. The active tab of the room being left takes its snapshot first and is unloaded when the snapshot arrives. - Unloading keeps the interaction state. It saves WebKit's
interactionState(back/forward list and scroll position, NSData of 1–2 KB), which is restored on reload. - Two drawables. GPUI's
CAMetalLayeris limited to two window-sized drawables instead of three (about 15 MB saved per window at 1200×800 on Retina, 60 MB fullscreen on 5K). - No image decoders. GPUI's
img()would add about 1.1 MB of decoders, so the one image Litr shows (the web app icon preview) is a nativeNSImageView.
Persistence
storage.rs uses the system libsqlite3 (the rusqlite crate without
bundled) in WAL mode with a small cache. The schema version is in
PRAGMA user_version:
| Table | Contents |
|---|---|
history |
url (key), title, host, last_visit, visit_count |
tabs |
position, url, title, theme_color (packed RGBA), zoom, state (interaction state blob), pinned_url, active (per room), snapshot (JPEG), room |
blocked |
day (local day number), host, count; key (day, host); 30 days kept |
bookmarks |
id, url, title, folder (path; empty for Litr's own), added; unique (url, folder) |
link_rules |
id, site, target (app:<bundle id>, room:<id> or forget) |
rooms |
id (key), name, color (packed RGB), position, apps (web app bundle ids, one per line) |
schedules |
url, title, action, daily, at (Unix time or minute of day), last_fired_day |
web_apps |
bundle_id (key), name, url, path |
dev_sites |
origin (key), folder, watch |
settings |
key, value |
Tab changes are debounced by a second. They are also saved when the window closes and when the app quits.
Ad blocking
scripts/update_blocklist.py converts EasyList and EasyPrivacy into WebKit
content blocker JSON and compresses it (resources/blocklist.json.zlib). The
file is embedded with include_bytes!.
WKContentRuleListStore compilation of about 117,000 rules peaks at around
400 MB, so it runs in a child process, litr --compile-blocklist. The child
inflates the rules with miniz_oxide, compiles them into a store at
~/Library/Application Support/Litr/ContentRules, and exits. The store is
keyed by an FNV-1a hash of the embedded rules, so each rule set compiles once
for Litr and all its web apps. Stale lists are removed.
At startup the browser looks up the compiled list and delays the first tab until the lookup answers, which takes milliseconds. If the list isn't there it starts the child, browses meanwhile, and reloads live tabs once it's ready.
The rule list and resources/youtube.js (a document-start user script, in all
frames) are added to the shared configuration's WKUserContentController.
Popups inherit the controller. Turning blocking off removes both.
Web apps
A web app is a copy of the running binary in ~/Applications/<Name>.app with:
CFBundleIdentifierset tono.gets.litr.webapp.<slug>,LitrWebAppURLandLitrWebAppVersionin its Info.plist.
At startup webapp::detect() reads NSBundle.mainBundle and switches to app
mode:
- a separate database directory,
- the site as a pinned first tab,
- no sidebar or toolbar,
- the app's name in the menus.
WebKit keys cookies and storage by bundle identifier, which gives each web app its own data.
Icons: the page's best icon URL is found with JavaScript and fetched on a
background thread. It is composed onto a squircle with AppKit drawing
(NSBitmapImageRep, NSBezierPath, NSImage), then converted with
sips -z and iconutil.
Signing: bundles are signed ad hoc. Litr's LSFileQuarantineEnabled
quarantines everything Litr and its child processes write, so the quarantine
flag is cleared after codesign. Otherwise Gatekeeper would translocate and
block the app.
Updates: main-mode Litr refreshes the binaries of the web apps it recorded
when LitrWebAppVersion differs from its own version.
Updates
update.rs follows eterm's updater, using system facilities instead of crates:
| Step | How |
|---|---|
| Fetch the feed | NSData dataWithContentsOfURL: |
| Read it | NSJSONSerialization |
| Check the zip's hash | CommonCrypto's CC_SHA256 |
| Unpack | ditto -x -k |
| Check the signature | codesign --verify --deep --strict -R "=<requirement>" |
The requirement is Apple's Developer ID designated requirement narrowed to
team 7G383N3VY7 and identifier no.gets.litr. After the checks, the
quarantine flag (set because Litr quarantines what it writes) is cleared.
The running bundle is renamed aside (.Litr.app.previous), not overwritten,
because macOS kills a process whose signed executable changes under it. The
new bundle is moved in, and the old one is restored if that fails. Relaunch
runs open -n on the bundle and quits.
Checks run 30 seconds after launch and then daily, in main mode only. The
status (update::Status) drives the strip under the toolbar, the About panel
and the settings row.
Dependencies
| Crate | Why |
|---|---|
gpui (pinned =0.2.2, runtime_shaders) |
UI. Pinned because GPUI follows Zed and changes without notice. Runtime shaders mean building doesn't need Xcode's metal compiler. |
objc2, objc2-foundation, objc2-app-kit, objc2-web-kit, block2 |
AppKit and WebKit bindings, Objective-C blocks. |
raw-window-handle |
GPUI's native view. |
async-channel |
WebKit events to the UI. |
rusqlite (system SQLite) |
Storage. |
miniz_oxide |
Inflating the embedded rules. |