Layout
connector/: theovenlightGo program.internal/policyedits tailnet policy files,internal/tsapitalks to the Tailscale API, ande2e/holds the end-to-end sharing test.project.yml,Ovenlight/,OvenlightTests/: the iPhone app.android/: the Android app.scripts/build-android-core.shbuilds its Go core,android/core/, into the library the app links.scripts/build-tailscalekit.sh: builds the iPhone app's embedded Tailscale library.scripts/release-connector.sh: builds a connector release, andscripts/publish-connector.shpublishes it.design/icon/generate.py: writes the iPhone and Android app icons, the Google Play icon, and the site'sicon.svgandicon-180.png..claude-plugin/marketplace.json,plugins/ovenlight/: the Claude Code plugin (the MCP server and a skill that points toovenlight guide). Check it withclaude plugin validate .. Raiseversionin itsplugin.jsonwith every change to the plugin: Claude Code keeps people on their cached copy until it changes.design/og/generate.py: writes the link preview images,site/og.jpgfor the site andsite/og-invite.jpgfor invite links.
The connector
Build and install it from a checkout with connector/install.sh, or
connector\install.ps1 on Windows (see
connector.md). The minimum Go version is the go line
in connector/go.mod.
- One node per app. Each published app gets its own tsnet node named after its slug,
with state in
nodes/<slug>/in the state directory (connector.md's table has it for each system). The commands the connector runs for apps are incommands.jsonbeside the app list.publishedits both and reloads the running connector over its control socket, as doeskill -HUPon macOS and Linux. - Per-system code sits in files named for it:
lock_*.go(flock, or LockFileEx on Windows),listen_*.go(what listens on an app's port, fordoctor),internal/jsonfile/private_*.go(files only you can read: mode 0600, or on Windows an access list that grants only you; there, saving, reading and removing also wait a moment while another process has the file open),peer_*.go(on Windows, refuses control connections from another user) andsupervise_*.go(the process groups of the apps the connector runs). - Global flags. Commands take
--config <file>and--state <dir>. - The service. The launchd agent, systemd unit and scheduled task start
ovenlight run --service, the only connector that updates itself, and only in a release build (selfUpdatinginconnector/updater.go, security.md). - The building guide.
ovenlight guideand the MCPguidetool serveconnector/building-apps.md, a copy of building-apps.md that Go can embed. After editing the doc, rungo generateinconnector/; a test fails while the two differ. ovenlight versionprints the build: a release's version, else the module version, elsedevel-<commit>, with-modifiedwhen the tree had changes.
Tests
go -C connector test ./...
The unit tests include the Tailscale API client and setup-sharing against an in-memory
fake of the API. TestWireFiles compares the connector's answers with the files in
testdata/wire/, which Ovenlight's tests decode too
(protocol.md);
go -C connector test -run TestWireFiles -update rewrites them, for a change Ovenlight
still reads. It refuses to drop a key a file already has unless -allow-drop is added
too, which is only for a key no shipped build of Ovenlight needs.
The end-to-end sharing test runs against a throwaway local Headscale, on macOS or Linux
(CI runs it on Linux). It passes against Headscale v0.29.4 (go install github.com/juanfont/headscale/cmd/headscale@v0.29.4):
OVENLIGHT_E2E_HEADSCALE="$(go env GOPATH)/bin/headscale" go -C connector test -tags e2e ./e2e -v -timeout 15m
CI (.github/workflows/ci.yml) runs on every push and pull request, and weekly, on Linux
and Windows: go vet (with and without the e2e tag), the tests (with -race on Linux)
and govulncheck, then the install scripts as a user runs them: install, a killed
connector restarting, reinstall and uninstall --purge. The install jobs then run the
updater end to end, on macOS too, where the job also dry-runs the release scripts with
scripts/test-connector-release.sh. On Linux it also runs gofmt, go vet and
govulncheck for darwin/arm64 with cgo off, a check that scripts/third-party-notices.sh
still finds a license kept below a module's root, and the end-to-end test against
Headscale. It also renders the docs with scripts/sitedocs, which fails on a link or
#anchor that goes nowhere. Its android job checks the Android app, its side of the wire
files included (Android tests). The iPhone app's side runs only in Xcode,
locally and before shipping, never in CI, so a change to testdata/wire/ must also pass
the iPhone app's tests.
Releases
On the mini, in ~/src/ovenlight:
scripts/release-connector.sh 1.2.0 1.1.3
It takes the version, the oldest secure one, and optionally reinstallBelow, the oldest
version that may update to it by binary alone (by default the highest reinstallBelow of
the releases below it, so it carries forward). --unsigned, before the version, skips
all signing, to test the rest.
release-connector.sh writes the release to build/release/, replacing the one before:
ovenlight-connector-macos.tar.gz: a universal (arm64 and x86_64) binary for macOS 13 or later, signed with the Developer ID and notarized, withinstall.sh,uninstall.shand the launchd plist;ovenlight-connector-linux-amd64.tar.gzand-linux-arm64.tar.gz: static binaries, withinstall.sh,uninstall.shand the systemd unit;ovenlight-connector-windows-amd64.zipand-windows-arm64.zip: withinstall.ps1anduninstall.ps1, the binary and both scripts signed with Azure Artifact Signing;SHA256SUMSand the release's indexindex.json, both unsigned, andcommit.
Each archive holds an ovenlight-connector-<version> folder, with LICENSE, NOTICE and
THIRD_PARTY_NOTICES.txt, the licenses of what that system's build links. The archive
names carry no version, so the latest release keeps the same URLs. It builds with the Go
version on connector/go.mod's go line (through GOTOOLCHAIN), as CI does. Unless
--unsigned, it builds only a clean HEAD (no changes or untracked files) that is on
origin/master, and checks with go version -m that each binary carries that commit. Run
it in a clone: go1.26 stamps no commit in a git worktree.
Notarization uses an App Store Connect API key, named by the environment variables
ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_PATH. Over SSH, unlock the login keychain
first. Windows signing needs jsign (brew install jsign) and the Azure CLI
(brew install azure-cli), signed in with az login as someone with the Artifact Signing
Certificate Profile Signer role on the certificate profile that WIN_PROFILE in the
script names.
Try the Windows build on a PC with Smart App Control on before publishing. Then sign it on the Air, with the release key behind Touch ID:
scripts/sign-connector.sh 1.2.0
It copies the build's SHA256SUMS and index from ~/src/ovenlight/build/release on the
mini and prints what it signs: the index's fields, the commit, the fingerprints of its
release_keys, the connector changes since the last promoted release and SHA256SUMS. It
refuses a commit whose connector/release_keys isn't the two keys in
~/.ssh/ovenlight-release-keys/ plus the key signing it, when that folder doesn't hold
it. It signs SHA256SUMS, checks that signature with docs/allowed_signers at that
commit, as owners will, and signs a canary index, canary.json, which only
ovenlight update now --index takes. It keeps both in its history,
~/.ssh/ovenlight-release-keys/history/, the only record it reads, and sends a copy with
rsync to the release's folder in the mini's ~/Sync/ovenlight/releases/. Nothing on the
Air writes to its own ~/Sync, where a link an agent syncs would write into the Air's
files. --key <file> signs with another key, such as the paper backup.
Back on the mini, tag the release on the commit it was built from, which
build/release/commit names. Tag only once it's signed: a pushed connector-v* tag can't
move, and a refusal at signing may need a new build from a new commit.
git tag -s -m "Connector 1.2.0" connector-v1.2.0 "$(cat build/release/commit)"
git push origin connector-v1.2.0
Then upload the version's folder, and try the release on the mini's own connector, the
canary. The mini's connector must run the live release first: after a pull, a dropped
promote, or a canary that failed once its trial passed, it may still run a release that
isn't live, so install the live one by hand from its folder. The live index must also be
signed with a key the new release lists, so after a key change, --resign it first.
scripts/publish-connector.sh 1.2.0
ovenlight update now --index ~/Sync/ovenlight/releases/1.2.0/canary.json
The canary passes once ovenlight status shows Last update: <live> to 1.2.0, written
only when 1.2.0's 10-minute trial passes, and last checked no earlier than that, with no
failed check. A release that raises reinstallBelow passes once update now says it
needs its install script, the mini installs it by hand from connector/1.2.0/
(connector.md), and status shows version 1.2.0
running, with a check made since. A canary that rolls back says why in the mini's
~/Library/Logs/ovenlight.log, on its updates: rolling back from 1.2.0 line. A release
whose canary fails is never promoted: fix it under a new version, since a published folder
never changes.
Then promote it. Sign the promote on the Air:
scripts/sign-connector.sh --promote 1.2.0
then publish it on the mini:
scripts/publish-connector.sh --promote 1.2.0
That puts its files in connector/latest/ and its index in latest.json, and every
owner's two-day wait starts. The mini publishes only the newest index signed, so a publish
that fails partway is finished by running the same command again (a folder's before the
next build replaces build/release). If the publish names another index as the newest, a
.base it can't follow or a file it lacks, the mini lacks part of what the Air signed:
scripts/sign-connector.sh --check on the Air sends every index published and the newest
signed again, with their releases' signed files, as every run there does. To drop a
promote, pull or resign cut short instead, run scripts/sign-connector.sh --check on the
Air (after a lost Air, --import). If it says latest.json is that index, it went out:
pull it if it's a promote. Otherwise sign the next index and publish that: a --resign,
which puts the live release back in connector/latest/, or a promote of the next release
when nothing is promoted before it, or a fresh pull for a pull.
--pull, signed and published like a promote, takes the live release back out by
publishing the index of the release before it, signed again. Computers that already took
the pulled release keep it, since the connector never installs an older one, so the fix is
the next release. --resign publishes the live index signed again, to supersede a signed
promote that mustn't ship. Before signing anything the Air fetches latest.json, which
must be the last index it saw go up, so a signature is built only on what you were last
shown. --check also takes the newest index it signed, and marks it seen. After signing
an index for latest.json the Air waits until latest.json is that index, which Ctrl-C
leaves to --check. Its refusals say what to do, as when a pull signed and not yet
published blocks a promote or resign. Each script's header says what it checks, and
scripts/test-connector-release.sh dry-runs them all. Each release restarts every owner's
connector, and one promoted within two weeks of the last restarts the wait of every
computer that hasn't taken the last yet (a computer off for a long weekend, or two, can
take thirteen days), so batch what isn't urgent. A --resign restarts the wait too, for a
computer whose daily check fetches it before its moment to swap.
Uploads go to the R2 bucket behind https://downloads.ovenlight.app, through wrangler
(signed in with npx wrangler@4.147.0 login, or CLOUDFLARE_API_TOKEN and
CLOUDFLARE_ACCOUNT_ID):
connector/<version>/: the release's files, never replaced;connector/latest/: the same files of the latest release, which the install commands and download links in the docs point to;connector/latest.jsonand its signaturelatest.json.sig: the signed index the updater checks daily (security.md). A connector older than itssecureupdates without the two-day wait, anddoctorfails on it, so raisesecurewith any release that fixes a security problem.
Once a release is promoted, publish its GitHub Release, which is what people watching the repository's releases hear about:
gh release create connector-v1.2.0 --verify-tag --title "Connector 1.2.0" --notes-file notes.md
The notes say what changed for owners since the last Release, a security fix first, and
point a connector that doesn't update itself to the install command.
The archives stay on downloads.ovenlight.app alone, so the Release attaches none. A phone
app version gets a Release once it's live in its store: tag the commit its build came from
with git tag -s, as ios-v1.2.0 or android-v1.2.0, and push the tag. Give its What's
New as the notes, and add --latest=false, so the connector's stays the latest.
The first updater release
The first release that updates itself, 1.1.0 here, goes out once, with 1.1.1 after it:
-
On the Air, make the Touch ID key with the two commands under Changing a release key, if it isn't made yet. Then run key setup,
scripts/release-key-setup.sh, in Terminal there, not over ssh: it makes and prints the paper key, and files both public keys and a test signature by each in~/.ssh/ovenlight-release-keys/. -
Before the updater reaches master, swap the mock release keys for the real ones, in one commit:
connector/release_keysanddocs/allowed_signerslist the two real public keys, the latter in namespacesfile,ovenlight-latest. The mock lines, the keys that signed releases up to 1.0.9 and their comments leaveallowed_signers: a release up to 1.0.9 is checked with the file at its own tag.index.json.release-1.sigandindex.json.release-2.sig, which key setup signed with the two real keys, are copied in the Air's clone from~/.ssh/ovenlight-release-keys/intoconnector/testdata/update/.- The comments in
connector/keys.goandconnector/sshsig_test.golose what they say about the mock keys. connector/testdata/update/sign.shloses the lines that deleteindex.json.release-*.sigand sign them with the mock keys, and their comment.- The Air's
~/.ssh/ovenlight-release-keys/holds the two real public keys.
connector/testdata/releasekey/mock-*.pubstay:release-connector.shrefuses any key there inrelease_keysorallowed_signers. -
Merge the updater to master. Check that
~/Sync/ovenlight/releases/on the mini and~/.ssh/ovenlight-release-keys/history/on the Air hold no records yet, so 1.1.0 names its ownreinstallBelow:scripts/release-connector.sh 1.1.0 1.0.6 1.1.0. Sign, tag and publish its folder, then build, sign, tag and publish 1.1.1 from the same commit. -
Nothing before 1.1.0 updates itself, so it has no canary. The mini installs it by hand from
connector/1.1.0/(connector.md), then runs 1.1.1's canary. The trial passes on the canary index, but no daily check verifies whilelatest.jsonis 1.0.9's, which is unsigned. The mini's first verified check after 1.1.0's promote completes the canary. The mini's Windows on Arm VM installs 1.1.0 by hand too, with no canary, and stays on it, to take 1.1.1 the way owners will. -
Deploy the site, so its privacy policy covers the update check, then promote 1.1.0. From the merge until that promote, both sets of install steps fail: the new ones on 1.0.9's unsigned
latest.json, and 1.0.x's, still on the site, onlatest/'sSHA256SUMS, since master'sallowed_signersno longer lists 1.0.9's key. So promote soon after the merge. Promote 1.1.1 a day later, the owners' first automatic update.
Changing a release key
A change of release keys takes two releases. R brings in the new keys, signed with an old
one, and R+1 drops that old key. R's connector/release_keys is the two keys that sign
from then on plus the old key that signs R, as sign-connector.sh requires.
docs/allowed_signers adds the new keys. A key that leaves release_keys stays in
allowed_signers until the release after R+1 is promoted, since a pull back to a release
it signed puts that release's files in latest/ again, and 1.0.x's docs check those with
master's allowed_signers. publish-connector.sh --pull says when one has to come back.
The one-line installers, site/install.sh and site/install.ps1, check latest.json with
the keys they list, which a test keeps equal to allowed_signers: deploy the site from R's
commit before an index signed with a new key goes up, and again whenever a key leaves
allowed_signers.
TestReleaseKeySignatures wants one test signature in connector/testdata/update/ for
each key in release_keys. R adds one for each new key, as the next free
index.json.release-<n>.sig, and the commit that drops a key deletes its signature.
For a planned move to a new Air, the old Air signs R:
-
On the new Air, make its Touch ID key:
sc_auth create-ctk-identity -l ovenlight-release -k p-256-ne -t bio cd ~/.ssh && ssh-keygen -w /usr/lib/ssh-keychain.dylib -K -N ''Carry its public half,
~/.ssh/id_ecdsa_sk_rk_ovenlight-release.pub, to the old Air by AirDrop or a USB stick, never through~/Sync, and check thatssh-keygen -lfshows the same fingerprint for the copy as for the original on the new Air. Write that fingerprint on both paper copies. In the old Air's~/.ssh/ovenlight-release-keys/, the copy takes the place oftouchid.pub. The new Air's folder gets that folder'stouchid.pubandbackup.pubthe same way. -
In the new Air's clone, sign the new key's test signature (number 3 at the first change):
SSH_SK_PROVIDER=/usr/lib/ssh-keychain.dylib ssh-keygen -Y sign -q -f ~/.ssh/id_ecdsa_sk_rk_ovenlight-release -n ovenlight-test < connector/testdata/update/index.json > connector/testdata/update/index.json.release-3.sig -
Commit R's keys and signature, then release R as usual on the old Air. A release needed before R+1 can go out is signed there too and lists the same three keys.
-
R+1 drops the old key and is signed on the new Air. Its canary needs the live index signed with the new key (
--resign). That and R+1's promote are refused until six weeks after R's promote, and a pull back below R starts the wait again at the next promote. The--resignalso waits until two weeks afterlatest.jsonlast changed, since each change starts the wait of computers still below R again. Keep the old Air until R+1 is promoted, since it signs any pull before then. Before the new Air first signs, and each time the signing Air switches, carry the other Air's~/.ssh/ovenlight-release-keys/historythe same way and merge it in withcp -Rn <copy>/ ~/.ssh/ovenlight-release-keys/history. Without it, the Air refuses to sign oncelatest.jsonis an index it lacks.
After a lost Air, the paper key signs R. If the lost Air turns up, delete its
~/.ssh/ovenlight-release-keys/history before running a release script there, since each
run sends the newest index in it to the mini.
- On the replacement Air, make its Touch ID key with the two commands above. In
~/.ssh/ovenlight-release-keys/, if the Air has one, delete what a backup may have brought back (rm -f touchid.pub backup.pub index.json.release-1.sig index.json.release-2.sig). Keephistoryonly if it came from a disk only the lost Air used andscripts/sign-connector.sh --checkfindslatest.jsonin it. Otherwiserm -r history, then write into a new file each key therelease_keysin~/Sync/ovenlight/releases/list whose fingerprint is on the current paper key's page, printed or written on, except a paper key that was lost.cat ~/Sync/ovenlight/releases/*/release_keys | sort -ulists those keys, andssh-keygen -lf -on that list prints their fingerprints in the same order. Thenscripts/sign-connector.sh --import <file>copies what those keys signed into the history, with the index each was built on, checkslatest.jsonagainst it and prints it: check the list against the releases you published. If it's wrong, find out who changed the records before you deletehistoryand import again. Then run key setup (scripts/release-key-setup.sh). It makes a new paper key, files both new public keys and a test signature by each in~/.ssh/ovenlight-release-keys/. Its page also lists the fingerprints of the keys earlier releases list. - R lists the two new keys and the old paper key, and drops the lost key and its
signature. Copy key setup's
index.json.release-1.sigandindex.json.release-2.sigin under the next two free numbers. - The old paper key signs what R needs: its folder, the
--resignbefore its canary and its promote. The old paper key also signs any pull before R+1 is promoted. A release needed before R+1 can go out is signed with it too and lists the same three keys. For each, make a folder withmktemp -d, type the key from paper into a file there andchmod 600it. Write its public half beside it withssh-keygen -y -f <file> > <file>.pub, which also checks the passphrase. Sign with--key <file>, then delete the folder. - R+1 drops the old paper key and is signed with the new Touch ID key. Its
--resignand promote wait as a planned move's do.
After a lost paper key, the Air signs R, which lists its own key and a new paper key and
takes a version above every release signed so far, since those list the lost key and none
of them is promoted any more. Note the lost key's fingerprint
(ssh-keygen -lf ~/.ssh/ovenlight-release-keys/backup.pub), then delete backup.pub
(the lost key may be in other hands, so the Air trusts nothing it signs) and run key
setup. Its page lists the lost key among the keys earlier releases list, so cross it out
on both copies. Copy only its index.json.release-2.sig in under the next free number.
During a planned move, delete backup.pub on both Airs, merge the old Air's history into
the new Air's as step 4 does, and run key setup on the new Air, then carry the new
backup.pub to the old Air as step 1 carries the new key: the old Air signs R, and the
move's R+1 still follows. Drop the lost key's signature and its line in
allowed_signers, since it signed no release. Outside a planned move, if the Air's key is
still inside its wait, the old paper key, which every release so far lists, signs this R
instead, and R+1 drops that key. Otherwise the Air's key signs this R, which waits for
nothing, and no other R+1 follows. If the Air is lost too, the new paper key's --resign
and promote wait as a planned move's do.
A lost Air inside a new Touch ID key's wait is answered by an R signed with the paper key
every release so far lists, which waits for nothing. After a planned move whose old Air is
lost, the new Air keeps its key and history and deletes backup.pub. If
sign-connector.sh --check then says latest.json isn't in the history, the old Air
signed past the last merge: delete history and import it, as after a lost Air. Run key
setup and copy only its index.json.release-2.sig in, as after a lost paper key. R lists
the new Air's key, the new paper key and the old paper key that signs it.
After losing both keys, no computer can verify a release signed with new ones, so every
owner installs the next release by hand once. On the mini, move
~/Sync/ovenlight/releases/ aside. On the Air, move ~/.ssh/ovenlight-release-keys/
aside, make the Touch ID key with the two commands above and run key setup. On the mini,
write an unsigned index naming the live release and its secure to build/latest.json in
the repo, as 1.0.9's was (for example {"latest": "1.2.0", "secure": "1.0.6"}), and upload
it:
npx wrangler@4.147.0 r2 object put ovenlight-downloads/connector/latest.json --file build/latest.json --content-type application/json --cache-control "public, max-age=300" --remote
Then release as in the first updater release: a version above every release so far,
naming its own reinstallBelow, with it and secure no lower than the live index's
(every installed connector keeps that index, and refuses one that lowers either), whose
release_keys and allowed_signers list only the new keys.
The iPhone app
Building the app needs Xcode 26 or later, for the Icon Composer icon and the iOS 26 glass
effects. Minimum iOS is 18.1, TailscaleKit's minimum. The Xcode project is generated from
project.yml, and the .xcodeproj is committed, so XcodeGen (brew install xcodegen) is
needed only after editing project.yml: then run xcodegen generate.
TailscaleKit
scripts/build-tailscalekit.sh builds Frameworks/TailscaleKit.xcframework from
tailscale/libtailscale at a pinned commit. Run it once before building in Xcode. The
output is gitignored and cached; --force rebuilds. It needs Go, and fetches the
go1.27.3 toolchain itself. On top of upstream it:
- builds with the
ts_omit_debugtag, which drops the node's debug LocalAPI, pprof and debug HTTP handlers, withts_omit_peerapiserverandts_omit_cachenetmap(the script says why), and withts_omit_webclient, which leaves out Tailscale's web client and the font and JavaScript bundle it embeds; - turns off log upload to
log.tailscale.com, and the log files tsnet would keep in each node's folder, from a Goinit, for every node the app runs, guests' included; - serves each node's loopback SOCKS5 port with a proxy of its own from
scripts/libtailscale, which dials only the node's peers and the internet by the Android core's rules (security.md), and runs its tests before each build; - raises
golang.org/xmodules past libtailscale's own when they have security fixes; - adds a privacy manifest to the framework, which Apple requires of a framework that calls a required-reason API.
Tests
Any iPhone simulator with iOS 18.1 or later works as the destination.
xcrun simctl list devices available lists them.
scripts/build-tailscalekit.sh && xcodebuild test -scheme Ovenlight -destination "platform=iOS Simulator,name=iPhone 17,OS=latest"
Debug launch arguments
Debug builds take these as launch arguments:
-openApp "Interview Coach"opens an app directly.ovenlight://open?app=<name>does the same on a device, in any build.-controlURL <url> -authKey <key>joins a local Headscale instead of Tailscale.-joinInvite "<link>"opens an invite, so one simulator can be an owner and, for another local owner, a guest.-sharingFixture YESfills in sample sharing data for screenshots.
Debug builds also accept an http control server on loopback in invites; release builds
only https. Only debug builds load an app at localhost directly, so a seeded launcher
can open a local demo app for screenshots (loopback IP literals stay blocked).
Local Headscale
ovenlight run takes --control-url, --auth-key, --dev-tls-cert and --dev-tls-key
(Headscale can't issue ts.net certificates) only with OVENLIGHT_DEV=1. Use a separate
--config and --state so your real nodes are untouched. Trust the test CA in the
simulator only for the length of the test:
xcrun simctl keychain booted add-root-cert ca.pem
For sharing, add --dev-headscale <bin> --dev-headscale-config <file>: the connector
then drives Headscale's CLI instead of the Tailscale API for keys, devices and tags.
Ovenlight leaves a development server's policy alone, so setup-sharing works only
against Tailscale. publish --shareable still records the owner, but you write the policy
it would have written into Headscale's policy file by hand.
To try guest mode in one simulator, run two Headscales with different base_domains
(two owners), one dev connector each, and a test certificate covering both wildcards.
Sign Ovenlight in to the first with -authKey, and open the second owner's invite with
-joinInvite.
How the iPhone app works
- First launch asks nothing of a guest: no Face ID until the iPhone keeps something
(an app, an invite or a node). The empty launcher, "Welcome to Ovenlight", offers Join
with Invite and Use My Own Computers. Only the second (also in Settings) starts the
owner's node and shows "Connect Your Computers"; Connect opens Tailscale's sign-in page
in Ovenlight and closes it once connected. With device approval on, the sign-in page stays
open until the iPhone is approved. Closed before that, it gives way to Tailscale's
Machines page, in Ovenlight's browser rather than Safari, which stays until Done so an
app waiting there can be approved too. An "Approve This iPhone" card names this iPhone as
the tailnet lists it and opens that page again, and an app opened meanwhile shows the same
card. Settings shows the account
and has Sign Out; while no account is signed in, it has Stop Using My Own Computers in
its place. Both undo the choice (
NodeManager.disableOwner): the node logs out, its state and the remembered tailnet are forgotten, the apps found on the owner's computers go with their data (AppRegistry.removeDiscovered), and the launcher goes back to its welcome when nothing else is saved. Apps added by hand and shared ones stay, and signing in again finds the owner's apps. Sign Out asks first, and so does Stop Using My Own Computers when it has apps to remove. A Sign Out cut short by a quit finishes at the next launch. Both close every app view that went through the owner's node, kept in the background included (WebViewPool.discard(attachedTo:)). - Memberships. One embedded node per network: the owner's own tailnet (state in
Application Support/Ovenlight/tailscale/owner) and one guest node per owner whose invite this iPhone accepted (tailscale/<membership ID>, listed inmemberships.json). Each has its own loopback proxy, state, reachability and random device name, and each shared app's data store goes through its membership's node. Which node carries an app of your own is in security.md:Routing.isOnOwnersTailnetdecides,Routing.mayLoadchecks the route before every probe and load, andWebViewPool.discardStaleRoutescloses the views whose route changed. - Lifecycle. Nodes stay up in the background. Every return to the foreground checks each node's loopback listener (iOS can reclaim it while Ovenlight is suspended) and starts a new node only for a dead one; that membership's data stores get the new SOCKS5 proxy in place.
- Opening an app shows its icon and "Connecting…" at once, but the page loads only
after the node is ready and the app's host answers a probe through the current proxy
(
AppLoadGate). After about 10 seconds the app shows "Can't Reach <App>" with Try Again. When the network lists the app's computer as offline (believed by a guest's node only once its network has listed a peer online), it shows that at once, keeps trying quietly while it's on screen, and opens as soon as the computer answers. Launcher tiles dim with a badge while their computer isn't answering, or is listed offline, which the launcher doesn't probe. When the connector answers but the app itself isn't running (a 502 with the codeunavailable), the first page is tried a few more times, then Ovenlight says the app isn't responding rather than showing the connector's text. - The open app starts below a band at the top, the status bar's height plus the
handle's 20 points (
AppChrome.handleHeight), painted in the page's header color: itstheme-color, else its background, and before the first load the one remembered from the last (StatusBarTint). The status bar's text takes the tone that reads on what is under it: iOS 26 and later choose it, and before thatStatusBarTonesets it from the band. A page withviewport-fit=coverruns to the bottom and side edges. Any other stays inside the side safe areas. The one piece of native chrome is a small capsule at the top with the way home and the app's menu. It starts tucked up under the status bar, leaving its handle in the band (the first time, it shows with a one-time tip about the handle until the tip is closed). Before the app has loaded, its sign-in, approval and error screens each have Back to Apps. Tapping the band or pulling down at the top of the page brings it back. With no status bar above the band (a phone on its side, the iPhone Duo's cover), the handle's tap reaches 44 points down at its middle. Tapping the status bar scrolls the page to the top, as iOS does, and leaves the chrome alone. Ovenlight removes WebRTC from every frame of every page (WebViewPool.noWebRTC). - Lock. What locking holds back is in security.md.
WebViewPool.stopCaptureends the microphone and camera streams already running. - Permissions. Besides the camera (scanning invite codes, and pages when allowed) and the microphone (pages), Ovenlight asks to add to the photo library, for Save to Photos on an image in a page.
- Launcher. It works like the Home Screen: one scrolling grid of icons, 4 columns (3
at large text sizes). Long-press an icon for its menu, or empty space (or More, Edit
Apps) to edit: icons jiggle, show a remove badge and drag to a new place among
those on screen. VoiceOver gets Move Before and Move After actions on each icon. An app
that arrived on its own (shared with this iPhone, or discovered on your computers) has a
blue dot before its name until it's first opened (
WebApp.isNew, saved inapps.json). Lock and More (Add App, Edit Apps, Join with Invite, People & Sharing for an owner, Settings) sit in the top corners. - Apps come from discovery: the owner's online devices serving
ovenlight.jsonshow up in the launcher, and Ovenlight looks again each time it comes to the front. Add App also takes an address, but only one on the owner's own tailnet (AppAddress, withRouting.isOnOwnersTailnet). Before the owner has signed in it says to sign in first, and any other address gets a refusal that points to publishing the app or asking its owner for an invite. Once the owner's tailnet is known, an app of your own off it, such as one from a tailnet the owner was signed in to before, keeps its tile but is refused (Routing.route): it shows Can't Reach and nothing goes out. Until then a*.ts.netapp waits behind Sign In, and any other address is refused. One tile per host either way, and a removed app stays removed until Sign Out. Discovery only adds: an app unpublished on its computer keeps its tile until the owner removes it. - Copy. Everything a guest can see (connection states, joining, errors,
"No Longer Shared with You", feedback) is in
ConnectionCopyand never says Tailscale or tailnet; a unit test checks. Only the owner's sign-in card, status pill and Settings do. - Brand. An ember accent (
AccentColor) and one oven light (theLampLightview): a cone of warm light from behind the Dynamic Island, or from its side in landscape, that lights the launcher, the lock screen and the privacy cover; the colors are asset catalog color sets. Grouped lists and forms (Settings, People & Sharing, the join sheet) sit onGroupedBackground, a warm paper in place of the system's cool grey. New York is the typeface for the wordmark, titles and monograms. The icon is an Icon Composer document,Ovenlight/Resources/AppIcon.icon, written bydesign/icon/generate.py; Xcode renders every appearance and older iOS from it. - Presentation is
Router, inOvenlightApp.swift.
Guests
- Joining. An invite tapped in Messages or scanned with the Camera opens Ovenlight
directly: Associated Domains lists
applinks:ovenlight.app, and the site'sapple-app-site-association, athttps://ovenlight.app/.well-known/apple-app-site-association, lists this app for/join*only. Without Ovenlight, the link opens the join page,ovenlight.app/join, served from this repo'ssite/directory, which reads the fragment in the browser, sends nothing, and offers a way to get the app, Open in Ovenlight on Android and Copy Invite on iPhone. Ovenlight also takes invite links pasted or typed under More, Join with Invite, or scanned there as a QR code (VisionKit, on devices with a camera). The join flow itself is in protocol.md. The app then appears on the launcher labeled "from <owner>". An invite for an app already here offers Join Again rather than claiming on its own, and a membership's saved names change only when a claim succeeds. - Failures say what happened without naming the network: key already used, no longer valid, invite canceled, used on another phone, or the owner's computer not answering (the invite is kept for Try Again).
- Report a Problem (every app's menu, and a guest's long-press menu) opens a mail to
team@snowyghost.com with the app, its address, who shared it, its connection state (in
detail, without the sign-in link), how far opening it got, and the Ovenlight and iOS
versions. Settings, Support has Contact Us, the privacy policy
(
AppLinksinSupport.swift) and Acknowledgements, the licensesscripts/build-tailscalekit.shputs in the framework. - Leaving. Settings, Shared with You, Leave (or Leave in a shared app's menu) removes that owner's apps and data and logs the node out. An open app closes without loading again. The owner's connector still lists the guest until the owner removes them.
Owners
- Per app. Long-press one of your apps for Share… (disabled, with a note, until
publish --shareableon its computer) and People (guests, open invites, that app's feedback). A guest's long-press menu has Report a Problem and Leave instead. - People & Sharing (More menu) talks to each connector's admin API on port 8443
through the owner's own node, and asks the owner's online devices at once (after the
first answer, one app node per computer). It lists each app with whether it's online
and shareable, its guests (one row per person with a count of their devices and when
they last opened it; swipe to add a device or remove them), open invites (swipe to
cancel) and the feedback inbox with screenshots. People lists everyone across apps, those
who opened one most recently first, with Select to remove several at once. A person's
page shows each of their apps, and each device when they have more than one, with when
it was last opened, to remove them from one, or remove an old device. The footer counts
the tagged devices the apps and guests use (
taggedDevices). - Share asks for someone new (a name, only a label) or someone already shared with, who then keeps one identity across apps and devices.
- Invites show a QR code for in person, Send Invite (the share sheet, with the
connector's
message, the oneovenlight shareprints, or just the link when there is none) and Copy Link, all with the universal link. Share in an open app's menu does the same for that app; it shows only for the owner's shareable apps. - Send Feedback is in every app from a connector, owner or guest ("Send Feedback to
<owner>" in a shared app, so it isn't mistaken for Report a Problem). It takes a snapshot
of the page (
WKWebView.takeSnapshot, shrunk under the connector's 5 MB), adds an optional note, and posts both to/__ovenlight/feedbackthrough the app's own node.
App Review invites
App Review, for TestFlight and for the App Store, needs a way in. Make it an invite,
using the app's slug: ovenlight share <slug> --to "App Review" --review.
It uses the same tag:ovenlight-guest-<slug> tag and claim step as any invite. Its key
is reusable, so up to 10 of the reviewer's devices can claim it, and lasts 90 days,
Tailscale's longest, since a review can take weeks. It runs only in a terminal, is
marked review in guests, status and the admin API's invites, and
prints the link and notes to paste into App Store Connect (App Review Information, Notes). The app's
computer has to stay on and the connector running until the review is done. While a
reviewer is still a guest or has an open invite, another review invite needs
--existing.
Revoke it after the review: ovenlight revoke "App Review". share --cancel <id> only
stops more devices from claiming it: those that have keep the app until revoked.
Google Play's review needs a review invite of its own, for another person such as
--to "Play Review", so revoking one store's reviewers leaves the other's. Paste its link
and notes into Play Console, under App content, Sign-in details. Play expects them to keep
working and reviews any change to them, so make the next one with --review --existing
well before the invite expires or its 10 places are used up (a device that leaves or is
removed keeps its place). Once the new details are live and no review still uses the old
link, run ovenlight revoke with the old invite's ID (share printed it, and
ovenlight guests --json shows it as inviteId on each device that claimed it). That
removes the devices that claimed it and cancels it if it's still open. Revoking by the
person's name would end the new invite too.
Shipping
Build TailscaleKit first (scripts/build-tailscalekit.sh), then archive and upload with
Xcode or your own tooling. Before uploading, check the archived app: TailscaleKit is
embedded with its license notices (THIRD_PARTY_NOTICES.txt) and privacy manifest.
Export compliance is answered in App Store Connect, since the app ships WireGuard
encryption.
Forking
To build and sign your own copy, change to your own:
- in
project.yml,DEVELOPMENT_TEAM,bundleIdPrefix, the bundle identifiers and the associated domains (see Invite links), then runxcodegen generate. A free Apple ID's personal team can't sign Associated Domains: to build for a device with one, remove thecom.apple.developer.associated-domainsentitlement before generating; - in
Ovenlight/Sources/Support.swift,AppLinks.supportEmailandAppLinks.privacyPolicy; - to release the connector, the Developer ID identity (
IDENTITY) and the Artifact Signing profile (WIN_ENDPOINT,WIN_PROFILE) inscripts/release-connector.sh, the bucket and site inscripts/publish-connector.sh, the download host, doc links and contact address in the site'sinstall.shandinstall.ps1(and the signing profile's EKUinstall.ps1checks), andreleaseBase,installDocsandolderDocsinconnector/update.go. A fork signs with release keys of its own: list them inconnector/release_keys, which every build trusts, indocs/allowed_signers(a test and the signing script check it lists each one), and in the site'sinstall.shandinstall.ps1(a test checks they list the same keys), put their public halves in~/.ssh/ovenlight-release-keys/on the computer that signs, and changeHANDLE, theminihost and the sitefetchlivereads inscripts/sign-connector.sh,minireleaseandminisyncinscripts/connector-release-lib.sh, and theteam@snowyghost.comprincipal, whichsign-connector.sh,publish-connector.sh, connector.md and the site's installers verifySHA256SUMS.sigandlatest.json.sigwith, andconnector/keys_test.gowants inallowed_signersand the installers.
Invite links
Invite links point at https://ovenlight.app/join, set in
connector/invite.go and Ovenlight/Sources/InviteLink.swift, and the app claims that
domain with Associated Domains in project.yml. iOS opens a universal link only in the
app the site lists, so in a fork's build, or one without the entitlement, an invite tapped
in Messages or scanned with the Camera opens the join page, whose Copy Invite copies the
link, which Join with Invite takes pasted. Pasted or scanned there, an invite opens in
either form.
The Android app
The Android app is native Kotlin and Jetpack Compose, in android/. It follows the iPhone
app's structure: most Swift files have a Kotlin twin of the same name (NodeManager.kt
for NodeManager.swift), and the iPhone tests are ported under their own names. It runs
on Android 10 (API 29) or later and opens apps in Android System WebView 144 or later, the
first that gives every web view its safe areas. On an older WebView it asks for an update
and opens no app.
Building it needs JDK 21 (JAVA_HOME), Go, and the Android SDK (ANDROID_HOME) with the
platform, build tools and NDK that android/gradle.properties names. From the
repository's root:
android/gradlew -p android :app:assembleDebug
The Android Tailscale library
android/core is a Go module over Tailscale's tsnet, with Tailscale pinned in its
go.mod and Go in scripts/build-android-core.sh. It runs one node per network, as the
iPhone app does, and the local proxy that every page and every native request goes through.
scripts/build-android-core.sh binds it with gomobile into
android/app/libs/ovenlight-core.aar, gitignored and cached (--force rebuilds), with
the licenses of what it links beside it for Acknowledgements. Gradle runs the script
before every build: for arm64-v8a only in a debug build, and for arm64-v8a, armeabi-v7a
and x86_64 when a release or bundle task is asked for, so a debug build runs only on an
arm64 phone or emulator (such as one on an Apple silicon computer). It builds with the
ts_omit_debug, ts_omit_peerapiserver and ts_omit_cachenetmap tags, links for 16 KB
pages, and turns off log upload, and the log files tsnet would keep for it, for every
node.
Android tests
From the repository's root:
android/gradlew -p android :app:lintDebug :app:testDebugUnitTest
go -C android/core test ./...
android/scripts/test-parity.sh
The JVM tests run the screens under Robolectric, and
testDecodesEveryGoldenFileFromTheConnector decodes every file in
connector/testdata/wire/: a new file fails it until it has a case there.
test-parity.sh fails when an iPhone test has no Android twin of the same name, outside
a short list of exclusions, each with its reason. It reads the iPhone tests from
origin/app-1.0.2, or the ref that OVENLIGHT_SWIFT_SPEC names. CI runs all three, with
go vet and govulncheck for the core, a release bundle and verify-bundle.sh (see
Shipping on Google Play).
The device tests need an emulator or phone with WebView 144 or later:
android/gradlew -p android :app:connectedDebugAndroidTest. android/e2e/e2e.sh runs
whole scenarios against a local Headscale and a dev connector, on the emulators that
android/scripts/avds.sh keeps. Its header lists them.
Debug launch extras
Debug builds take extras on adb shell am start -n com.snowyghost.ovenlight/.MainActivity,
as the iPhone app takes launch arguments:
--es controlURL <url> --es authKey <key>signs the owner's node in to a local Headscale with a key.--es joinInvite <link>opens an invite.--es openApp <name>opens an app.ovenlight://open?app=<name>does the same in any build, and so doesovenlight://open?id=<app ID>, which home screen shortcuts use. Both wait for the unlock.--ez lockNow truelocks Ovenlight.--ez sharingFixture truefills in sample sharing data for screenshots.--ez fakeOldWebView trueshows the screen that asks for a WebView update.
DebugLaunch.kt lists the rest, including the lab that first checked the proxy and
profiles. As on iPhone, debug builds also accept an http control server on loopback in
invites. To reach a Headscale on your own computer from an emulator, forward its port
with adb reverse. Debug builds trust one more certificate authority, for the
certificates ovenlight run --dev-tls-cert serves: test-ca/ca.pem beside the SDK
folder, which e2e.sh makes, or else a throwaway one.
How the Android app differs
- One proxy. Every page and every request of Ovenlight's own (probes, claims, the
admin API, feedback, downloads, through OkHttp) goes through one password-protected
proxy in the Go core (
android/core/proxy.go,TailnetCore.kt), and each app has its own WebView profile. security.md says what it refuses, and On Android how it's guarded. - The lock is Android's biometric prompt: a fingerprint or face, or the screen lock.
Android 10's prompt can't fall back to the screen lock, so there a button opens the
screen lock's own screen, at once when no fingerprint or face is enrolled
(
UnlockPrompt.kt). The lock screen is a window of its own (LockWindowinMainActivity.kt). - Coming and going. A trip out that hands something back (the sign-in tab, a picker,
the lock's prompt) keeps nodes and pages running for five minutes. Any other departure,
or a trip out past five minutes, pauses polling and pages' timers, and after a minute
closes every node, whose state stays on disk. Coming back starts them again
(
AppLifecycle.kt). - Tailscale's sign-in and Machines page open in a Custom Tab, in the phone's browser, since Google refuses sign-ins inside web views. A Custom Tabs session keeps Ovenlight running behind the tab in the browser, so it comes back by itself once the phone is approved.
- Invites open Ovenlight as App Links (the site's
assetlinks.json), asovenlight://joinlinks with the fields in the query or the fragment, pasted, or scanned in Ovenlight with CameraX and ZXing. - Pages. Back goes back in the page, then to the launcher. The page script is
assets/page.js. app-contract.md says what it and downloads do.
Shipping on Google Play
android/gradlew -p android --no-configuration-cache :app:bundleRelease builds the
release bundle, for all three ABIs, signed with the upload key (PKCS12, alias upload)
when PLAY_UPLOAD_KEYSTORE names its file and PLAY_UPLOAD_PASSWORD holds its password.
android/scripts/verify-bundle.sh checks the bundle: the target SDK, every native library
aligned for 16 KB pages, the license notices, no advertising ID or media permissions, the
camera and microphone not required, WebView's metrics and Safe Browsing off, and nothing
backed up or moved to a new phone. Play signs what reaches phones with Play App Signing,
and gives every upload its versionCode: the Gradle Play Publisher plugin, on only when
PLAY_CREDENTIALS names a service account's key, asks Play for one past the highest on
any track.
Forking the Android app
To build and sign your own copy, change applicationId in android/app/build.gradle.kts,
AppLinks in Support.kt, the App Link host in AndroidManifest.xml and
InviteLink.kt, the invite host in connector/invite.go, and the package in
site/join.js's intent: link, and serve your own assetlinks.json with your signing
key. On Android 12 and later, an App Link opens only an app whose key the site lists, so
in a fork's build an invite opens the join page, whose Open in Ovenlight opens the app
that site/join.js names.