Thanks for wanting to make Lilo better. This guide takes you from a fresh clone to a merged pull request. Questions are welcome in Discussions; everyone taking part follows the Code of Conduct.
- Read ROADMAP.md, mostly its non-goals: a pull request that implements one is closed. If you are unsure whether an idea fits, open a discussion first.
- Pick an issue, or a bug you hit yourself. Small, focused changes are the easiest to review and merge.
- Set up the project (Setup), make the change, add a test, and run
make validate. - Open a pull request against
main(Pull requests).
docs/ARCHITECTURE.md tells you which folder holds what, and docs/DEBUGGING.md how to inspect the interface, read logs and debug extensions.
| Tool | Version | Needed for |
|---|---|---|
| macOS | 26 or later | running Lilo |
| Xcode | 27, selected with xcode-select, and its Metal toolchain |
building the app (Swift 6, the macOS 27 SDK) |
| Node.js | 22.23.4+ (.nvmrc pins 22) |
the Svelte interface, extension bridge and Astro website; all make ui-preview needs |
| Changie | 1.26.0 (downloaded by Make) | release notes and app versioning |
| SwiftLint, XcodeGen | 0.65.0 and 2.46.0 (downloaded by Make) | make validate and make project-sync |
| Zig | 0.16.x (brew install zig) |
only make vt-build-source, which compiles libghostty-vt instead of downloading it |
Python 3 comes with Xcode. No Apple Developer account is needed.
git clone https://github.com/Alexays/Lilo.git
cd Lilo
make hooks-install # enable commit-message checks once per clone
make bootstrap # checks the tools above, then npm deps, libghostty-vt, interface bundle, extension bridge
make app # Debug build in build/debug/export/Lilo.app
open build/debug/export/Lilo.appmake bootstrap says what is missing and how to install it, and is a no-op once everything is
in place. It downloads libghostty-vt, the terminal library, prebuilt for the pinned Ghostty commit
and checked against the sha256 in the Makefile; make vt-build-source (or VT_FROM_SOURCE=1)
compiles it with Zig instead, which is also what happens, with a message, when the download is
not available. make install-debug installs the Debug build into ~/Applications, and
make install a Release build into /Applications.
Run sudo DevToolsSecurity -enable once: otherwise xcodebuild eventually fails to launch the test
host with INTERNAL ERROR: childPID > 0, once its cached debugger authorization expires.
To work on the sidebar, the command bar, settings or onboarding, Node.js is enough:
make ui-preview # opens http://localhost:5173/preview/ with hot reloadlilo-ui/preview/ runs the interface in any browser against the fake
host the end-to-end tests use, seeded with sample Spaces, pinned tabs, folders, extensions and
settings. Query parameters pick what it shows: ?world=default|empty|onboarding,
&theme=light|dark (the system's otherwise) and &open=settings|commandbar, for example
/preview/?world=onboarding&theme=dark. Commands the interface sends are in
window.__sentCommands. The preview never ships: the app embeds only the index.html build.
Without signing setup, every build is ad-hoc signed. It runs on the Mac that built it, but macOS asks again for camera, microphone and keychain access after each rebuild, and the helper that keeps the Mac awake with the lid closed stays off.
To sign with a stable identity (a free Apple ID's Personal Team is enough), copy
Makefile.local.example to Makefile.local and set LILO_DEBUG_TEAM_ID. The file is git-ignored
and explains how to find your team ID. Release signing and notarization are in
docs/RELEASING.md.
Only a Release build is the installed Lilo. A Debug build (make app, make install-debug,
Xcode) and the test host never touch its data:
| Build | Bundle id | Data |
|---|---|---|
| Release | fr.arouillard.lilo |
~/Library/Application Support/Lilo |
| Debug | fr.arouillard.lilo.debug |
~/Library/Application Support/Lilo-Debug |
make swift-test host |
fr.arouillard.lilo.tests |
temporary folders per test |
The bundle id also separates the preferences (~/Library/Preferences/<bundle id>.plist), the
WebKit data (~/Library/WebKit/<bundle id>) and the launch-at-login item;
Lilo/App/BuildIdentity.swift separates the rest (database, agent socket, keychain items, the
power helper's launch daemon). LILO_APP_SUPPORT_DIR overrides the data folder, agent socket
included. Tests wipe the WebKit stores they create when the run ends, and the next run removes
what an interrupted one left under ~/Library/WebKit/fr.arouillard.lilo.tests.
The Makefile is the only command surface: make help lists every target. The ones
you use daily:
# Interface work, with hot module reload in the running app
make ui-dev # terminal 1: Vite dev server on localhost:5173
make run-ui-dev # terminal 2: builds the app and launches it against the dev server
make ui-preview # or the interface alone in a browser, without Xcode
# Swift work
make app # Debug bundle in build/debug/export/Lilo.app
make project-sync # after adding or renaming a Swift file, or editing project.yml
# Before opening a pull request
make validateproject.ymlis the source of the Xcode project. Never editLilo.xcodeprojby hand. After adding or renaming a Swift file, runmake project-syncand commit the regenerated project:xcodebuild,make swift-testand CI fail until it is in sync.- Generated files are build outputs. Never hand-edit the extension bundles in
Lilo/Extensions/(ChromeAPIAugmentation.js,ChromeAPIBackgroundPrelude.js,ChromeAPIGapModules.json,ChromeWebStoreShim.js,ChromeContentCompat.js,ChromeStaticRules.js),*.gen.swift,tabProtocol.gen.ts, or the git-ignoredLilo/Generated/NativeStrings.swiftandLilo/Generated/ChromeUIAssets.swift. Regenerate them withmake ext-bridge-build,make bridge-genormake ui-build. Package.swiftis for fast local checks only. Oncemake vt-buildandmake ui-buildhave run,swift buildandswift test --filter <TestClass>work, butxcodebuild(throughmake) is the official build, andswift runlacks the app's Info.plist: usemake run-ui-devto launch a real bundle.
make validate is the gate: UI lint, type checks, tests and build; the extension bridge build and
tests; SwiftLint; the Xcode build and the Swift tests; commit hook tests; website checks and tests;
release-note and version sync checks.
Run it once your change is done, and confirm it passes before you open a pull request.
| Command | Runs |
|---|---|
make ui-lint |
ESLint and svelte-check over lilo-ui/ |
make ui-test |
UI unit tests, including the catalog key and placeholder parity checks |
make e2e |
Playwright end-to-end tests of the interface, in Chromium |
make ext-bridge-test |
extension bridge TypeScript tests |
make hooks-test |
commit-message validation and real Git hook/range checks in temporary repositories |
make lint-commit-message |
checks a commit header or PR title read from stdin |
make lint-commits |
checks non-merge commit headers in BASE..COMMIT_HEAD (default origin/main..HEAD) |
make release-status |
pending release notes, changelog and app version consistency |
make release-test |
release preparation, Sparkle build number synchronization and Discord announcement tests (CI runs them; make validate does not) |
make site-check |
Ultracite lint and formatting, Astro templates and TypeScript |
make site-fix |
fix website lint and formatting with Oxlint and Oxfmt |
make site-test |
website build, release notes, routes, links and metadata |
make swift-test |
Swift tests in parallel app instances, the slow ones aside |
make swift-test-slow |
the few Swift tests that wait out real clocks, WebKit's or Lilo's (SLOW_TESTS in the Makefile) |
make check-commits |
builds every commit of BASE..HEAD (default origin/main) in a throwaway worktree |
Heavier checks, run when your change touches their area:
| Command | Runs |
|---|---|
make ext-smoke |
the 100 most used Web Store extensions against Chrome's recorded behavior (their errors, what their popup and options page show, their popup again once the worker went idle, what their content scripts change on a local page, their context menu items, with the slow steps reported), and what a few do on a local page past their popup (network; TOP=500, IDS=a,b) |
make ext-nightly |
unattended ext-smoke of the top 500 (TOP=1000 for all), with a report in .derived.noindex/ext-smoke/report.txt and a non-zero exit on a regression |
make terminal-bench |
terminal throughput and lock-fairness benchmarks |
make startup-bench |
launch times of a Release build against a throwaway profile |
make ext-bench |
a Release build with 20 store extensions: restore, worker starts, API latency and idle CPU, failing when idle CPU % goes over MAX_IDLE_CPU=35,25 (Lilo, its WebKit processes; MAX_IDLE_CPU= only measures) |
make e2e-energy |
idle energy guard on a Release build (about 3 minutes, see DEBUGGING.md) |
- Swift:
LiloTests/mirrors the source tree, so the tests ofLilo/<Folder>/X.swiftgo inLiloTests/<Folder>/XTests.swift. Shared helpers (temporary databases and browser contexts, fixtures, async polling) live inLiloTests/Support/. - Interface:
*.test.tsbeside the Svelte source inlilo-ui/src/, and Playwright specs inlilo-ui/e2e/. - Extension bridge:
*.test.tsbeside the TypeScript source inLilo/Extensions/bridge-src/src/. - Commit hooks:
scripts/commit_hooks.test.shexercises the shared validator and real Git commits in temporary repositories, without Node dependencies.
Test through the real entry point (the public method, the bridge message, the rendered component) rather than carving out a helper just to reach it.
.github/workflows/build.yml runs on every push to main and every pull request:
- Release tooling and website (ubuntu): Changie fragments, version sync, Ultracite and Astro checks, website tests and a Cloudflare deployment dry run.
- UI (ubuntu):
make validate-uiandmake e2e. - Extension bridge (ubuntu):
make ext-bridge-test, and a check that the generated bundles matchbridge-src. - App (the GitHub-hosted
xcode-27macOS image, no signing secrets): a check thatLilo.xcodeprojmatchesproject.yml, SwiftLint, the Debug and Release builds,make swift-testandmake swift-test-slow. - libghostty-vt (
.github/workflows/ghostty-vt.yml, by hand or whenGHOSTTY_SHAchanges onmain): builds the xcframeworkmake vt-builddownloads and publishes it as theghostty-vt-<sha>prerelease, whose notes give the newVT_SHA256. - Startup benchmark (informational, pushes to
main):make startup-bench RUNS=5 TABS=10on the app the Release build just exported, uploaded as thestartup-bench-<sha>artifact. It never fails the run.
.github/workflows/extensions-nightly.yml runs every night (and by hand) in Alexays/Lilo only, on the
xcode-27 image: make ext-bench against its idle CPU budget, then make ext-nightly TOP=1000. The
reports are the extensions-nightly-<sha> artifact, and the run's summary quotes them.
build.yml needs no secrets and only reads the repository, so pull requests from forks run it as is. CI runs
a failed Swift or Playwright test once more and lists the tests that passed only on that retry in
the run's summary; make validate and make e2e never retry, so a flaky test fails locally.
If your pull request fails on a test your change does not touch, say so in a comment with the failing
test's name: a maintainer reruns the job, and an issue with the failure message (the
swift-test-results and playwright-traces artifacts hold the details) gets the test fixed. Don't skip
or loosen the test in your pull request.
.github/workflows/commits.yml checks commit messages on pushes to main and pull requests,
and PR titles when a pull request is opened, updated, reopened or edited. It uses the same shell
validator as the local hook and checks the PR's own commits, excluding GitHub's generated merge
commit. Title edits run only this lightweight workflow. Maintainers should require its
Conventional Commits status check in the rules for main.
AGENTS.md holds the rules for everyone, human or agent. In short:
- KISS. The least code that solves the problem: no speculative abstraction, no single-use protocol, no setting with one caller.
- Surgical diffs. Touch only what the task needs and match the surrounding style.
- Placement. A new file goes where the placement rule says.
- Design bar: Apple and Arc. Native conventions (HIG spacing, SF Symbols, system fonts, standard shortcuts and menus), calm motion that respects Reduce motion, no layout jumps, AA contrast, every control reachable by keyboard.
- Efficiency first. No polling, no redundant renders, nothing at idle. A feature that costs CPU, memory or battery when unused is a bug.
- One engine. WebKit only, and direct dependencies only.
- Comments say what the code cannot: a constraint, a non-obvious why, a deliberate oddity. Never what the next line does, or what the code used to do.
- Formatting follows
.editorconfig(4 spaces in Swift and shell, 2 elsewhere) and.swiftlint.yml, whichmake swiftlintenforces in strict mode.
Every user-facing string is translated into every supported language.
- Add the key under the right namespace in every catalog under
lilo-ui/src/lib/i18n/locales/(en.json,fr.json,es.json,zh-Hans.json,pt-BR.json,ru.json,uk.json,de.json,ja.json,it.json,ko.json,zh-Hant.json,pl.json,tr.json,nl.json). - In Svelte, use
t("namespace.key"). In AppKit, write the English string with.localizedand add an"English": "Français"row under thenativenamespace of every catalog. make ui-testfails if a key is missing from any catalog.
docs/I18N.md covers plurals, placeholders and adding a language.
Lilo relies on WebKit's native WKWebExtension support first. Add a Gap API only when WebKit
lacks a Chrome API and a real, user-visible extension flow needs it.
- Swift handler in
Lilo/Extensions/GapAPI/, conforming toGapHandler. Each receives oneGapCall, already authenticated byGapBridgeRouter, carrying the calling extension's id and its profile's registry. Never read an extension id from the call's params. - Register it on the gap router in
WKEngineFactory. Per-profile dependencies (stores) come fromcall.registry, so a call always reaches its own profile. A handler that needs window-level collaborators stays app-wide on the router and lets each window attach to it, asOmniboxGapRelaydoes. - JS surface as a module under
Lilo/Extensions/bridge-src/src/augmentation/, installed byshared/install-gap-apis.tsin extension pages and service workers. A namespace that needs a permission goes inshared/gap-modules.ts, so a context whose manifest does not use it never evaluates it. Runmake ext-bridge-buildand commit the regenerated bundles. - Update COMPATIBILITY.md, which lists only what differs from Chrome:
add the API to the "Supported as in Chrome" line when it fully matches, or document its
differences.
ChromeSurfaceTestsreads that file. - Tests: Swift tests in
LiloTests/Extensions/, bridge tests beside the TypeScript source. - Verify with
make ext-bridge-testand the smallest Swift check that covers the change. For a manual pass, loadtest-extensions/extension-probe/with Develop > Load Unpacked Extension...: it exercises the MV3 surface (messaging, DNR, tabGroups, content-script worlds).
docs/ARCHITECTURE.md explains the layer, and docs/WEBKIT-GAPS.md what WebKit itself is missing.
Guard every private selector with responds(to:) and degrade gracefully when it is missing, and
register new private delegate hooks in PrivateDelegateSelectorTests. The conventions are in
docs/ARCHITECTURE.md.
Edit bridge-schema/tab.json, then run make bridge-gen to regenerate
lilo-ui/src/lib/bridge/tabProtocol.gen.ts and Lilo/Generated/TabBridgeSchema.gen.swift.
make validate runs every Swift test but the few that wait out WebKit's own clocks (make swift-test-slow, run by CI), and it should take about 30 s on a warm machine. The Swift tests are most of it, so keep each one cheap:
- Wait for a condition with
pollUntil, never a fixed sleep. When the app itself waits (a settle time, a grace period), shorten it from astatic varthe test base class sets, asAgentToolTestCasedoes withAgentTools.settleQuiet, and put it back intearDown. - Every
WKWebViewlaunches a WebContent process. Test logic without one when you can, and add a case to an existing web view test that shares your setup rather than a new test. - Query website data with
WKWebsiteDataStore.testDataTypes, notallWebsiteDataTypes(): Screen Time's type waits on a system service, for seconds when several runs ask at once. - Size fixtures for the behavior, not for realism, and guard scaling by comparing a run with one twice its size (CPU time, not wall time), as
ImportScaleTestsdoes.
- Fork the repository and branch from
main. - Make focused commits, each building on its own (
make check-commitschecks a branch). Runmake lint-commitsto check their messages;BASE=mainselects a different base. - Run
make validateand confirm it passes. - For a change people using Lilo will notice, run
make change. ChooseFixedorChangedfor a patch release,Addedfor a minor release, orBreakingfor a major release. Write the summary for the person using the app and commit the generated.changes/unreleased/*.yamlfile with your code. Documentation, tests and build tooling usually need no fragment.CHANGELOG.mdis updated when a maintainer prepares the release. CI warns on a pull request that changesLilo/orlilo-ui/src/without a note. - Open the pull request. The template asks what it does, how to test it, and for a screenshot or recording of any UI change.
Commit messages and pull request titles follow
Conventional Commits:
type(scope): description, lowercase, imperative, no trailing period, with an optional scope
and ! before the colon for a breaking change. Types: feat, fix, perf, refactor, docs, test, build, ci,
chore, style. Common scopes: sidebar, tabs, spaces, commandbar, urlbar, settings,
extensions, agent, terminal, window, bridge, import, ui.
make hooks-install points this clone's Git core.hooksPath at the tracked .githooks/
directory. It refuses when core.hooksPath is already set or .git/hooks holds a hook that
would stop running. The native commit-msg hook and CI share a shell validator for the
header's type, syntax, lowercase scope, lowercase nonempty description and absence of trailing
whitespace or a final period. Git's Revert "..." and Reapply "..." headers are accepted.
English wording and imperative style are reviewed by humans. Bodies and footers remain free-form. To check a title locally:
printf '%s\n' 'fix(sidebar): keep pinned tabs visible' | make lint-commit-messageGit-generated merge commits are exempt. Temporary fixup!, squash! and amend! commits are
allowed locally; autosquash them before merging, since CI rejects them. Local hooks can be
bypassed, so CI also checks every non-merge commit introduced by a PR and the PR title.
Code, comments, commit messages, issues and pull request descriptions are written in English. User-facing copy and the README are translated; see Add or change a user-facing string and README translations.
There is no CLA and no DCO. By opening a pull request you agree that your contribution is licensed under the project's MIT license.
Releases are cut by maintainers, by tagging vX.Y.Z. The process is in
docs/RELEASING.md.