diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d2ea895..c40a315 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -97,3 +97,69 @@ jobs:
otool -L "$BIN" | tee /tmp/o.txt
if otool -L "$BIN" | tail -n +2 | grep -iq tailscale; then
echo "::error::updater links a tailscale product (self-update circularity)"; exit 1; fi
+
+ build-legacy106:
+ name: Cross-build + legacy106 gate (macos-26)
+ runs-on: macos-26
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+
+ - name: Install mavericks-shipyard
+ uses: ModernMavericks/shipyard/.github/actions/install@v1
+
+ - name: Install patched Go (mavericks-golang cross toolchain)
+ run: |
+ ver="$(cat components/golang/version)"
+ base="https://github.com/ModernMavericks/golang/releases/download/${ver}"
+ curl -fL --retry 3 -o SHA256SUMS "$base/SHA256SUMS"
+ gover="${ver%%-mavericks.*}"; rev="${ver#*-}"; pkg=""; sum=""
+ for pref in golang go126; do
+ cand="${pref}-${gover}-cross-${rev}.pkg"
+ s=$(awk -v f="$cand" '$2==f' SHA256SUMS || true)
+ if [ -n "$s" ]; then pkg="$cand"; sum="$s"; break; fi
+ done
+ [ -n "$pkg" ] || { echo "::error::no cross pkg for $ver in golang SHA256SUMS"; exit 1; }
+ curl -fL --retry 3 -o "$pkg" "$base/$pkg"
+ printf '%s\n' "$sum" | shasum -a 256 -c -
+ sudo installer -pkg "$pkg" -target /
+ echo "/usr/local/go126-cross/bin" >> "$GITHUB_PATH"
+
+ - name: Confirm the patched cross toolchain is on PATH
+ run: |
+ test "$(command -v go)" = /usr/local/go126-cross/bin/go
+ go version
+
+ - name: Cache SDKs (distinct key from the 10.9 job's cache)
+ uses: actions/cache@v6
+ with:
+ path: |
+ ${{ github.workspace }}/.sdk-cache
+ ${{ github.workspace }}/.sdk106-cache
+ key: macosx-sdk106-v1
+
+ - name: Derive the upstream version from the pin
+ run: sh build/derive-upstream-version.sh
+
+ - name: Build + gate the 10.6-floor binaries (x86_64 / min-10.6 / Core 2 safe)
+ env:
+ MAVERICKS_SDK_CACHE: ${{ github.workspace }}/.sdk-cache
+ MAVERICKS_SDK106_CACHE: ${{ github.workspace }}/.sdk106-cache
+ run: |
+ cmake --preset cross-legacy
+ cmake --build --preset cross-legacy
+ ctest --preset cross-legacy
+
+ - name: Build the 10.6 updater (must be min-10.6, x86_64)
+ env:
+ MAVERICKS_SDK_CACHE: ${{ github.workspace }}/.sdk-cache
+ MAVERICKS_SDK106_CACHE: ${{ github.workspace }}/.sdk106-cache
+ run: |
+ VER="$(cat UPSTREAM_VERSION 2>/dev/null || echo 1.0)"
+ sh cmake/build_updater106.sh build/updater106 "$VER"
+ BIN=build/updater106/TailscaleUpdater.app/Contents/MacOS/TailscaleUpdater
+ ARCHS=$(lipo -info "$BIN" | sed -n 's/.*: //p')
+ [ "$ARCHS" = "x86_64" ] || { echo "::error::updater archs: $ARCHS (expected x86_64)"; exit 1; }
+ MIN=$(otool -l "$BIN" | awk '/LC_VERSION_MIN_MACOSX/{f=1} f && /version/{print $2; exit}')
+ [ "$MIN" = "10.6" ] || { echo "::error::updater min-version is $MIN, expected 10.6"; exit 1; }
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a9732fe..dd5cd0c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -157,11 +157,13 @@ jobs:
test "$(command -v go)" = /usr/local/go126-cross/bin/go
go version
- - name: Cache the MacOSX10.9 SDK
+ - name: Cache the MacOSX10.9 SDK + assembled SDK106
uses: actions/cache@v6
with:
- path: ${{ github.workspace }}/.sdk-cache
- key: macosx10.9-sdk-v1
+ path: |
+ ${{ github.workspace }}/.sdk-cache
+ ${{ github.workspace }}/.sdk106-cache
+ key: macosx10.9-sdk-v2
- name: Build + gate the three Go binaries (x86_64 / min-10.9 / no post-10.9 imports)
env:
@@ -180,6 +182,22 @@ jobs:
if otool -L "$BIN" | tail -n +2 | grep -iq tailscale; then
echo "::error::updater links a tailscale product (self-update circularity)"; exit 1; fi
+ - name: Build + gate the 10.6-floor binaries
+ env:
+ MAVERICKS_SDK_CACHE: ${{ github.workspace }}/.sdk-cache
+ MAVERICKS_SDK106_CACHE: ${{ github.workspace }}/.sdk106-cache
+ run: |
+ cmake --preset cross-legacy
+ cmake --build --preset cross-legacy
+ ctest --preset cross-legacy
+
+ - name: Build the 10.6 updater
+ env:
+ MAVERICKS_SDK_CACHE: ${{ github.workspace }}/.sdk-cache
+ MAVERICKS_SDK106_CACHE: ${{ github.workspace }}/.sdk106-cache
+ VER: ${{ steps.ver.outputs.version }}
+ run: sh cmake/build_updater106.sh build/updater106 "$VER"
+
# Bundle the packaging inputs into ONE tarball so executable bits (the binaries, the updater .app's
# Mach-Os) survive the artifact round-trip -- artifacts otherwise drop file modes.
- name: Stage + upload packaging inputs
@@ -190,6 +208,10 @@ jobs:
cp build-cross/gobin/tailscale stage/tailscale
cp build-cross/gobin/tailscale-systray stage/tailscale-systray
cp -R build/updater/TailscaleUpdater.app stage/TailscaleUpdater.app
+ cp build-cross-legacy/gobin/tailscaled stage/tailscaled-106
+ cp build-cross-legacy/gobin/tailscale stage/tailscale-106
+ cp build-cross-legacy/gobin/tailscale-systray stage/tailscale-systray-106
+ cp -R build/updater106/TailscaleUpdater.app stage/TailscaleUpdater106.app
find stage -name '._*' -delete 2>/dev/null || true
tar -C stage -czf packaging-inputs.tgz .
- uses: actions/upload-artifact@v7
@@ -268,6 +290,24 @@ jobs:
--version "$VER" --pkg-url "$URL" --min-os 10.9.5 \
--notes-file dist/RELEASE_NOTES.md --pkg "$PKG" > dist/appcast.xml
echo "=== appcast.xml ==="; cat dist/appcast.xml
+
+ # 10.6-floor pkg: same version, different binaries + install floor.
+ PKG106="dist/tailscale-$VER-macos10.6.pkg"
+ sh cmake/make_app.sh in/tailscale-systray-106 "in/Mavericks Tailscale 106.app" "$VER" "$GITHUB_WORKSPACE/Tailscale.icns" 10.6
+ SHIPYARD_SCRIPTS="$SH" sh cmake/package_pkg_legacy106.sh --out "$PKG106" --version "$VER" \
+ --tailscaled in/tailscaled-106 --tailscale in/tailscale-106 \
+ --systray-app "in/Mavericks Tailscale 106.app" \
+ --updater-app in/TailscaleUpdater106.app \
+ --daemon-plist "$GITHUB_WORKSPACE/dist/com.tailscale.tailscaled.plist" \
+ --systray-agent "$GITHUB_WORKSPACE/dist/com.tailscale.systray.plist" \
+ --dist "$GITHUB_WORKSPACE/dist"
+
+ # Serve a SEPARATE 10.6 appcast (not mixed into the Sparkle feed:
+ # unsigned items confuse 10.9 Sparkle clients). The 10.6 updater
+ # reads this file; the 10.9 Sparkle reads the original appcast.
+ PKG106_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/$VER/$(basename "$PKG106")"
+ printf '\n\n \n Tailscale for Snow Leopard\n - \n Version %s\n %s\n 10.6\n \n
\n \n\n' \
+ "$VER" "$VER" "$PKG106_URL" > dist/appcast-10.6.xml
# No SHA256SUMS here: publish-release.yml regenerates it over everything it attaches, so the
# checksums cover the appcast too and no repo hand-rolls that step.
# Conformance: the .pkg, the appcast and the version we are about to publish must agree with each
@@ -291,6 +331,7 @@ jobs:
path: |
dist/*.pkg
dist/appcast.xml
+ dist/appcast-10.6.xml
dist/RELEASE_NOTES.md
# Before anything is published: no log of this run, and no file about to be released, carries a
diff --git a/.gitignore b/.gitignore
index 72e9021..2e831d2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,3 +16,4 @@ build/updater/
# Finder, and git prints "non-monotonic index" for every ._pack-*.idx it finds.
._*
.DS_Store
+build-cross-legacy/
diff --git a/CMakePresets.json b/CMakePresets.json
index 8f3cbfe..c830125 100644
--- a/CMakePresets.json
+++ b/CMakePresets.json
@@ -1,26 +1,72 @@
{
"version": 6,
- "cmakeMinimumRequired": { "major": 3, "minor": 25, "patch": 0 },
+ "cmakeMinimumRequired": {
+ "major": 3,
+ "minor": 25,
+ "patch": 0
+ },
"configurePresets": [
{
"name": "native",
- "displayName": "native — build on Mavericks 10.9 (mavericks-golang go126 .pkg)",
+ "displayName": "native \u2014 build on Mavericks 10.9 (mavericks-golang go126 .pkg)",
"binaryDir": "${sourceDir}/build-native",
- "cacheVariables": { "MAVERICKS_TAILSCALE_EXPECTED_MODE": "native" }
+ "cacheVariables": {
+ "MAVERICKS_TAILSCALE_EXPECTED_MODE": "native"
+ }
},
{
"name": "cross",
- "displayName": "cross — build for Mavericks 10.9 on a modern host (go126-cross)",
+ "displayName": "cross \u2014 build for Mavericks 10.9 on a modern host (go126-cross)",
"binaryDir": "${sourceDir}/build-cross",
- "cacheVariables": { "MAVERICKS_TAILSCALE_EXPECTED_MODE": "cross" }
+ "cacheVariables": {
+ "MAVERICKS_TAILSCALE_EXPECTED_MODE": "cross"
+ }
+ },
+ {
+ "name": "cross-legacy",
+ "displayName": "cross-legacy \u2014 build for Snow Leopard 10.6 on a modern host (go126-cross + legacy106 floor)",
+ "binaryDir": "${sourceDir}/build-cross-legacy",
+ "cacheVariables": {
+ "MAVERICKS_TAILSCALE_EXPECTED_MODE": "cross",
+ "MAVERICKS_TAILSCALE_FLOOR": "10.6"
+ }
}
],
"buildPresets": [
- { "name": "native", "configurePreset": "native" },
- { "name": "cross", "configurePreset": "cross" }
+ {
+ "name": "native",
+ "configurePreset": "native"
+ },
+ {
+ "name": "cross",
+ "configurePreset": "cross"
+ },
+ {
+ "name": "cross-legacy",
+ "configurePreset": "cross-legacy"
+ }
],
"testPresets": [
- { "name": "native", "configurePreset": "native", "output": { "outputOnFailure": true } },
- { "name": "cross", "configurePreset": "cross", "output": { "outputOnFailure": true } }
+ {
+ "name": "native",
+ "configurePreset": "native",
+ "output": {
+ "outputOnFailure": true
+ }
+ },
+ {
+ "name": "cross",
+ "configurePreset": "cross",
+ "output": {
+ "outputOnFailure": true
+ }
+ },
+ {
+ "name": "cross-legacy",
+ "configurePreset": "cross-legacy",
+ "output": {
+ "outputOnFailure": true
+ }
+ }
]
-}
+}
\ No newline at end of file
diff --git a/cmake/build_tailscale.sh b/cmake/build_tailscale.sh
index d3b950a..4bf8565 100644
--- a/cmake/build_tailscale.sh
+++ b/cmake/build_tailscale.sh
@@ -8,12 +8,27 @@
# $3 GO go binary (the MM go126 .pkg)
# $4 ROOT repo root (for patches/ + overlays/)
# $5 VER product version (longStamp)
+# $6 FLOOR macOS deployment floor: "10.9" (default) or "10.6" (Snow Leopard).
+# 10.6 swaps the CC wrapper for cmake/legacy106/mavericks-cross-clang-106
+# (min-10.6 + the 10.6 symbol archive) and builds with GOAMD64=v1 —
+# SL Macs are Core 2 class and SIGILL on POPCNT/SSE4.2 (the Go >=1.26
+# default baseline emits them).
set -eu
-SRC=$1; OUT=$2; GO=$3; ROOT=$4; VER=$5
+SRC=$1; OUT=$2; GO=$3; ROOT=$4; VER=$5; FLOOR=${6:-10.9}
+case "$FLOOR" in
+ 10.9|10.6) ;;
+ *) echo "build_tailscale: FLOOR must be 10.9 or 10.6, got '$FLOOR'" >&2; exit 2 ;;
+esac
# Build on LOCAL disk. The repo (and thus a repo-relative OUT/SRC) is on NFS: slow, and it leaks
# ._ AppleDouble sidecars into vendor/ (which then break the build / contaminate archives). wrksrc +
# the Go build/module caches live under WORK ($HOME/.cache, local); override with MAVERICKS_TAILSCALE_WORK.
WORK="${MAVERICKS_TAILSCALE_WORK:-$HOME/.cache/mavericks-tailscale/work}"
+# Normalize to absolute: the script cd's into wrksrc below, so any relative
+# WORK would silently re-anchor to the wrong directory.
+case "$WORK" in
+ /*) ;;
+ *) WORK="$(cd "$WORK" 2>/dev/null && pwd || echo "$PWD/$WORK")" ;;
+esac
WRK="$WORK/wrksrc"
export GOCACHE="$WORK/gocache" GOMODCACHE="$WORK/gomodcache" GOPATH="$WORK/gopath"
export COPYFILE_DISABLE=1 # no ._ sidecars when copying off the NFS source
@@ -37,6 +52,15 @@ for p in "$ROOT"/patches/*.patch; do echo ">> patch $(basename "$p")"; patch -p0
# 2. Vendor the module graph, then overlay the third-party 10.9-SDK shims into vendor/ (these modules
# call Security/Cocoa APIs newer than the 10.9 SDK and won't compile without a version-gated reimpl).
+
+# 2b. The 10.6 symbol-shim package (blank-imported from ipnauth). Copied for
+# every floor BEFORE vendoring -- main-module packages build from source
+# under -mod=vendor, but `go mod vendor` must see the import target exist.
+# Inert where its symbols are unreferenced, load-bearing on 10.6.
+echo ">> overlay legacy106 package"
+mkdir -p legacy106
+cp "$ROOT/overlays/legacy106/legacy106.go" "$ROOT/overlays/legacy106/legacy106_off.go" legacy106/
+
unset CC
echo ">> go mod vendor"; "$GO" mod vendor
echo ">> overlay certstore shim"
@@ -45,9 +69,34 @@ if [ -d vendor/fyne.io/systray ]; then
echo ">> overlay systray shim"; cp "$ROOT/overlays/systray_darwin.m" vendor/fyne.io/systray/systray_darwin.m
fi
+
# 3. Build each binary. -linkmode=external routes even pure-Go binaries through go.env's min-10.9 CC
# wrapper (Go 1.26 internal-links them to a 12.0 floor otherwise -- see mavericks-golang).
export CGO_ENABLED=1 GOARCH=amd64 GOFLAGS=-mod=vendor
+if [ "$FLOOR" = 10.6 ]; then
+ # Snow Leopard floor: min-10.6 CC wrapper (with our crt1.10.6.o — 10.6 dyld
+ # needs a classic _start) + GOAMD64=v1 + the 10.6 symbol package. The symbol
+ # implementations must be cgo C code (a relocatable object on the link line):
+ # Go references them only via dynamic-bind entries, which an archive member
+ # cannot win. CC env overrides go.env's default wrapper.
+ GO_ABS="$(command -v "$GO")"
+ export MAVERICKS_GO_PREFIX="$(cd "$(dirname "$GO_ABS")/.." && pwd)"
+ export MAVERICKS_LEGACY106_ROOT="$ROOT/cmake/legacy106"
+ LEGACY106_A="$WORK/liblegacy106.a"
+ export MAVERICKS_LEGACY106_A="$LEGACY106_A"
+ export CC="$ROOT/cmake/legacy106/mavericks-cross-clang-106"
+ # stubs.c touches DIR internals (__dd_fd): compile it against the SAME
+ # prepared 10.6-floor sysroot every other object uses, not the host SDK.
+ SDK106=$(sh "$MAVERICKS_LEGACY106_ROOT/prepare_sdk106.sh" "$MAVERICKS_GO_PREFIX" "$ROOT")
+ /usr/bin/clang -arch x86_64 -isysroot "$SDK106" -mmacosx-version-min=10.6 \
+ -c "$ROOT/overlays/legacy106/stubs.c" -o "$WORK/stubs106.o"
+ rm -f "$LEGACY106_A"
+ ar rcs "$LEGACY106_A" "$WORK/stubs106.o"
+ # darwin_10_6 gates the legacy106 package definitions to this floor only
+ # (see the tag comment in overlays/legacy106/legacy106.go).
+ export GOFLAGS="-mod=vendor -tags=darwin_10_6"
+ export GOAMD64=v1
+fi
# Stamp BOTH version strings to the clean upstream semver (e.g. 1.98.8). Without a stamp, tailscale
# derives the version from module VCS info -- stripped in our build -- and prints "-ERR-BuildInfo".
# We deliberately report the plain upstream version to the control server (Hostinfo.IPNVersion is
diff --git a/cmake/build_updater106.sh b/cmake/build_updater106.sh
new file mode 100755
index 0000000..7b75070
--- /dev/null
+++ b/cmake/build_updater106.sh
@@ -0,0 +1,55 @@
+#!/bin/sh
+# build_updater106.sh — build the 10.6-native TailscaleUpdater.app.
+# Pure ObjC, no external framework, no Go runtime — compiled directly
+# with /usr/bin/clang against the prepared 10.6 SDK.
+set -eu
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+OUT="$1"; VER="${2:-1.0}"
+# Only probe PATH when the override is unset (command -v fails hard
+# under set -e when go isn't found, making the override unreachable).
+if [ -n "${MAVERICKS_GO_PREFIX:-}" ]; then
+ PREFIX="$MAVERICKS_GO_PREFIX"
+else
+ GO_BIN="$(command -v go 2>/dev/null)" || {
+ echo 'build_updater106: go not on PATH and MAVERICKS_GO_PREFIX not set' >&2; exit 1; }
+ PREFIX="$(cd "$(dirname "$GO_BIN")/.." && pwd)"
+fi
+
+SDK=$(sh "$ROOT/cmake/legacy106/prepare_sdk106.sh" "$PREFIX" "$ROOT")
+APP="$OUT/TailscaleUpdater.app"
+mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
+
+# The feed URL is build-configurable so forks get the right repo.
+# Default: derive from the git remote, fall back to the upstream repo.
+REPO="$(git -C "$ROOT" remote get-url origin 2>/dev/null \
+ | sed 's|.*github.com[:/]||;s|\.git$||' || true)"
+[ -n "$REPO" ] || REPO="startergo/tailscale-legacy"
+FEED_URL="${UPDATER_FEED_URL:-https://github.com/${REPO}/releases/latest/download/appcast-10.6.xml}"
+
+/usr/bin/clang -arch x86_64 -isysroot "$SDK" -mmacosx-version-min=10.6 \
+ -x objective-c -fno-objc-arc \
+ -Wno-deprecated-declarations -Wno-format-security \
+ -DUPDATER_FEED_URL="\"$FEED_URL\"" \
+ -framework Cocoa \
+ -o "$APP/Contents/MacOS/TailscaleUpdater" \
+ "$ROOT/updater/updater106.m"
+
+cat > "$APP/Contents/Info.plist" <
+
+
+
+ CFBundleName TailscaleUpdater
+ CFBundleIdentifier dev.modernmavericks.TailscaleUpdater
+ CFBundleVersion $VER
+ CFBundlePackageType APPL
+ CFBundleExecutable TailscaleUpdater
+ CFBundleInfoDictionaryVersion6.0
+ NSPrincipalClass NSApplication
+ LSUIElement
+ LSMinimumSystemVersion 10.6
+
+
+PLIST
+
+echo "OK: $APP"
diff --git a/cmake/legacy106/mavericks-cross-clang-106 b/cmake/legacy106/mavericks-cross-clang-106
new file mode 100755
index 0000000..6ef2b74
--- /dev/null
+++ b/cmake/legacy106/mavericks-cross-clang-106
@@ -0,0 +1,28 @@
+#!/bin/sh
+# Cross CC for the 10.6 (Snow Leopard) floor: identical to the toolchain's
+# mavericks-cross-clang (same 10.9 SDK, same arch, same -Wl,-U allowances,
+# same MacPorts legacy archive) except:
+# -mmacosx-version-min=10.6 (LC_VERSION_MIN 10.6; 10.6 dyld refuses newer)
+# + libLegacy106Support.a (the 10.6-only symbols, see overlays/legacy106/)
+#
+# The toolchain root arrives via MAVERICKS_GO_PREFIX (set by build_tailscale.sh
+# from the go binary it was given). Flags are passed explicitly per invocation
+# (never through an unquoted multi-argument string -- an SDK cache path with a
+# space would split it).
+set -eu
+PREFIX="${MAVERICKS_GO_PREFIX:?MAVERICKS_GO_PREFIX must point at the go toolchain root}"
+LEGACY106_A="${MAVERICKS_LEGACY106_A:?MAVERICKS_LEGACY106_A must point at the built stubs archive}"
+SDK=$(sh "${MAVERICKS_LEGACY106_ROOT:?}/prepare_sdk106.sh" "$PREFIX" "${MAVERICKS_LEGACY106_ROOT%/cmake/legacy106}")
+for a in "$@"; do
+ case "$a" in -c|-E|-S)
+ exec /usr/bin/clang -arch x86_64 -isysroot "$SDK" -mmacosx-version-min=10.6 "$@"
+ ;;
+ esac
+done
+exec /usr/bin/clang -arch x86_64 -isysroot "$SDK" -mmacosx-version-min=10.6 "$@" \
+ "$PREFIX/lib/libMacportsLegacySupport.a" \
+ "$LEGACY106_A" \
+ -Wl,-U,_SecTrustEvaluateWithError \
+ -Wl,-U,_SecTrustCopyCertificateChain \
+ -Wl,-U,_notify_is_valid_token \
+ -Wl,-U,_xpc_date_create_from_current
diff --git a/cmake/legacy106/prepare_sdk106.sh b/cmake/legacy106/prepare_sdk106.sh
new file mode 100644
index 0000000..033ca25
--- /dev/null
+++ b/cmake/legacy106/prepare_sdk106.sh
@@ -0,0 +1,126 @@
+#!/bin/sh
+# prepare_sdk106.sh — assemble the 10.6-floor sysroot: the toolchain's verified
+# MacOSX10.9 SDK (fetched/cached by its own fetch_sdk.sh) plus our assembled
+# crt1.10.6.o (from overlays/legacy106/crt106.S) in usr/lib, where ld searches.
+# Prints the sdk106 root. Cached per-machine; rebuilt if inputs change.
+#
+# The stamp fingerprints BOTH inputs: the source SDK path and the sha256 of
+# crt106.S (so editing the startup source rebuilds the object), and the crt
+# object's presence is re-verified every call.
+#
+# Locking: the CC wrapper runs this from every parallel cgo compile, so the
+# rebuild is guarded by a mkdir lock with the ownership PID recorded INSIDE
+# the lock dir (where no competitor can overwrite it). Protocol: (a) only
+# the owner removes its own lock (EXIT trap); (b) a dead holder's lock is
+# broken via kill -0 on the recorded PID; (c) the mkdir-to-echo gap is
+# covered by patient waiting (empty lock = holder is mid-stamp), with a
+# long-timeout fallback for a holder killed exactly in the gap; (d) the
+# need-check re-runs inside the acquired lock so a waiter that slept
+# through the previous holder's completion does not rebuild a fresh tree.
+#
+# $1 = go toolchain prefix (for libexec/fetch_sdk.sh)
+# $2 = repo root (for overlays/legacy106/crt106.S)
+set -eu
+PREFIX="$1"; ROOT="$2"
+CACHE="${MAVERICKS_SDK106_CACHE:-$HOME/Library/Caches/mavericks-sdk106}"
+SDK109="$(sh "$PREFIX/libexec/fetch_sdk.sh")"
+CRT_SRC="$ROOT/overlays/legacy106/crt106.S"
+CRT_SHA="$(shasum -a 256 "$CRT_SRC" | cut -d' ' -f1)"
+
+# Key the cache path on the input hash so concurrent builds using
+# DIFFERENT toolchain/SDK/crt inputs never collide on the same directory
+# (a build with input A must not rm -rf the sysroot a build with input B
+# just assembled and is actively using).
+INPUT_KEY=$(printf '%s\n%s' "$SDK109" "$CRT_SHA" | shasum -a 256 | cut -c1-16)
+SDK="$CACHE/sdk106-$INPUT_KEY"
+STAMP="$SDK/.legacy106-stamp"
+LOCK="$CACHE/.sdk106-$INPUT_KEY.lock"
+
+satisfied() {
+ [ -f "$STAMP" ] && [ "$(cat "$STAMP")" = "$SDK109:$CRT_SHA" ] \
+ && [ -f "$SDK/usr/lib/crt1.10.6.o" ]
+}
+
+if satisfied; then
+ echo "$SDK"
+ exit 0
+fi
+
+mkdir -p "$CACHE"
+i=0; lock_err=0
+until mkdir "$LOCK" 2>/dev/null; do
+ # Distinguish 'lock exists' from 'cannot create'. If the lock no longer
+ # exists (a competitor just finished), retry; only fail on persistent
+ # permission/filesystem errors.
+ if [ ! -d "$LOCK" ]; then
+ sleep 1 # give a finishing competitor's cleanup a moment
+ [ -d "$LOCK" ] && continue # was a race; retry acquisition
+ # Still gone after sleep: could be permissions OR another race.
+ # Retry up to 3 times, then fail (persistent filesystem error).
+ lock_err=$((lock_err + 1))
+ if [ "$lock_err" -gt 3 ]; then
+ echo "prepare_sdk106: cannot create lock ${LOCK} (persistent error)" >&2
+ exit 1
+ fi
+ continue
+ fi
+ lock_err=0
+ if [ -f "$LOCK/pid" ]; then
+ # Ownership record exists: break only if the holder process is dead.
+ # (Edge case: PID reuse could make a dead holder look alive; accepted
+ # risk on macOS where PIDs cycle slowly.)
+ HOLDER_PID=$(cat "$LOCK/pid" 2>/dev/null || echo 0)
+ if [ "${HOLDER_PID:-0}" -gt 0 ] && ! kill -0 "$HOLDER_PID" 2>/dev/null; then
+ # Atomically CLAIM the stale lock by renaming it (only ONE process
+ # can rename a given directory; the loser's mv fails and it goes
+ # back to waiting). The old rm -rf approach raced: two waiters could
+ # both remove the stale lock, then one would delete the OTHER's
+ # freshly-acquired lock, and both would rebuild concurrently.
+ # Fall through to timeout+sleep on failure (a bare 'continue' would
+ # skip both, causing a busy loop when the rename persistently fails).
+ if mv "$LOCK" "$LOCK.stale.$$" 2>/dev/null; then
+ rm -rf "$LOCK.stale.$$" 2>/dev/null || true
+ continue
+ fi
+ fi
+ fi
+ # Empty lock (holder in the mkdir-to-echo gap): WAIT, do not evict.
+ # Evicting an unowned lock can break a live holder that was paused
+ # (SIGSTOP, debugger, swap) before writing its PID, leading to two
+ # concurrent rebuilds. The general timeout (600s) handles the "killed
+ # in the gap" case conservatively by failing the build rather than
+ # risking concurrent access.
+ i=$((i + 1))
+ # 600s total: the empty-lock fallback needs 300 iterations to fire, so
+ # the general timeout must exceed it (a 120s timeout would exit before
+ # the fallback could ever trigger).
+ if [ "$i" -gt 600 ]; then
+ # NEVER evict the lock — even an empty one could be a live holder
+ # paused (SIGSTOP, debugger, swap) in the mkdir-to-PID gap. Evicting
+ # it would allow a second rebuild to rm -rf the sysroot under the
+ # paused holder. Fail cleanly; a human can remove the lock manually.
+ echo "prepare_sdk106: lock timeout (${LOCK})." >&2
+ echo "prepare_sdk106: if no other build is running, remove ${LOCK} manually." >&2
+ exit 1
+ fi
+ sleep 1
+done
+
+# Lock acquired -- stamp ownership immediately (nanoseconds after mkdir;
+# the empty-lock patience above covers this gap for waiters).
+trap 'rm -rf "$LOCK" 2>/dev/null || true' EXIT
+echo $$ > "$LOCK/pid"
+
+# Re-check INSIDE the lock: a waiter that slept through the previous holder's
+# completion would otherwise rebuild (and rm -rf) the tree it just built.
+if satisfied; then
+ echo "$SDK"
+ exit 0
+fi
+
+rm -rf "$SDK"
+cp -R "$SDK109" "$SDK"
+/usr/bin/clang -arch x86_64 -mmacosx-version-min=10.6 -c \
+ "$CRT_SRC" -o "$SDK/usr/lib/crt1.10.6.o"
+echo "$SDK109:$CRT_SHA" > "$STAMP"
+echo "$SDK"
diff --git a/cmake/make_app.sh b/cmake/make_app.sh
index 943a577..5c0bc4a 100644
--- a/cmake/make_app.sh
+++ b/cmake/make_app.sh
@@ -1,11 +1,13 @@
#!/bin/sh
-# make_app.sh [icon.icns]
+# make_app.sh [icon.icns] [min-macos]
+# min-macos: LSMinimumSystemVersion stamped into Info.plist (default 10.9;
+# the legacy106 floor passes 10.6)
# Wrap the tailscale-systray Go binary in a menu-bar-only (LSUIElement) .app. No ObjC -- the Go binary
# IS the app; the bundle just gives it an Info.plist so LaunchServices treats it as a menu-bar agent.
# An optional .icns is installed as the bundle icon (CFBundleIconFile) so Finder/About show the Tailscale
# logo instead of the generic app icon; omit it and the bundle simply has no custom icon.
set -eu
-BIN=$1; APP=$2; VER=$3; ICON=${4:-}
+BIN=$1; APP=$2; VER=$3; ICON=${4:-}; MIN=${5:-10.9}
rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
install -m 0755 "$BIN" "$APP/Contents/MacOS/tailscale-systray"
@@ -29,7 +31,7 @@ ${ICON_PLIST} CFBundleVersion${VER}
CFBundleShortVersionString${VER}
CFBundlePackageTypeAPPL
LSUIElement
- LSMinimumSystemVersion10.9
+ LSMinimumSystemVersion${MIN}
PLIST
diff --git a/cmake/package_pkg_legacy106.sh b/cmake/package_pkg_legacy106.sh
new file mode 100755
index 0000000..54a6219
--- /dev/null
+++ b/cmake/package_pkg_legacy106.sh
@@ -0,0 +1,159 @@
+#!/bin/sh
+# package_pkg_legacy106.sh — the Snow Leopard floor variant of package_pkg.sh.
+# Differences from the 10.9 product:
+# - hard install floor 10.6 (vs 10.9.5)
+# - NO Sparkle updater (not yet validated on 10.6): the postinstall already
+# tolerates its absence (agent-load.sh optional), and the systray's
+# "Check for updates" item simply has no helper to hand off to.
+# Payload layout is identical to the 10.9 product (native darwin paths:
+# tailscaled in /usr/local/sbin, state under /Library/Tailscale).
+#
+# Usage: package_pkg_legacy106.sh --out PKG --version V --tailscaled BIN --tailscale BIN \
+# --systray-app APP.app --daemon-plist PLIST --systray-agent PLIST --dist DIR
+#
+# Wired into release.yml (the 10.6 packaging step) and usable standalone. To build a Snow Leopard pkg manually (CI does this automatically) after 'cmake --preset
+# cross-legacy && cmake --build --preset cross-legacy':
+# SHIPYARD_SCRIPTS=/scripts sh cmake/package_pkg_legacy106.sh \
+# --out tailscale-1.102.4-mavericks-legacy106.pkg --version 1.102.4 \
+# --tailscaled build-cross-legacy/gobin/tailscaled \
+# --tailscale build-cross-legacy/gobin/tailscale \
+# --systray-app \
+# --daemon-plist dist/com.tailscale.tailscaled.plist \
+# --systray-agent dist/com.tailscale.systray.plist --dist dist
+set -eu
+export COPYFILE_DISABLE=1
+OUT=""; VER=""; TSD=""; TS=""; SYSTRAY=""; DAEMON=""; AGENT=""; DIST=""; UPD_APP=""
+SHIPYARD="${SHIPYARD_SCRIPTS:-}"
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --out) OUT="$2"; shift 2;; --version) VER="$2"; shift 2;;
+ --tailscaled) TSD="$2"; shift 2;; --tailscale) TS="$2"; shift 2;;
+ --systray-app) SYSTRAY="$2"; shift 2;; --updater-app) UPD_APP="$2"; shift 2;;
+ --daemon-plist) DAEMON="$2"; shift 2;;
+ --systray-agent) AGENT="$2"; shift 2;; --dist) DIST="$2"; shift 2;;
+ *) echo "package_pkg_legacy106: unknown arg: $1" >&2; exit 2;;
+ esac
+done
+[ -n "$OUT" ] && [ -n "$VER" ] && [ -n "$TSD" ] && [ -n "$TS" ] && [ -n "$SYSTRAY" ] \
+ && [ -n "$DAEMON" ] && [ -n "$AGENT" ] && [ -n "$DIST" ] \
+ || { echo "package_pkg_legacy106: need --out --version --tailscaled --tailscale --systray-app --daemon-plist --systray-agent --dist" >&2; exit 2; }
+# Updater is optional (the no-updater path is supported for CI/debug builds)
+UPD_APPDIR="/Library/Application Support/ModernMavericks"
+if [ -n "${UPD_APP:-}" ]; then
+ [ -d "$UPD_APP" ] || { echo "package_pkg_legacy106: --updater-app must be an .app bundle dir: $UPD_APP" >&2; exit 1; }
+fi
+[ -n "$SHIPYARD" ] || { echo "package_pkg_legacy106: SHIPYARD_SCRIPTS not set" >&2; exit 2; }
+
+for h in set_install_floor.sh build_component_pkg.sh assert_pkg_installs_in_place.sh \
+ postinstall-stop-gui.sh assert_gui_relaunch_safe.sh; do
+ [ -f "$SHIPYARD/$h" ] || { echo "package_pkg_legacy106: shared helper missing: $SHIPYARD/$h" >&2; exit 1; }; done
+for f in "$TSD" "$TS" "$DAEMON" "$AGENT" "$DIST/scripts/preinstall" "$DIST/scripts/postinstall"; do
+ [ -f "$f" ] || { echo "package_pkg_legacy106: missing input (or not a regular file): $f" >&2; exit 1; }; done
+[ -d "$SYSTRAY" ] || { echo "package_pkg_legacy106: --systray-app must be an .app bundle directory, got: $SYSTRAY" >&2; exit 1; }
+
+IDENT="dev.modernmavericks.tailscale"
+
+WORK=$(mktemp -d "${TMPDIR:-/tmp}/tailscale-pkg106.XXXXXX")
+trap 'rm -rf "$WORK"' EXIT
+stage="$WORK/stage"; scripts="$WORK/scripts"; comp="$WORK/component.pkg"
+
+# --- product payload (identical layout to the 10.9 product) ---
+mkdir -p "$stage/usr/local/sbin" "$stage/usr/local/bin" "$stage/Applications" \
+ "$stage/Library/LaunchDaemons" "$stage/Library/LaunchAgents"
+install -m 0755 "$TSD" "$stage/usr/local/sbin/tailscaled"
+install -m 0755 "$TS" "$stage/usr/local/bin/tailscale"
+cp -R "$SYSTRAY" "$stage/Applications/Mavericks Tailscale.app"
+install -m 0644 "$DAEMON" "$stage/Library/LaunchDaemons/com.tailscale.tailscaled.plist"
+install -m 0644 "$AGENT" "$stage/Library/LaunchAgents/com.tailscale.systray.plist"
+
+# Updater: our own 10.6-native updater (replaces the Sparkle-based one the
+# 10.9 product ships -- Sparkle's binary declares min-10.9).
+# The daily update-check agent mirrors the 10.9 product's schedule.
+if [ -n "${UPD_APP:-}" ]; then
+ mkdir -p "$stage$UPD_APPDIR" "$stage/Library/LaunchAgents"
+ rm -rf "$stage$UPD_APPDIR/$(basename "$UPD_APP")"
+ cp -R "$UPD_APP" "$stage$UPD_APPDIR/"
+ sed -e "s#@MAVERICKS_AGENT_LABEL@#com.tailscale.updatecheck#g" \
+ -e "s#@MAVERICKS_UPDATER_INSTALLED_EXEC@#$UPD_APPDIR/$(basename "$UPD_APP")/Contents/MacOS/TailscaleUpdater#g" \
+ "$DIST/../updater/updatecheck.plist.in" > "$stage/Library/LaunchAgents/com.tailscale.updatecheck.plist" 2>/dev/null \
+ || cat > "$stage/Library/LaunchAgents/com.tailscale.updatecheck.plist" <
+
+
+
+ Labelcom.tailscale.updatecheck
+ ProgramArguments
+
+ $UPD_APPDIR/$(basename "$UPD_APP")/Contents/MacOS/TailscaleUpdater
+ --background
+
+ RunAtLoad
+ StartInterval86400
+
+
+UPDPLIST
+fi
+
+# --- install scripts (no-updater build: agent-load.sh is simply absent) ---
+# The staged preinstall additionally REMOVES a 10.9 product's updater leftovers
+# (this build ships none): keeping them would leave a daily update-check agent
+# relaunching an updater that upgrades the box back to a 10.9-floor build.
+mkdir -p "$scripts"
+# Compose preinstall: shebang + the original script (updater cleanup
+# and agent loading belong in POSTINSTALL, after the payload is laid down).
+{
+ head -1 "$DIST/scripts/preinstall"
+ tail -n +2 "$DIST/scripts/preinstall"
+} > "$scripts/preinstall"
+chmod 0755 "$scripts/preinstall"
+
+# Compose postinstall: the original + updater agent load (if shipped) +
+# old-updater cleanup (if this is a no-updater build replacing a 10.9 install).
+{
+ # Strip trailing 'exit 0' — appended blocks below must execute.
+ sed '/^exit 0$/d' "$DIST/scripts/postinstall"
+ if [ -n "${UPD_APP:-}" ]; then
+ cat <<'POSTAGENT'
+# legacy106: load the daily update-check agent for the console user.
+CONSOLE_UID=$(stat -f %u /dev/console 2>/dev/null || echo 0)
+if [ "${CONSOLE_UID:-0}" -gt 0 ]; then
+ launchctl asuser "$CONSOLE_UID" launchctl load \
+ /Library/LaunchAgents/com.tailscale.updatecheck.plist 2>/dev/null || true
+fi
+POSTAGENT
+ else
+ cat <<'POSTCLEAN'
+# legacy106 (no-updater build): remove any 10.9-product updater leftovers
+# so the old daily update agent cannot relaunch and downgrade this install.
+CONSOLE_UID=$(stat -f %u /dev/console 2>/dev/null || echo 0)
+if [ "${CONSOLE_UID:-0}" -gt 0 ]; then
+ launchctl asuser "$CONSOLE_UID" launchctl unload \
+ /Library/LaunchAgents/com.tailscale.updatecheck.plist 2>/dev/null || true
+fi
+rm -f /Library/LaunchAgents/com.tailscale.updatecheck.plist
+rm -rf "/Library/Application Support/ModernMavericks/TailscaleUpdater.app"
+rm -rf "/Library/Application Support/ModernMavericks/TailscaleUpdater106.app"
+POSTCLEAN
+ fi
+} > "$scripts/postinstall"
+chmod 0755 "$scripts/postinstall"
+# NOTE: postinstall was already composed above (original + agent/cleanup)
+install -m 0644 "$SHIPYARD/postinstall-stop-gui.sh" "$scripts/stop-gui.sh"
+
+sh "$SHIPYARD/assert_gui_relaunch_safe.sh" "$scripts/postinstall" >&2
+sh -n "$scripts/postinstall" || { echo "package_pkg_legacy106: postinstall syntax error" >&2; exit 1; }
+sh -c '. "$1"; command -v mav_stop_gui_instance >/dev/null' _ "$scripts/stop-gui.sh" \
+ || { echo "package_pkg_legacy106: staged stop-gui.sh broken" >&2; exit 1; }
+
+# --- flat component pkg, install-in-place ---
+find "$stage" -name '._*' -delete 2>/dev/null || true
+sh "$SHIPYARD/build_component_pkg.sh" --root "$stage" --identifier "$IDENT" --version "$VER" \
+ --install-location / --scripts "$scripts" --out "$comp" >&2
+
+# --- product archive with the hard 10.6 OS floor ---
+sh "$SHIPYARD/set_install_floor.sh" \
+ --identifier "$IDENT" --title "Tailscale (Snow Leopard floor) $VER" \
+ --component "$comp" --out "$OUT" --require-scripts --min-os 10.6 >&2
+
+sh "$SHIPYARD/assert_pkg_installs_in_place.sh" "$OUT" >&2
+echo "$OUT"
diff --git a/cmake/tailscale_go.cmake b/cmake/tailscale_go.cmake
index 39e2925..3a787cd 100644
--- a/cmake/tailscale_go.cmake
+++ b/cmake/tailscale_go.cmake
@@ -25,6 +25,39 @@ endfunction()
mavericks_tailscale_read_pin("${CMAKE_SOURCE_DIR}/components/tailscale" TS_REPO TS_REF TS_DIGEST)
set(TS_SRC "${MAVERICKS_TAILSCALE_SRC_CACHE}/tailscale-${TS_REF}")
+# Deployment floor: 10.9 (default, stock gate) or 10.6 (Snow Leopard: GOAMD64=v1 +
+# 10.6 symbol archive + min-10.6 CC wrapper + the legacy106 gate). Preset sets this.
+set(MAVERICKS_TAILSCALE_FLOOR "10.9" CACHE STRING "macOS deployment floor: 10.9 or 10.6")
+if(NOT MAVERICKS_TAILSCALE_FLOOR MATCHES "^10\\.(9|6)$")
+ message(FATAL_ERROR "MAVERICKS_TAILSCALE_FLOOR must be 10.9 or 10.6, got '${MAVERICKS_TAILSCALE_FLOOR}'")
+endif()
+
+# The custom command's outputs are floor-independent paths, so re-pointing an existing build
+# directory at the other floor would silently test/package the previous floor's binaries
+# (same outputs, dependencies unchanged -> no rebuild). Fail closed instead: each build dir
+# records its floor at first configure and refuses a different one. Presets use separate
+# dirs (build-cross / build-cross-legacy) and never trip this.
+set(_floor_marker "${CMAKE_BINARY_DIR}/.tailscale-floor")
+if(EXISTS "${_floor_marker}")
+ file(READ "${_floor_marker}" _recorded)
+ string(STRIP "${_recorded}" _recorded)
+ if(NOT _recorded STREQUAL MAVERICKS_TAILSCALE_FLOOR)
+ message(FATAL_ERROR "this build dir was configured for floor ${_recorded}; "
+ "changing MAVERICKS_TAILSCALE_FLOOR to ${MAVERICKS_TAILSCALE_FLOOR} here would reuse stale "
+ "floor-${_recorded} binaries. Configure a fresh build dir (the presets do: build-cross vs "
+ "build-cross-legacy).")
+ endif()
+else()
+ # First configure: reject a pre-marker dir that already has binaries (they
+ # are from a different floor and would be silently reused as stale outputs).
+ if(EXISTS "${CMAKE_BINARY_DIR}/gobin/tailscaled")
+ message(FATAL_ERROR "this build dir contains existing binaries but no floor "
+ "marker -- they may be from a different floor. Delete the build dir and "
+ "reconfigure (the presets use fresh dirs: build-cross vs build-cross-legacy).")
+ endif()
+ file(WRITE "${_floor_marker}" "${MAVERICKS_TAILSCALE_FLOOR}\n")
+endif()
+
# 1. Clone the pinned source, verified against the commit DIGEST (shipyard's clone_pinned.sh bails
# on a mismatch -- moved tag, MITM). Idempotent: no-ops on a cache hit.
add_custom_command(
@@ -34,6 +67,36 @@ add_custom_command(
VERBATIM)
# 2. Build the three binaries. Rebuilds when the script, our patches/overlays, or the pin change.
+#
+# Build-graph correctness: EVERYTHING build_tailscale.sh reads must be a dependency, and it reads
+# all of patches/ and overlays/ (plus the legacy106 tooling). Two stale-binary bugs came from
+# hand-maintaining this list (a new patch file; then legacy106.go), and a first glob attempt put
+# the file() calls INSIDE add_custom_command where CMake silently swallows them as junk arguments
+# (configure succeeds, nothing globs) -- so they run here, at their own statement level.
+# LIST_DIRECTORIES false: GLOB_RECURSE includes directories by default (e.g. overlays/legacy106/),
+# which make useless or broken DEPENDS entries (especially with Ninja).
+# CONFIGURE_DEPENDS: a file()d glob is evaluated at generate time, so CMake re-runs generate on
+# build when a matching file is added/removed -- the coverage actually tracks new patches.
+file(GLOB_RECURSE MAVERICKS_TAILSCALE_PATCHES CONFIGURE_DEPENDS LIST_DIRECTORIES false "${CMAKE_SOURCE_DIR}/patches/*")
+file(GLOB_RECURSE MAVERICKS_TAILSCALE_OVERLAYS CONFIGURE_DEPENDS LIST_DIRECTORIES false "${CMAKE_SOURCE_DIR}/overlays/*")
+file(GLOB MAVERICKS_TAILSCALE_LEGACY106_TOOLS CONFIGURE_DEPENDS LIST_DIRECTORIES false "${CMAKE_SOURCE_DIR}/cmake/legacy106/*")
+# CONFIGURE_DEPENDS re-globs on build, but a SHRINKING dependency list does
+# not necessarily dirty the output (nothing that still exists changed). A
+# manifest stamp does: any addition OR removal changes its content, which
+# dirties the stamp file, which is an explicit dependency below.
+set(MAVERICKS_TAILSCALE_INPUT_MANIFEST "${CMAKE_BINARY_DIR}/.tailscale-input-manifest")
+# Write only when content changes: an unconditional file(WRITE) bumps the
+# mtime on every configure, dirtying all outputs even when nothing changed.
+set(_new_manifest "${MAVERICKS_TAILSCALE_PATCHES};${MAVERICKS_TAILSCALE_OVERLAYS};${MAVERICKS_TAILSCALE_LEGACY106_TOOLS}")
+if(EXISTS "${MAVERICKS_TAILSCALE_INPUT_MANIFEST}")
+ file(READ "${MAVERICKS_TAILSCALE_INPUT_MANIFEST}" _old_manifest)
+ if(NOT _old_manifest STREQUAL _new_manifest)
+ file(WRITE "${MAVERICKS_TAILSCALE_INPUT_MANIFEST}" "${_new_manifest}")
+ endif()
+else()
+ file(WRITE "${MAVERICKS_TAILSCALE_INPUT_MANIFEST}" "${_new_manifest}")
+endif()
+
set(TS_GOBIN "${CMAKE_BINARY_DIR}/gobin")
set(TS_BINS "${TS_GOBIN}/tailscaled" "${TS_GOBIN}/tailscale" "${TS_GOBIN}/tailscale-systray")
add_custom_command(
@@ -41,23 +104,28 @@ add_custom_command(
COMMAND sh "${CMAKE_SOURCE_DIR}/cmake/build_tailscale.sh"
"${TS_SRC}" "${TS_GOBIN}" "${MAVERICKS_TAILSCALE_GO}"
"${CMAKE_SOURCE_DIR}" "${MAVERICKS_TAILSCALE_VERSION}"
+ "${MAVERICKS_TAILSCALE_FLOOR}"
DEPENDS "${TS_SRC}/.git/HEAD"
+ "${MAVERICKS_TAILSCALE_INPUT_MANIFEST}"
"${CMAKE_SOURCE_DIR}/cmake/build_tailscale.sh"
- "${CMAKE_SOURCE_DIR}/patches/systray.go.patch"
- "${CMAKE_SOURCE_DIR}/patches/tailscaled.go.patch"
- "${CMAKE_SOURCE_DIR}/patches/hostinfo_darwin.go.patch"
- "${CMAKE_SOURCE_DIR}/overlays/certstore_darwin.go.patch"
- "${CMAKE_SOURCE_DIR}/overlays/systray_darwin.m"
+ ${MAVERICKS_TAILSCALE_PATCHES}
+ ${MAVERICKS_TAILSCALE_OVERLAYS}
+ ${MAVERICKS_TAILSCALE_LEGACY106_TOOLS}
"${CMAKE_SOURCE_DIR}/components/tailscale/version"
- COMMENT "cross-building tailscaled / tailscale / tailscale-systray for 10.9"
+ COMMENT "cross-building tailscaled / tailscale / tailscale-systray for ${MAVERICKS_TAILSCALE_FLOOR}"
VERBATIM)
add_custom_target(tailscale_binaries ALL DEPENDS ${TS_BINS})
-# 3. Compat gate per binary: x86_64 + min-10.9 + _clock_gettime defined + no post-10.9 imports.
-# assert_binary_compatible.sh is the shipyard gate (honors MAVERICKS_REQUIRE_DEFINED_SYMBOLS);
-# the same script container-tools uses for its Go binaries.
+# 3. Compat gate per binary. 10.9: the shipyard gate (x86_64 + min-10.9 + _clock_gettime
+# defined + no post-10.9 imports). 10.6: our legacy106 twin (min == 10.6, the 10.6 symbol
+# set defined, and a POPCNT site-count tripwire for the GOAMD64=v1 baseline).
foreach(_b tailscaled tailscale tailscale-systray)
- add_test(NAME compat_guard_${_b}
- COMMAND ${CMAKE_COMMAND} -E env MAVERICKS_REQUIRE_DEFINED_SYMBOLS=_clock_gettime
- sh "${MavericksShipyard_SCRIPTS}/assert_binary_compatible.sh" "${TS_GOBIN}/${_b}")
+ if(MAVERICKS_TAILSCALE_FLOOR STREQUAL "10.6")
+ add_test(NAME legacy106_guard_${_b}
+ COMMAND sh "${CMAKE_SOURCE_DIR}/tests/assert_legacy106_compatible.sh" "${TS_GOBIN}/${_b}")
+ else()
+ add_test(NAME compat_guard_${_b}
+ COMMAND ${CMAKE_COMMAND} -E env MAVERICKS_REQUIRE_DEFINED_SYMBOLS=_clock_gettime
+ sh "${MavericksShipyard_SCRIPTS}/assert_binary_compatible.sh" "${TS_GOBIN}/${_b}")
+ endif()
endforeach()
diff --git a/overlays/legacy106/crt106.S b/overlays/legacy106/crt106.S
new file mode 100644
index 0000000..291eb9c
--- /dev/null
+++ b/overlays/legacy106/crt106.S
@@ -0,0 +1,30 @@
+/*
+ * crt106.S — minimal x86_64 C runtime startup for the 10.6 floor.
+ *
+ * Modern toolchains (min >= 10.8) emit LC_MAIN and embed startup in
+ * libclang_rt; 10.6's dyld instead requires a classic _start symbol, which
+ * Apple shipped as /usr/lib/crt1.10.6.o (dropped from every modern toolchain
+ * and absent from the phracker SDKs). ld searches for it by name, so we
+ * assemble this into /usr/lib/crt1.10.6.o at build time. Apple SDK
+ * bytes are never committed; neither is this needed to be theirs.
+ *
+ * Kernel hands _start the initial stack: [argc][argv...][NULL][envp...][NULL].
+ * Go's external linker provides `main`; we align the stack per the ABI, call
+ * it, and exit with its status. No keymgr/GCC-eh setup: a Go+cgo binary on
+ * 10.6 uses none of it (lazy binding resolves through dyld's own helper).
+ */
+
+.text
+.globl _start
+.globl start
+_start:
+start:
+ pop %rdi /* argc */
+ mov %rsp, %rsi /* argv */
+ lea 8(%rsi,%rdi,8), %rdx /* envp */
+ and $-16, %rsp
+ call _main
+ mov %eax, %edi
+ call _exit
+ hlt
+.subsections_via_symbols
diff --git a/overlays/legacy106/legacy106.go b/overlays/legacy106/legacy106.go
new file mode 100644
index 0000000..0af8d64
--- /dev/null
+++ b/overlays/legacy106/legacy106.go
@@ -0,0 +1,112 @@
+// Package legacy106 supplies the libSystem symbols a 10.6 (Snow Leopard) floor
+// needs beyond what the mavericks-golang toolchain already covers. Go's runtime
+// references these only through dynamic-bind entries, which an archive cannot
+// satisfy — as cgo C code the implementations land in a relocatable object on
+// the link line and win over the dynamic import.
+//
+// This file is copied into the tailscale source tree (wrksrc/legacy106) by
+// build_tailscale.sh and blank-imported from THREE roots, one per
+// binary: the peercred patch (ipn/ipnauth — covers tailscaled), the
+// systray patch (client/systray — the systray's graph does not reach
+// ipn/ipnauth), and the tailscaled patch (cmd/tailscale — the CLI's
+// graph does not reach ipn/ipnauth either). All three are needed. The build tag keeps every definition out of non-10.6
+// floors: on 10.9 the Security constants below would otherwise statically
+// satisfy certstore's references and shadow the REAL Security.framework
+// values. build_tailscale.sh adds -tags=darwin_10_6 only for the 10.6 floor.
+
+//go:build darwin_10_6
+
+package legacy106
+
+/*
+#cgo LDFLAGS: -framework CoreFoundation -lobjc
+
+#include
+#include
+
+// arc4random_buf: 10.7+; real implementation via the ancient arc4random().
+extern uint32_t arc4random(void);
+__attribute__((used)) void arc4random_buf(void *buf, unsigned long n) {
+ unsigned char *p = (unsigned char *)buf;
+ while (n > 0) {
+ uint32_t r = arc4random();
+ unsigned long take = n < 4 ? n : 4;
+ for (unsigned long i = 0; i < take; i++) { *p++ = (unsigned char)(r & 0xff); r >>= 8; }
+ n -= take;
+ }
+}
+
+// SecTransform-era Security constants (10.7+) referenced by certstore's C
+// code as ordinary undefineds — cgo relocatables resolve them here. The
+// transform-based sign path is MDM-only and dormant on a standard tailnet;
+// the exact string values only matter if that path ever runs (10.6's
+// Security matches dictionary keys by pointer anyway, and it has no
+// SecTransform to hand them to).
+const void *kSecDigestLengthAttribute = (const void *)CFSTR("DigestLength");
+const void *kSecDigestSHA2 = (const void *)CFSTR("SHA2");
+const void *kSecDigestTypeAttribute = (const void *)CFSTR("DigestType");
+const void *kSecInputIsAttributeName = (const void *)CFSTR("InputIs");
+const void *kSecInputIsDigest = (const void *)CFSTR("Digest");
+const void *kSecTransformInputAttributeName = (const void *)CFSTR("Input");
+
+// ARC runtime entry points (10.7+) that modern clang emits even for
+// -fno-objc-arc ObjC (bridging casts, strong-by-default compiler temp
+// retention). 10.6's libobjc has objc_msgSend and the GC-era property
+// helpers but none of these. Non-ARC semantics via message sends, NULL-safe
+// per the ARC contract; the *ReturnValue variants only differ under real
+// ARC's return-value optimization, which we cannot trigger from plain C.
+typedef void *objc_id;
+typedef void *objc_sel;
+extern objc_id objc_msgSend(objc_id, objc_sel);
+extern objc_sel sel_registerName(const char *);
+static objc_id l106_send(objc_id o, const char *n) {
+ return ((objc_id (*)(objc_id, objc_sel))objc_msgSend)(o, sel_registerName(n));
+}
+__attribute__((used)) void objc_release(objc_id obj) { if (obj) l106_send(obj, "release"); }
+__attribute__((used)) objc_id objc_retain(objc_id obj) { return obj ? l106_send(obj, "retain") : obj; }
+__attribute__((used)) objc_id objc_autorelease(objc_id obj) { return obj ? l106_send(obj, "autorelease") : obj; }
+__attribute__((used)) objc_id objc_retainAutoreleasedReturnValue(objc_id obj) { return objc_retain(obj); }
+__attribute__((used)) objc_id objc_autoreleaseReturnValue(objc_id obj) { return objc_autorelease(obj); }
+__attribute__((used)) void objc_storeStrong(objc_id *loc, objc_id obj) {
+ objc_id prev = *loc;
+ if (obj) objc_retain(obj);
+ *loc = obj;
+ if (prev) objc_release(prev);
+}
+
+// l106_used is a volatile pointer table referencing every function and
+// l106_root writes the address of every shim function and global into
+// a volatile pointer (each store is a spec-guaranteed observable side
+// effect the compiler cannot eliminate, and each address reference
+// prevents linker dead-code elimination of the target section).
+// __attribute__((used)) on each function additionally prevents
+// compiler-level elimination. A static-initializer array was tried and
+// failed: CFSTR globals are not compile-time constants.
+static void __attribute__((used)) l106_root(void) {
+ void * volatile sink;
+ sink = (void *)arc4random_buf;
+ sink = (void *)objc_release;
+ sink = (void *)objc_retain;
+ sink = (void *)objc_autorelease;
+ sink = (void *)objc_retainAutoreleasedReturnValue;
+ sink = (void *)objc_autoreleaseReturnValue;
+ sink = (void *)objc_storeStrong;
+ sink = (void *)kSecDigestLengthAttribute;
+ sink = (void *)kSecDigestSHA2;
+ sink = (void *)kSecDigestTypeAttribute;
+ sink = (void *)kSecInputIsAttributeName;
+ sink = (void *)kSecInputIsDigest;
+ sink = (void *)kSecTransformInputAttributeName;
+ (void)sink;
+}
+*/
+import "C"
+
+// init roots every C definition against dead-code elimination: the linker
+// strips unreferenced subsections, and nothing else in the binary
+// references these (that is the whole point of the package). init() of an
+// imported package always survives; the volatile stores in l106_root
+// create an unoptimizable reference chain to every shim symbol.
+func init() {
+ C.l106_root()
+}
diff --git a/overlays/legacy106/legacy106_off.go b/overlays/legacy106/legacy106_off.go
new file mode 100644
index 0000000..3c01827
--- /dev/null
+++ b/overlays/legacy106/legacy106_off.go
@@ -0,0 +1,7 @@
+//go:build !darwin_10_6
+
+// Placeholder for non-10.6 floors: every real definition in this package is
+// darwin_10_6-tagged (see legacy106.go for why), but a package with zero
+// buildable files is an import error -- so the blank imports in the patched
+// upstream files need this file to exist on every floor.
+package legacy106
diff --git a/overlays/legacy106/stubs.c b/overlays/legacy106/stubs.c
new file mode 100644
index 0000000..11e2e44
--- /dev/null
+++ b/overlays/legacy106/stubs.c
@@ -0,0 +1,50 @@
+/*
+ * legacy106/stubs.c — libSystem symbols present on 10.9 but missing on
+ * 10.6.8, linked in addition to the toolchain's MacPorts legacy-support
+ * archive (which covers the post-10.9 set: clock_gettime & friends).
+ *
+ * arc4random_buf is NOT defined here: it lives in the legacy106 Go package
+ * (overlays/legacy106/legacy106.go). Archive members are pulled whole, so a
+ * pull for any one of these symbols would also drag in a duplicate
+ * arc4random_buf and fail the link.
+ *
+ * pthread_main_thread_np not exported from 10.6 libSystem — real
+ * implementation: a constructor runs on the main
+ * thread before main(), so cache pthread_self()
+ * strnlen, dirfd POSIX, but absent from 10.6 libSystem
+ * (the $UNIX2003 era)
+ *
+ * (xpc_date_create_from_current / notify_is_valid_token are also NOT stubbed
+ * here: the mavericks-golang runtime never calls them — the CC wrapper's
+ * -Wl,-U allowances are exactly for those dormant imports.)
+ *
+ * Each function is correct on every macOS version, so defining them
+ * unconditionally is safe.
+ */
+
+#include
+static pthread_t legacy106_main_thread;
+__attribute__((constructor)) static void legacy106_cache_main_thread(void) {
+ legacy106_main_thread = pthread_self();
+}
+pthread_t pthread_main_thread_np(void) {
+ return legacy106_main_thread;
+}
+
+#include
+#include
+#include
+#undef strnlen
+#undef dirfd
+size_t strnlen(const char *s, size_t maxlen) {
+ const char *p = s;
+ while (maxlen-- > 0 && *p) p++;
+ return (size_t)(p - s);
+}
+int dirfd(DIR *dirp) {
+ if (dirp == NULL) {
+ errno = EBADF;
+ return -1;
+ }
+ return dirp->__dd_fd;
+}
diff --git a/patches/peercred-legacy-kernels.patch b/patches/peercred-legacy-kernels.patch
new file mode 100644
index 0000000..a609d27
--- /dev/null
+++ b/patches/peercred-legacy-kernels.patch
@@ -0,0 +1,67 @@
+Mac OS X 10.6 (Snow Leopard): the kernel does not implement the
+LOCAL_PEERPID socket option (getsockopt fails with EOPNOTSUPP, errno 102 --
+darwin keeps it distinct from ENOTSUP=45), so localapi's peercred.Get fails
+on every CLI connection and tailscaled answers 401 to everything. For exactly
+EOPNOTSUPP/ENOTSUP on darwin, treat the failure as "not implemented"
+(creds == nil) instead of rejecting the connection; any other peercred error
+still propagates. IsReadonlyConn grants read-write when creds are nil on
+darwin -- the pre-peercred behavior, the socket's permissions remaining the
+only local gate. macOS versions that implement peer credentials never hit
+either path.
+
+Also blank-imports tailscale.com/legacy106 (the 10.6 libSystem/Security
+shims) from this file. The package's contents are darwin_10_6-tagged, so on
+other floors the import compiles to nothing.
+
+--- ipn/ipnauth/ipnauth.go.orig
++++ ipn/ipnauth/ipnauth.go
+@@ -176,6 +176,14 @@
+ }
+ creds := ci.creds
+ if creds == nil {
++ if runtime.GOOS == "darwin" {
++ // macOS 10.6/10.7 kernels cannot report unix-socket peer
++ // credentials (LOCAL_PEERCRED/LOCAL_PEERPID unimplemented).
++ // Grant read-write as in the pre-peercred era; the socket's
++ // permissions remain the only gate.
++ logf("connection from unknown peer; darwin legacy kernel, read-write")
++ return rw
++ }
+ logf("connection from unknown peer; read-only")
+ return ro
+ }
+--- ipn/ipnauth/ipnauth_unix_creds.go.orig
++++ ipn/ipnauth/ipnauth_unix_creds.go
+@@ -6,8 +6,15 @@
+ package ipnauth
+
+ import (
++ "errors"
+ "net"
++ "runtime"
++ "syscall"
+
++ // Blank-import the 10.6 symbol shims on darwin (inert on newer floors:
++ // the implementations are correct everywhere, just unreferenced).
++ _ "tailscale.com/legacy106"
++
+ "github.com/tailscale/peercred"
+ "tailscale.com/types/logger"
+ )
+@@ -24,6 +31,16 @@
+ } else if err == peercred.ErrNotImplemented {
+ // peercred.Get is not implemented on this OS (such as OpenBSD)
+ // Just leave creds as nil, as documented.
++ } else if runtime.GOOS == "darwin" &&
++ (errors.Is(err, syscall.EOPNOTSUPP) || errors.Is(err, syscall.ENOTSUP)) {
++ // macOS 10.6 kernels do not implement LOCAL_PEERPID: getsockopt
++ // then fails with EOPNOTSUPP (errno 102 on darwin, distinct from
++ // ENOTSUP's 45) on every connection -- probed on real 10.6.8
++ // hardware. Treat exactly that errno pair as "unimplemented" rather
++ // than rejecting the connection (IsReadonlyConn handles nil creds).
++ // Any OTHER failure on a modern Mac still propagates -- nil-creds
++ // read-write is a legacy-kernel accommodation, not a blanket trust
++ // fallback.
+ } else {
+ return nil, err
+ }
diff --git a/patches/systray-psn-classic-macos.patch b/patches/systray-psn-classic-macos.patch
new file mode 100644
index 0000000..540a8c0
--- /dev/null
+++ b/patches/systray-psn-classic-macos.patch
@@ -0,0 +1,36 @@
+Classic macOS (10.8 and earlier): LaunchServices launches apps with a Carbon-era
+-psn__ ProcessSerialNumber argument; flag.Parse treats it as an unknown
+flag and exits, so Finder launches died instantly (running the binary directly
+worked). Strip -psn_* arguments before parsing.
+
+--- cmd/systray/systray.go.orig
++++ cmd/systray/systray.go
+@@ -8,6 +8,8 @@
+
+ import (
+ "flag"
++ "os"
++ "strings"
+
+ "tailscale.com/client/local"
+ "tailscale.com/client/systray"
+@@ -18,6 +20,19 @@
+ var theme = flag.String("theme", "dark", "color theme for Tailscale icon: dark, dark:nobg, light, light:nobg")
+
+ func main() {
++ // Classic macOS (10.8 and earlier) LaunchServices launches apps with a
++ // Carbon-era -psn__ ProcessSerialNumber argument. flag.Parse
++ // treats it as an unknown flag, prints usage, and exits -- so a Finder
++ // launch flashed and died while running the binary directly worked.
++ // Strip it (and any sibling -psn_ arguments) before parsing.
++ args := os.Args[:1]
++ for _, a := range os.Args[1:] {
++ if strings.HasPrefix(a, "-psn_") {
++ continue
++ }
++ args = append(args, a)
++ }
++ os.Args = args
+ flag.Parse()
+ lc := &local.Client{Socket: *socket}
+ systray.SetTheme(*theme)
diff --git a/patches/systray.go.patch b/patches/systray.go.patch
index 2291d90..183bd13 100644
--- a/patches/systray.go.patch
+++ b/patches/systray.go.patch
@@ -4,17 +4,30 @@ systray "Connect" action start interactive login on a logged-out node so the log
URL opens in the system browser (as "tailscale up" would); and add a macOS "Check
for updates" item that hands off to the bundled ModernMavericks Sparkle updater.
+Also blank-imports tailscale.com/legacy106 (the 10.6 floor shims) here, because
+the systray binary's import graph does not reach ipn/ipnauth where the peercred
+patch roots it.
+
+And adds a "Peers" section (online-first, capped at 15; click copies the peer's
+Tailscale IP) -- the upstream menu has no peer list, which is unhelpful on a
+machine whose whole point is reaching the tailnet.
+
--- client/systray/systray.go.orig
+++ client/systray/systray.go
-@@ -16,6 +16,7 @@
+@@ -16,6 +16,12 @@
"log"
"net/http"
"os"
+ "os/exec"
++ "sort"
++
++ // 10.6 floor: the libSystem/Security shims (reachability: the systray
++ // binary does not import ipn/ipnauth, where the peercred patch roots it).
++ _ "tailscale.com/legacy106"
"os/signal"
"runtime"
"slices"
-@@ -62,7 +63,13 @@
+@@ -62,7 +68,13 @@
signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM)
select {
case <-interrupt:
@@ -29,7 +42,7 @@ for updates" item that hands off to the bundled ModernMavericks Sparkle updater.
case <-menu.bgCtx.Done():
}
}()
-@@ -72,7 +79,11 @@
+@@ -72,7 +84,11 @@
// set initial title, which is used by the systray package as the ID of the StatusNotifierItem.
// This value will get overwritten later as the client status changes.
// This must be called before systray.Run.
@@ -42,7 +55,7 @@ for updates" item that hands off to the bundled ModernMavericks Sparkle updater.
systray.Run(menu.onReady, menu.onExit)
}
-@@ -95,13 +106,14 @@
+@@ -95,13 +111,14 @@
bgCancel context.CancelFunc
// Top-level menu items
@@ -64,10 +77,60 @@ for updates" item that hands off to the bundled ModernMavericks Sparkle updater.
rebuildCh chan struct{} // triggers a menu rebuild
accountsCh chan ipn.ProfileID
-@@ -347,6 +359,26 @@
+@@ -321,6 +338,48 @@
+ menu.self.Disable()
+ }
+ systray.AddSeparator()
++
++ // Peers: the tailnet's other devices, online-first, capped for menu
++ // sanity. Clicking a peer copies its Tailscale IP (same affordance as
++ // the per-device copy action elsewhere in the menu).
++ if menu.status != nil && len(menu.status.Peer) > 0 {
++ peers := systray.AddMenuItem("Peers", "")
++ type peerEntry struct {
++ name string
++ ps *ipnstate.PeerStatus
++ }
++ var entries []peerEntry
++ for _, ps := range menu.status.Peer {
++ entries = append(entries, peerEntry{
++ name: strings.Split(ps.HostName, ".")[0], ps: ps})
++ }
++ sort.Slice(entries, func(i, j int) bool {
++ if entries[i].ps.Online != entries[j].ps.Online {
++ return entries[i].ps.Online
++ }
++ return entries[i].name < entries[j].name
++ })
++ if len(entries) > 15 {
++ entries = entries[:15]
++ }
++ for _, e := range entries {
++ title := e.name
++ if len(e.ps.TailscaleIPs) > 0 {
++ title = fmt.Sprintf("%s (%s)", e.name, e.ps.TailscaleIPs[0])
++ }
++ if !e.ps.Online {
++ title += " — offline"
++ }
++ item := peers.AddSubMenuItem(title, "Click to copy this peer's Tailscale IP")
++ onClick(ctx, item, func(ctx context.Context) {
++ select {
++ case <-ctx.Done():
++ default:
++ menu.copyTailscaleIP(e.ps)
++ }
++ })
++ }
++ }
+
+ if !menu.readonly {
+ menu.rebuildExitNodeMenu(ctx)
+@@ -346,6 +405,40 @@
+ }
})
menu.rebuildMenu.Enable()
-
++
+ // On macOS (the ModernMavericks packaging) offer a manual update check that
+ // hands off to the bundled Sparkle updater. Launching its executable with
+ // --user runs Sparkle's interactive check (update dialog, or "you're up to
@@ -75,9 +138,22 @@ for updates" item that hands off to the bundled ModernMavericks Sparkle updater.
+ // --background. Reap the child so a long-lived menu bar app doesn't leave
+ // zombies across repeated checks.
+ if runtime.GOOS == "darwin" {
++ // Only show the item when the bundled updater binary actually exists
++ // (the legacy106 floor pkg deliberately omits the Sparkle updater).
++ // Probe both bundle names (the 10.9 Sparkle updater and the 10.6 ObjC updater)
++ updaterPath := ""
++ // Probe the 10.6 updater FIRST: if both are installed (a 10.6 pkg
++ // replaced a 10.9 install), the 10.9 Sparkle binary is incompatible.
++ for _, p := range []string{
++ "/Library/Application Support/ModernMavericks/TailscaleUpdater106.app/Contents/MacOS/TailscaleUpdater",
++ "/Library/Application Support/ModernMavericks/TailscaleUpdater.app/Contents/MacOS/TailscaleUpdater",
++ } {
++ if _, err := os.Stat(p); err == nil { updaterPath = p; break }
++ }
++ if updaterPath != "" {
+ menu.checkUpdates = systray.AddMenuItem("Check for updates", "")
+ onClick(ctx, menu.checkUpdates, func(_ context.Context) {
-+ const updater = "/Library/Application Support/ModernMavericks/TailscaleUpdater.app/Contents/MacOS/TailscaleUpdater"
++ updater := updaterPath
+ cmd := exec.Command(updater, "--user")
+ if err := cmd.Start(); err != nil {
+ log.Printf("error launching updater: %v", err)
@@ -86,15 +162,16 @@ for updates" item that hands off to the bundled ModernMavericks Sparkle updater.
+ go cmd.Wait()
+ })
+ menu.checkUpdates.Enable()
++ }
+ }
-+
+
menu.quit = systray.AddMenuItem("Quit", "Quit the app")
menu.quit.Enable()
-
-@@ -452,6 +484,17 @@
+@@ -451,6 +544,17 @@
+ })
if err != nil {
log.Printf("error connecting: %v", err)
- }
++ }
+ // Setting WantRunning alone won't authenticate a logged-out node, so
+ // (like "tailscale up") kick off interactive login when the backend
+ // needs it. That makes tailscaled emit a BrowseToURL, which
@@ -105,7 +182,6 @@ for updates" item that hands off to the bundled ModernMavericks Sparkle updater.
+ if err := menu.lc.StartLoginInteractive(ctx); err != nil {
+ log.Printf("error starting interactive login: %v", err)
+ }
-+ }
+ }
case <-menu.disconnect.ClickedCh:
- _, err := menu.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
diff --git a/patches/tailscaled.go.patch b/patches/tailscaled.go.patch
index 2c60fd6..1d1d2d2 100644
--- a/patches/tailscaled.go.patch
+++ b/patches/tailscaled.go.patch
@@ -1,13 +1,29 @@
-Report ModernMavericks as the packaging type via Hostinfo.Package.
+Report ModernMavericks as the packaging type via Hostinfo.Package, and root
+the 10.6 floor's shim package into the CLI (whose import graph never reaches
+ipn/ipnauth, where the peercred patch roots it for the daemon (the systray has its own blank import via patches/systray.go.patch)).
--- cmd/tailscaled/tailscaled.go.orig
+++ cmd/tailscaled/tailscaled.go
-@@ -495,6 +495,8 @@ func run() (err error) {
+@@ -539,6 +539,8 @@
+ if app := envknob.App(); app != "" {
hostinfo.SetApp(app)
}
-
-+ hostinfo.SetPackage("ModernMavericks")
+
++ hostinfo.SetPackage("ModernMavericks")
+
return startIPNServer(context.Background(), logf, publicLogID, sys)
}
+--- cmd/tailscale/tailscale.go.orig
++++ cmd/tailscale/tailscale.go
+@@ -12,6 +12,11 @@
+ "strings"
+
+ "tailscale.com/cmd/tailscale/cli"
++
++ // The CLI's import graph never reaches ipn/ipnauth (that is daemon
++ // territory), so the 10.6 floor's shim package is rooted here instead.
++ // darwin_10_6-tagged: compiles to nothing on other floors.
++ _ "tailscale.com/legacy106"
+ )
+ func main() {
diff --git a/tailscale-1.102.4-mavericks-legacy106.pkg b/tailscale-1.102.4-mavericks-legacy106.pkg
new file mode 100644
index 0000000..06449fc
Binary files /dev/null and b/tailscale-1.102.4-mavericks-legacy106.pkg differ
diff --git a/tests/assert_legacy106_compatible.sh b/tests/assert_legacy106_compatible.sh
new file mode 100755
index 0000000..3f2aa32
--- /dev/null
+++ b/tests/assert_legacy106_compatible.sh
@@ -0,0 +1,117 @@
+#!/bin/sh
+# usage: assert_legacy106_compatible.sh ...
+# Snow Leopard (10.6) floor guard, run INSTEAD of shipyard's 10.9 gate
+# for a -legacy106 build. Per binary:
+# (1) arch exactly x86_64
+# (2) LC_VERSION_MIN_MACOSX == 10.6
+# (3) the 10.6 shims are DEFINED (not left as dynamic imports):
+# _arc4random_buf (the cgo package) + the toolchain's
+# _clock_gettime; _strnlen/_dirfd/_pthread_main_thread_np are
+# not referenced by this toolchain's runtime and may be absent.
+# (4) GOAMD64=v1 opcode audit: Core 2 has no POPCNT (SIGILL).
+# Under v1, Go 1.26 inline-multi-versions math/bits PER FUNCTION:
+# one runtime.x86HasPOPCNT check at the function entry guards
+# every popcnt in that function's body with a software fallback.
+# The audit verifies that every non-runtime function containing
+# popcnt also contains the x86HasPOPCNT reference somewhere in
+# its body (matching the compiler's emission model). Runtime-
+# internal sites (countbody, pageBits, scanObjects*) are allowed
+# as a bounded set: that exact code is hardware-proven on a
+# Core 2 P8700 (a full tailscaled ran joined for hours).
+# Fail-closed if nothing measured.
+#
+# platform: the stock 10.9 gate asserts LC_VERSION_MIN == 10.9 exactly, so it
+# would (correctly!) reject a 10.6-floor binary; this is its 10.6 twin.
+set -eu
+
+die() { echo "legacy106 guard CANNOT MEASURE (fail-closed): $*" >&2; exit 4; }
+
+RUNTIME_ALLOWANCE=20
+
+fail=0; checked=0
+# One temp dir for every binary's disassembly, removed once at exit: a
+# per-iteration trap would be overwritten each pass and leak all but the
+# last file.
+DISDIR=$(mktemp -d "${TMPDIR:-/tmp}/l106dis.XXXXXX")
+trap 'rm -rf "$DISDIR"' EXIT
+for b in "$@"; do
+ [ -f "$b" ] || { echo "legacy106 guard: MISSING $b" >&2; fail=1; continue; }
+ checked=$((checked+1))
+
+ # exactly x86_64: extract the arch list from -info output (portable to
+ # exactly x86_64 — not just "x86_64 present". lipo -info formats:
+ # thin: "Non-fat file: X is architecture: x86_64" → 1 arch token
+ # fat: "Architectures in the fat file: X are: i386 x86_64" → 2 tokens
+ # Taking only the LAST field ($NF) would pass a fat binary that happens
+ # to list x86_64 last. Instead: reject any output containing "are:"
+ # (fat-file marker) and require the thin-file last field to be x86_64.
+ raw=$(lipo -info "$b" 2>/dev/null) || raw=""
+ [ -n "$raw" ] || die "lipo produced no output for $b"
+ case "$raw" in
+ *"are:"*)
+ echo "$b: multi-arch binary (not exactly x86_64): $raw" >&2
+ fail=1
+ ;;
+ *)
+ archs=$(printf '%s\n' "$raw" | awk '{print $NF}')
+ [ "$archs" = "x86_64" ] || { echo "$b: not exactly x86_64: $archs" >&2; fail=1; }
+ ;;
+ esac
+
+ vmin=$(otool -l "$b" 2>/dev/null | awk '/LC_VERSION_MIN_MACOSX/{f=1} f && /version /{print $2; exit}')
+ [ -n "$vmin" ] || die "no LC_VERSION_MIN_MACOSX in $b"
+ [ "$vmin" = "10.6" ] || { echo "$b: min-version $vmin != 10.6" >&2; fail=1; }
+
+ # required shims: _clock_gettime must be defined (toolchain shim). The cgo
+ # legacy106 symbols (arc4random_buf ...) are only present when this
+ # toolchain actually imports them — an undefined IMPORT is the failure;
+ # total absence means the patched runtime never references them.
+ if ! /usr/bin/nm "$b" 2>/dev/null | grep -q "[Tt] _clock_gettime\$"; then
+ echo "$b: _clock_gettime not defined (toolchain shim missing)" >&2; fail=1
+ fi
+ # Fail closed: if nm cannot read the binary, the guard must not pass.
+ # A piped nm | grep would return grep's exit status, treating a failed
+ # nm the same as "no undefined imports found".
+ if ! nmout=$(/usr/bin/nm -m "$b" 2>&1); then
+ echo "$b: nm cannot read the binary -- cannot audit imports" >&2; fail=1; continue
+ fi
+ for s in _arc4random_buf _pthread_main_thread_np _strnlen _dirfd; do
+ if printf '%s\n' "$nmout" | grep -F '(undefined)' | sed -E 's/ \([^)]*\)$//' \
+ | awk '{print $NF}' | grep -qx "$s"; then
+ echo "$b: $s left as an undefined import (shim not linked)" >&2; fail=1
+ fi
+ done
+
+ # opcode audit. Go 1.26 inline-multi-versions math/bits per FUNCTION: one
+ # runtime.x86HasPOPCNT check guards every popcnt the function contains, with
+ # a software fallback. A non-runtime function containing popcnt must show
+ # the guard somewhere in its body; runtime-internal sites (countbody,
+ # pageBits, scanObjects*, ...) are a bounded, hardware-proven set.
+ # Fail-closed: disassemble to a file first and verify otool's status --
+ # a piped `otool | awk` only surfaces awk's exit, so a failed disassembly
+ # would audit an empty stream and pass.
+ dis="$DISDIR/$(basename "$b").dis"
+ if ! otool -tvV "$b" > "$dis" 2>/dev/null || [ ! -s "$dis" ]; then
+ echo "$b: disassembly failed -- cannot audit opcodes" >&2; fail=1; continue
+ fi
+ awk -v allow=$RUNTIME_ALLOWANCE -v bin="$b" '
+ function flushsym() {
+ if (sym != "" && nsym_popcnt > 0 && sym !~ /^_runtime\./ && sym !~ /^_countbody/ \
+ && sym !~ /^_internal\// && sym !~ /^_go:/) {
+ if (sym_guarded == 0) {
+ printf "%s: UNGUARDED popcnt (no x86HasPOPCNT in body): %s\n", bin, sym
+ bad = 1
+ }
+ }
+ if (sym ~ /^_runtime\./ || sym ~ /^_countbody/ || sym ~ /^_internal\//) rt += nsym_popcnt
+ }
+ /^([0-9a-f]+[[:space:]]+)?[a-zA-Z_][a-zA-Z0-9_.\/()*]+:$/ { flushsym(); sym = $0; sub(/^[0-9a-f]+[[:space:]]+/, "", sym); nsym_popcnt = 0; sym_guarded = 0 }
+ /x86HasPOPCNT/ { sym_guarded = 1 }
+ /popcntl|popcntq/ { nsym_popcnt++ }
+ END { flushsym();
+ if (rt > allow) { printf "%s: %d runtime popcnt sites > allowance %d\n", bin, rt, allow; bad = 1 }
+ if (bad) exit 1 }' "$dis" || fail=1
+done
+[ "$checked" -gt 0 ] || die "no binaries measured"
+if [ "$fail" -eq 0 ]; then echo "legacy106 guard: OK ($checked binary(ies))"; fi
+exit "$fail"
diff --git a/updater/updater106.m b/updater/updater106.m
new file mode 100644
index 0000000..1bf6588
--- /dev/null
+++ b/updater/updater106.m
@@ -0,0 +1,379 @@
+// TailscaleUpdater for the 10.6 floor — a minimal ObjC updater that replaces
+// the Sparkle-based updater the 10.9 product ships (Sparkle 1.27.3's binary
+// declares LC_VERSION_MIN_MACOSX 10.9; dyld on 10.6 refuses to load it).
+//
+// Same CLI contract as the Sparkle updater:
+// (no args) silent background check (the daily LaunchAgent calls this)
+// --user foreground check: show a dialog on update, install on OK
+//
+// All APIs are 10.6-safe: NSXMLParser, NSTask, NSAlert, NSData.
+// No blocks, no ARC, no GCD, no NSUserNotification, no external framework.
+
+#import
+#import
+#import
+
+static BOOL userInitiatedFlag = NO;
+static BOOL alreadyAlerted = NO; // prevents double dialogs (TLS then generic)
+#ifndef UPDATER_FEED_URL
+#define UPDATER_FEED_URL "https://github.com/startergo/tailscale-legacy/releases/latest/download/appcast-10.6.xml"
+#endif
+static NSString *const kFeedURL = @UPDATER_FEED_URL;
+
+// cleanupAndExit removes the temp directory and exits. Called at EVERY exit
+// point after tmpDir exists, so daily background checks never accumulate.
+static void cleanupAndExit(NSString *tmpDir, int code) {
+ [[NSFileManager defaultManager] removeItemAtPath:tmpDir error:nil];
+ exit(code);
+}
+
+
+#pragma mark - Version helpers
+
+static NSString *installedVersion(void) {
+ // Check pkgutil exists before launching (NSTask throws if missing).
+ if (![[NSFileManager defaultManager] isExecutableFileAtPath:@"/usr/sbin/pkgutil"]) {
+ NSLog(@"updater: /usr/sbin/pkgutil not found");
+ return @"";
+ }
+ NSTask *t = [[[NSTask alloc] init] autorelease];
+ [t setLaunchPath:@"/usr/sbin/pkgutil"];
+ [t setArguments:[NSArray arrayWithObjects:@"--pkg-info", @"dev.modernmavericks.tailscale", nil]];
+ NSPipe *p = [NSPipe pipe];
+ [t setStandardOutput:p];
+ [t launch];
+ NSData *d = [[[p fileHandleForReading] readDataToEndOfFile] retain];
+ [t waitUntilExit];
+ NSString *out = [[[NSString alloc] initWithData:d encoding:NSUTF8StringEncoding] autorelease];
+ [d release];
+ for (NSString *line in [out componentsSeparatedByString:@"\n"])
+ if ([line hasPrefix:@"version: "])
+ return [line substringFromIndex:9];
+ return @"";
+}
+
+static int compareVersions(NSString *a, NSString *b) {
+ NSArray *ap = [a componentsSeparatedByString:@"."];
+ NSArray *bp = [b componentsSeparatedByString:@"."];
+ NSUInteger n = MAX([ap count], [bp count]);
+ for (NSUInteger i = 0; i < n; i++) {
+ NSInteger ai = i < [ap count] ? [[ap objectAtIndex:i] integerValue] : 0;
+ NSInteger bi = i < [bp count] ? [[bp objectAtIndex:i] integerValue] : 0;
+ if (ai < bi) return -1;
+ if (ai > bi) return 1;
+ }
+ return 0;
+}
+
+#pragma mark - Binary-safe fetch (no UTF-8 transcoding)
+
+// Downloads to a FILE, preserving binary bytes exactly. The HTTP status
+// is written via curl's -w to stdout, never mixed with the body.
+static BOOL fetchToFile(NSString *urlString, NSString *destPath) {
+ NSString *curlPath = @"/opt/local/bin/curl";
+ if (![[NSFileManager defaultManager] isExecutableFileAtPath:curlPath]) {
+ // Stock 10.6 curl (OpenSSL 0.9.8) cannot negotiate TLS 1.2, which
+ // GitHub requires. This is a hard dependency for HTTPS update checks;
+ // fail with a clear message rather than a cryptic TLS error.
+ if ([urlString hasPrefix:@"https://"]) {
+ NSLog(@"updater: FATAL: MacPorts curl not found at /opt/local/bin/curl.");
+ NSLog(@"updater: 10.6's system curl lacks TLS 1.2; GitHub HTTPS requires it.");
+ NSLog(@"updater: Install MacPorts curl: sudo port install curl");
+ if (userInitiatedFlag)
+ NSRunAlertPanel(@"Mavericks Tailscale",
+ @"Cannot check for updates: MacPorts curl is required "
+ @"for HTTPS on 10.6 (system curl lacks TLS 1.2).\n\n"
+ @"Install it with: sudo port install curl",
+ @"OK", nil, nil);
+ alreadyAlerted = YES;
+ return NO;
+ }
+ // HTTPS only: an auto-updater accepting plain HTTP is a MITM /
+ // downgrade vector. No fallback.
+ curlPath = nil;
+ }
+ if (!curlPath) return NO; // unreachable for HTTPS (handled above)
+ NSTask *t = [[[NSTask alloc] init] autorelease];
+ [t setLaunchPath:curlPath];
+ [t setArguments:[NSArray arrayWithObjects:
+ @"-sL", @"--max-time", @"60",
+ @"-o", destPath,
+ @"-w", @"%{http_code}",
+ urlString, nil]];
+ NSPipe *p = [NSPipe pipe];
+ [t setStandardOutput:p];
+ [t setStandardError:[NSPipe pipe]];
+ [t launch];
+ NSData *statusData = [[[p fileHandleForReading] readDataToEndOfFile] retain];
+ [t waitUntilExit];
+ NSString *status = [[[NSString alloc] initWithData:statusData
+ encoding:NSASCIIStringEncoding] autorelease];
+ [statusData release];
+ if ([t terminationStatus] != 0) {
+ NSLog(@"updater: curl exited %ld", [t terminationStatus]);
+ return NO;
+ }
+ if (![status isEqualToString:@"200"]) {
+ NSLog(@"updater: HTTP %@ for %@", status, urlString);
+ return NO;
+ }
+ NSDictionary *attrs = [[NSFileManager defaultManager] fileAttributesAtPath:destPath
+ traverseLink:NO];
+ if (!attrs || [[attrs objectForKey:NSFileSize] unsignedLongValue] == 0) {
+ NSLog(@"updater: empty or missing download at %@", destPath);
+ return NO;
+ }
+ return YES;
+}
+
+#pragma mark - Appcast parser (selects the 10.6-compatible item)
+
+@interface AppcastParser : NSObject {
+ NSMutableArray *items;
+ NSMutableDictionary *curItem;
+ NSString *textBuf;
+ BOOL inItem;
+ BOOL inVersionElem;
+ BOOL inMinSysElem;
+ NSString *curEnclosureURL;
+}
+@property (readonly) NSMutableArray *items;
+@end
+
+@implementation AppcastParser
+- (id)init {
+ self = [super init];
+ if (self) items = [[NSMutableArray alloc] init];
+ return self;
+}
+- (void)parser:(NSXMLParser *)p didStartElement:(NSString *)name
+ namespaceURI:(NSString *)ns qualifiedName:(NSString *)q attributes:(NSDictionary *)a {
+ textBuf = @"";
+ if ([name isEqualToString:@"item"]) {
+ inItem = YES;
+ curItem = [NSMutableDictionary dictionary];
+ curEnclosureURL = nil;
+ } else if ([name isEqualToString:@"enclosure"] && inItem) {
+ curEnclosureURL = [[a objectForKey:@"url"] retain];
+ } else if (inItem && ([name isEqualToString:@"sparkle:version"] ||
+ [name isEqualToString:@"version"])) {
+ inVersionElem = YES;
+ } else if (inItem && [name isEqualToString:@"sparkle:minimumSystemVersion"]) {
+ inMinSysElem = YES;
+ }
+}
+- (void)parser:(NSXMLParser *)p foundCharacters:(NSString *)s {
+ textBuf = [textBuf stringByAppendingString:s];
+}
+- (void)parser:(NSXMLParser *)p didEndElement:(NSString *)name
+ namespaceURI:(NSString *)ns qualifiedName:(NSString *)q {
+ if ([name isEqualToString:@"item"]) {
+ inItem = NO;
+ if (curEnclosureURL)
+ [curItem setObject:curEnclosureURL forKey:@"url"];
+ if ([curItem count] > 0)
+ [items addObject:curItem];
+ [curEnclosureURL release]; curEnclosureURL = nil;
+ curItem = nil;
+ } else if (inVersionElem && ([name isEqualToString:@"sparkle:version"] ||
+ [name isEqualToString:@"version"])) {
+ inVersionElem = NO;
+ [curItem setObject:textBuf forKey:@"version"];
+ } else if (inMinSysElem && [name isEqualToString:@"sparkle:minimumSystemVersion"]) {
+ inMinSysElem = NO;
+ [curItem setObject:textBuf forKey:@"minSys"];
+ }
+ textBuf = @"";
+}
+- (NSMutableArray *)items { return items; }
+- (void)dealloc {
+ [items release];
+ [curEnclosureURL release];
+ [super dealloc];
+}
+@end
+
+// Selects the LAST item whose minSysVersion is <= 10.6 (or absent).
+// The release workflow appends the 10.6 enclosure after the 10.9 one,
+// so the LAST compatible item is the 10.6-specific entry.
+static NSDictionary *selectCompatibleItem(AppcastParser *parser) {
+ NSDictionary *best = nil;
+ NSString *bestVersion = nil;
+ for (NSDictionary *item in parser.items) {
+ NSString *minSys = [item objectForKey:@"minSys"];
+ if (minSys && compareVersions(minSys, @"10.6") > 0) continue; // incompatible
+ NSString *v = [item objectForKey:@"version"];
+ NSString *u = [item objectForKey:@"url"];
+ if (!v || !u) continue;
+ // Pick the HIGHEST version among compatible items (not just the
+ // last — ordering is a feed convention, not a guarantee).
+ if (!bestVersion || compareVersions(v, bestVersion) > 0) {
+ best = item;
+ bestVersion = v;
+ }
+ }
+ return best;
+}
+
+#pragma mark - Elevated install
+
+// Uses osascript to prompt for admin credentials and run installer,
+// the standard macOS pattern for a GUI app installing a system pkg.
+static BOOL installPkgElevated(NSString *pkgPath) {
+ // Escape single quotes for the shell: our PID-based path contains no
+ // quotes, but belt-and-suspenders against future path changes.
+ NSString *safePath = [pkgPath stringByReplacingOccurrencesOfString:@"'"
+ withString:@"'\\''"];
+ NSString *script = [NSString stringWithFormat:
+ @"do shell script \"/usr/sbin/installer -pkg '%@' -target /\" "
+ @"with administrator privileges", safePath];
+ NSTask *t = [[[NSTask alloc] init] autorelease];
+ [t setLaunchPath:@"/usr/bin/osascript"];
+ [t setArguments:[NSArray arrayWithObjects:@"-e", script, nil]];
+ NSPipe *errPipe = [NSPipe pipe];
+ [t setStandardError:errPipe];
+ [t launch];
+ [t waitUntilExit];
+ if ([t terminationStatus] != 0) {
+ NSData *errData = [[[errPipe fileHandleForReading] readDataToEndOfFile] retain];
+ NSString *err = [[[NSString alloc] initWithData:errData
+ encoding:NSUTF8StringEncoding] autorelease];
+ [errData release];
+ NSLog(@"updater: install failed: %@", err);
+ return NO;
+ }
+ return YES;
+}
+
+#pragma mark - Main
+
+int main(int argc, const char *argv[]) {
+ BOOL background = NO;
+ BOOL userInitiated = NO;
+ if (argc < 2) {
+ background = YES; // no args = silent background check
+ } else if (strcmp(argv[1], "--background") == 0) {
+ background = YES;
+ } else if (strcmp(argv[1], "--user") == 0) {
+ userInitiated = YES;
+ userInitiatedFlag = YES; // global: fetchToFile reads this for TLS dialog
+ } else {
+ fprintf(stderr, "usage: %s [--background|--user]\n", argv[0]);
+ return 2;
+ }
+ if (argc > 2) {
+ fprintf(stderr, "updater: unexpected extra arguments\n");
+ return 2;
+ }
+
+ NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
+ [NSApplication sharedApplication];
+
+ NSString *current = installedVersion();
+ if ([current length] == 0) {
+ NSLog(@"updater: no installed version found");
+ if (userInitiated)
+ NSRunAlertPanel(@"Mavericks Tailscale",
+ @"Cannot determine the installed version.", @"OK", nil, nil);
+ [pool release];
+ return 1;
+ }
+
+ // Fetch appcast (binary-safe: temp file, no transcoding)
+ // Secure temp directory (mkdtemp: unpredictable name, 0700 perms).
+ char tmpl[] = "/tmp/tsupd.XXXXXX";
+ char *dir = mkdtemp(tmpl);
+ if (!dir) { NSLog(@"updater: mkdtemp failed"); [pool release]; return 1; }
+ NSString *tmpDir = [NSString stringWithUTF8String:dir];
+ NSString *appcastPath = [tmpDir stringByAppendingPathComponent:@"appcast-10.6.xml"];
+ if (!fetchToFile(kFeedURL, appcastPath)) {
+ if (userInitiated && !alreadyAlerted)
+ NSRunAlertPanel(@"Mavericks Tailscale",
+ @"Could not check for updates (network error).", @"OK", nil, nil);
+ cleanupAndExit(tmpDir, 1);
+ }
+ NSData *xmlData = [NSData dataWithContentsOfFile:appcastPath];
+
+ // Parse ALL items, then select the 10.6-compatible one
+ AppcastParser *parser = [[[AppcastParser alloc] init] autorelease];
+ NSXMLParser *xp = [[[NSXMLParser alloc] initWithData:xmlData] autorelease];
+ [xp setDelegate:parser];
+ [xp parse];
+
+ NSDictionary *item = selectCompatibleItem(parser);
+ if (!item) {
+ NSLog(@"updater: no compatible item in appcast");
+ if (userInitiated)
+ NSRunAlertPanel(@"Mavericks Tailscale",
+ @"No compatible update found for this system.", @"OK", nil, nil);
+ cleanupAndExit(tmpDir, 0);
+ }
+
+ NSString *availVersion = [item objectForKey:@"version"];
+ NSString *downloadURL = [item objectForKey:@"url"];
+
+ if (compareVersions(availVersion, current) <= 0) {
+ NSLog(@"updater: up to date (%@)", current);
+ if (userInitiated)
+ NSRunAlertPanel(@"Mavericks Tailscale",
+ [NSString stringWithFormat:@"You're up to date! (version %@)", current],
+ @"OK", nil, nil);
+ cleanupAndExit(tmpDir, 0);
+ }
+
+ NSLog(@"updater: %@ -> %@", current, availVersion);
+
+ if (background) {
+ NSLog(@"updater: update available (background check is silent)");
+ cleanupAndExit(tmpDir, 0);
+ }
+
+ if (userInitiated) {
+ NSInteger choice = NSRunAlertPanel(
+ @"Mavericks Tailscale Update",
+ [NSString stringWithFormat:
+ @"Version %@ is available (you have %@).\n\nDownload and install now?",
+ availVersion, current],
+ @"Install", @"Not Now", nil);
+ if (choice != NSAlertDefaultReturn) {
+ cleanupAndExit(tmpDir, 0);
+ }
+ }
+
+ // Download the pkg (binary-safe)
+ NSLog(@"updater: downloading %@...", downloadURL);
+ // PID-unique path; NEVER embed the remote version string (shell injection
+ // via crafted appcast sparkle:version). The PID suffix prevents both
+ // symlink attacks and collisions between concurrent updater runs.
+ NSString *pkgPath = [tmpDir stringByAppendingPathComponent:@"update.pkg"];
+ if (!fetchToFile(downloadURL, pkgPath)) {
+ if (userInitiated)
+ NSRunAlertPanel(@"Mavericks Tailscale",
+ @"Download failed. Try again later.", @"OK", nil, nil);
+ cleanupAndExit(tmpDir, 1);
+ }
+
+ // Install with admin privileges
+ NSLog(@"updater: installing (admin authorization required)...");
+ if (!installPkgElevated(pkgPath)) {
+ if (userInitiated)
+ NSRunAlertPanel(@"Mavericks Tailscale",
+ @"Installation failed. Check Console.app for details.", @"OK", nil, nil);
+ cleanupAndExit(tmpDir, 1);
+ }
+
+ // The pkg's postinstall reloads the daemon; nothing to do here.
+ // (The previous bare system() launchctl call was a silent no-op from
+ // a non-elevated process — LaunchDaemons require root to unload/load.)
+
+ // Clean up the entire secure temp directory
+ [[NSFileManager defaultManager] removeItemAtPath:tmpDir error:nil];
+
+ NSLog(@"updater: installed %@", availVersion);
+ if (userInitiated)
+ NSRunAlertPanel(@"Mavericks Tailscale",
+ [NSString stringWithFormat:@"Updated to %@. The daemon has been reloaded.", availVersion],
+ @"OK", nil, nil);
+
+ cleanupAndExit(tmpDir, 0);
+}