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.
This commit is contained in:
2026-08-14 20:32:17 +01:00
commit ef3582f2aa
68 changed files with 11534 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
#
# Clean-build the release APK and install it on the attached device.
#
# scripts/build-android.sh # build + install
# ABIS=arm64-v8a,x86_64 scripts/… # add an emulator ABI
# MINIFY=1 scripts/… # turn R8 on (see the note below — not the default)
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOGS="${TMPDIR:-/tmp}offscale-build"
mkdir -p "$LOGS"
log="$LOGS/android.log"
: >"$log"
cd "$ROOT" || exit 1
# The vendored Tailscale core is built by hand, per platform, and forgetting it fails much later as a
# missing symbol rather than a missing rebuild — Android says "Unresolved reference 'loginTo'" at
# Kotlin compile time. Do it here instead of relying on anyone remembering.
. "$ROOT/scripts/ensure-core.sh" android
echo "logs: $log"
# `bun run prebuild`, NOT `bunx expo prebuild`: the package.json script runs gen-adaptive-icon.sh
# first, and calling expo directly silently skips it — which ships an icon the Android launcher mask
# crops. Trailing args append to that script, and `expo prebuild` is its last command.
if ! bun run prebuild --platform android --clean --no-install >>"$log" 2>&1; then
echo "PREBUILD FAILED — $log"; exit 1
fi
# Build for phones only.
#
# Expo's generated gradle.properties asks for `armeabi-v7a,arm64-v8a,x86,x86_64`, and the native
# libraries are most of an APK — for an app this shape, roughly 65 MB of it is lib/x86 and lib/x86_64,
# Intel builds for emulators that no phone will ever execute, and another 21 MB is armeabi-v7a for
# 32-bit devices. The Tailscale core is a large .so and gets built once per ABI, so this matters more
# here than in an app without one.
#
# Overridden here rather than in gradle.properties because `expo prebuild --clean` on the line above
# regenerates that file every single build, so an edit there survives exactly nothing.
#
# ABIS=… to override — an emulator needs x86_64 and will refuse an APK without it.
ABIS="${ABIS:-arm64-v8a}"
# MINIFY=1 turns R8 on. OFF by default, and that default is the honest one.
#
# R8 deletes every class nothing reaches from an entry point. It decides "nothing reaches this" by
# reading the code — so anything reached by REFLECTION looks dead and gets deleted. React Native and
# Expo both do that, and so does the gomobile binding layer. The build still succeeds; the app crashes
# on the device. This cannot be verified by looking at a file size, and it must not become the default
# until a build with it has been opened on a real phone, connected to a tailnet, and used.
MINIFY_ARGS=()
if [[ "${MINIFY:-0}" == "1" ]]; then
MINIFY_ARGS=(-Pandroid.enableMinifyInReleaseBuilds=true)
fi
# assembleRelease, always. A debug build needs Metro attached and behaves differently from what ships,
# so testing it answers the wrong question.
if ! (cd android && ./gradlew :app:assembleRelease -PreactNativeArchitectures="$ABIS" \
"${MINIFY_ARGS[@]}" --console=plain) >>"$log" 2>&1; then
echo "GRADLE FAILED — $log"; exit 1
fi
apk="$(find android/app/build/outputs/apk/release -name '*.apk' | head -1)"
[[ -z "$apk" ]] && { echo "NO APK"; exit 1; }
if ! adb install -r "$apk" >>"$log" 2>&1; then
# Almost always the cable, not the build. The APK is on disk either way: rerun just the install
# rather than the whole thing.
echo "INSTALL FAILED (device attached?) — apk at $apk"; exit 1
fi
echo "OK"
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env bash
#
# Clean-build the Release .app and install it on the cabled iPhone.
#
# scripts/build-ios.sh # build + install
# DEVICE=<udid> scripts/build-ios.sh # pick a specific device
# NO_INSTALL=1 scripts/build-ios.sh # build only; leave the phone alone
#
# NO_INSTALL exists because installing restarts the app, and a long-running pass on the phone dies
# with it. The .app path is printed so it can be installed by hand:
# xcrun devicectl device install app --device <udid> <path>
#
# xcodebuild + devicectl rather than `expo run:ios`: the expo installer has hung on this setup, and
# this way the build and the install fail separately and legibly.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOGS="${TMPDIR:-/tmp}offscale-build"
mkdir -p "$LOGS"
log="$LOGS/ios.log"
: >"$log"
cd "$ROOT" || exit 1
# The vendored Tailscale core is built by hand, per platform, and forgetting it fails much later as a
# missing symbol rather than a missing rebuild — iOS says "cannot find 'TailcoreListProfiles' in
# scope", long after the Go compiled cleanly. Do it here instead of relying on anyone remembering.
. "$ROOT/scripts/ensure-core.sh" ios
# The iPhone to install on, unless one is named explicitly. Matched by UDID shape rather than column
# position: the Model column has a variable number of words, so counting fields from either end picks
# up part of the model name instead.
#
# Three things this has to get right, all learned the hard way:
#
# - devicectl says "connected" for a cabled device on some Xcode versions and "available (paired)"
# on others. Both are installable. Matching only "connected" exits with "No connected iPhone"
# while a cabled phone sits there working fine.
# - It must be an iPhone. A paired iPad is "available" too and sorts first, so widening the state
# match alone silently retargets the whole build at the iPad.
# - "unavailable" CONTAINS "available", so a bare /available/ matches a phone that is paired but not
# plugged in. Picking one does not fail here — it fails fifteen lines into xcodebuild with "Unable
# to find a destination matching the provided destination specifier", which reads like a broken
# project rather than an unplugged cable.
#
# With more than one iPhone paired there is no correct guess, so it refuses and lists them.
if [[ -z "${DEVICE:-}" ]]; then
cands="$(xcrun devicectl list devices 2>/dev/null | awk '
/\(iPhone[0-9]/ && (/connected/ || /(^|[^n])available/) {
for (i = 1; i <= NF; i++) if ($i ~ /^[0-9A-F]{8}-[0-9A-F]{4}-/) { print $i; break }
}')"
n="$(printf '%s' "$cands" | grep -c . || true)"
if [[ "$n" -eq 1 ]]; then
DEVICE="$cands"
elif [[ "$n" -gt 1 ]]; then
echo "!! More than one iPhone is paired — refusing to guess. Pass DEVICE=<uuid>:" >&2
xcrun devicectl list devices 2>/dev/null | awk '/\(iPhone[0-9]/ && (/connected/ || /(^|[^n])available/)' >&2
exit 1
fi
fi
# No device is not fatal: `generic/platform=iOS` builds an unsigned-to-no-particular-device arm64
# .app, which is what a build server wants. Passing `id=<absent device>` instead fails deep inside
# xcodebuild with a destination error that reads like a broken project.
if [[ -z "${DEVICE:-}" ]]; then
echo "device: none — building for generic iOS, no install"
destination="generic/platform=iOS"
NO_INSTALL=1
else
echo "device: $DEVICE"
destination="id=$DEVICE"
fi
echo "logs: $log"
# `bun run prebuild`, NOT `bunx expo prebuild`: the package.json script runs gen-adaptive-icon.sh
# first, and calling expo directly silently skips it — which ships an icon the Android launcher mask
# crops. Trailing args append to that script, and `expo prebuild` is its last command.
if ! bun run prebuild --platform ios --clean --no-install >>"$log" 2>&1; then
echo "PREBUILD FAILED — $log"; exit 1
fi
if ! (cd ios && pod install) >>"$log" 2>&1; then
echo "POD INSTALL FAILED — $log"; exit 1
fi
ws="$(find ios -maxdepth 1 -name '*.xcworkspace' | head -1)"
scheme="$(basename "$ws" .xcworkspace)"
derived="$LOGS/derived"
# A SwiftPM binary artifact that was extracted by an interrupted build stays half-unpacked, and
# xcodebuild then fails with "There is no Info.plist found at .../Some.xcframework". It reads like a
# corrupt download and is not — the zip in ~/Library/Caches/org.swift.swiftpm is fine; only this
# extraction of it is short. Any .xcframework missing its Info.plist means the whole SourcePackages
# tree is suspect, so it goes and is re-extracted from the (good) cache. Cheap: no re-download.
if [[ -d "$derived/SourcePackages/artifacts" ]]; then
while IFS= read -r fw; do
[[ -f "$fw/Info.plist" ]] && continue
echo "half-extracted $(basename "$fw") — clearing SourcePackages"
rm -rf "$derived/SourcePackages"
break
done < <(find "$derived/SourcePackages/artifacts" -maxdepth 4 -name '*.xcframework' -type d 2>/dev/null)
fi
# Wipe old products first. A rename leaves the PREVIOUS bundle behind in the same derivedDataPath,
# and picking "a" .app then installs the stale one.
rm -rf "$derived/Build/Products"
# Release, always. Bare Debug needs Metro attached and behaves differently from what ships, so
# testing it answers the wrong question.
#
# DEVELOPMENT_TEAM is also in app.json as ios.appleTeamId, so prebuild writes it into the pbxproj;
# passed here too so the script still works against an ios/ dir that predates that.
#
# A Network Extension needs its own provisioning profile, and -allowProvisioningUpdates cannot create
# one 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". Fix is one interactive
# step: Xcode -> Settings -> Accounts -> sign in to the team, then rebuild.
if ! xcodebuild -workspace "$ws" -scheme "$scheme" -configuration Release \
-destination "$destination" -derivedDataPath "$derived" \
DEVELOPMENT_TEAM="${DEVELOPMENT_TEAM:-RGHC4NJC77}" CODE_SIGN_STYLE=Automatic \
-allowProvisioningUpdates build >>"$log" 2>&1; then
echo "XCODEBUILD FAILED — $log"; exit 1
fi
# Newest by mtime, belt-and-braces with the wipe above.
appPath="$(find "$derived/Build/Products" -maxdepth 2 -name '*.app' -print0 2>/dev/null \
| xargs -0 stat -f '%m %N' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-)"
[[ -z "$appPath" ]] && { echo "NO .app"; exit 1; }
if [[ -n "${NO_INSTALL:-}" ]]; then
echo "BUILT, not installed — $appPath"; exit 0
fi
# Reinstalling invalidates the saved VPN configuration: iOS hands it back from loadAllFromPreferences,
# reports "unchanged" on save, then refuses to start it. The app removes and rewrites it on failure,
# which is self-healing — but it is why the first tunnel start after an install can take two attempts.
if ! xcrun devicectl device install app --device "$DEVICE" "$appPath" >>"$log" 2>&1; then
echo "INSTALL FAILED (device attached?) — app at $appPath"; exit 1
fi
echo "OK ($scheme)"
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
#
# Rebuild the vendored Tailscale core when the Go source is newer than the built artefact.
#
# . scripts/ensure-core.sh ios # or: android
#
# WHY THIS EXISTS
#
# Changing modules/officer-net/gocore requires rebuilding the core by hand, per platform, and nothing
# enforces it. Forgetting is silent until link or compile time, and the error names a symbol rather
# than the cause:
#
# Android e: Unresolved reference 'loginTo'
# iOS error: cannot find 'TailcoreSubnetRoutes' in scope
#
# iOS is the nastier of the two: the Go compiles cleanly and the failure arrives minutes later, in
# Swift, against a vendored xcframework whose headers are simply older than the source beside them.
#
# It compares mtimes rather than hashes: gomobile takes minutes, and a spurious rebuild is far cheaper
# than a stale one. The artefact is touched on success so an unchanged tree does not rebuild twice.
set -uo pipefail
_ec_platform="${1:?usage: ensure-core.sh <ios|android>}"
_ec_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
_ec_gocore="$_ec_root/modules/officer-net/gocore"
case "$_ec_platform" in
ios)
_ec_artifact="$_ec_root/modules/officer-net/ios/libs/TailCore.xcframework"
_ec_script="$_ec_root/modules/officer-net/scripts/build-libtailscale-ios.sh"
;;
android)
_ec_artifact="$_ec_root/modules/officer-net/android/libs/tailcore.aar"
_ec_script="$_ec_root/modules/officer-net/scripts/build-libtailscale-android.sh"
;;
*)
echo "!! ensure-core: unknown platform '$_ec_platform'" >&2
return 1 2>/dev/null || exit 1
;;
esac
# Newest input: any Go source, plus the module files that pin tailscale.com itself.
_ec_newest_src="$(find "$_ec_gocore" -type f \( -name '*.go' -o -name 'go.mod' -o -name 'go.sum' \) \
-exec stat -f '%m' {} + 2>/dev/null | sort -rn | head -1)"
if [[ -e "$_ec_artifact" ]]; then
# The xcframework is a directory, and its own mtime is what this script touches.
_ec_artifact_mtime="$(stat -f '%m' "$_ec_artifact" 2>/dev/null || echo 0)"
else
_ec_artifact_mtime=0
fi
if [[ -z "${_ec_newest_src:-}" ]]; then
echo "ensure-core: no Go sources found — skipping (is gocore missing?)" >&2
elif (( _ec_newest_src > _ec_artifact_mtime )); then
if (( _ec_artifact_mtime == 0 )); then
echo "==> core artefact missing for $_ec_platform — building it (a few minutes)"
else
echo "==> gocore is newer than the built core for $_ec_platform — rebuilding (a few minutes)"
fi
# gomobile lives in ~/go/bin, which is not on PATH in every shell.
if PATH="$PATH:$HOME/go/bin" "$_ec_script"; then
touch "$_ec_artifact" 2>/dev/null || true
echo "==> core rebuilt for $_ec_platform"
else
echo "!! core rebuild FAILED for $_ec_platform — the app build would link a stale core, stopping." >&2
return 1 2>/dev/null || exit 1
fi
else
echo "==> core is up to date for $_ec_platform"
fi
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
#
# Regenerates the Android adaptive-icon foreground from the iOS icon.
#
# scripts/gen-adaptive-icon.sh # check, and generate if missing
# scripts/gen-adaptive-icon.sh --force # regenerate even if one exists but differs
#
# Android launcher masks (round, squircle, teardrop — the launcher decides) crop the icon. The art is
# a full-bleed render on a black backdrop, so a mask ate its edges. The fix is to shrink the art inside
# the same canvas and let the transparent margin absorb whatever the mask takes.
#
# Only Android needs this. iOS draws icon.png full-bleed with its own rounding, so insetting there
# would just add a black border — icon.png is never touched.
#
# Runs from the `prebuild` npm script, because `expo prebuild` is what turns these assets into
# android/app/src/main/res/*.webp. The generated PNG is also committed, so a machine without
# ImageMagick still builds a correct icon — this regenerates it, it is not the only source. That is
# also why calling `expo prebuild` directly instead of `bun run prebuild` is a mistake: it skips this
# silently.
set -euo pipefail
# Android's adaptive-icon spec: the outer 1/3 of the foreground is reserved for mask and parallax,
# leaving a guaranteed-visible circle of ~66%. 72% sits just above that — as large as the art can be
# drawn while no supported mask clips it.
readonly INSET_PERCENT=72
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ICON="$ROOT/assets/icon.png"
ADAPTIVE="$ROOT/assets/adaptive-icon.png"
FORCE=false
for a in "$@"; do [[ "$a" == "--force" ]] && FORCE=true; done
if ! command -v magick >/dev/null 2>&1; then
echo "!! ImageMagick not found — skipping adaptive-icon generation." >&2
echo "!! The committed adaptive-icon.png is used as-is. brew install imagemagick" >&2
exit 0
fi
# True when `existing` already IS `icon` inset — compared by pixel content, not by bytes and not by
# geometry.
#
# Geometry alone is not enough: checking only that the art fills ~72% of the canvas passes an adaptive
# icon built from SUPERSEDED art, so swapping in new icon.png art leaves the Android icon silently
# stale. Bytes are not enough in the other direction: re-encoding an identical image can differ, which
# would rewrite a megabyte on every prebuild. RMSE of the decoded pixels is exact here, so the
# threshold only absorbs a future encoder change.
matches_icon() {
local existing="$1" icon="$2" size="$3" art="$4"
local tmp="$existing.expected.png"
magick "$icon" -resize "${art}x${art}" -background none -gravity center \
-extent "${size}x${size}" "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }
# Differing canvas sizes make compare fail outright, which is itself a mismatch.
local rmse
rmse="$(magick compare -metric RMSE "$existing" "$tmp" null: 2>&1 || true)"
rmse="${rmse%% *}"
rm -f "$tmp"
awk -v v="$rmse" 'BEGIN { exit !(v ~ /^[0-9.]+$/ && v < 100) }'
}
[[ -f "$ICON" ]] || { echo "!! no assets/icon.png" >&2; exit 1; }
# Same canvas as the source; only the art inside it shrinks. Rounded, not truncated.
size="$(magick identify -format '%w' "$ICON")"
art=$(((size * INSET_PERCENT + 50) / 100))
if [[ -f "$ADAPTIVE" ]] && matches_icon "$ADAPTIVE" "$ICON" "$size" "$art"; then
echo "adaptive icon: ok"
elif [[ -f "$ADAPTIVE" ]] && [[ "$FORCE" != true ]]; then
# Deliberately does NOT overwrite. An existing adaptive icon that does not match icon.png is
# ambiguous: it is either stale art (regenerate) or art framed on purpose. Say so and let a human
# decide; --force applies the regeneration.
echo "adaptive icon: DIFFERS from icon.png — not overwritten (--force to regenerate)"
else
magick "$ICON" -resize "${art}x${art}" \
-background none -gravity center -extent "${size}x${size}" "$ADAPTIVE"
echo "adaptive icon: regenerated (${size} canvas, ${art} art)"
fi
# The adaptive background sits behind the inset art, so it must match the art's own backdrop or a seam
# shows at the edges. This only reports — app.json is easy to edit by hand and silently rewriting it
# is worse than saying what to change.
corner="#$(magick "$ICON" -format '%[hex:p{0,0}]' info: | tr '[:lower:]' '[:upper:]')"
configured="$(grep -oE 'backgroundColor.{0,4}#[0-9A-Fa-f]{6}' "$ROOT/app.json" 2>/dev/null \
| grep -oE '#[0-9A-Fa-f]{6}' | head -1 | tr '[:lower:]' '[:upper:]' || true)"
if [[ -z "$configured" ]]; then
echo " no android.adaptiveIcon.backgroundColor set — use $corner"
elif [[ "$corner" != "$configured" ]]; then
echo " backgroundColor is $configured, art corner is $corner"
fi