Files
offscale-mobile/README.md
T
pastilhas ef3582f2aa init: OffScale — standalone MIT Tailscale/Headscale client
Extracted from the officer-suite monorepo into its own public repo. The app is
open source and free; the rest of the suite is not, so it could not stay in a
tree that ships an account layer.

What moved:

- apps/offscale/*                  -> repo root (App shell, app.json, assets)
- packages/officer-net/            -> modules/officer-net/, a `file:` dependency
- packages/core VPN closure        -> src/vpn/ (screen, hook, native bridge, invite)
- packages/core shared primitives  -> src/ui/ (theme, cards, tabs, backdrop, splash)
- scripts/build-{ios,android}.sh   -> scripts/, cut down to this one app

What deliberately did NOT move: the platform account layer. VpnScreen used to
render core's AppSettingsScreen, which drags in ServersSection -> servers.ts,
useAuth, api.ts, shared-session, install-guard, api-keys. None of that exists
here. src/settings/SettingsScreen.tsx is a fresh replacement rendering exactly
what VpnScreen passed it — the app's own sections plus About — with no Servers
tab, no Appearance control (the app is pinned to dark) and no sign-out.

The one platform reference left in the closure was a dead `import { request }
from './api'` in officer-net.ts, unused since enrollVpn() was removed. Gone. The
app now contacts nothing but the control server you point it at, its peers, and
the enrolment sidecar named inside an invite link you tapped.

Dependencies are pinned to the exact versions the monorepo has installed, not to
ranges: this repo starts from versions already proven to build, and an unpinned
install is a supply-chain decision nobody made. react-query, react-dom,
react-native-screens, expo-secure-store, track-player, expo-audio and
expo-file-system were all declared by the old app and imported by none of this
code, so they are not here.

NOTICE carries Tailscale's BSD-3 text, names the built artifacts that contain
their compiled code, and treats the trademark question separately — including
that src/ui/TailscaleMark.tsx reproduces their logo in the attribution strip.
2026-08-14 20:32:17 +01:00

152 lines
7.3 KiB
Markdown

# OffScale
A mesh VPN client for iOS and Android that works with **your own Headscale** and with
**Tailscale's** hosted control plane, treating both as equals.
It embeds the real Tailscale client core (Go, compiled with `gomobile`) and runs it inside the OS VPN
APIs — `NEPacketTunnelProvider` on iOS, `VpnService` on Android. It is not a wrapper around another
app and not a reimplementation of the protocol: it is the same core, in a different shell.
MIT licensed, free, and intended to stay both.
## Why it exists
The thing that made this worth building is that **Headscale is a first-class destination here, not an
"advanced" option buried three screens deep.** If you self-host, you should not have to feel like you
are using the software wrong. First run offers two equal buttons: your Headscale, or Tailscale's
network.
## It never talks to a server of ours
There is no sign-in, no account, no analytics, no crash reporting and no update check. There is no
backend belonging to this project for the app to contact, and no code path that reaches for one.
The only hosts OffScale ever connects to are:
- **the control server you point it at** — your Headscale, or `controlplane.tailscale.com`;
- **the peers on your tailnet**, over WireGuard;
- **the enrolment sidecar named inside an invite link**, and only if you tapped one (see
`src/vpn/invite.ts`; the base URL comes out of the link's fragment and nowhere else).
This is a design constraint rather than a current fact. A VPN client that needed some other service to
be reachable in order to authenticate would be a bootstrap deadlock, because the tunnel is very often
the thing that makes that service reachable in the first place. Please keep it that way in any fork:
the dependency runs one direction only.
Node keys and WireGuard keys are generated and held by the core in the Keychain / Keystore. They never
cross into Swift, Kotlin or JavaScript, let alone the network.
## Features
- Multiple tailnets at once, with a switcher — a self-hosted Headscale and a Tailscale account side by
side, each with its own node key and prefs.
- Pre-auth key enrolment, Tailscale interactive login, and one-tap invite links.
- Peer list with online state, exit-node selection, and subnet-route acceptance.
- Node-key expiry warnings before the device quietly stops working.
- Reconnect on boot (Android), and a tunnel-crash report you can read locally.
- Tablet-aware layout; phone-shaped, not stretched.
## Building
Requires **bun**, **Xcode** (iOS), the **Android SDK/NDK** (Android), **Go 1.26+** with `gomobile`, and
**ImageMagick** for icon generation (optional — a committed icon is used if it is missing).
```bash
bun install --frozen-lockfile
go install golang.org/x/mobile/cmd/gomobile@latest
go install golang.org/x/mobile/cmd/gobind@latest
```
Then, with a device attached:
```bash
scripts/build-ios.sh # Release .app, installed on the cabled iPhone
DEVICE=<udid> scripts/build-ios.sh # pick one, when several are paired
NO_INSTALL=1 scripts/build-ios.sh # build only
scripts/build-android.sh # Release APK, installed over adb
ABIS=arm64-v8a,x86_64 scripts/build-android.sh # add an emulator ABI
```
Both scripts build the **Release** configuration and nothing else. A Debug build needs Metro attached
and behaves differently from what ships, so testing one answers the wrong question.
Logs land in `$TMPDIR/offscale-build/`.
### Two things about the scripts that are load-bearing
**They run `bun run prebuild`, not `expo prebuild`.** The package script runs
`scripts/gen-adaptive-icon.sh` first, which insets the Android adaptive-icon foreground so the launcher
mask does not crop the artwork. Calling `expo prebuild` directly skips that silently and ships a
cropped icon.
**Android builds `arm64-v8a` only.** Expo's generated `gradle.properties` asks for four ABIs, and the
Tailscale core is a large `.so` that gets built once per ABI. `-PreactNativeArchitectures` is passed on
the command line rather than edited into `gradle.properties`, because `expo prebuild --clean`
regenerates that file on every build and an edit there survives nothing. Pass `ABIS=` to override.
### iOS needs an Xcode account signed in
The packet tunnel requires the Network Extensions entitlement, and `-allowProvisioningUpdates` cannot
create the profile for it while Xcode has no account signed in. It falls back to the wildcard team
profile and fails with *"Provisioning profile … doesn't include the Network Extensions capability"*.
Sign in under **Xcode → Settings → Accounts** first. Android has no equivalent gate. See
`modules/officer-net/plugin/README.md` for the full provisioning list.
## Changing the Go core means rebuilding it BY HAND, per platform
This is the trap that costs the most time, so it gets its own section.
`modules/officer-net/gocore/` is bound with `gomobile` into a `.xcframework` (iOS) and an `.aar`
(Android). **Neither is regenerated by an ordinary app build.** Touch any `.go` file, `go.mod` or
`go.sum` and you must run:
```bash
PATH="$PATH:$HOME/go/bin" modules/officer-net/scripts/build-libtailscale-ios.sh # a few minutes
PATH="$PATH:$HOME/go/bin" modules/officer-net/scripts/build-libtailscale-android.sh
```
Forgetting does not fail where you changed something. It fails as a missing symbol, and the two
platforms fail very differently:
| platform | error | when |
|----------|-------------------------------------------------------|------|
| Android | `e: Unresolved reference 'loginTo'` | Kotlin compile — loud, early, obvious |
| iOS | `error: cannot find 'TailcoreListProfiles' in scope` | Swift compile, **long after the Go compiled cleanly** against a vendored xcframework whose headers are simply older than the source beside them |
`scripts/ensure-core.sh` compares mtimes and runs the right script for you before each app build, which
is why both build scripts source it — a rule you have to remember every time is not a rule. If you are
debugging, confirm the symbols actually landed:
```bash
grep Tailcore modules/officer-net/ios/libs/TailCore.xcframework/ios-arm64/Tailcore.framework/Headers/*.h
```
The built artifacts are gitignored because of their size. They are not optional.
## Layout
```
src/
App.tsx the whole shell — splash, theme, one screen
ui/ shared primitives: theme, cards, tabs, the wallpaper frame
vpn/ the screen, its state hook, the native bridge, invite handling
settings/ the settings page (no accounts, no servers, no sign-in)
modules/officer-net/ the native module: Go core shim, Swift NE, Kotlin VpnService, config plugin
scripts/ build-ios.sh, build-android.sh, ensure-core.sh, gen-adaptive-icon.sh
```
`src/vpn/officer-net.ts` is the readable index of the native surface — every capability the app has
passes through it.
## Licence
MIT — see [LICENSE](LICENSE).
It embeds Tailscale's client core under the BSD 3-Clause licence. **[NOTICE](NOTICE) is required
reading before you distribute a build**: it carries Tailscale's copyright and licence text, says which
built artifacts contain their code, and covers the trademark position separately from the licence.
OffScale is an independent, unofficial client. It is not affiliated with, endorsed by or supported by
Tailscale Inc. Please do not ask their support about it.