Files
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

139 lines
6.8 KiB
Bash
Executable File

#!/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)"