21 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 2e8bd497df fix(dns): re-apply on the fast path, where overrides were never picked up
The first fix widened matchDomains but only where the netmap refine runs — first
connect. Every connect after that takes the fast path, which applies settings before
any netmap exists and then deliberately does not touch the live tunnel again.

So its match domains could only come from the file the previous refine wrote. Fine
for the suffix, which never changes; wrong for extra_records and split-DNS domains,
because on the first connect there is no file yet — the tunnel comes up resolving
tailnet names only and stays that way, since nothing re-applies on any later connect.

That is the shape of what Andre still saw after reinstalling and reconnecting:
officer.pertento.ai going to public DNS and failing TLS against 100.64.0.4, on a
device that had connected more than once.

The fast path now watches for the overrides and re-applies once if they differ from
what it used. Bounded like the existing refine, silent when they match, and
re-applying on a live tunnel is not novel — the exit-node and accept-routes paths
have always done it.

Still unconfirmed on hardware: this is the most likely cause found by reading, not a
diagnosis from device logs. The tunnel now says which domains it claimed, so the next
attempt can be settled rather than reasoned about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 06:02:07 +01:00
pastilhasandClaude Opus 5 a8e9dd712c fix(dns): iOS never matched the domains control overrides
Reported from an iPad on 2026-08-18: officer.pertento.ai, gitea.pertento.ai and
*.edge.pertento.ai all failed with "couldn't establish a secure connection", while
desktop clients on the same tailnet reached them with a valid certificate.

The provider DID apply dnsSettings — it just scoped them to the MagicDNS suffix
alone. Everything Headscale overrides lives under an unrelated domain: dns.extra_records
serves an A record for a name like officer.pertento.ai, and dns.nameservers.split
gives one domain its own resolver. Neither is under the tailnet suffix, so iOS never
sent those queries to us. The device asked public DNS instead and got the public
answer — which for these names is a TAILNET address belonging to a DIFFERENT machine
(100.64.0.4 rather than the bridge at 100.64.0.12). TLS then failed against a host
holding no certificate for that name, which is exactly what Safari reported.

Nothing was missing from the server: CorpDNS is true, so the netmap already carried
the split routes and the extra records. The data simply had no way across to Swift.

So Go gains DNSMatchDomains(), returning the split-DNS route keys, the tailnet search
domains, and the PARENT zone of each extra record — the parent because a record is a
host, and one entry then covers its siblings without the device being told about each
one. Swift builds matchDomains from the suffix plus those, and remembers them exactly
as it remembers the suffix, because the fast path applies settings before any netmap
exists and would otherwise reintroduce this bug on every reconnect. Forgotten with the
suffix when the profile changes: those domains belong to the tailnet that sent them.

Deliberately NOT the catch-all [""], which the COMMS note suggested as an option. That
is already in this file and reserved for exit-node mode. Off an exit node it would put
every lookup on the phone through a resolver that cannot always forward — the failure
Android hit, and worse than this one: a broken tailnet name is an app that will not
load, a broken everything is a phone with no internet.

Android needs no change: it has no matchDomains equivalent and already routes all DNS
at the core.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 05:53:27 +01:00
pastilhasandClaude Opus 5 5b1c0e76e1 Icon: warm grey backdrop, rim at the edge, art at 64%
Mirrors monorepo-mobile/apps/offscale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:10:47 +01:00
pastilhasandClaude Opus 5 ce3c1b8a2a Icon: the new 3x3 mark, framed
Mirrors monorepo-mobile/apps/offscale. This repo has no icon pipeline — its
assets are committed directly — so the framed icon is copied in rather than
generated here. Both trees have to be updated for every icon change until one
of them goes away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 20:51:55 +01:00
pastilhasandClaude Opus 5 8c95c35684 chore: icon at 0.8x
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 03:51:10 +01:00
pastilhasandClaude Opus 5 5d1b7c4311 fix: back to the hex mark, without the duck
The duck-in-the-honeycomb icon was rejected on sight on a phone. This is the plain 4x4
mark with the wordmark-matched yellow — what the app had before — now with the margin an
icon wants instead of filling the canvas.

The splash keeps the fuller mesh scene, unaffected, which is the point of them being
separate assets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 03:25:45 +01:00
pastilhasandClaude Opus 5 debd280f76 fix: the splash keeps the old art, which the new icon had silently replaced
The splash drew the app icon, so yesterday's icon change changed the splash with it —
not asked for and not noticed until it was on a phone.

They were the same picture until the icon became a reduction that has to survive 40
points. The splash has room for the fuller mesh scene, so it keeps it: assets/splash.png
is the exact icon that was there before this change, restored from the previous commit
rather than regenerated, so it is the image that was actually on screen.

AppSplash gains the  prop the monorepo's copy already had — the extraction predates
it, which is why this repo silently kept the old behaviour. Drawn untiled and
letterboxed rather than masked and cropped, because a composition is not an icon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 23:58:39 +01:00
pastilhasandClaude Opus 5 cb12b1b023 feat: the new-brief app icon
The honeycomb reduced to a 4x4 grid with the duck in the four white cells — the same
mark as the wordmark's world, drawn to survive an icon rather than to be looked at.

Checked at 120, 60 and 40 points before adopting: the grid still reads as a grid, the
white cells stay distinct, and the duck is still a duck at 40. That is the whole test an
icon has to pass.

The master arrived correct — 1254 square, genuinely transparent, artwork at 81% by 89%
with real padding — so this is the pipeline and nothing else. iOS icon flattened onto
black with the alpha channel REMOVED rather than merely opaque, which is what iOS
rejects; Android foreground at the 72% inset, verified against a circular mask.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 23:47:59 +01:00
pastilhasandClaude Opus 5 0aec569019 docs: over-the-air upgrades need the tunnel off first
An update from the install page downloads in full and then silently does nothing while
the tunnel is running. No error, no progress, the app stays at the old version — which
cost several hours yesterday, chasing the manifest, the short version string, the
signing and the server headers in turn.

Established across seven attempts on 2026-08-15: every upgrade with the tunnel down
succeeded, every upgrade with it up failed identically. The server log is the only place
the two are distinguishable, and both look the same up to the point where the download
completes.

The likely cause is that iOS will not replace a bundle whose network extension is live,
the packet tunnel being inside the very bundle being replaced. The same .ipa installs
fine while connected via devicectl, which goes through developer services rather than
the ordinary installer.

The workaround is one tap in the app — toggle the VPN off, install, toggle it back on —
and needs no trip to iOS Settings. A first install is unaffected, since there is no
tunnel yet, which is the case that matters for anyone new.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 16:31:41 +01:00
pastilhasandClaude Opus 5 b87afa8992 refactor(vpn): drop the 'This device' heading
The card sits directly under the connection toggle and shows this device's own name,
address and key expiry. The only other card that could be confused with it is the peer
list, which is plainly a list. A heading that repeats what the content already says
costs a line of vertical space on a phone and tells nobody anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 16:12:29 +01:00
pastilhasandClaude Opus 5 409ee11e9e fix(vpn): show the sign-in form when there is nothing to sign into
A state existed that the screen could not render: the core reaches NeedsLogin, no
profile is persisted, nothing connects.

signingIn is then true, so the manual form was suppressed as 'already in progress'. But
there was no profile, nothing was connecting, and nothing would ever complete. What the
screen showed was 'Waiting for sign-in' above a toggle whose value is bound to
connected — so it could not move, and tapping it appeared to do nothing — with no form,
no chooser, and (until the previous commit) no reset. The only exit was deleting the app
from the home screen.

A node in NeedsLogin with no profile is not signing in. It is stuck, and the way out of
stuck is the form that asks where to connect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:03:55 +01:00
pastilhasandClaude Opus 5 7672cf57bb fix(vpn): the reset must never be conditional on the state being sane
'Forget all connections' was shown only when registered || connected ||
profiles.length > 0 — evidence that something had worked.

A half-finished sign-in leaves none of those true. The core reaches NeedsLogin, no
profile is persisted, nothing connects — and the only control that could recover the app
was hidden, because it was reading the very state that had broken. The screen said
'Waiting for sign-in' with no way to choose another server, enter another key, or start
over. Deleting the app from the home screen was the only exit, on a real phone, at one
in the morning.

The comment above that gate already described the same mistake in a different case,
which is the tell: every condition here is a bet that the app knows its own state, and
this button exists for the times it does not. So there is no condition now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:59:45 +01:00
pastilhasandClaude Opus 5 e3024a7818 fix(settings): read the build number from expo-application
The row said '1.0.0 (dev)' on a build stamped 2026081502.

Constants.nativeBuildVersion is deprecated in SDK 57 and returns nothing — not an
error, not a throw, just undefined, so the fallback fired and the screen reported the
opposite of the truth with complete confidence. A deprecation that quietly returns
undefined is the worst kind: everything looks like it is working.

expo-application is the documented replacement and reads the value from the bundle at
runtime, which is the number OAS stamps before signing.

Also removes the last use of expo-constants here, and with it the '1.0.0' half — the
marketing string that has never changed and cannot tell two builds apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:21:12 +01:00
pastilhasandClaude Opus 5 e7e2d8c6b7 feat(settings): show our versioning, not Apple's marketing string
The row read '1.0.0 (2026081501)'. The first half has said 1.0.0 for every build ever
made here and conveys nothing — nobody maintains it, and it cannot distinguish two
builds. What identifies a build is the date-stamped number OAS assigns and writes into
CFBundleVersion before signing.

So the row now shows exactly that, dotted for reading: 2026.08.15.01. The same string
the install page shows, which is the point — 'is the thing on my phone the thing on the
site?' becomes a question you answer by looking rather than by remembering which link
you tapped.

A build that never went through OAS has no such number. Anything that is not exactly
ten digits is shown verbatim as 'dev (…)' rather than sliced into a fake date.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:12:32 +01:00
pastilhasandClaude Opus 5 de433794ac fix: pin the native Expo packages that arrive transitively
The app installed and then died at launch, before the splash screen, with dyld:

  Symbol not found: ExpoModulesCore.BaseModule.willDestroy()
  Referenced from: ExpoFileSystem.framework
  Expected in:     ExpoModulesCore.framework

expo-file-system was 57.0.4 here against 57.0.1 in the monorepo, with the same
expo-modules-core 57.0.7 underneath. The newer file-system calls a method that patch of
modules-core does not have.

The gap was in how this repo was pinned. Direct dependencies were fixed to versions the
monorepo proves; transitive ones were not, and expo-file-system arrives through expo, so
it resolved fresh to whatever was newest. Comparing both trees found six native packages
that had drifted this way — asset, dev-launcher, dev-menu, file-system,
modules-autolinking, server — now pinned through overrides to the monorepo's versions.

Same family as the expo-image-picker trap already written down in CLAUDE.md: a wrong
Expo version typechecks, installs, and fails much later in a way that looks like a
broken toolchain rather than a wrong dependency. Here it was worse than a build error —
it built, signed, installed, and crashed on a phone.

The crash log named it in one line. That file remains the fastest diagnostic available.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:07:36 +01:00
pastilhasandClaude Opus 5 fe389c9752 feat(settings): show which build this is
Two OffScale builds from different source trees were installed on the same phone and
were indistinguishable: both reported 1.0.0 (1), because nothing ever stamped a version.
The only way to answer 'did that actually install?' was to remember which URL it came
from, which is not available to the person holding the phone.

Reads nativeBuildVersion rather than the value in app.json. The config says what the
project asked for; the native plist says what shipped, and it is the second one that
answers the question. OAS stamps its YYYYMMDDNN into CFBundleVersion before signing, so
the row now shows the same number the install page does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 23:24:02 +01:00
pastilhasandClaude Opus 5 859a716b5d fix(build): empty array expansion breaks under set -u on bash 3.2
scripts/build-android.sh died with 'MINIFY_ARGS[@]: unbound variable' before Gradle
ever ran. macOS ships bash 3.2, where expanding an EMPTY array under set -u is itself
an unbound-variable error — the array is declared and empty, which is the normal case,
since MINIFY defaults to off.

The monorepo's copy of this code has the same bare expansion and works, because that
script does not set -u. So the bug was latent there and became real the moment a
stricter copy ran it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 23:17:11 +01:00
pastilhasandClaude Opus 5 5846e80ddf chore(gocore): the golang.org/x versions this core was actually built with
gomobile upgraded six indirect golang.org/x modules while building the core — crypto,
mobile, mod, net, text, tools — and go.mod recorded it. Committing so a fresh clone
compiles the same core rather than whatever those pseudo-versions resolve to next week.

All indirect, all golang.org/x, no change to the tailscale.com pin at v1.102.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 23:04:23 +01:00
pastilhasandClaude Opus 5 79fbfee5fb fix: build standalone — five things the monorepo was quietly providing
The extraction typechecked from day one and had never been built. It took five
attempts, each failing further along, and every cause was the same shape: something the
shared node_modules or the monorepo's layout supplied without anyone declaring it.

1. The config plugin had the monorepo's geometry written into it —
   ../../../packages/officer-net/ios/libs, correct from apps/<name>/ios and meaningless
   from a repo root. It reported a path belonging to neither repo. Now derived from the
   plugin's own location with path.relative, so it is right wherever the module sits.

2. That fix appeared to do nothing, because officer-net was a  dependency and bun
   COPIES those. Prebuild ran a week-old copy while the source was edited — a clean edit
   and a stale build, the same shadow-copy trap that cost a night in the monorepo.
   Making it a workspace turns node_modules/officer-net into a symlink.

3. @babel/plugin-transform-react-jsx was never declared by anything. It was hoisted into
   the monorepo by some other package, so babel-preset-expo with jsxImportSource found
   it by luck.

4. Same for react-native-reanimated and react-native-worklets: nativewind depends on
   react-native-css-interop, which lists reanimated as a PEER, whose babel plugin
   requires react-native-worklets/plugin. Three levels of nobody-declared-it.

5. And the self-inflicted one. Adding  for (2) silently flipped bun to its
   isolated linker — only direct dependencies at top level, everything else symlinked
   into node_modules/.bun, which Metro cannot resolve. It surfaced as 'Unable to resolve
   module react-native-css-interop/jsx-runtime', naming a package that was installed and
   in the lockfile and simply not where the resolver looks. bunfig.toml now pins both
   properties that are needed at once: workspaces for the symlink, hoisted for the
   layout.

Result: OffScale.app, 82 MB, with OffTailTunnel.appex at 20 MB carrying 129 Tailcore
symbols — the Go core is in the extension, which is the half that matters.

Not yet run on a device. It builds; that is a different claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 23:04:09 +01:00
pastilhasandClaude Opus 5 c63662082a docs: record where the Tailscale logo permission actually stands
Tailscale replied on 2026-08-14, after a week, that their logo is not to be used for
MARKETING purposes.

That is not this use. The mark appears in the attribution strip inside an installed
app, naming the project the client speaks to — no store listing, no screenshot, no
website, and this repository is not published anywhere it reads as promotion. So it
stays, deliberately and provisionally, on that reading.

Written into the component and the NOTICE rather than left in a conversation, because
the decision has two halves and only one of them is obvious later. The first is that
it may stay for now. The second is that it must NOT migrate into a store listing or a
promotional image on the strength of already being in the app — that is exactly the
use they declined, and it is the mistake this note exists to prevent.

If they say remove it, it is one component and its two call sites. That containment is
why it was worth keeping rather than pre-emptively cutting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:15:11 +01:00
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