commit ef3582f2aaef1d1656d3bb9875f3e68746a09edd Author: Andre Padez Date: Fri Aug 14 20:32:17 2026 +0100 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0489d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +.expo/ +dist/ +web-build/ +*.log +.DS_Store + +# Generated native projects. `expo prebuild` rewrites these wholesale on every build, so nothing in +# them is a source of truth. The hand-written native bridge lives in modules/officer-net/{ios,android} +# and is tracked — see that directory's own .gitignore, which keeps its build output out. +/ios/ +/android/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..edb5578 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 André Alçada Padez Monteiro da Conceição + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..137bdaa --- /dev/null +++ b/NOTICE @@ -0,0 +1,147 @@ +OffScale +Copyright (c) 2026 André Alçada Padez Monteiro da Conceição + +This product is licensed under the MIT License (see LICENSE). It bundles and +links third-party software under its own terms, reproduced below. + + +================================================================================ +1. Tailscale — BSD 3-Clause License +================================================================================ + +WHAT IS EMBEDDED, AND WHERE + +OffScale is a Tailscale client. It does not reimplement the protocol; it embeds +Tailscale's own Go client library and runs it inside the app. + + modules/officer-net/gocore/ + A small Go shim (`tailcore.go` and siblings) that imports `tailscale.com` + and exposes a narrow API to Swift and Kotlin through gomobile. Its + `go.mod` requires: + + tailscale.com v1.102.0 + + Everything under `gocore/` that is not Tailscale's own source is part of + this project and MIT-licensed; what it *imports* is Tailscale's, under the + BSD 3-Clause licence below. + + modules/officer-net/ios/libs/TailCore.xcframework (built, not committed) + modules/officer-net/android/libs/tailcore.aar (built, not committed) + The compiled output of `gomobile bind` over that shim. These artifacts + CONTAIN compiled Tailscale code, so any binary distribution of OffScale — + an .ipa, an .apk, a TestFlight or store build — is a binary distribution of + Tailscale's software and must carry this notice with it. They are produced + by `modules/officer-net/scripts/build-libtailscale-{ios,android}.sh` and + are gitignored because of their size, not because they are unimportant. + + modules/officer-net/gocore/go.sum + Records the exact module versions, including transitive dependencies of + `tailscale.com`, several of which carry their own permissive licences + (BSD, MIT, Apache-2.0). Their full texts ship inside the Go module cache + that `go build` resolves; run `go mod download` in `gocore/` and read + `$(go env GOMODCACHE)` for the authoritative set. + +The rest of this repository — the React Native app, the Swift Network Extension +bridge, the Kotlin VpnService bridge, the Expo config plugin and the build +scripts — is original work under the MIT licence. + +LICENCE TEXT + +Copyright (c) 2020 Tailscale Inc & AUTHORS. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Tailscale Inc nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +TRADEMARKS — READ THIS SEPARATELY FROM THE LICENCE ABOVE + +The BSD 3-Clause licence covers Tailscale's SOURCE CODE. It does not grant any +right to the Tailscale name or logo — in fact its third clause explicitly +withholds the use of the name to endorse or promote derived products. + +OffScale is an independent, unofficial client. It is **not affiliated with, +sponsored by, endorsed by or supported by Tailscale Inc.** Do not read the +presence of the name in this repository as any kind of approval. Please do not +ask Tailscale's support for help with this app. + +Two places in this repository use Tailscale's marks, both deliberately and both +worth reviewing before any public release or store submission: + + 1. The word "Tailscale" appears throughout the UI and the source. It is used + nominatively — to say truthfully which network protocol and which hosted + service this client speaks to. That is the ordinary permitted use of + another party's trademark, and it is why the app is called OffScale rather + than anything Tailscale-shaped. + + 2. `src/ui/TailscaleMark.tsx` REPRODUCES THE TAILSCALE LOGO. It draws + Tailscale's 3x3 dot mark as nine circles rather than as their supplied + bezier paths, but the rendered result is their logo, and it is displayed in + the attribution strip at the bottom of every screen. It is drawn in a + single flat colour (white by default) and is deliberately never tinted to + this app's accent colour. + + This is a TRADEMARK question, not a licensing one, and the licence above + does not answer it. It is included here as an attribution — crediting the + project this client is built on — which is the use most trademark policies + permit. If Tailscale Inc. would prefer it removed, remove it: nothing else + in the app depends on that component. + +"Tailscale" is a trademark of Tailscale Inc. + + +================================================================================ +2. Headscale +================================================================================ + +OffScale can be pointed at Headscale (https://headscale.net), an open-source, +self-hosted implementation of the Tailscale control server, licensed BSD +3-Clause. No Headscale code is included in this repository or in any build of +this app — Headscale is a server you run, and this app is a client that speaks +to it over the network. It is credited in the app's attribution strip for the +same reason Tailscale is: it is what makes self-hosting possible. + +Headscale is likewise not affiliated with this project. + + +================================================================================ +3. JavaScript and native dependencies +================================================================================ + +The app depends on React Native, Expo, NativeWind, react-native-svg and others, +each under its own permissive licence (MIT, Apache-2.0, BSD). `package.json` +lists them with exact pinned versions, and the full licence text of each ships +inside its package in `node_modules`. + + +================================================================================ +4. Artwork +================================================================================ + +The app icon, the adaptive icon, the wordmark and the two wallpaper photographs +in `assets/` and `src/assets/` are original work, Copyright (c) 2026 André +Alçada Padez Monteiro da Conceição. The MIT licence above covers the SOFTWARE. +Redistributing the artwork under the OffScale name, or shipping a fork that +presents itself as OffScale, is not something this licence is meant to permit — +fork the code freely, and please use your own name and icon. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1d316be --- /dev/null +++ b/README.md @@ -0,0 +1,151 @@ +# OffScale + +A mesh VPN client for iOS and Android that works with **your own Headscale** and with +**Tailscale's** hosted control plane, treating both as equals. + +It embeds the real Tailscale client core (Go, compiled with `gomobile`) and runs it inside the OS VPN +APIs — `NEPacketTunnelProvider` on iOS, `VpnService` on Android. It is not a wrapper around another +app and not a reimplementation of the protocol: it is the same core, in a different shell. + +MIT licensed, free, and intended to stay both. + +## Why it exists + +The thing that made this worth building is that **Headscale is a first-class destination here, not an +"advanced" option buried three screens deep.** If you self-host, you should not have to feel like you +are using the software wrong. First run offers two equal buttons: your Headscale, or Tailscale's +network. + +## It never talks to a server of ours + +There is no sign-in, no account, no analytics, no crash reporting and no update check. There is no +backend belonging to this project for the app to contact, and no code path that reaches for one. + +The only hosts OffScale ever connects to are: + +- **the control server you point it at** — your Headscale, or `controlplane.tailscale.com`; +- **the peers on your tailnet**, over WireGuard; +- **the enrolment sidecar named inside an invite link**, and only if you tapped one (see + `src/vpn/invite.ts`; the base URL comes out of the link's fragment and nowhere else). + +This is a design constraint rather than a current fact. A VPN client that needed some other service to +be reachable in order to authenticate would be a bootstrap deadlock, because the tunnel is very often +the thing that makes that service reachable in the first place. Please keep it that way in any fork: +the dependency runs one direction only. + +Node keys and WireGuard keys are generated and held by the core in the Keychain / Keystore. They never +cross into Swift, Kotlin or JavaScript, let alone the network. + +## Features + +- Multiple tailnets at once, with a switcher — a self-hosted Headscale and a Tailscale account side by + side, each with its own node key and prefs. +- Pre-auth key enrolment, Tailscale interactive login, and one-tap invite links. +- Peer list with online state, exit-node selection, and subnet-route acceptance. +- Node-key expiry warnings before the device quietly stops working. +- Reconnect on boot (Android), and a tunnel-crash report you can read locally. +- Tablet-aware layout; phone-shaped, not stretched. + +## Building + +Requires **bun**, **Xcode** (iOS), the **Android SDK/NDK** (Android), **Go 1.26+** with `gomobile`, and +**ImageMagick** for icon generation (optional — a committed icon is used if it is missing). + +```bash +bun install --frozen-lockfile +go install golang.org/x/mobile/cmd/gomobile@latest +go install golang.org/x/mobile/cmd/gobind@latest +``` + +Then, with a device attached: + +```bash +scripts/build-ios.sh # Release .app, installed on the cabled iPhone +DEVICE= scripts/build-ios.sh # pick one, when several are paired +NO_INSTALL=1 scripts/build-ios.sh # build only + +scripts/build-android.sh # Release APK, installed over adb +ABIS=arm64-v8a,x86_64 scripts/build-android.sh # add an emulator ABI +``` + +Both scripts build the **Release** configuration and nothing else. A Debug build needs Metro attached +and behaves differently from what ships, so testing one answers the wrong question. + +Logs land in `$TMPDIR/offscale-build/`. + +### Two things about the scripts that are load-bearing + +**They run `bun run prebuild`, not `expo prebuild`.** The package script runs +`scripts/gen-adaptive-icon.sh` first, which insets the Android adaptive-icon foreground so the launcher +mask does not crop the artwork. Calling `expo prebuild` directly skips that silently and ships a +cropped icon. + +**Android builds `arm64-v8a` only.** Expo's generated `gradle.properties` asks for four ABIs, and the +Tailscale core is a large `.so` that gets built once per ABI. `-PreactNativeArchitectures` is passed on +the command line rather than edited into `gradle.properties`, because `expo prebuild --clean` +regenerates that file on every build and an edit there survives nothing. Pass `ABIS=` to override. + +### iOS needs an Xcode account signed in + +The packet tunnel requires the Network Extensions entitlement, and `-allowProvisioningUpdates` cannot +create the profile for it while Xcode has no account signed in. It falls back to the wildcard team +profile and fails with *"Provisioning profile … doesn't include the Network Extensions capability"*. +Sign in under **Xcode → Settings → Accounts** first. Android has no equivalent gate. See +`modules/officer-net/plugin/README.md` for the full provisioning list. + +## Changing the Go core means rebuilding it BY HAND, per platform + +This is the trap that costs the most time, so it gets its own section. + +`modules/officer-net/gocore/` is bound with `gomobile` into a `.xcframework` (iOS) and an `.aar` +(Android). **Neither is regenerated by an ordinary app build.** Touch any `.go` file, `go.mod` or +`go.sum` and you must run: + +```bash +PATH="$PATH:$HOME/go/bin" modules/officer-net/scripts/build-libtailscale-ios.sh # a few minutes +PATH="$PATH:$HOME/go/bin" modules/officer-net/scripts/build-libtailscale-android.sh +``` + +Forgetting does not fail where you changed something. It fails as a missing symbol, and the two +platforms fail very differently: + +| platform | error | when | +|----------|-------------------------------------------------------|------| +| Android | `e: Unresolved reference 'loginTo'` | Kotlin compile — loud, early, obvious | +| iOS | `error: cannot find 'TailcoreListProfiles' in scope` | Swift compile, **long after the Go compiled cleanly** against a vendored xcframework whose headers are simply older than the source beside them | + +`scripts/ensure-core.sh` compares mtimes and runs the right script for you before each app build, which +is why both build scripts source it — a rule you have to remember every time is not a rule. If you are +debugging, confirm the symbols actually landed: + +```bash +grep Tailcore modules/officer-net/ios/libs/TailCore.xcframework/ios-arm64/Tailcore.framework/Headers/*.h +``` + +The built artifacts are gitignored because of their size. They are not optional. + +## Layout + +``` +src/ + App.tsx the whole shell — splash, theme, one screen + ui/ shared primitives: theme, cards, tabs, the wallpaper frame + vpn/ the screen, its state hook, the native bridge, invite handling + settings/ the settings page (no accounts, no servers, no sign-in) +modules/officer-net/ the native module: Go core shim, Swift NE, Kotlin VpnService, config plugin +scripts/ build-ios.sh, build-android.sh, ensure-core.sh, gen-adaptive-icon.sh +``` + +`src/vpn/officer-net.ts` is the readable index of the native surface — every capability the app has +passes through it. + +## Licence + +MIT — see [LICENSE](LICENSE). + +It embeds Tailscale's client core under the BSD 3-Clause licence. **[NOTICE](NOTICE) is required +reading before you distribute a build**: it carries Tailscale's copyright and licence text, says which +built artifacts contain their code, and covers the trademark position separately from the licence. + +OffScale is an independent, unofficial client. It is not affiliated with, endorsed by or supported by +Tailscale Inc. Please do not ask their support about it. diff --git a/app.json b/app.json new file mode 100644 index 0000000..1eee9d5 --- /dev/null +++ b/app.json @@ -0,0 +1,71 @@ +{ + "expo": { + "name": "OffScale", + "slug": "offscale", + "version": "1.0.0", + "orientation": "portrait", + "userInterfaceStyle": "automatic", + "icon": "./assets/icon.png", + "assetBundlePatterns": [ + "**/*" + ], + "scheme": "officer-offscale", + "plugins": [ + "expo-dev-client", + "expo-status-bar", + "expo-system-ui", + [ + "officer-net", + { + "appGroup": "group.pastilhas.officer.offscale", + "neBundleId": "dev.officer.offscale.net", + "enableNetworkExtension": true + } + ] + ], + "experiments": { + "tsconfigPaths": true + }, + "ios": { + "bundleIdentifier": "dev.officer.offscale", + "appleTeamId": "RGHC4NJC77", + "supportsTablet": true, + "infoPlist": { + "ITSAppUsesNonExemptEncryption": false + } + }, + "android": { + "package": "dev.officer.offscale", + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#000000" + }, + "permissions": [ + "android.permission.FOREGROUND_SERVICE", + "android.permission.FOREGROUND_SERVICE_SPECIAL_USE", + "android.permission.RECEIVE_BOOT_COMPLETED" + ] + }, + "extra": { + "eas": { + "build": { + "experimental": { + "ios": { + "appExtensions": [ + { + "targetName": "OffTailTunnel", + "bundleIdentifier": "dev.officer.offscale.net", + "entitlements": { + "com.apple.developer.networking.networkextension": [ + "packet-tunnel-provider" + ] + } + } + ] + } + } + } + } + } + } +} diff --git a/assets/adaptive-icon.png b/assets/adaptive-icon.png new file mode 100644 index 0000000..9e19588 Binary files /dev/null and b/assets/adaptive-icon.png differ diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..5713ab9 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/wordmark.png b/assets/wordmark.png new file mode 100644 index 0000000..ecd559a Binary files /dev/null and b/assets/wordmark.png differ diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..7d507e1 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'], + }; +}; diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..b81e47f --- /dev/null +++ b/bun.lock @@ -0,0 +1,1285 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "offscale-mobile", + "dependencies": { + "@expo/vector-icons": "15.1.1", + "@react-native-async-storage/async-storage": "2.2.0", + "expo": "57.0.8", + "expo-clipboard": "57.0.1", + "expo-dev-client": "57.0.9", + "expo-modules-core": "57.0.7", + "expo-status-bar": "57.0.1", + "expo-system-ui": "57.0.1", + "nativewind": "4.2.6", + "officer-net": "file:./modules/officer-net", + "react": "19.2.3", + "react-native": "0.86.0", + "react-native-safe-area-context": "5.7.0", + "react-native-svg": "15.15.4", + }, + "devDependencies": { + "@babel/core": "7.29.7", + "@types/react": "19.2.17", + "babel-preset-expo": "57.0.4", + "tailwindcss": "3.4.19", + "typescript": "6.0.3", + }, + }, + }, + "packages": { + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], + + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="], + + "@babel/helper-create-regexp-features-plugin": ["@babel/helper-create-regexp-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "regexpu-core": "^6.3.1", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg=="], + + "@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.8", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "debug": "^4.4.3", "lodash.debounce": "^4.0.8", "resolve": "^1.22.11" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-remap-async-to-generator": ["@babel/helper-remap-async-to-generator@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-wrap-function": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og=="], + + "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.29.7", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="], + + "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helper-wrap-function": ["@babel/helper-wrap-function@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "@babel/plugin-proposal-decorators": ["@babel/plugin-proposal-decorators@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-decorators": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg=="], + + "@babel/plugin-proposal-export-default-from": ["@babel/plugin-proposal-export-default-from@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-p+G5BNXDcy3bOXplhY4HybQ1GxH3i2Tppmdm/3epyRu2VgJJZuUlZ61MqRTg582Q7ZLBdP7fePYvsumSEkMxcQ=="], + + "@babel/plugin-syntax-decorators": ["@babel/plugin-syntax-decorators@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg=="], + + "@babel/plugin-syntax-dynamic-import": ["@babel/plugin-syntax-dynamic-import@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ=="], + + "@babel/plugin-syntax-export-default-from": ["@babel/plugin-syntax-export-default-from@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-foag0BB37ROhdeIX9O8G0jX7hw0UekJc04cHMrYLOnrErsnBKqJGHJ8eDRpoCFZBvEPPygmmtw4qyU97qa4oOw=="], + + "@babel/plugin-syntax-flow": ["@babel/plugin-syntax-flow@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], + + "@babel/plugin-syntax-nullish-coalescing-operator": ["@babel/plugin-syntax-nullish-coalescing-operator@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ=="], + + "@babel/plugin-syntax-optional-chaining": ["@babel/plugin-syntax-optional-chaining@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg=="], + + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA=="], + + "@babel/plugin-transform-arrow-functions": ["@babel/plugin-transform-arrow-functions@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ=="], + + "@babel/plugin-transform-async-generator-functions": ["@babel/plugin-transform-async-generator-functions@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-remap-async-to-generator": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA=="], + + "@babel/plugin-transform-async-to-generator": ["@babel/plugin-transform-async-to-generator@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-remap-async-to-generator": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w=="], + + "@babel/plugin-transform-block-scoping": ["@babel/plugin-transform-block-scoping@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ=="], + + "@babel/plugin-transform-class-properties": ["@babel/plugin-transform-class-properties@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA=="], + + "@babel/plugin-transform-class-static-block": ["@babel/plugin-transform-class-static-block@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.12.0" } }, "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A=="], + + "@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g=="], + + "@babel/plugin-transform-destructuring": ["@babel/plugin-transform-destructuring@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg=="], + + "@babel/plugin-transform-export-namespace-from": ["@babel/plugin-transform-export-namespace-from@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA=="], + + "@babel/plugin-transform-flow-strip-types": ["@babel/plugin-transform-flow-strip-types@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-flow": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ=="], + + "@babel/plugin-transform-for-of": ["@babel/plugin-transform-for-of@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ=="], + + "@babel/plugin-transform-logical-assignment-operators": ["@babel/plugin-transform-logical-assignment-operators@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q=="], + + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ=="], + + "@babel/plugin-transform-named-capturing-groups-regex": ["@babel/plugin-transform-named-capturing-groups-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ=="], + + "@babel/plugin-transform-nullish-coalescing-operator": ["@babel/plugin-transform-nullish-coalescing-operator@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg=="], + + "@babel/plugin-transform-object-rest-spread": ["@babel/plugin-transform-object-rest-spread@7.29.7", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-transform-destructuring": "^7.29.7", "@babel/plugin-transform-parameters": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A=="], + + "@babel/plugin-transform-optional-catch-binding": ["@babel/plugin-transform-optional-catch-binding@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng=="], + + "@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ=="], + + "@babel/plugin-transform-parameters": ["@babel/plugin-transform-parameters@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g=="], + + "@babel/plugin-transform-private-methods": ["@babel/plugin-transform-private-methods@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug=="], + + "@babel/plugin-transform-private-property-in-object": ["@babel/plugin-transform-private-property-in-object@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA=="], + + "@babel/plugin-transform-react-display-name": ["@babel/plugin-transform-react-display-name@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q=="], + + "@babel/plugin-transform-react-jsx": ["@babel/plugin-transform-react-jsx@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/types": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A=="], + + "@babel/plugin-transform-react-jsx-development": ["@babel/plugin-transform-react-jsx-development@7.29.7", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], + + "@babel/plugin-transform-react-pure-annotations": ["@babel/plugin-transform-react-pure-annotations@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-H5E+HBgDpr6Q5t+Aj11tL7XkIui1jhbIoArVQnqjgXo5/3YxkN7ZEBcWF4RQlB0T4rrxJQbXS6kiFV6B7XTqUA=="], + + "@babel/plugin-transform-regenerator": ["@babel/plugin-transform-regenerator@7.29.8", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg=="], + + "@babel/plugin-transform-runtime": ["@babel/plugin-transform-runtime@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "babel-plugin-polyfill-corejs2": "^0.4.14", "babel-plugin-polyfill-corejs3": "^0.13.0", "babel-plugin-polyfill-regenerator": "^0.6.5", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q=="], + + "@babel/plugin-transform-shorthand-properties": ["@babel/plugin-transform-shorthand-properties@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg=="], + + "@babel/plugin-transform-template-literals": ["@babel/plugin-transform-template-literals@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA=="], + + "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-syntax-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw=="], + + "@babel/plugin-transform-unicode-regex": ["@babel/plugin-transform-unicode-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA=="], + + "@babel/preset-typescript": ["@babel/preset-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/plugin-transform-modules-commonjs": "^7.29.7", "@babel/plugin-transform-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="], + + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@expo/cli": ["@expo/cli@57.0.15", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~57.0.7", "@expo/config-plugins": "~57.0.8", "@expo/devcert": "^1.2.1", "@expo/env": "~2.4.2", "@expo/image-utils": "^0.11.4", "@expo/inline-modules": "^0.1.5", "@expo/json-file": "^11.0.1", "@expo/log-box": "^57.0.2", "@expo/metro": "~56.0.0", "@expo/metro-config": "~57.0.8", "@expo/metro-file-map": "^57.0.1", "@expo/osascript": "^2.7.1", "@expo/package-manager": "^1.13.1", "@expo/plist": "^0.8.1", "@expo/prebuild-config": "^57.0.12", "@expo/require-utils": "^57.0.4", "@expo/router-server": "^57.0.6", "@expo/schema-utils": "^57.0.2", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.86.2", "accepts": "^1.3.8", "agent-cli-detector": "^0.1.2", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^57.0.3", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.2", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "sandbox-cli-detector": "^0.2.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-YIE5xgZlkwx6OyjjVq/zAfqtYvczsWLEkOukDJwaZDaMQl8x5/CjoqC/Oe670DFQc5xjqguH6cmZUWHHL7yH3g=="], + + "@expo/code-signing-certificates": ["@expo/code-signing-certificates@0.0.6", "", { "dependencies": { "node-forge": "^1.3.3" } }, "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w=="], + + "@expo/config": ["@expo/config@57.0.7", "", { "dependencies": { "@expo/config-plugins": "~57.0.7", "@expo/config-types": "^57.0.2", "@expo/json-file": "^11.0.1", "@expo/require-utils": "^57.0.4", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", "slugify": "^1.3.4" } }, "sha512-4A+V8x5OmQqNm76l84S+RrB6kVoeFrvcm/Xn/6d+ELPF/HeucDheAFchdYxYNy+NEvWzuNlI/oCvrftIeK+dbQ=="], + + "@expo/config-plugins": ["@expo/config-plugins@57.0.8", "", { "dependencies": { "@expo/config-types": "^57.0.2", "@expo/json-file": "~11.0.1", "@expo/plist": "^0.8.1", "@expo/require-utils": "^57.0.4", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^13.0.0", "semver": "^7.5.4", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-x6lx4s/19i39/+1dMPwb9tFCc4WR843dG5Yi+C64lhKL3YHX+6oKrT2Kv72PCEalnUY+usQDkB3NrLSrYOWtDg=="], + + "@expo/config-types": ["@expo/config-types@57.0.2", "", {}, "sha512-ewW08OonrcRIsRKIlFvvcmmafE5zemb1ocu3HkNwtVPyRtj2w42pZCAkMIROYpcVBaPnc3mDT9UZDzwXWC3i6g=="], + + "@expo/devcert": ["@expo/devcert@1.2.1", "", { "dependencies": { "@expo/sudo-prompt": "^9.3.1", "debug": "^3.1.0" } }, "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA=="], + + "@expo/devtools": ["@expo/devtools@57.0.1", "", { "dependencies": { "chalk": "^4.1.2" }, "peerDependencies": { "react": "*", "react-native": "*" }, "optionalPeers": ["react", "react-native"] }, "sha512-GyUf+wFNkbttaX0jR7MZa9bm77U0IrLg6d2AjpxdyoXw/w4abHoXG0oFufwLMgP9zLTd5+Ct4X/ffNUTnlzZgg=="], + + "@expo/dom-webview": ["@expo/dom-webview@57.0.1", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-lAKsME4SAq+8sf56oN0DX5TBYyruupoRxbWbD2xf9RnKY8y6x8eb9LCE5pxSN0qyWdqnp+0wmyWzDkKboThKAw=="], + + "@expo/env": ["@expo/env@2.4.2", "", { "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", "getenv": "^2.0.0" } }, "sha512-28pqaEqwnmLduZ00Pq9HkSzE5wbj1MTwp5/n8nm8rD8MCjR9eUnVOwmNksPI3Be2ReAPO/DbPn1puy0mvoocsQ=="], + + "@expo/expo-modules-macros-plugin": ["@expo/expo-modules-macros-plugin@0.6.1", "", {}, "sha512-cpsLZE4rqkc1Y3eZTkxB98jrqY1YXgetmtxFt8q89jBRmk3quRuk1BZo+VcnCSObZardjg99r1k5xijEMONFGA=="], + + "@expo/fingerprint": ["@expo/fingerprint@0.20.7", "", { "dependencies": { "@expo/env": "^2.4.2", "@expo/spawn-async": "^1.8.0", "arg": "^5.0.2", "chalk": "^4.1.2", "debug": "^4.3.4", "getenv": "^2.0.0", "glob": "^13.0.0", "ignore": "^5.3.1", "minimatch": "^10.2.2", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "bin": { "fingerprint": "bin/cli.js" } }, "sha512-tYyZD4XZSn1C30pr9IvjN/BjAqpf6r9e1NL09lPvheO1DMLByOVdmMHFLSwW8Pu6veVgzue7GDWmq9P8mc8GMg=="], + + "@expo/image-utils": ["@expo/image-utils@0.11.4", "", { "dependencies": { "@expo/require-utils": "^57.0.4", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "getenv": "^2.0.0", "jimp-compact": "0.16.1", "parse-png": "^2.1.0", "semver": "^7.6.0" } }, "sha512-pn/4770DIEOcYZr484uazuwg20FX/qaDkeMRF6J+oxejynDmEmO8wLsCudaNShFE0BhyKGQTYrs2rsRhqrqESw=="], + + "@expo/inline-modules": ["@expo/inline-modules@0.1.5", "", { "dependencies": { "@expo/config-plugins": "~57.0.7" } }, "sha512-LC+kWeIwnvsGIvDaFBd8uzleWzWZiZTCG7CmtfxBLjW/Gify596X67ZqTi76iHzm5KToAfwwX0nppAKPDA9vQw=="], + + "@expo/json-file": ["@expo/json-file@11.0.1", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "json5": "^2.2.3" } }, "sha512-zxHWj4MKKMAL29ZQSY/Fssx4Thluk40JmuGNaeS078wy/NhlFhnVi+rHHunulE3xJAJ0CM73m8VK2+GkF9eRwQ=="], + + "@expo/local-build-cache-provider": ["@expo/local-build-cache-provider@57.0.6", "", { "dependencies": { "@expo/config": "~57.0.7", "chalk": "^4.1.2" } }, "sha512-6aFMROb1SzIvrefpwhgS5QGNELU9T2lpK0Hcl6oiZ4/mbKgZRk0WEPauo/dHH6IgDmyK25InCMHF5nj7XY4LWg=="], + + "@expo/log-box": ["@expo/log-box@57.0.2", "", { "dependencies": { "@expo/dom-webview": "^57.0.1", "anser": "^1.4.9", "stacktrace-parser": "^0.1.10" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-ZsFyfIR7YCbQAdVLzuTUmMHofZC7ZS9ywYCJNPlLc78x59cI8GwXFEIVbRjjC0uJERpNtXx/tsNNnkhexXlzMw=="], + + "@expo/metro": ["@expo/metro@56.0.0", "", { "dependencies": { "metro": "0.84.4", "metro-babel-transformer": "0.84.4", "metro-cache": "0.84.4", "metro-cache-key": "0.84.4", "metro-config": "0.84.4", "metro-core": "0.84.4", "metro-file-map": "0.84.4", "metro-minify-terser": "0.84.4", "metro-resolver": "0.84.4", "metro-runtime": "0.84.4", "metro-source-map": "0.84.4", "metro-symbolicate": "0.84.4", "metro-transform-plugins": "0.84.4", "metro-transform-worker": "0.84.4" } }, "sha512-5gIgQHtEpjjvsjKfVtIv23a98LLRV0/y07PDShEwYSytAMlE3FSF8RHXqtHc1sUJL6dn7hnuIBpIbrLXXuVi0A=="], + + "@expo/metro-config": ["@expo/metro-config@57.0.8", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", "@expo/config": "~57.0.7", "@expo/env": "~2.4.2", "@expo/json-file": "~11.0.1", "@expo/metro": "~56.0.0", "@expo/require-utils": "^57.0.4", "@expo/spawn-async": "^1.8.0", "@jridgewell/gen-mapping": "^0.3.13", "@jridgewell/remapping": "^2.3.5", "@jridgewell/sourcemap-codec": "^1.5.5", "browserslist": "^4.25.0", "chalk": "^4.1.0", "debug": "^4.3.2", "getenv": "^2.0.0", "glob": "^13.0.0", "hermes-parser": "^0.36.0", "jsc-safe-url": "^0.2.4", "lightningcss": "^1.30.1", "picomatch": "^4.0.4", "postcss": "^8.5.14", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*" }, "optionalPeers": ["expo"] }, "sha512-cZOVjbljqRBMCXcloc5k23gsFOhWdiKXhDkp5jU/wY6IXR6G7cYtNOwxKfxI1QLhs0KcXY0gDmZ2UIueoHzY7g=="], + + "@expo/metro-file-map": ["@expo/metro-file-map@57.0.1", "", { "dependencies": { "debug": "^4.3.4", "fb-watchman": "^2.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "walker": "^1.0.8" } }, "sha512-8JXfVstZN7QnP4NianZZnlTVboOWR0sG8trUDNajOjnbGlPln29vponXM84tY+3tAHapz5/TxE53L0ixUwqPtA=="], + + "@expo/osascript": ["@expo/osascript@2.7.1", "", { "dependencies": { "@expo/spawn-async": "^1.8.0" } }, "sha512-Zn03EX6In7ts2lPUW2ESUSkEhEWQN1qqsiXjadtZMJOuZRkMiAg1ZQHuvz9DjByDWNJ2pBwAGyrts9lj9k389g=="], + + "@expo/package-manager": ["@expo/package-manager@1.13.1", "", { "dependencies": { "@expo/json-file": "^11.0.1", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "resolve-workspace-root": "^2.0.0" } }, "sha512-y/K+CaYYpZpNGZhSX4HyLT/vyIunFjNfyoxNysPBCefeLKI/VCx6f9LNPzrxayr3rCYO5bl9O8H+HRQK265Nkg=="], + + "@expo/plist": ["@expo/plist@0.8.1", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-3gTReGIUm0oRaMClsAJYxBnVPCl6fVpsl8HS+DTVxDhW4GyVyxg9E/Znm3BvcHtUJ51RJJI14pC1wvrNilCRHw=="], + + "@expo/prebuild-config": ["@expo/prebuild-config@57.0.12", "", { "dependencies": { "@expo/config": "~57.0.7", "@expo/config-plugins": "~57.0.8", "@expo/config-types": "^57.0.2", "@expo/image-utils": "^0.11.4", "@expo/json-file": "^11.0.1", "@react-native/normalize-colors": "0.86.2", "debug": "^4.3.1", "expo-modules-autolinking": "~57.0.10", "resolve-from": "^5.0.0", "semver": "^7.6.0" } }, "sha512-3vwrQ2DSGijJUa8K/j3xUfOdhYgygKdWAjJ7o3r143BGnKD1SwPIMEN7dSDhkmDWR4FAXTQwgaD8dHh5VZd/jA=="], + + "@expo/require-utils": ["@expo/require-utils@57.0.4", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.25.2", "@babel/plugin-transform-modules-commonjs": "^7.24.8" }, "peerDependencies": { "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["typescript"] }, "sha512-e7xbg/9BTQcsZE/oErafZXtI7kh5IgfasLJ97J5sFSzX2cA74pDvdlhW1KHVSaDkQyQv6h1LSLhsY7dEeOk7hw=="], + + "@expo/router-server": ["@expo/router-server@57.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "@expo/metro-runtime": "^57.0.10", "expo": "*", "expo-constants": "^57.0.11", "expo-font": "^57.0.1", "expo-router": "*", "expo-server": "^57.0.3", "react": "*", "react-dom": "*", "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" }, "optionalPeers": ["@expo/metro-runtime", "expo-router", "react-dom", "react-server-dom-webpack"] }, "sha512-/0H7OIilU4lmdR3V9UZuKou71cwaJ11sneW8/T6Rtr46LA71lgBWzdRldvVxaWv8dT+cu90f4jl+M3DYXN9MqQ=="], + + "@expo/schema-utils": ["@expo/schema-utils@57.0.2", "", {}, "sha512-fMu/jyN0l1Wzv7XkeWR4IYCx1M8ryui3FdBNGrWwbRgJ7EhxXxK8E2jxP2W3pbgUwUY0V3hG8+GyfCZwny+Lxw=="], + + "@expo/sdk-runtime-versions": ["@expo/sdk-runtime-versions@1.0.0", "", {}, "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ=="], + + "@expo/spawn-async": ["@expo/spawn-async@1.8.0", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw=="], + + "@expo/sudo-prompt": ["@expo/sudo-prompt@9.3.2", "", {}, "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw=="], + + "@expo/vector-icons": ["@expo/vector-icons@15.1.1", "", { "peerDependencies": { "expo-font": ">=14.0.4", "react": "*", "react-native": "*" } }, "sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw=="], + + "@expo/ws-tunnel": ["@expo/ws-tunnel@2.0.0", "", { "peerDependencies": { "ws": "^8.0.0" } }, "sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw=="], + + "@expo/xcpretty": ["@expo/xcpretty@4.4.4", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw=="], + + "@isaacs/ttlcache": ["@isaacs/ttlcache@1.4.1", "", {}, "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA=="], + + "@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + + "@jest/types": ["@jest/types@29.6.3", "", { "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^17.0.8", "chalk": "^4.0.0" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@react-native-async-storage/async-storage": ["@react-native-async-storage/async-storage@2.2.0", "", { "dependencies": { "merge-options": "^3.0.4" }, "peerDependencies": { "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw=="], + + "@react-native/assets-registry": ["@react-native/assets-registry@0.86.0", "", {}, "sha512-nIaXbm2jX1OTYp0qbviJ3O6KZivoE8z3BnhUQ2LsqfZSWRoOK/n1qsiAr6oALiNKWnXY3j2KPwtYORnZzp8xew=="], + + "@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.86.0", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@react-native/codegen": "0.86.0" } }, "sha512-qdsABWNW7uTll90l4Vh03gjeyu3WVDi2CyiiyvYGMRDcoYbjbQi6df3BMAm9lQI2yslZ1T14LlDDAsgTwNxplA=="], + + "@react-native/babel-preset": ["@react-native/babel-preset@0.87.0", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-react-display-name": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.25.2", "@babel/plugin-transform-react-jsx-self": "^7.24.7", "@babel/plugin-transform-react-jsx-source": "^7.24.7", "@babel/plugin-transform-regenerator": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@react-native/babel-plugin-codegen": "0.87.0", "babel-plugin-syntax-hermes-parser": "0.36.1", "babel-plugin-transform-flow-enums": "^0.0.2", "react-refresh": "^0.14.0" } }, "sha512-vthkHlSDWFVCQtQ22JZadM1J7wicn0773sP0C8wMb+2g6+6hn1209A1rMJz0ijpTI+RQ4XyQRmbnyTsf4kuIWg=="], + + "@react-native/codegen": ["@react-native/codegen@0.86.0", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.29.0", "hermes-parser": "0.36.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "tinyglobby": "^0.2.15", "yargs": "^17.6.2" } }, "sha512-uTs9DBo3+/lUqinsGZK0FKJRBVClrwMXoZToaDxE1Q2SL2e55vs2GwyZfIKzPl5uJnbu4PfFMIp0/mLXLWUMuA=="], + + "@react-native/community-cli-plugin": ["@react-native/community-cli-plugin@0.86.0", "", { "dependencies": { "@react-native/dev-middleware": "0.86.0", "debug": "^4.4.0", "invariant": "^2.2.4", "metro": "^0.84.3", "metro-config": "^0.84.3", "metro-core": "^0.84.3", "semver": "^7.1.3" }, "peerDependencies": { "@react-native-community/cli": "*", "@react-native/metro-config": "0.86.0" }, "optionalPeers": ["@react-native-community/cli", "@react-native/metro-config"] }, "sha512-Jv8p1ebEPfTzs8gmrjsdT2XMXFfeAg45Pman+XPLFGaSeGAZkutRFRyX9Cs9aGTSOyIA9YPJ6vDNb1ayTf1FKQ=="], + + "@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.86.2", "", {}, "sha512-KGS1aV5F6cIqpnoIUhLBXyVzy1oAj8jBFGau6vX4Vy0HXRJN7p+68RU7x6NuyraHvQcR14ccMGT5TkFuNjQ4gA=="], + + "@react-native/debugger-shell": ["@react-native/debugger-shell@0.86.2", "", { "dependencies": { "cross-spawn": "^7.0.6", "debug": "^4.4.0", "fb-dotslash": "0.5.8" } }, "sha512-/TaVJ2+gGajZPJGrFaObUQmHmlaxAlfmOPZicl6pNKDUjzSgFMpcLkdTOExvb+USYTVdGX1XwxXyvjQdUO2bvg=="], + + "@react-native/dev-middleware": ["@react-native/dev-middleware@0.86.2", "", { "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.86.2", "@react-native/debugger-shell": "0.86.2", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.3.0", "connect": "^3.6.5", "debug": "^4.4.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "open": "^7.0.3", "serve-static": "^1.16.2", "ws": "^7.5.10" } }, "sha512-B7L0vKvg+IcEElT7Vpqh1xj5yJAqWUegjbP+bQRaorJMAYnv11GkliTnZV2AdTDfZQJWgOEx8i8LGkHkUg7bnA=="], + + "@react-native/gradle-plugin": ["@react-native/gradle-plugin@0.86.0", "", {}, "sha512-a1RcfaEDqWExCGfCwadIxt4l8FvKYgFqeMf2uzeKyAOnb+vTGNIeCvifFL2MqvgaeYxlER437HbMIajGcuJ1pQ=="], + + "@react-native/js-polyfills": ["@react-native/js-polyfills@0.86.0", "", {}, "sha512-zYy/Cjd1VTnZ2iCNaG9bDF9C3l2ntESiPRscjIlI5FKugu6aeTwsDSv1aI8Bc4Kp3vEdoVg+UQhLAhE4svREaQ=="], + + "@react-native/metro-babel-transformer": ["@react-native/metro-babel-transformer@0.87.0", "", { "dependencies": { "@babel/core": "^7.25.2", "@react-native/babel-preset": "0.87.0", "hermes-parser": "0.36.1", "nullthrows": "^1.1.1" } }, "sha512-z57jsBX3q0fh2jTnrXisBo8A5qfkVFrnVJ0HMKRa8+cVsw/bz6MiEkXA/dsBK8eJfjawccxzXhAIzTzwbnxl4Q=="], + + "@react-native/metro-config": ["@react-native/metro-config@0.87.0", "", { "dependencies": { "@react-native/js-polyfills": "0.87.0", "@react-native/metro-babel-transformer": "0.87.0", "metro-config": "^0.87.0", "metro-runtime": "^0.87.0" } }, "sha512-Pg0DmsJ/DbLP8JuxnRSDmrIaQjKhpzd2uYAZRyB2A8fJQzJWpAFLr7TUAXhiGD5025F6+PVPAnJL3HXaeMfZqQ=="], + + "@react-native/normalize-colors": ["@react-native/normalize-colors@0.86.0", "", {}, "sha512-kG0wfCGghUKlfxkJyyHCDVutWVYWK7/DG58ojA/4v9EfulgF+osuSQmlbNb3rcKX58qutm7JcldSeVLgGFha9g=="], + + "@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.86.0", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.2.0", "react": "*", "react-native": "0.86.0" }, "optionalPeers": ["@types/react"] }, "sha512-4/ZLXdf/OSpPDVO0AsQ1SJdRIzt5t9BNQ46QwGgxvX7/cirYR5k8KXctNGGgW8lQo2gZChEfY2zFCZg9nM/jiw=="], + + "@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="], + + "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], + + "@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="], + + "@types/istanbul-reports": ["@types/istanbul-reports@3.0.4", "", { "dependencies": { "@types/istanbul-lib-report": "*" } }, "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ=="], + + "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], + + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + + "@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="], + + "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.3", "", {}, "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg=="], + + "@xmldom/xmldom": ["@xmldom/xmldom@0.8.14", "", {}, "sha512-T4EDRUBVZYRldYApjEJiU0e1stYWaRAX7CuSnKzrpwdZKo53zGV8/pqfzV6FfwNl9YThD2OumQYvqtvjvgG7aQ=="], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + + "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], + + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "agent-cli-detector": ["agent-cli-detector@0.1.5", "", { "bin": { "agent-cli-detector": "dist/cli.js" } }, "sha512-6xvLw0EGPuxoYGZeqyMV4AK+WoZ31jsqb7a5pln1BTf6oDFsrgvfCJT7E7y9oAqifJZhJ3fZ0J36H7o6JzrB0Q=="], + + "anser": ["anser@1.4.10", "", {}, "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww=="], + + "ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "array-timsort": ["array-timsort@1.0.3", "", {}, "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ=="], + + "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], + + "babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.17", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w=="], + + "babel-plugin-polyfill-corejs3": ["babel-plugin-polyfill-corejs3@0.13.0", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5", "core-js-compat": "^3.43.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A=="], + + "babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.8", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.8" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg=="], + + "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="], + + "babel-plugin-react-native-web": ["babel-plugin-react-native-web@0.21.2", "", {}, "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA=="], + + "babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.36.1", "", { "dependencies": { "hermes-parser": "0.36.1" } }, "sha512-ycduwJbvdvIMmVvlAZqGggS+pm5Eu4Bk9pcV9Sm2Z4PJNRVsKkv0g7vHj+LeuC1gHTeF67sJXFOq61IlqCa2hA=="], + + "babel-plugin-transform-flow-enums": ["babel-plugin-transform-flow-enums@0.0.2", "", { "dependencies": { "@babel/plugin-syntax-flow": "^7.12.1" } }, "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ=="], + + "babel-preset-expo": ["babel-preset-expo@57.0.4", "", { "dependencies": { "@babel/generator": "^7.20.5", "@babel/helper-module-imports": "^7.25.9", "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-class-static-block": "^7.27.1", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-export-namespace-from": "^7.25.9", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-react-display-name": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.28.6", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@babel/plugin-transform-react-pure-annotations": "^7.27.1", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-plugin-codegen": "0.86.0", "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-native-web": "~0.21.0", "babel-plugin-syntax-hermes-parser": "^0.36.0", "babel-plugin-transform-flow-enums": "^0.0.2", "debug": "^4.3.4" }, "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", "expo-widgets": "^57.0.6", "react-refresh": ">=0.14.0 <1.0.0" }, "optionalPeers": ["@babel/runtime", "expo", "expo-widgets"] }, "sha512-EkFcNoE23HVzQT6ZNXs/adN8+G7rqEAF6tQn6LpRPYa1YY7wmX5GxhJF0kaYMNtBndNrMTN4+0rYE17VG13KFg=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.14", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww=="], + + "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], + + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="], + + "bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="], + + "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="], + + "bser": ["bser@2.1.1", "", { "dependencies": { "node-int64": "^0.4.0" } }, "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], + + "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="], + + "chromium-edge-launcher": ["chromium-edge-launcher@0.3.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0", "mkdirp": "^1.0.4" } }, "sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA=="], + + "ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + + "cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "^2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="], + + "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "comment-json": ["comment-json@4.6.2", "", { "dependencies": { "array-timsort": "^1.0.3", "esprima": "^4.0.1" } }, "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w=="], + + "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], + + "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="], + + "connect": ["connect@3.7.0", "", { "dependencies": { "debug": "2.6.9", "finalhandler": "1.1.2", "parseurl": "~1.3.3", "utils-merge": "1.0.1" } }, "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "core-js-compat": ["core-js-compat@3.50.0", "", { "dependencies": { "browserslist": "^4.28.7" } }, "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-tree": ["css-tree@1.1.3", "", { "dependencies": { "mdn-data": "2.0.14", "source-map": "^0.6.1" } }, "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], + + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + + "dnssd-advertise": ["dnssd-advertise@1.1.6", "", {}, "sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg=="], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.406", "", {}, "sha512-hWH5ORBi3d0IipnMh7BN5GDTaAmrSSSWmznwt2zltdiRNEWoEQyTwF0FFSBxzHO7hLSRT6loQu3IQGV0wg/Tvg=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "expo": ["expo@57.0.8", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "^57.0.10", "@expo/config": "~57.0.6", "@expo/config-plugins": "~57.0.6", "@expo/devtools": "~57.0.1", "@expo/dom-webview": "~57.0.1", "@expo/fingerprint": "^0.20.6", "@expo/local-build-cache-provider": "^57.0.4", "@expo/log-box": "^57.0.1", "@expo/metro": "~56.0.0", "@expo/metro-config": "~57.0.7", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~57.0.4", "expo-asset": "~57.0.7", "expo-constants": "~57.0.7", "expo-file-system": "~57.0.1", "expo-font": "~57.0.1", "expo-keep-awake": "~57.0.1", "expo-modules-autolinking": "~57.0.9", "expo-modules-core": "~57.0.7", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" }, "peerDependencies": { "@expo/metro-runtime": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-web": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/metro-runtime", "react-dom", "react-native-web", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-0IxxoPZbT54IH4fHL5NihkvED9HBVQx3uNdPvyv8pFUHWJ81RdFjL0aJys1IB7hbo09KTz4xW4I2aWVOXKAcJQ=="], + + "expo-asset": ["expo-asset@57.0.11", "", { "dependencies": { "@expo/image-utils": "^0.11.4", "expo-constants": "~57.0.11" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-tDaExt9fOKeSc51mmZNqfejyBt9argzo14XP9OmKEdAk2E/mWKJnUNj6rfUzHC5MhG120xuZC/d1ncqq0aC7Pg=="], + + "expo-clipboard": ["expo-clipboard@57.0.1", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-HWICri4+1ao7S6QEfcorxVumXDiDnx1guGGewjZgGJWLGxFYs0RgH8ujBs+lkTzBkMmlwADaWSlaesR+nDJt5Q=="], + + "expo-constants": ["expo-constants@57.0.11", "", { "dependencies": { "@expo/env": "~2.4.2" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-gpSZ13Nfx0BOc9ALjG1SMhks5csb8Z6ncEVdhDgv0NRrmJcHPRvyUlWaKjRjzwW730XrQ6PIXFRcnNmAxOLj5A=="], + + "expo-dev-client": ["expo-dev-client@57.0.9", "", { "dependencies": { "expo-dev-launcher": "~57.0.9", "expo-dev-menu": "~57.0.9", "expo-dev-menu-interface": "~57.0.0", "expo-manifests": "~57.0.1", "expo-updates-interface": "~57.0.1" }, "peerDependencies": { "expo": "*" } }, "sha512-DDqinc0EHMsqDUqkfad6c3pc5vr2oCxwAojSGVSBZe5paRIPgQTEWA+x76Qel13F1LO2LSG+DLs5W1vk3JwUow=="], + + "expo-dev-launcher": ["expo-dev-launcher@57.0.12", "", { "dependencies": { "@expo/schema-utils": "^57.0.2", "expo-dev-menu": "~57.0.12", "expo-manifests": "~57.0.1" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-Au00VBTezkzdlwbwxb6pcWGDmF/QlA0kTAYZZY1JFi8A351FLBEv9kcVdgNU+xasr8qloaD3wWwS429eqA6lWA=="], + + "expo-dev-menu": ["expo-dev-menu@57.0.12", "", { "dependencies": { "expo-dev-menu-interface": "~57.0.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-ub513/edOV/QIPTxLU86+TynUNCIlOAMAUrhHy7hJAHRLCJWn2qVpOxnfcGZimSGk7lthpRAf67oyAK0VE2egA=="], + + "expo-dev-menu-interface": ["expo-dev-menu-interface@57.0.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-F47VdzOHYc19FhI/jBgctpO8a5UskTIxG6a1E5t3W5gF8VImuvBQffdXXfLHhsuCl7dS3v3U0R45cleeVXO1Zg=="], + + "expo-file-system": ["expo-file-system@57.0.4", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-h8qDOxSW1SQfnZyvH0+HeOfXCr2X4v3x50+d19JuyQcjDR+JA3tng20TlQxIHazzLPPbwtSKA1Dj3+XIOpnoKg=="], + + "expo-font": ["expo-font@57.0.1", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-QyS9L1Kh9sKJg4gfU6rdbpxpmH+DyzBX8z6jVvXMUDoqLr1GqmkO/Wu379KCXjL///kWbhpNlbi7AgBuj4VdIQ=="], + + "expo-json-utils": ["expo-json-utils@57.0.1", "", {}, "sha512-cgTe1NqzQdYs/WN+3nIY5IZg8s0pb0xaTUbhYvxQDn137GbwRfHoGM2se3m3Vsl4Qu+B9G4RPEK5WJDEU2Do7g=="], + + "expo-keep-awake": ["expo-keep-awake@57.0.1", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-28lkFImeXTS+bhAjuCFV7w7tW5bXg27BJVrxv+nC/nyYa86qEa0oFeHwqol6ha5k4pdVDQgBF09GM4A1k76Ssg=="], + + "expo-manifests": ["expo-manifests@57.0.1", "", { "dependencies": { "expo-json-utils": "~57.0.1" }, "peerDependencies": { "expo": "*" } }, "sha512-qB/mDG2dYdl+EvUeQuqP8KFYCFgFCQjJYdWIHo8SFBgDzMYmdF286DFY2M1M9Okr99wkb5M4tgA3aCcwv3aEQA=="], + + "expo-modules-autolinking": ["expo-modules-autolinking@57.0.10", "", { "dependencies": { "@expo/require-utils": "^57.0.4", "@expo/spawn-async": "^1.8.0", "chalk": "^4.1.0", "commander": "^7.2.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-jNqLswMx8QHN8VXRgud+xT+9q+J9U63CEGlx+k4V/IE9Qyt9HrKbWp+pIDma0fOquta5HBfSfsShcI+NkEpTiA=="], + + "expo-modules-core": ["expo-modules-core@57.0.7", "", { "dependencies": { "@expo/expo-modules-macros-plugin": "0.6.1", "expo-modules-jsi": "~57.0.4", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-worklets": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0" }, "optionalPeers": ["react-native-worklets"] }, "sha512-5HrbCfgYmLs0a5dzfM4GmRGelVTPIg+eYp0vmSbWnKRTOPj5DyVOfg1rHGEsuNKp07QwDxfLJfQVp8CNbuvxMQ=="], + + "expo-modules-jsi": ["expo-modules-jsi@57.0.4", "", { "peerDependencies": { "react-native": "*" } }, "sha512-vt7FyqUqqFXiRVnBqYD7y+GSPTgeua5Ocoy0+SYt+RSHkZEA2Fyop7If3g1TYDzQObYybPRo7TG2Rle1XLaWFw=="], + + "expo-server": ["expo-server@57.0.3", "", {}, "sha512-aK+LdKzauHSGmsOStZtyxdzv0zWssCkxTw3m4QuOhfDSJsZaMRTd9O41d8ixU/QfELTbaJ0oRNcF7JFV/7O9YQ=="], + + "expo-status-bar": ["expo-status-bar@57.0.1", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-Xwaq1gAoVRWx5dPG5VhT5RSbnI9OilhZnO5qoPBnUaBAa5VzRzfdS8q0/bsPt0jR2DKLtGuP0bQ6efMJ4RIMDg=="], + + "expo-system-ui": ["expo-system-ui@57.0.1", "", { "dependencies": { "@react-native/normalize-colors": "0.86.0", "debug": "^4.3.2" }, "peerDependencies": { "expo": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-r8a6Jk2suL0vI7Uq4iKJab5Eesk8dkB56Q6HksVNkzuAExV0axoikQwZv8aAyHGbu2VHp0artB0N1/PQDLSgBg=="], + + "expo-updates-interface": ["expo-updates-interface@57.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-+LUWwJ0gf/TEKMVdQAw/Gjih4dvrk+URgy24X9qEGKuuMDZqjBRm9T4yQyBVALGL5TTdPUaB6ILxx3lshm3pwQ=="], + + "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + + "fb-dotslash": ["fb-dotslash@0.5.8", "", { "bin": { "dotslash": "bin/dotslash" } }, "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA=="], + + "fb-watchman": ["fb-watchman@2.0.2", "", { "dependencies": { "bser": "2.1.1" } }, "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fetch-nodeshim": ["fetch-nodeshim@0.4.10", "", {}, "sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "finalhandler": ["finalhandler@1.1.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "on-finished": "~2.3.0", "parseurl": "~1.3.3", "statuses": "~1.5.0", "unpipe": "~1.0.0" } }, "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA=="], + + "flow-enums-runtime": ["flow-enums-runtime@0.0.6", "", {}, "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw=="], + + "fontfaceobserver": ["fontfaceobserver@2.3.0", "", {}, "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg=="], + + "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="], + + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "hermes-compiler": ["hermes-compiler@250829098.0.14", "", {}, "sha512-5meXwsZxgiqFaJjNzwjzI9IyUkuGGBisu+z9BvQWmGVpjH6nz11hgqkyxe4dl8UAdyIV4lTbz91+Dlnjz0VxqA=="], + + "hermes-estree": ["hermes-estree@0.36.1", "", {}, "sha512-guv1nQ6IJ7S83NRFPWc3SA7IBZrdNC9kapwOq6uXvF4wP+sDCgjzQbKPCoyYmoyZRzztF/n/c36l/rccCZSiCw=="], + + "hermes-parser": ["hermes-parser@0.36.1", "", { "dependencies": { "hermes-estree": "0.36.1" } }, "sha512-GApNk4zLHi2UWoWZZkx7LNCOSzLSc5lB55pZ/PhK7ycFeg7u5LcF88p/WbpIi1XUDtE0MpHE3uRR3u3KB7TjSQ=="], + + "hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="], + + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], + + "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-plain-obj": ["is-plain-obj@2.1.0", "", {}, "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA=="], + + "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jest-get-type": ["jest-get-type@29.6.3", "", {}, "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw=="], + + "jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="], + + "jest-validate": ["jest-validate@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "camelcase": "^6.2.0", "chalk": "^4.0.0", "jest-get-type": "^29.6.3", "leven": "^3.1.0", "pretty-format": "^29.7.0" } }, "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw=="], + + "jest-worker": ["jest-worker@29.7.0", "", { "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw=="], + + "jimp-compact": ["jimp-compact@0.16.1", "", {}, "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww=="], + + "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], + + "jsc-safe-url": ["jsc-safe-url@0.2.4", "", {}, "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + + "lan-network": ["lan-network@0.2.1", "", { "bin": { "lan-network": "dist/lan-network-cli.js" } }, "sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A=="], + + "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], + + "lighthouse-logger": ["lighthouse-logger@1.4.2", "", { "dependencies": { "debug": "^2.6.9", "marky": "^1.2.2" } }, "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g=="], + + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], + + "lodash.throttle": ["lodash.throttle@4.1.1", "", {}, "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="], + + "log-symbols": ["log-symbols@2.2.0", "", { "dependencies": { "chalk": "^2.0.1" } }, "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "makeerror": ["makeerror@1.0.12", "", { "dependencies": { "tmpl": "1.0.5" } }, "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg=="], + + "marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="], + + "mdn-data": ["mdn-data@2.0.14", "", {}, "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow=="], + + "memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="], + + "merge-options": ["merge-options@3.0.4", "", { "dependencies": { "is-plain-obj": "^2.1.0" } }, "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ=="], + + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "metro": ["metro@0.84.4", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "accepts": "^2.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.35.0", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.84.4", "metro-cache": "0.84.4", "metro-cache-key": "0.84.4", "metro-config": "0.84.4", "metro-core": "0.84.4", "metro-file-map": "0.84.4", "metro-resolver": "0.84.4", "metro-runtime": "0.84.4", "metro-source-map": "0.84.4", "metro-symbolicate": "0.84.4", "metro-transform-plugins": "0.84.4", "metro-transform-worker": "0.84.4", "mime-types": "^3.0.1", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA=="], + + "metro-babel-transformer": ["metro-babel-transformer@0.84.4", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.35.0", "metro-cache-key": "0.84.4", "nullthrows": "^1.1.1" } }, "sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g=="], + + "metro-cache": ["metro-cache@0.84.4", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.84.4" } }, "sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg=="], + + "metro-cache-key": ["metro-cache-key@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw=="], + + "metro-config": ["metro-config@0.84.4", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.84.4", "metro-cache": "0.84.4", "metro-core": "0.84.4", "metro-runtime": "0.84.4", "yaml": "^2.6.1" } }, "sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q=="], + + "metro-core": ["metro-core@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.84.4" } }, "sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ=="], + + "metro-file-map": ["metro-file-map@0.84.4", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ=="], + + "metro-minify-terser": ["metro-minify-terser@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ=="], + + "metro-resolver": ["metro-resolver@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A=="], + + "metro-runtime": ["metro-runtime@0.84.4", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q=="], + + "metro-source-map": ["metro-source-map@0.84.4", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.84.4", "nullthrows": "^1.1.1", "ob1": "0.84.4", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g=="], + + "metro-symbolicate": ["metro-symbolicate@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.84.4", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA=="], + + "metro-transform-plugins": ["metro-transform-plugins@0.84.4", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow=="], + + "metro-transform-worker": ["metro-transform-worker@0.84.4", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "metro": "0.84.4", "metro-babel-transformer": "0.84.4", "metro-cache": "0.84.4", "metro-cache-key": "0.84.4", "metro-minify-terser": "0.84.4", "metro-source-map": "0.84.4", "metro-transform-plugins": "0.84.4", "nullthrows": "^1.1.1" } }, "sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="], + + "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "multitars": ["multitars@1.0.2", "", {}, "sha512-6GwVw5eLi9sThdtlS4PKwC7yRLaf45pYhIEzKBHdKxi+YOXGKFX8acIniH+Uh/+k9mS2lQOupTccjoe5r0/1IQ=="], + + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + + "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], + + "nativewind": ["nativewind@4.2.6", "", { "dependencies": { "comment-json": "^4.2.5", "debug": "^4.3.7", "react-native-css-interop": "0.2.6" }, "peerDependencies": { "tailwindcss": ">3.3.0" } }, "sha512-kLPRH3Yh/hhIFufXzUZDe39fuK471GwaPBCqYNgcRygILr+obV+wFfDmG+zaJnniVUFFQzYOTCEldem6XktZ+A=="], + + "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + + "node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="], + + "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], + + "node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "npm-package-arg": ["npm-package-arg@11.0.3", "", { "dependencies": { "hosted-git-info": "^7.0.0", "proc-log": "^4.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^5.0.0" } }, "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "nullthrows": ["nullthrows@1.1.1", "", {}, "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw=="], + + "ob1": ["ob1@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], + + "officer-net": ["officer-net@file:modules/officer-net", { "dependencies": { "@expo/config-plugins": "^57.0.0" } }], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "on-headers": ["on-headers@1.1.0", "", {}, "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="], + + "onetime": ["onetime@2.0.1", "", { "dependencies": { "mimic-fn": "^1.0.0" } }, "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ=="], + + "open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], + + "ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="], + + "parse-png": ["parse-png@2.1.0", "", { "dependencies": { "pngjs": "^3.3.0" } }, "sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], + + "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + + "plist": ["plist@3.1.1", "", { "dependencies": { "@xmldom/xmldom": "^0.9.10", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA=="], + + "pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="], + + "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], + + "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], + + "postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="], + + "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], + + "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="], + + "postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + + "proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="], + + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + + "promise": ["promise@8.3.0", "", { "dependencies": { "asap": "~2.0.6" } }, "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg=="], + + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + + "queue": ["queue@6.0.2", "", { "dependencies": { "inherits": "~2.0.3" } }, "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], + + "react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="], + + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "react-native": ["react-native@0.86.0", "", { "dependencies": { "@react-native/assets-registry": "0.86.0", "@react-native/codegen": "0.86.0", "@react-native/community-cli-plugin": "0.86.0", "@react-native/gradle-plugin": "0.86.0", "@react-native/js-polyfills": "0.86.0", "@react-native/normalize-colors": "0.86.0", "@react-native/virtualized-lists": "0.86.0", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-plugin-syntax-hermes-parser": "0.36.0", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "hermes-compiler": "250829098.0.14", "invariant": "^2.2.4", "memoize-one": "^5.0.0", "metro-runtime": "^0.84.3", "metro-source-map": "^0.84.3", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "tinyglobby": "^0.2.15", "whatwg-fetch": "^3.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "peerDependencies": { "@react-native/jest-preset": "0.86.0", "@types/react": "^19.1.1", "react": "^19.2.3" }, "optionalPeers": ["@react-native/jest-preset", "@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-17ALh/dd6AO4pgOVmOO5Axll5PbErEo3XFyLokyzW6usyi+OShIEPwUW26wLPlhVifgSOIfECCH0WN+0IqtJ1w=="], + + "react-native-css-interop": ["react-native-css-interop@0.2.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.22.15", "@babel/traverse": "^7.23.0", "@babel/types": "^7.23.0", "debug": "^4.3.7", "lightningcss": "~1.27.0", "semver": "^7.6.3" }, "peerDependencies": { "react": ">=18", "react-native": "*", "react-native-reanimated": ">=3.6.2", "react-native-safe-area-context": "*", "react-native-svg": "*", "tailwindcss": "~3" }, "optionalPeers": ["react-native-safe-area-context", "react-native-svg"] }, "sha512-uXad6EWqufupnRjrba2De30tiAx26kLEcwSV3eSLVBwpfYeOnrpDc9k6kHk6SBNbmHHlE5sc9vsfKdVRS9msYg=="], + + "react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.3.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA=="], + + "react-native-reanimated": ["react-native-reanimated@4.5.3", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", "semver": "^7.7.3" }, "peerDependencies": { "react": "*", "react-native": "0.83 - 0.86", "react-native-worklets": "0.10.x - 0.11.x" } }, "sha512-+owIckpD4sA13XKHaLrr8V1RP4paqoxKvz/jy2wl/ULjMsfcRlfIJdKJxR1kcQr4McBZ6hAoPo01NyLnmy2ycQ=="], + + "react-native-safe-area-context": ["react-native-safe-area-context@5.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ=="], + + "react-native-svg": ["react-native-svg@15.15.4", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A=="], + + "react-native-worklets": ["react-native-worklets@0.11.4", "", { "dependencies": { "@babel/generator": "^7.27.1", "@babel/plugin-transform-arrow-functions": "^7.27.1", "@babel/plugin-transform-class-properties": "^7.28.6", "@babel/plugin-transform-classes": "^7.28.6", "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", "@babel/plugin-transform-optional-chaining": "^7.28.6", "@babel/plugin-transform-shorthand-properties": "^7.27.1", "@babel/plugin-transform-template-literals": "^7.27.1", "@babel/plugin-transform-unicode-regex": "^7.27.1", "@babel/preset-typescript": "^7.28.5", "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1", "convert-source-map": "^2.0.0", "semver": "^7.7.4" }, "peerDependencies": { "@babel/core": "*", "@react-native/metro-config": "*", "react": "*", "react-native": "0.83 - 0.86" } }, "sha512-yNiDDQAVvt1wacBhnrEMTBlQzZ8Y8Rg7Dbdxs/r595EXw4REsZGE5sY1Ug8SmlQUT+0L+fX7oY0SuCs0mjiA4w=="], + + "react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], + + "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], + + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + + "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], + + "regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="], + + "regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="], + + "regexpu-core": ["regexpu-core@6.4.0", "", { "dependencies": { "regenerate": "^1.4.2", "regenerate-unicode-properties": "^10.2.2", "regjsgen": "^0.8.0", "regjsparser": "^0.13.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.2.1" } }, "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA=="], + + "regjsgen": ["regjsgen@0.8.0", "", {}, "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q=="], + + "regjsparser": ["regjsparser@0.13.2", "", { "dependencies": { "jsesc": "~3.1.0" }, "bin": { "regjsparser": "bin/parser" } }, "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + + "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], + + "resolve-workspace-root": ["resolve-workspace-root@2.0.1", "", {}, "sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w=="], + + "restore-cursor": ["restore-cursor@2.0.0", "", { "dependencies": { "onetime": "^2.0.0", "signal-exit": "^3.0.2" } }, "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "sandbox-cli-detector": ["sandbox-cli-detector@0.2.0", "", { "bin": { "sandbox-cli-detector": "dist/cli.js" } }, "sha512-4lyHX0ZU0AZKwjgZ1InxZAa3PNpyEb8rOQ+Zss1ReYmhNzW0Q+h1zE5nvniXN0HaAWZaZE1zgVNEirb0R7LmNg=="], + + "sax": ["sax@1.6.1", "", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], + + "serialize-error": ["serialize-error@2.1.0", "", {}, "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw=="], + + "serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "shell-quote": ["shell-quote@1.10.0", "", {}, "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="], + + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "simple-plist": ["simple-plist@1.3.1", "", { "dependencies": { "bplist-creator": "0.1.0", "bplist-parser": "0.3.1", "plist": "^3.0.5" } }, "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "slugify": ["slugify@1.6.9", "", {}, "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg=="], + + "source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + + "stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="], + + "stacktrace-parser": ["stacktrace-parser@0.1.11", "", { "dependencies": { "type-fest": "^0.7.1" } }, "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "stream-buffers": ["stream-buffers@2.2.0", "", {}, "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="], + + "structured-headers": ["structured-headers@0.4.1", "", {}, "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg=="], + + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "supports-hyperlinks": ["supports-hyperlinks@2.3.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], + + "terminal-link": ["terminal-link@2.1.1", "", { "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" } }, "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ=="], + + "terser": ["terser@5.50.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-CN9BVxWhgS/hRxtUMjtC2uRWSTcSfQFHMDWma6sKKfIivCD91sM+FOPfvwoaRMqCSrUpe1nv3jDamd9eEQ4y+w=="], + + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], + + "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + + "throat": ["throat@5.0.0", "", {}, "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tmpl": ["tmpl@1.0.5", "", {}, "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "toqr": ["toqr@0.1.1", "", {}, "sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA=="], + + "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], + + "type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="], + + "unicode-match-property-ecmascript": ["unicode-match-property-ecmascript@2.0.0", "", { "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" } }, "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q=="], + + "unicode-match-property-value-ecmascript": ["unicode-match-property-value-ecmascript@2.2.1", "", {}, "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg=="], + + "unicode-property-aliases-ecmascript": ["unicode-property-aliases-ecmascript@2.2.0", "", {}, "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.3.1", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], + + "uuid": ["uuid@7.0.3", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg=="], + + "validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "vlq": ["vlq@1.0.1", "", {}, "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w=="], + + "walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="], + + "warn-once": ["warn-once@0.1.1", "", {}, "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q=="], + + "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], + + "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], + + "whatwg-url-minimum": ["whatwg-url-minimum@0.1.2", "", {}, "sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="], + + "xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="], + + "xml2js": ["xml2js@0.6.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w=="], + + "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@expo/cli/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "@expo/cli/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "@expo/cli/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + + "@expo/config/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "@expo/config-plugins/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "@expo/devcert/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "@expo/fingerprint/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "@expo/image-utils/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "@expo/metro-config/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "@expo/prebuild-config/@react-native/normalize-colors": ["@react-native/normalize-colors@0.86.2", "", {}, "sha512-EzPFc9Y6lzYOWeso2almwXI7f8+qReHxWvT+algsOczb2UhWXIWXDoSvkdwoSfiwwmGt/ijJgKJoeHlzPkLwRg=="], + + "@expo/prebuild-config/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "@expo/ws-tunnel/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + + "@react-native/babel-preset/@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.87.0", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@react-native/codegen": "0.87.0" } }, "sha512-q0CCSAYgomb2G1fkmJ5ughwvVLqjHcdJ4w3Ffxw4YsdBenWYZsnMARyr/ghvcvfV33/YsZQhdMZ3XnpG6oth/Q=="], + + "@react-native/codegen/hermes-parser": ["hermes-parser@0.36.0", "", { "dependencies": { "hermes-estree": "0.36.0" } }, "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w=="], + + "@react-native/community-cli-plugin/@react-native/dev-middleware": ["@react-native/dev-middleware@0.86.0", "", { "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.86.0", "@react-native/debugger-shell": "0.86.0", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.3.0", "connect": "^3.6.5", "debug": "^4.4.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "open": "^7.0.3", "serve-static": "^1.16.2", "ws": "^7.5.10" } }, "sha512-20pTO6yTybmvXvro520H6C7jydIQnLKOl5qFtVEcHSdFrY63r3OGei+Rx9bILgSRmH6jgnfEcijcMx7pwWuQtw=="], + + "@react-native/community-cli-plugin/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "@react-native/metro-config/@react-native/js-polyfills": ["@react-native/js-polyfills@0.87.0", "", {}, "sha512-iwX3S/6XnR/TkOIk6Cz+FjeToDM/a4M1Cz/o1tfDakULjhKMI/UBtta1DsJgeg5c4WIJjkD4c8XlAJEb8pWOtg=="], + + "@react-native/metro-config/metro-config": ["metro-config@0.87.0", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.87.0", "metro-cache": "0.87.0", "metro-core": "0.87.0", "metro-runtime": "0.87.0" } }, "sha512-yZ9QAIzWH9MxwrzwRlX/CBGRWOT14l7klSDYg8hdtSdnoUs5A7MQRdHE2KB9iHVzGQW5wgWM5aXJswNeWbSQPA=="], + + "@react-native/metro-config/metro-runtime": ["metro-runtime@0.87.0", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-XsXZkgEwI0ZMYSBfvOMAbenzwa60XlObXJ27g6/Khgrz9ESbiBbAsd7hR62G2jRBYOhGAzG61Gk22dpRJj/mdw=="], + + "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], + + "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "compressible/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "compression/negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], + + "connect/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "css-tree/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "finalhandler/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], + + "finalhandler/on-finished": ["on-finished@2.3.0", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww=="], + + "finalhandler/statuses": ["statuses@1.5.0", "", {}, "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA=="], + + "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + + "lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "log-symbols/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], + + "metro/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="], + + "metro/hermes-parser": ["hermes-parser@0.35.0", "", { "dependencies": { "hermes-estree": "0.35.0" } }, "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA=="], + + "metro/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "metro-babel-transformer/hermes-parser": ["hermes-parser@0.35.0", "", { "dependencies": { "hermes-estree": "0.35.0" } }, "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA=="], + + "npm-package-arg/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "ora/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], + + "path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + + "plist/@xmldom/xmldom": ["@xmldom/xmldom@0.9.11", "", {}, "sha512-tW8bcK3hsG0/uqSnNz6TK4BkcuZSezoU7DlnYssILmZDktPnSHHuDJJFM0AJv+13gz2r0iGdrj6qqKeUnxXEDg=="], + + "react-native/babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.36.0", "", { "dependencies": { "hermes-parser": "0.36.0" } }, "sha512-LhD0xdoedDw7ansQgXbB2DADLZIK/LRXuWNBPuVzMc5S2WK5GyT89tCM+cQzxFGO0mGyLK6D5TrVOJJzAoDy8Q=="], + + "react-native/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "react-native-css-interop/lightningcss": ["lightningcss@1.27.0", "", { "dependencies": { "detect-libc": "^1.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.27.0", "lightningcss-darwin-x64": "1.27.0", "lightningcss-freebsd-x64": "1.27.0", "lightningcss-linux-arm-gnueabihf": "1.27.0", "lightningcss-linux-arm64-gnu": "1.27.0", "lightningcss-linux-arm64-musl": "1.27.0", "lightningcss-linux-x64-gnu": "1.27.0", "lightningcss-linux-x64-musl": "1.27.0", "lightningcss-win32-arm64-msvc": "1.27.0", "lightningcss-win32-x64-msvc": "1.27.0" } }, "sha512-8f7aNmS1+etYSLHht0fQApPc2kNO8qGRutifN5rVIc6Xo6ABsEbqOr758UwI7ALVbTt4x1fllKt0PYgzD9S3yQ=="], + + "react-native-css-interop/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "react-native-reanimated/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "react-native-worklets/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="], + + "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="], + + "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + + "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + + "tinyglobby/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + + "@react-native/babel-preset/@react-native/babel-plugin-codegen/@react-native/codegen": ["@react-native/codegen@0.87.0", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.29.0", "hermes-parser": "0.36.1", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "tinyglobby": "^0.2.15", "yargs": "^17.6.2" } }, "sha512-odcBGR0A9Ee83A/XvzOp9juGRkSQOQcI4ZYs+3H20MySMxvZJ34/2nVHoxdmj0YY0Mn6gF8BjLzYkL0axbHuCA=="], + + "@react-native/codegen/hermes-parser/hermes-estree": ["hermes-estree@0.36.0", "", {}, "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w=="], + + "@react-native/community-cli-plugin/@react-native/dev-middleware/@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.86.0", "", {}, "sha512-7Mb3nDfyJeys+ELF75Ageu7VKERlnIMoO+aNPoXqTXvz+b41L6l2CqMyLpDHxkBSlenij6gEepPNgaIyWHbJZw=="], + + "@react-native/community-cli-plugin/@react-native/dev-middleware/@react-native/debugger-shell": ["@react-native/debugger-shell@0.86.0", "", { "dependencies": { "cross-spawn": "^7.0.6", "debug": "^4.4.0", "fb-dotslash": "0.5.8" } }, "sha512-Y0zEkZzLz8ou6o/VLml1A31X/rMgc6DRjwxwzPMa94qRTMY070WeBCNTITQo4kKTBAUgbxh07oXPQqp0Tpja8w=="], + + "@react-native/metro-config/metro-config/metro": ["metro@0.87.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "accepts": "^2.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.36.1", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.87.0", "metro-cache": "0.87.0", "metro-cache-key": "0.87.0", "metro-config": "0.87.0", "metro-core": "0.87.0", "metro-file-map": "0.87.0", "metro-resolver": "0.87.0", "metro-runtime": "0.87.0", "metro-source-map": "0.87.0", "metro-symbolicate": "0.87.0", "metro-transform-plugins": "0.87.0", "metro-transform-worker": "0.87.0", "mime-types": "^3.0.1", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-fRqFhSzQhLNQSCvJFeuRzBRXAOOKXf1O8d2cvmMtG6yFR0jCllQ7vBsXoLP18yuqtf+N1XwWXTPF11eWy9q6dQ=="], + + "@react-native/metro-config/metro-config/metro-cache": ["metro-cache@0.87.0", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.87.0" } }, "sha512-146vS1BMSKcp99jddOhFBfHwzUEWN35NrsnSJDF2sQQ0ZT5OsBcOjd574PM233TWEZISRJ5DOK+vokD+1ubx+w=="], + + "@react-native/metro-config/metro-config/metro-core": ["metro-core@0.87.0", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.87.0" } }, "sha512-yW57+pCOHRC/CJZ99GA2PTd+30dORwDAjUPRCokj91IWW5In9Jwtt2FB5wACrGO8P0GHTyVHdTwDNyZsNndUbA=="], + + "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "log-symbols/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], + + "log-symbols/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + + "log-symbols/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], + + "metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.35.0", "", {}, "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg=="], + + "metro/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "metro/hermes-parser/hermes-estree": ["hermes-estree@0.35.0", "", {}, "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg=="], + + "metro/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "ora/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], + + "ora/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + + "ora/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], + + "react-native-css-interop/lightningcss/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], + + "react-native-css-interop/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.27.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gl/lqIXY+d+ySmMbgDf0pgaWSqrWYxVHoc88q+Vhf2YNzZ8DwoRzGt5NZDVqqIW5ScpSnmmjcgXP87Dn2ylSSQ=="], + + "react-native-css-interop/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.27.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-0+mZa54IlcNAoQS9E0+niovhyjjQWEMrwW0p2sSdLRhLDc8LMQ/b67z7+B5q4VmjYCMSfnFi3djAAQFIDuj/Tg=="], + + "react-native-css-interop/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.27.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-n1sEf85fePoU2aDN2PzYjoI8gbBqnmLGEhKq7q0DKLj0UTVmOTwDC7PtLcy/zFxzASTSBlVQYJUhwIStQMIpRA=="], + + "react-native-css-interop/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.27.0", "", { "os": "linux", "cpu": "arm" }, "sha512-MUMRmtdRkOkd5z3h986HOuNBD1c2lq2BSQA1Jg88d9I7bmPGx08bwGcnB75dvr17CwxjxD6XPi3Qh8ArmKFqCA=="], + + "react-native-css-interop/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.27.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-cPsxo1QEWq2sfKkSq2Bq5feQDHdUEwgtA9KaB27J5AX22+l4l0ptgjMZZtYtUnteBofjee+0oW1wQ1guv04a7A=="], + + "react-native-css-interop/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.27.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-rCGBm2ax7kQ9pBSeITfCW9XSVF69VX+fm5DIpvDZQl4NnQoMQyRwhZQm9pd59m8leZ1IesRqWk2v/DntMo26lg=="], + + "react-native-css-interop/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.27.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Dk/jovSI7qqhJDiUibvaikNKI2x6kWPN79AQiD/E/KeQWMjdGe9kw51RAgoWFDi0coP4jinaH14Nrt/J8z3U4A=="], + + "react-native-css-interop/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.27.0", "", { "os": "linux", "cpu": "x64" }, "sha512-QKjTxXm8A9s6v9Tg3Fk0gscCQA1t/HMoF7Woy1u68wCk5kS4fR+q3vXa1p3++REW784cRAtkYKrPy6JKibrEZA=="], + + "react-native-css-interop/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.27.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-/wXegPS1hnhkeG4OXQKEMQeJd48RDC3qdh+OA8pCuOPCyvnm/yEayrJdJVqzBsqpy1aJklRCVxscpFur80o6iQ=="], + + "react-native-css-interop/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/OJLj94Zm/waZShL8nB5jsNj3CfNATLCTyFxZyouilfTmSoLDX7VlVAmhPHoZWVFp4vdmoiEbPEYC8HID3m6yw=="], + + "react-native/babel-plugin-syntax-hermes-parser/hermes-parser": ["hermes-parser@0.36.0", "", { "dependencies": { "hermes-estree": "0.36.0" } }, "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w=="], + + "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "@react-native/metro-config/metro-config/metro/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "@react-native/metro-config/metro-config/metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="], + + "@react-native/metro-config/metro-config/metro/metro-babel-transformer": ["metro-babel-transformer@0.87.0", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.36.1", "metro-cache-key": "0.87.0", "nullthrows": "^1.1.1" } }, "sha512-IEn1K1FyY4J1sA5y6zqDjf2OkfmpTEqhZOeP6MJX8HepSW0cuHGw1m8bYOdv2adkG3XUE9dtM0csUs0gP/Xa5w=="], + + "@react-native/metro-config/metro-config/metro/metro-cache-key": ["metro-cache-key@0.87.0", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-Q+MPt6jl0zQogr4Q02WaJK6HY+GtE5A0nzj8kIV1Owgrx6OMNvm6scPTr1SM/R4LpCE8EH/Y5qfbXQ84GHTr0Q=="], + + "@react-native/metro-config/metro-config/metro/metro-file-map": ["metro-file-map@0.87.0", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-Dc57t8jsINwA90bbVlqaeDlxf1rVGgj5SmOEnOMbaHNUM/HCYYTJxPV8SRdOBwh7qTz/biO9vaQvBTjRBgbbsg=="], + + "@react-native/metro-config/metro-config/metro/metro-resolver": ["metro-resolver@0.87.0", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-Xl3M9R3KToaHJvXlI2lSOxtYHitzxite+195DSi00HL9PcS7Xik5+3xlRjfkKb3FA86SZxYPj+WJwlcfgaZoxg=="], + + "@react-native/metro-config/metro-config/metro/metro-source-map": ["metro-source-map@0.87.0", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.87.0", "nullthrows": "^1.1.1", "ob1": "0.87.0", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-31BrYqu1c2co93rF1LN9Pw+7g+BrfDyxJkNQWrYm+pfA/+eVYVumF7tFMHbXLePLmHfhvSgVvbK6su1Oyiw1ng=="], + + "@react-native/metro-config/metro-config/metro/metro-symbolicate": ["metro-symbolicate@0.87.0", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.87.0", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-uOpTxAXu74N+RSujUZ78L6gjI6bDdnz6XuW+AIUNuubZDEQPIpaX0StzIb0GMeQWP6zfHOHwFrWPP5Iquu1GXw=="], + + "@react-native/metro-config/metro-config/metro/metro-transform-plugins": ["metro-transform-plugins@0.87.0", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-i8keUe9+BaSwMuQM26DGheElCpTtflAKIrSwJAm8ZsgDb50RAUQus+e6zt2suaJXJ1OxZa7vqgtqoZxdniM6Fw=="], + + "@react-native/metro-config/metro-config/metro/metro-transform-worker": ["metro-transform-worker@0.87.0", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "metro": "0.87.0", "metro-babel-transformer": "0.87.0", "metro-cache": "0.87.0", "metro-cache-key": "0.87.0", "metro-minify-terser": "0.87.0", "metro-source-map": "0.87.0", "metro-transform-plugins": "0.87.0", "nullthrows": "^1.1.1" } }, "sha512-YftLzNJxCTYxEN5k4AzR8KYwiENTEuz30L+4QeoMrtDd+U8mDThZg/ArR3JVRd8LaikwPOjVAS5SP3xPJN0AaA=="], + + "@react-native/metro-config/metro-config/metro/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "@react-native/metro-config/metro-config/metro-core/metro-resolver": ["metro-resolver@0.87.0", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-Xl3M9R3KToaHJvXlI2lSOxtYHitzxite+195DSi00HL9PcS7Xik5+3xlRjfkKb3FA86SZxYPj+WJwlcfgaZoxg=="], + + "log-symbols/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], + + "log-symbols/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], + + "ora/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], + + "ora/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], + + "react-native/babel-plugin-syntax-hermes-parser/hermes-parser/hermes-estree": ["hermes-estree@0.36.0", "", {}, "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w=="], + + "@react-native/metro-config/metro-config/metro/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "@react-native/metro-config/metro-config/metro/metro-source-map/ob1": ["ob1@0.87.0", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-8Q8sKCiUwsxgSmjDtVWyRgmxsgeJXXam3oQH6Id8ADfNaJMV6GZKyeAl8+pGVVdgwAZabfk5+aExle7AP/nZiA=="], + + "@react-native/metro-config/metro-config/metro/metro-transform-worker/metro-minify-terser": ["metro-minify-terser@0.87.0", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-tPa0O983PDutFu3LXbArRH5NduogcKrvW6fs9VHhksTKUA1iqDyoD1ZSj/Me52xJ6T9/9pOwIVyj9ulKc/zMkg=="], + + "@react-native/metro-config/metro-config/metro/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "log-symbols/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], + + "ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], + } +} diff --git a/metro.config.js b/metro.config.js new file mode 100644 index 0000000..fdfec62 --- /dev/null +++ b/metro.config.js @@ -0,0 +1,11 @@ +// Standard Expo Metro config, plus NativeWind. +// +// Nothing monorepo-shaped here: `officer-net` is a `file:` dependency inside this repo, so it is +// symlinked into node_modules and resolves like any other package. That is the whole reason it lives +// under modules/ rather than being a second workspace. +const { getDefaultConfig } = require('expo/metro-config'); +const { withNativeWind } = require('nativewind/metro'); + +const config = getDefaultConfig(__dirname); + +module.exports = withNativeWind(config, { input: './src/global.css' }); diff --git a/modules/officer-net/.gitignore b/modules/officer-net/.gitignore new file mode 100644 index 0000000..33714a7 --- /dev/null +++ b/modules/officer-net/.gitignore @@ -0,0 +1,8 @@ +# Vendored gomobile Tailscale core (libtailscale) — built/fetched by scripts/build-libtailscale-*.sh +# and pinned to a Headscale-compatible tag. Track the Swift/Kotlin bridge + gradle + plugin, but NOT +# the binaries (mirrors how bw-vault gitignores its vendored Bitwarden SDK). See README.md. +libs/ +ios/vendor/ +android/build/ +android/libs/ +android/src/main/jniLibs/ diff --git a/modules/officer-net/README.md b/modules/officer-net/README.md new file mode 100644 index 0000000..21dd722 --- /dev/null +++ b/modules/officer-net/README.md @@ -0,0 +1,133 @@ +# officer-net — the OffScale VPN engine (native) + +The native half of an in-app Tailscale client. It embeds the **Tailscale core** (Go, via `gomobile`) +behind the OS VPN APIs — **iOS `NEPacketTunnelProvider`** and **Android `VpnService`** — and talks +directly to a control server: your own **Headscale**, or Tailscale's. + +It contacts nothing else. There is no backend, no telemetry and no account layer anywhere in this +package; the data path is the mesh, and the only host it reaches is the control URL it was configured +with. Node/WireGuard keys are generated and persisted by the core in the **Keychain / Keystore** and +never cross into Swift, Kotlin or JS. + +The JS side is one line — `requireNativeModule('OfficerNet')`, in `src/vpn/officer-net.ts` at the repo +root. This package is Swift + Kotlin + Go + the Expo config plugin + the two build scripts. + +## Layout + +``` +officer-net/ + package.json index.ts expo-module.config.json app.plugin.js .gitignore + gocore/ # the Go shim bound by gomobile + tailcore.go # the whole exported surface (profiles, up/down, prefs, status) + dns.go protect.go maketun_apple.go maketun_droid.go interfaces_droid.go + go.mod go.sum # pins tailscale.com — see ../../NOTICE + ios/ + OfficerNet.podspec # vendors libs/TailCore.xcframework, compiles the Swift below + OfficerNetModule.swift # RN process: NETunnelProviderManager config + commands + PacketTunnelProvider.swift # NE target: hosts the core, owns packetFlow + android/ + build.gradle + src/main/AndroidManifest.xml + src/main/java/expo/modules/officernet/ + OfficerNetModule.kt # RN process: prepare()/start/stop/status + OfficerVpnService.kt # VpnService: builds the tun, hands the fd to the core + BootReceiver.kt # reconnect-on-boot + plugin/ + app.plugin.js # entitlements, App Group, Android service, iOS NE target + withNetworkExtensionTarget.js # synthesises the NE Xcode target at prebuild + README.md + scripts/ + build-libtailscale-ios.sh # gomobile bind → ios/libs/TailCore.xcframework + build-libtailscale-android.sh # gomobile bind → android/libs/tailcore.aar + libs/ ios/libs/ android/libs/ # the built core (gitignored — see below) +``` + +## The built core is NOT in the repo, and NOT rebuilt by an app build + +`gomobile bind` output is tens of megabytes per platform, so it is gitignored and produced on the +machine that builds the app. **Nothing rebuilds it automatically.** Changing anything under `gocore/` +means running the script for each platform you are about to build: + +```bash +PATH="$PATH:$HOME/go/bin" modules/officer-net/scripts/build-libtailscale-ios.sh # a few minutes +PATH="$PATH:$HOME/go/bin" modules/officer-net/scripts/build-libtailscale-android.sh +``` + +Forgetting is silent until much later, and the error names a symbol rather than the cause: + +| platform | how it fails | when | +|----------|-------------------------------------------------------|---------------------| +| Android | `e: Unresolved reference 'loginTo'` | Kotlin compile — loud and early | +| iOS | `error: cannot find 'TailcoreListProfiles' in scope` | Swift compile — long after the Go compiled cleanly | + +The repo's `scripts/ensure-core.sh` compares mtimes and runs the right script for you before each app +build, which is why the two build scripts source it. Confirm the symbols landed if you are debugging: + +```bash +grep Tailcore modules/officer-net/ios/libs/TailCore.xcframework/ios-arm64/Tailcore.framework/Headers/*.h +``` + +Requires Go plus gomobile on `PATH`: + +```bash +go install golang.org/x/mobile/cmd/gomobile@latest +go install golang.org/x/mobile/cmd/gobind@latest +``` + +`TAILSCALE_REF` at the top of each script pins the Tailscale version. Keep it paired with the Headscale +release you run — Headscale implements a specific client protocol version, and a core built far ahead +of it will fail to register. + +## Native surface + +Mirrored one-for-one by `src/vpn/officer-net.ts`; that file is the readable index of it. Roughly: +`configure(controlUrl, hostname?)`, `loginWithAuthKey`, `loginTo` (add a second tailnet in one core +operation), `isRegistered`, `up`/`down`, `status`, `prefs`, `setExitNode`, `setAcceptRoutes`, profile +list/switch/delete, `logout`, and an `onStatus` event carrying `{ state, self?, peers, error? }`. + +## App wiring + +The config plugin is enabled from `app.json` at the repo root: + +```js +plugins: [["officer-net", { + appGroup: "group.pastilhas.officer.offscale", + neBundleId: "dev.officer.offscale.net", + enableNetworkExtension: true, +}]] +``` + +It adds the iOS Network Extensions entitlement, synthesises the NE Xcode target, and registers the +Android `VpnService`. See `plugin/README.md`. + +## Apple provisioning prerequisites (one-time) + +- An **App ID for the extension** (`dev.officer.offscale.net` by default) with the **Network + Extensions** capability. +- The **Network Extensions** capability on the main app ID. +- Provisioning profiles for both. + +`-allowProvisioningUpdates` cannot create these while Xcode has no account signed in — it falls back to +the wildcard team profile and fails with *"Provisioning profile … doesn't include the Network +Extensions capability"*. Sign in under Xcode → Settings → Accounts first. Android has no equivalent +gate. + +## Notes and traps + +- **iOS jetsams a packet tunnel over roughly 50 MB**, so the core must run WireGuard in userspace + (netstack). See the header comment in `PacketTunnelProvider.swift`. Crashes are *inferred* rather + than caught — no handler runs — via a start marker that is still `running` at the next start. +- **`neBundleId` must come from `OfficerNetExtensionBundleId` in `Info.plist`**, never a constant. A + hardcoded value asks iOS to launch an extension the binary does not contain: the start command is + accepted, nothing matches, and the toggle silently does nothing. +- **Android protects the core's sockets per-socket** (`gocore/protect.go` registers + `netns.SetAndroidProtectFunc`), not by excluding the whole app from its own tunnel. `gomobile` + generates `boolean protect(int)` from `tailcore.Protector` — the same signature as + `VpnService.protect(int)` — so a service implementing the interface directly would have its override + call itself forever. It delegates through `CoreProtector`. +- **Logging out needs a RUNNING extension.** The identity is `tailscaled.state` inside the extension's + container (Android: `filesDir`), unreachable from the app process, so `logout()` starts the tunnel + briefly just to issue the wipe. +- **Reinstalling invalidates the saved VPN configuration.** iOS hands it back from + `loadAllFromPreferences`, reports "unchanged" on save, then refuses to start it. `up()` removes and + rewrites it on failure, which is self-healing. diff --git a/modules/officer-net/android/build.gradle b/modules/officer-net/android/build.gradle new file mode 100644 index 0000000..f9bf2da --- /dev/null +++ b/modules/officer-net/android/build.gradle @@ -0,0 +1,31 @@ +apply plugin: 'com.android.library' + +group = 'expo.modules.officernet' +version = '0.1.0' + +def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") +apply from: expoModulesCorePlugin +applyKotlinExpoModulesCorePlugin() +useCoreDependencies() +useDefaultAndroidSdkVersions() +useExpoPublishing() + +android { + namespace "expo.modules.officernet" + defaultConfig { + minSdkVersion 24 + } + // The gomobile-built Tailscale core (libtailscale) ships as officernet.aar → exploded into + // libs/officernet.jar (Kotlin/Java bindings) + src/main/jniLibs//libgojni.so (the Go runtime), + // by scripts/build-libtailscale-android.sh. jniLibs/ is auto-picked up from src/main/jniLibs. + // arm64-v8a at minimum; add x86_64 for the emulator. All gitignored (see ../.gitignore). +} + +dependencies { + // The vendored gomobile Tailscale core bindings (tailcore.Tailcore). `api` so the consuming app links + // the types too. Produced by scripts/build-libtailscale-android.sh (aar → libs/tailcore.jar + + // src/main/jniLibs/arm64-v8a/libgojni.so). Gitignored; present after that script runs. + api files('libs/tailcore.jar') + + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1' +} diff --git a/modules/officer-net/android/src/main/AndroidManifest.xml b/modules/officer-net/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..22c88be --- /dev/null +++ b/modules/officer-net/android/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + diff --git a/modules/officer-net/android/src/main/java/expo/modules/officernet/BootReceiver.kt b/modules/officer-net/android/src/main/java/expo/modules/officernet/BootReceiver.kt new file mode 100644 index 0000000..21a145e --- /dev/null +++ b/modules/officer-net/android/src/main/java/expo/modules/officernet/BootReceiver.kt @@ -0,0 +1,55 @@ +package expo.modules.officernet + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.net.VpnService +import android.util.Log + +/** + * Bring the tunnel back after a reboot — but only if the user had it on. + * + * Android's counterpart to iOS's on-demand rules. Without this the VPN comes back DOWN after a + * restart and stays down until someone opens the app and taps Connect, which is silent and easy to + * miss: sites behind the tailnet simply fail while everything else works. + * + * `KEY_WANT_CONNECTED` is the user's intent, not the tunnel's state. `up()` sets it, `down()` clears + * it, and only `down()` clears it — a crash, a reboot or the system killing the service all leave it + * set, so those recover. A deliberate disconnect stays disconnected until the user says otherwise. + * + * `VpnService.prepare` is re-checked because consent can be revoked while the phone is off (another + * VPN taking over, for instance). It returns non-null when consent is needed, and that needs an + * activity we do not have at boot — so we stand down rather than crash, and the next manual connect + * will show the dialog properly. + */ +class BootReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + // BOOT_COMPLETED only. LOCKED_BOOT_COMPLETED fires before the user has unlocked, when there is + // no network and our prefs live in credential-encrypted storage we cannot even read yet. + if (intent.action != Intent.ACTION_BOOT_COMPLETED) return + + val prefs = context.getSharedPreferences("officer_net", Context.MODE_PRIVATE) + if (!prefs.getBoolean(KEY_WANT_CONNECTED, false)) { + Log.i(TAG, "boot: user had the tunnel off — leaving it off") + return + } + if (!prefs.getBoolean(KEY_REGISTERED, false)) { + Log.i(TAG, "boot: no node identity yet — nothing to reconnect") + return + } + if (VpnService.prepare(context) != null) { + Log.w(TAG, "boot: VPN consent not granted; a manual connect is needed") + return + } + + Log.i(TAG, "boot: reconnecting") + context.startForegroundService( + Intent(context, OfficerVpnService::class.java).setAction(ACTION_UP), + ) + } + + private companion object { + const val TAG = "OfficerVpnBoot" + } +} diff --git a/modules/officer-net/android/src/main/java/expo/modules/officernet/OfficerNetModule.kt b/modules/officer-net/android/src/main/java/expo/modules/officernet/OfficerNetModule.kt new file mode 100644 index 0000000..0a21917 --- /dev/null +++ b/modules/officer-net/android/src/main/java/expo/modules/officernet/OfficerNetModule.kt @@ -0,0 +1,336 @@ +package expo.modules.officernet + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.VpnService +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.Promise +import tailcore.Tailcore + +// OffTail VPN engine — Android RN-process module. Parity with ios/OfficerNetModule.swift and the JS +// bridge in packages/core/src/services/officer-net.ts (requireNativeModule('OfficerNet')). +// +// Unlike iOS (a separate NE process), Android's VpnService runs in THIS app's process — so this module +// binds/starts OfficerVpnService directly. The Tailscale core (libtailscale, gomobile .aar) is hosted +// inside OfficerVpnService, which owns the tun fd. This module just: persists config, drives the +// one-time system consent (VpnService.prepare), and starts/stops the service. +// +// The core call-sites (register/up/status via the gomobile bindings) live in OfficerVpnService.kt. + +private const val PREFS = "officer_net" +const val KEY_CONTROL_URL = "controlUrl" +const val KEY_HOSTNAME = "hostname" +const val KEY_AUTH_KEY = "authKey" // single-use Headscale pre-auth key; consumed by the core on up(). +const val KEY_REGISTERED = "registered" // set by the service once the core has a persisted identity. +/** The USER's intent, not the tunnel's state — only down() clears it, so crashes and reboots recover. */ +const val KEY_WANT_CONNECTED = "wantConnected" + +const val ACTION_UP = "dev.officer.mobile.net.UP" +const val ACTION_DOWN = "dev.officer.mobile.net.DOWN" + +// Request code for the VpnService.prepare() consent activity result, reached via up(). +private const val VPN_PREPARE_REQUEST = 0xF00D + +// Request code for consent asked for ON ITS OWN, by requestAuthorization(), before any tailnet is +// chosen. It has to be distinct from the one above: that path starts the tunnel as soon as consent +// lands, and here there is nothing to start — no control URL has been entered yet. +private const val VPN_AUTHORIZE_REQUEST = 0xF00E + +class OfficerNetModule : Module() { + private val context: Context + get() = requireNotNull(appContext.reactContext) { "React context unavailable" } + + private fun prefs() = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + /** + * Forget the cached node IP after anything that changes which tailnet we are on. + * + * The cached address is per-DEVICE but describes the CURRENT tailnet, and startTunnel trusts it to + * build the tun up front. After a switch it belongs to the network just left, so the tun comes up + * holding an address from the wrong tailnet — peers become unreachable even by IP, which reads as a + * routing or server fault rather than a stale cache. + * + * iOS has the same fix in the extension (forgetCachedNetmap); this is its Android counterpart, and + * it was missed when the rest of that family was closed. + */ + private fun forgetCachedNodeIp() { + // The exit node goes with it: it is a peer on the tailnet being left, so keeping it would claim + // the default route for a machine that is not on the new network. + prefs().edit().remove("nodeIpv4").remove("nodeIpv6").remove(KEY_EXIT_NODE).apply() + } + + // Pending up() promise, resolved after the user answers the consent dialog (see OnActivityResult). + private var pendingUp: Promise? = null + + // Pending requestAuthorization() promise. Separate from pendingUp so the two flows cannot resolve + // each other's caller — they answer different questions and only one of them starts the tunnel. + private var pendingAuthorize: Promise? = null + + override fun definition() = ModuleDefinition { + Name("OfficerNet") + + // Native → JS status/peer stream. Emitted by OfficerVpnService via the module (see emitStatus()). + Events("onStatus") + + OnCreate { + // OfficerVpnService pushes status snapshots here so onStatus/status() can carry peer detail. + OfficerVpnService.statusSink = { snapshot -> this@OfficerNetModule.sendEvent("onStatus", snapshot) } + } + + OnDestroy { + OfficerVpnService.statusSink = null + } + + // Point the node at a Headscale control server. Stored for the service to read on up(). No I/O. + AsyncFunction("configure") { controlUrl: String, hostname: String? -> + prefs().edit() + .putString(KEY_CONTROL_URL, controlUrl) + .putString(KEY_HOSTNAME, hostname) + .apply() + } + + // Register the node with a Headscale pre-auth key. We only stash it; the core consumes it on the + // next up() (the core owns + persists the node/WireGuard keys in the Keystore). + AsyncFunction("loginWithAuthKey") { authKey: String -> + prefs().edit().putString(KEY_AUTH_KEY, authKey).apply() + } + + // The embedded Tailscale core version, from the vendored gomobile .aar (tailcore.Tailcore). + AsyncFunction("coreVersion") { + Tailcore.version() + } + + // ── Profiles ───────────────────────────────────────────────────────────────────────────── + // + // Several tailnets on one device. The core has always supported it; these four just expose it. + // All of them require the node to be started, because they operate on the running LocalBackend — + // calling them while the tunnel is down returns "not started" from Go rather than crashing. + + /** Every stored login as JSON, with the active one flagged. */ + AsyncFunction("listProfiles") { + Tailcore.listProfiles() + } + + /** + * Make an existing login active. + * + * The tun is NOT recycled here: the node re-registers under the new identity and the netmap — + * and therefore the IPs and routes — change underneath it. The caller brings the tunnel down and + * up around this, which is also what makes the UI honest about what is happening. + */ + AsyncFunction("switchProfile") { id: String -> + Tailcore.switchProfile(id) + forgetCachedNodeIp() + } + + /** Begin an additional login. Lands in NeedsLogin, so status().authUrl carries where to go. */ + AsyncFunction("newProfile") { + Tailcore.newProfile() + } + + /** + * Join ANOTHER tailnet on the live backend, without recycling the tunnel. + * + * newProfile() alone cannot do it. It creates a blank profile, but the control URL and pre-auth + * key only reach the core through Start — and restarting to trigger that LOSES the blank profile, + * because an unregistered one is not persisted: the reload falls back to the previous profile and + * Start overwrites ITS control URL. The second tailnet then silently replaces the first, keeping + * the same profile id. Doing both inside the core is the only ordering where neither step undoes + * the other. See LoginTo in gocore/tailcore.go. + */ + AsyncFunction("loginToNewProfile") { controlUrl: String, authKey: String?, hostname: String? -> + Tailcore.loginTo(controlUrl, authKey ?: "", hostname ?: "") + // Keep the app's own record in step, or the next tunnel start hands the core the PREVIOUS + // tailnet's URL — the same global-vs-per-profile trap that put a Tailscale node on Headscale. + prefs().edit().putString(KEY_CONTROL_URL, controlUrl).putString(KEY_HOSTNAME, hostname).apply() + // The new tailnet issues its own address; the cached one belongs to the previous network. + forgetCachedNodeIp() + Unit + } + + // ── Live preferences ──────────────────────────────────────────────────────────────────── + // + // EditPrefs on the running node, so choosing an exit node does not drop the tunnel. + + /** + * Route all traffic through a peer. Empty id turns it off. + * + * The choice is also written to prefs, because the TUN has to be built differently for it — an + * exit node needs the default route, not just the tailnet ranges. The service watches this and + * re-establishes; setting the core's preference alone changes nothing observable. + */ + AsyncFunction("setExitNode") { stableId: String? -> + Tailcore.setExitNode(stableId ?: "") + prefs().edit().putString(KEY_EXIT_NODE, stableId ?: "").apply() + } + + /** Use subnet routes advertised by other nodes. Off by default in Tailscale. */ + AsyncFunction("setAcceptRoutes") { on: Boolean -> + Tailcore.setAcceptRoutes(on) + } + + /** Live prefs as JSON — what IS set, not what was last asked for. */ + AsyncFunction("prefs") { + Tailcore.prefs() + } + + /* + Collect a pending report about how the LAST tunnel run ended, or "" if it ended cleanly. + + Read straight off disk rather than through the service: the file is in the app's own filesDir, and + requiring a running service would mean the one case worth reporting — a tunnel that died and was + never restarted — is the case that can never be read. iOS has no such luxury and must ask the + extension. + + A TAKE, not a get: the report is cleared once handed over, so a crash is reported once. + */ + AsyncFunction("lastExitReport") { + val f = java.io.File(context.filesDir, "last-exit.json") + if (!f.exists()) return@AsyncFunction "" + runCatching { + val obj = org.json.JSONObject(f.readText()) + val previous = obj.optJSONObject("previous") ?: return@runCatching "" + obj.put("previous", org.json.JSONObject.NULL) + f.writeText(obj.toString()) + previous.toString() + }.getOrElse { "" } + } + + /** Forget a login. Deleting the ACTIVE one leaves the node logged out — switch away first. */ + AsyncFunction("deleteProfile") { id: String -> + Tailcore.deleteProfile(id) + forgetCachedNodeIp() + } + + // Whether a node identity already exists on this device (the service flips this once registered). + AsyncFunction("isRegistered") { + prefs().getBoolean(KEY_REGISTERED, false) + } + + /** + * Whether the OS has granted this app the right to run a tunnel. + * + * This is NOT `isRegistered` — that answers "does this device have a node identity on some + * tailnet", which is a completely different question and can be true while consent is absent + * (reinstall, restore, or the user revoking VPN rights). The first-run flow needs them apart: + * "Allow VPN" comes before choosing a tailnet, and choosing a tailnet is what creates identity. + * + * `VpnService.prepare()` is the authority and is cheap — it returns an Intent when consent is + * needed and null once granted. Never cache the answer: consent is revocable at any time from + * Settings, or by another VPN app taking over, and a stale `true` leaves a connect toggle that + * silently does nothing. + */ + AsyncFunction("isAuthorized") { + VpnService.prepare(context) == null + } + + /** + * Ask for tunnel consent on its own, without connecting. + * + * Deliberately does not start the service on success: at this point in the first-run flow no + * control URL exists, so there is nothing to connect to. Starting here would fail confusingly, or + * worse, come up against whatever stale prefs a previous install left behind. + */ + AsyncFunction("requestAuthorization") { promise: Promise -> + val consent = VpnService.prepare(context) + if (consent == null) { + promise.resolve(true) // already granted — nothing to ask + } else { + val activity = appContext.currentActivity + if (activity == null) { + // Nothing to host the system dialog. Report "not granted" rather than throwing: the caller + // shows the same "Allow VPN" button either way, and it will succeed once foregrounded. + promise.resolve(false) + } else { + pendingAuthorize = promise + activity.startActivityForResult(consent, VPN_AUTHORIZE_REQUEST) + } + } + } + + // Bring the tunnel up. VpnService needs a ONE-TIME system consent dialog: VpnService.prepare() + // returns a consent Intent the first time, or null once granted. If it returns an intent we launch + // it from the current activity and resolve after OnActivityResult; otherwise we start the service now. + AsyncFunction("up") { promise: Promise -> + val consent = VpnService.prepare(context) + if (consent != null) { + val activity = appContext.currentActivity + if (activity == null) { + // No foreground activity to host the dialog — tell JS consent is needed so it can retry. + promise.resolve(mapOf("needsConsent" to true)) + } else { + pendingUp = promise + activity.startActivityForResult(consent, VPN_PREPARE_REQUEST) + } + } else { + prefs().edit().putBoolean(KEY_WANT_CONNECTED, true).apply() + startVpn() + promise.resolve(mapOf("needsConsent" to false)) + } + } + + // Bring the tunnel down. + AsyncFunction("down") { + // Cleared here and ONLY here: this is the one path that means the user chose to be off, so + // BootReceiver leaves it off. Anything else — a crash, a reboot, the system reclaiming the + // service — leaves the flag set and recovers. + prefs().edit().putBoolean(KEY_WANT_CONNECTED, false).apply() + context.startService(serviceIntent(ACTION_DOWN)) + // Explicit Unit: startService returns a ComponentName, and Kotlin would make that this + // lambda's return value. Expo cannot bridge it and rejects the whole call — the function runs, + // then fails on the way back, so the tunnel stops but JS sees an error. + Unit + } + + // Current status snapshot. Sourced from the running service (self + peers from the core's netmap); + // "stopped" when the service isn't running. + AsyncFunction("status") { + OfficerVpnService.lastStatus ?: mapOf( + "state" to "stopped", + "self" to null, + "peers" to emptyList(), + "error" to null, + ) + } + + // Forget the node identity + keys (full reset): stop the tunnel, tell the service to wipe the core + // identity, and clear stored config. + AsyncFunction("logout") { + context.startService(serviceIntent(ACTION_DOWN).putExtra("logout", true)) + prefs().edit() + .remove(KEY_CONTROL_URL).remove(KEY_HOSTNAME).remove(KEY_AUTH_KEY).remove(KEY_REGISTERED) + .remove(KEY_WANT_CONNECTED) + .apply() + } + + // Resolve the pending up() promise once the consent dialog returns. + OnActivityResult { _, payload -> + if (payload.requestCode == VPN_PREPARE_REQUEST) { + val granted = payload.resultCode == Activity.RESULT_OK + if (granted) { + // Same as the no-consent-needed path: the user asked for this, so remember it for boot. + prefs().edit().putBoolean(KEY_WANT_CONNECTED, true).apply() + startVpn() + } + pendingUp?.resolve(mapOf("needsConsent" to false, "granted" to granted)) + pendingUp = null + } + // Consent asked for on its own: report the answer and start nothing. The first-run flow moves + // to "pick a tailnet" next, and the tunnel starts when that produces a control URL. + if (payload.requestCode == VPN_AUTHORIZE_REQUEST) { + pendingAuthorize?.resolve(payload.resultCode == Activity.RESULT_OK) + pendingAuthorize = null + } + } + } + + private fun serviceIntent(action: String): Intent = + Intent(context, OfficerVpnService::class.java).setAction(action) + + private fun startVpn() { + // Foreground service — required for a long-running VpnService (foregroundServiceType=specialUse). + context.startForegroundService(serviceIntent(ACTION_UP)) + } +} diff --git a/modules/officer-net/android/src/main/java/expo/modules/officernet/OfficerVpnService.kt b/modules/officer-net/android/src/main/java/expo/modules/officernet/OfficerVpnService.kt new file mode 100644 index 0000000..4e0f10f --- /dev/null +++ b/modules/officer-net/android/src/main/java/expo/modules/officernet/OfficerVpnService.kt @@ -0,0 +1,1003 @@ +package expo.modules.officernet + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Intent +import android.content.pm.ServiceInfo +import android.net.VpnService +import android.os.Build +import android.util.Log +import java.net.NetworkInterface +import java.util.Collections +import java.util.concurrent.atomic.AtomicBoolean +import org.json.JSONArray +import org.json.JSONObject +import tailcore.Protector +import tailcore.Tailcore + +// OffTail VPN data plane — Android. Runs in the app's OWN process (unlike iOS's separate NE). Builds the +// tun via VpnService.Builder, runs as a foreground service, and hosts the gomobile-built Tailscale core +// (tailcore.aar → tailcore.Tailcore). OfficerNetModule.kt only prepares consent + starts/stops this. +// +// Routing: the tun's address must be the node's real tailnet IP for the OS to route traffic through it, +// but we only learn that IP after registering (chicken-and-egg). So the first bring-up uses a placeholder +// address; once the core reports Running with its netmap IPs, we cache them and re-establish the tun with +// the real IP/routes/DNS (VpnService.establish returns a new fd, so this is a stop→re-establish→restart, +// not an in-place update like iOS's NE). The identity persists in stateDir, so the IP is stable and the +// reconnect is fast; subsequent connects use the cached IP up front and skip the extra step. + +private const val KEY_NODE_IPV4 = "nodeIpv4" // cached tailnet IP so later connects build the tun correctly +private const val KEY_NODE_IPV6 = "nodeIpv6" + +/** + * The exit node the user selected, or empty. + * + * Kept here as well as in the core because the TUN has to be built differently for it: an exit node + * carries ALL traffic, so the tunnel must claim the default route rather than just the tailnet + * ranges. The core's preference alone changes nothing — the OS would still route everything but + * tailnet addresses straight out over Wi-Fi, and the exit node would never see a single packet. + */ +const val KEY_EXIT_NODE = "exitNodeId" + +/** + * Lets the Go core exclude its own sockets from this tunnel, without the service implementing the + * core's interface itself. + * + * That separation is forced, not stylistic: `VpnService.protect(int)` and the interface gomobile + * generates from `tailcore.Protector` have the SAME Java signature — `boolean protect(int)`. A service + * implementing the interface directly would have the override call itself forever. Delegating through + * a separate object keeps the two apart, and `svc.protect` unambiguously means the framework's. + */ +private class CoreProtector(private val svc: VpnService) : Protector { + // Its own tag: the service's is private to its companion, and these lines want finding on their own + // anyway — a protect() failure is silent everywhere else. + private val tag = "OfficerVpnProtect" + + /** + * Called by the core, from whichever goroutine is dialling, for every socket it opens. + * + * Returning false fails that dial in the core. That is intended rather than a last resort: an + * unprotected socket loops back into the tunnel and hangs, and a refused connection is far easier to + * diagnose than a hung one. + */ + override fun protect(fd: Int): Boolean { + val ok = try { + svc.protect(fd) + } catch (e: Exception) { + Log.w(tag, "protect($fd) threw: ${e.message}") + false + } + if (!ok) Log.w(tag, "protect($fd) refused — consent revoked, or fd already closed") + return ok + } +} + +class OfficerVpnService : VpnService() { + + /** + * Hand the core the callback. False means it did not take, and the caller must fall back to + * excluding the whole app — without one of the two, every socket the core dials loops. + */ + private fun installProtector(): Boolean = try { + Tailcore.setProtector(CoreProtector(this)) + true + } catch (e: Throwable) { + // Throwable, not Exception: a core built without the symbol raises NoSuchMethodError, which is + // precisely the case this fallback exists for and is not an Exception. + Log.w(TAG, "setProtector unavailable: ${e.message}") + false + } + + + companion object { + private const val TAG = "OfficerVpnService" + private const val CHANNEL_ID = "offtale_vpn" + private const val NOTIF_ID = 1042 + + @Volatile var statusSink: ((Map) -> Unit)? = null + @Volatile var lastStatus: Map? = null + } + + private val running = AtomicBoolean(false) + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + /* + startForeground FIRST, unconditionally, before looking at the action. + * + * Once startForegroundService() is called, Android gives the service ~5 seconds to call + * startForeground() or it kills the whole app with + * ForegroundServiceDidNotStartInTimeException. The ACTION_DOWN branch below tore down and called + * stopSelf() without ever calling it — so whenever a start promise was answered by a DOWN, the + * app died about five seconds later. + * + * Switching profiles does exactly that: down() then up(), back to back. The tunnel would come up + * perfectly and the process was killed seconds afterwards, which is why it looked like a crash in + * the switch rather than an unfulfilled contract. It was intermittent because it depended on + * which command answered the promise. + * + * Calling it here costs nothing on the teardown path: stopSelf() removes the notification + * immediately after. + */ + startForegroundNotification() + + when (intent?.action) { + ACTION_DOWN -> { + teardown(intent.getBooleanExtra("logout", false)) + stopSelf() + return START_NOT_STICKY + } + // Marked here rather than in bringUp(): bringUp runs on a worker thread after establish(), and a + // process killed before it got that far would leave no record of having started at all. + else -> { markRunStarted(); startTunnel() } + } + return START_STICKY + } + + private fun startTunnel() { + startForegroundNotification() + + val prefs = getSharedPreferences("officer_net", MODE_PRIVATE) + val controlUrl = prefs.getString(KEY_CONTROL_URL, "") ?: "" + if (controlUrl.isEmpty()) { + emitStatus("error", error = "No controlUrl configured") + stopSelf() + return + } + val hostname = prefs.getString(KEY_HOSTNAME, null) + val authKey = prefs.getString(KEY_AUTH_KEY, null) + + // If we already learned this node's tailnet IP on a prior registration, build the tun with it up front + // so routing works on the first shot; otherwise start with a placeholder and reconfigure post-netmap. + val knownAddrs = buildList { + prefs.getString(KEY_NODE_IPV4, null)?.let { add(it to 32) } + prefs.getString(KEY_NODE_IPV6, null)?.let { add(it to 128) } + } + // Off the main thread: bringUp now waits for an upstream network first, and onStartCommand runs + // on the main thread — a 30s wait there is an ANR. The status poll it hands off to was already + // on a worker, so this only moves the establish() itself. + // A previously chosen exit node survives a reconnect, so the tun must be built for it up front + // rather than coming up tailnet-only and rebuilding a moment later. + val exitNodeActive = prefs.getString(KEY_EXIT_NODE, "").orEmpty().isNotEmpty() + emitStatus("connecting") + Thread({ + bringUp(knownAddrs.ifEmpty { null }, null, controlUrl, hostname, authKey, exitNodeActive) + }, "tailcore-bringup").start() + } + + // Establish the tun and start the core on a worker thread. addrs = the node's real tailnet IPs (with + // prefix len) when known, else null for a placeholder until the netmap arrives. searchDomain = MagicDNS + // suffix when known. Safe to call again mid-session to reconfigure (establish replaces the tun). + /** + * Wait for a usable non-VPN network before touching the tun. + * + * At BOOT_COMPLETED there is often no Wi-Fi yet. Establishing anyway captured all DNS at + * 100.100.100.100 while the core — unable to reach Headscale — never got a netmap, so its resolver + * had no routes and answered nothing. The phone came up with the VPN "on" and no internet at all, + * which is worse than not reconnecting. + * + * Polled rather than a ConnectivityManager callback because the caller is already on a worker path + * and the wait is bounded: after ~30s we give up and proceed, so a genuinely offline boot still + * ends in a normal "connecting" state the user can see, rather than a service that never starts. + */ + private fun awaitUpstreamNetwork(timeoutMs: Long = 30_000) { + val cm = getSystemService(android.net.ConnectivityManager::class.java) ?: return + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + val ready = runCatching { + cm.allNetworks.any { n -> + val caps = cm.getNetworkCapabilities(n) + caps != null && + !caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN) && + caps.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET) && + caps.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } + }.getOrDefault(false) + if (ready) { + Log.i(TAG, "upstream network is up") + return + } + Log.i(TAG, "waiting for an upstream network before establishing the tun") + try { Thread.sleep(1000) } catch (_: InterruptedException) { return } + } + Log.w(TAG, "no upstream network after ${timeoutMs}ms — proceeding anyway") + } + + private fun bringUp( + addrs: List>?, + searchDomain: String?, + controlUrl: String, + hostname: String?, + authKey: String?, + /** Claim the default route, because an exit node carries everything rather than tailnet traffic. */ + exitNodeActive: Boolean = false, + /** Advertised subnets the user has accepted; the tun must claim them or they never arrive. */ + subnetRoutes: List = emptyList(), + ) { + // Before ANY of this: a tun built with no upstream captures DNS and answers nothing. + awaitUpstreamNetwork() + + val upstream = upstreamDnsServers() + val builder = Builder().setSession("OffTail").setMtu(1280) + if (addrs == null) { + builder.addAddress("100.64.0.1", 32) // placeholder; replaced once we learn our real IP + } else { + for ((ip, prefix) in addrs) builder.addAddress(ip, prefix) + } + /* + With an exit node, the tunnel must claim EVERYTHING. + * + * Routing only the tailnet ranges is right for ordinary use and useless for an exit node: the OS + * hands us tailnet packets and sends the rest straight out over Wi-Fi, so the exit node never + * sees the traffic it exists to carry. Selecting one changed the core's preference and nothing + * observable — on and off behaved identically. + */ + if (exitNodeActive) { + builder.addRoute("0.0.0.0", 0) + builder.addRoute("::", 0) + Log.i(TAG, "tun routes: DEFAULT (exit node active)") + } else { + builder.addRoute("100.64.0.0", 10) // tailnet CGNAT v4 + builder.addRoute("fd7a:115c:a1e0::", 48) // tailnet ULA v6 + + /* + Subnet routes, when the user has accepted them. + * + * Accepting a route in the CORE is only half of it: the OS decides what enters the tunnel, so + * unless the tun claims these prefixes the traffic never reaches us and "use subnet routes" + * does nothing observable — the same gap that made choosing an exit node inert. + * + * Skipped entirely with an exit node active, since the default route above already covers them. + */ + for (cidr in subnetRoutes) { + val parts = cidr.split("/") + if (parts.size != 2) continue + runCatching { + builder.addRoute(java.net.InetAddress.getByName(parts[0]), parts[1].toInt()) + Log.i(TAG, "tun claims advertised subnet $cidr") + }.onFailure { Log.w(TAG, "addRoute $cidr: ${it.message}") } + } + Log.i(TAG, "tun routes: tailnet ranges + ${subnetRoutes.size} subnet route(s)") + } + + /* + Keep the SYSTEM's own resolvers out of the tunnel. + * + * This router hands out 100.90.1.1 and 100.100.1.1 over DHCP — addresses inside 100.64.0.0/10, + * which the route above claims wholesale. So every DNS query the phone makes gets pulled into the + * tun and dies there while the core has no netmap: the browser waits, then reports the site + * unreachable. Disabling DNS capture does not help, because the problem is not who answers, it is + * that the packet never leaves. + * + * The code below already refuses these as UPSTREAM resolvers for being circular. Excluding them + * from the routes is the other half of the same fact: they are LAN services that happen to sit in + * CGNAT space, and they must stay on the physical network. + * + * excludeRoute needs API 33. Below that the tun cannot express this and such a network simply + * cannot browse while connected — worth knowing rather than silently mis-routing. + */ + /* + Keep the local network reachable while an exit node carries the internet. + * + * The default route above swallows the LAN too, so without this, choosing an exit node cuts off + * the printer, the NAS and the router's own web UI — everything on the same Wi-Fi. Prefs already + * set ExitNodeAllowLANAccess; this is the tun honouring it, which is the half that actually + * decides where packets go. + */ + if (exitNodeActive && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + for (cidr in localSubnets()) { + runCatching { + builder.excludeRoute(cidr) + Log.i(TAG, "excluded local network $cidr from the tun — LAN stays reachable") + }.onFailure { Log.w(TAG, "excludeRoute $cidr: ${it.message}") } + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + for (ip in systemDnsInsideTailnetRange()) { + runCatching { + builder.excludeRoute(android.net.IpPrefix(java.net.InetAddress.getByName(ip), if (ip.contains(':')) 128 else 32)) + Log.i(TAG, "excluded system resolver $ip from the tun — it lives in the tailnet range") + }.onFailure { Log.w(TAG, "excludeRoute $ip: ${it.message}") } + } + } + /* + * NO DNS SERVER — deliberately, and this is not a regression. + * + * `addDnsServer("100.100.100.100")` was here and it took the whole internet down with the + * tunnel. Android's VpnService has NO split-DNS: setting a resolver sends EVERY query from EVERY + * app to it system-wide, and addSearchDomain only appends a suffix without scoping which + * resolver is used. iOS scopes it with NEDNSSettings.matchDomains, which has no counterpart here. + * + * And nothing was answering. gocore/tailcore.go builds the engine with "Router + DNS + * intentionally nil" — the core runs no resolver at all, so 100.100.100.100 was a black hole. + * Every public lookup died while IP-literal traffic kept working, which is exactly why the + * tailnet tested clean (ping/TCP/SSH, all by IP) and a browser could not reach google.com. + * + * OUTDATED as of 2026-08-06 — kept only because the reasoning above still explains WHY Android + * cannot scope DNS the way iOS does. MagicDNS now works here: the block below sets + * 100.100.100.100 and the core forwards non-tailnet names to the upstream resolvers passed by + * SetUpstreamDNS. Verified on device: `alpha:9010` and `100.64.0.15:9010` both resolve. + * + * This comment was quoted as fact twice while it was already false, including into a document + * headed for a store listing. The claim to retire is "MagicDNS does not work on Android". + */ + /* + * MagicDNS. The core's own resolver answers `alpha` and friends, and forwards everything else to + * the upstream resolvers handed in by SetUpstreamDNS. + * + * This only works because the node now accepts DNS config from the control plane (CorpDNS in + * gocore). Without that the netmap arrived empty, the resolver had no routes and no upstreams, + * and pointing the OS here black-holed every lookup — twice. + * + * Headscale sends override_local_dns:false and no global nameservers, so the config carries a + * route for ts.pastilhas.eu and a search domain but no default resolvers. That is the split-DNS + * path, where the manager blends in our GetBaseConfig — hence the upstream list still matters. + */ + /* + ONLY once the node has a netmap — otherwise this deadlocks the sign-in. + * + * Pointing the tun's DNS at the core makes it the system-wide resolver: Android has no split-DNS, + * so EVERY app's lookups go here, and the core only forwards them upstream once it is Running. On + * a first connect the node sits in NeedsLogin with no netmap, so nothing resolves — and the page + * you need in order to leave NeedsLogin is a web page. No site loads at all, which reads as the + * VPN having broken the internet. + * + * `addrs != null` means we already have a node IP, i.e. this device has registered before. On the + * very first connect we leave system DNS alone; the netmap poll re-establishes the tun with real + * addresses and MagicDNS a few seconds later. + * + * iOS has no such problem: NEDNSSettings.matchDomains scopes capture to the tailnet suffix, so + * non-tailnet lookups never reach the core. Android has no equivalent. + */ + if (addrs != null) { + builder.addDnsServer("100.100.100.100") + Log.i(TAG, "tun dns=100.100.100.100 (MagicDNS; core forwards to $upstream)") + } else { + Log.i(TAG, "tun dns: left to the system — no netmap yet, capturing DNS now would break sign-in") + } + if (!searchDomain.isNullOrEmpty()) try { builder.addSearchDomain(searchDomain) } catch (_: Exception) {} + + /* + Registered before establish(), not merely before Tailcore.start(): the core can dial the moment it + comes up, and a socket created before the hook exists is an unprotected one that loops. It also + has to happen before the choice below, which reads the result. + */ + val protectorInstalled = installProtector() + + /* + Keep the core's own sockets out of the tunnel — the narrow way if we can, the blunt way if not. + + A VPN cannot route its own WireGuard packets through itself: they would come straight back to the + interface that produced them and loop. Two ways to prevent it, and they are not equivalent: + + protect(fd) excludes ONE socket — the core's, which is the only one that + actually needs it + addDisallowedApplication(us) excludes the ENTIRE app, including everything it does that has + nothing to do with running the tunnel + + The blunt one was here from the start and works, at a cost that is invisible until you want it: + nothing this app does can travel through the VPN it is holding open. Not an in-app browser, not + Officer's calls to the platform, not any service that exists only on the tailnet. An app holding a + tunnel it cannot itself use is a strange thing to ship. + + The fallback is deliberate rather than defensive. If the protector did not register, every socket + the core dials would loop and the tunnel would fail entirely — so on that path we take the blunt + exclusion back and accept the old limitation, which at least connects. + */ + if (protectorInstalled) { + Log.i(TAG, "socket protection: protect(fd) per socket — app traffic may use the tunnel") + } else { + try { builder.addDisallowedApplication(packageName) } catch (_: Exception) {} + Log.w(TAG, "socket protection: FELL BACK to excluding the whole app — protector unavailable") + } + + excludeCarProjection(builder) + + val pfd = builder.establish() + Log.i(TAG, "establish() -> ${if (pfd == null) "NULL" else "fd ok"} addrs=${addrs ?: "placeholder 100.64.0.1"} search=${searchDomain ?: "-"}") + if (pfd == null) { + emitStatus("error", error = "Failed to establish tun (consent revoked?)") + stopSelf() + return + } + // Hand fd ownership to the Go core (it closes it on stop); mtu=0 in the core so it won't setMTU. + val fd = pfd.detachFd() + emitStatus("connecting") + running.set(true) + val tunHasRealIp = addrs != null + val tunHasExitNode = exitNodeActive + val tunSubnets = subnetRoutes + + Thread({ + try { + // Android denies Go's netlink interface enumeration; feed the core interfaces from Java BEFORE + // start (netmon reads them during bring-up). See gocore/interfaces_droid.go. + Tailcore.setAndroidInterfaces(enumerateInterfacesJson()) + // Before start: the core cannot discover these once the tunnel is up, because by then it + // would only see its own. Without them every non-tailnet lookup dies at the core's resolver. + Tailcore.setUpstreamDNS(JSONArray(upstream).toString()) + Log.i(TAG, "TailcoreStart fd=$fd control=$controlUrl host=$hostname realIp=$tunHasRealIp core=${Tailcore.version()}") + Tailcore.start(fd, controlUrl, authKey ?: "", hostname ?: "", filesDir.absolutePath) + getSharedPreferences("officer_net", MODE_PRIVATE).edit() + .putBoolean(KEY_REGISTERED, true).remove(KEY_AUTH_KEY).apply() + Log.i(TAG, "TailcoreStart returned OK — polling status") + pollStatusLoop(tunHasRealIp, controlUrl, hostname, tunHasExitNode, searchDomain, tunSubnets) + } catch (e: Exception) { + Log.e(TAG, "TailcoreStart failed: ${e.message}", e) + emitStatus("error", error = e.message) + running.set(false) + stopSelf() + } + }, "tailcore-start").start() + } + + // Poll the core's status so the UI reflects backend state. When the tun is still on a placeholder and the + // node reaches Running with real netmap IPs, cache them and re-establish the tun with the real IP so the + // OS routes tailnet traffic (the Android routing last mile). This hands off to a fresh bringUp and returns. + private fun pollStatusLoop( + tunHasRealIp: Boolean, + controlUrl: String, + hostname: String?, + /** What the tun was BUILT for, so a change of mind can be detected and the routes rebuilt. */ + tunHasExitNode: Boolean, + searchDomain: String?, + /** What the tun currently claims, so a change in the accepted set can be detected. */ + tunSubnets: List, + ) { + while (running.get()) { + try { + val o = JSONObject(Tailcore.status()) + val backend = o.optString("BackendState", "") + // NeedsLogin is not an error. The node reaches it whenever control wants a human to + // authorise it, which is the normal path for anything but a pre-auth key — and the URL to + // send them to is in this same JSON, as AuthURL. Reporting it as an error with a guessed + // cause ("key rejected/expired?") is what made interactive login look broken. + val state = when (backend) { + "Running" -> "connected" + "NeedsLogin" -> "needsLogin" + "Stopped" -> "stopped" + else -> "connecting" + } + val authUrl = o.optString("AuthURL", "").takeIf { it.isNotEmpty() } + var self: Map? = null + var ips: List = emptyList() + o.optJSONObject("Self")?.let { s -> + val ipsArr = s.optJSONArray("TailscaleIPs") + ips = if (ipsArr != null) (0 until ipsArr.length()).map { ipsArr.getString(it) } else emptyList() + + // MagicDNS names arrive fully qualified with a trailing dot — correct as DNS, reads like a + // typo in a UI. + val dns = s.optString("DNSName").removeSuffix(".") + val host = s.optString("HostName").takeIf { it.isNotEmpty() } + + // The account this node registered under. ipnstate.Status carries a User map keyed by the + // numeric user id, and Self.UserID points into it, so the login email has been in this JSON + // all along. It is what answers "which account is this?" once more than one tailnet exists. + val login = o.optJSONObject("User") + ?.optJSONObject(s.optLong("UserID").toString()) + ?.let { u -> u.optString("LoginName").takeIf { it.isNotEmpty() } ?: u.optString("DisplayName").takeIf { it.isNotEmpty() } } + + val tailnet = o.optJSONObject("CurrentTailnet")?.optString("Name")?.takeIf { it.isNotEmpty() } + + self = mapOf( + // The short host name leads; the fully-qualified one is a DNS record, not a device name. + "name" to (host ?: dns), + "magicDNSName" to dns, + "ips" to ips, + "hostName" to host, + "os" to s.optString("OS").takeIf { it.isNotEmpty() }, + "loginName" to login, + "tailnet" to tailnet, + /* + When this device's key expires, and whether it already has. + + A node key expires on the tailnet's own schedule and the device then stops working with + no explanation of its own. Surfacing it lets the app warn beforehand instead of the + expiry arriving as an unexplained disconnection. + */ + "keyExpiry" to s.optString("KeyExpiry").takeIf { it.isNotEmpty() }, + "expired" to s.optBoolean("Expired", false), + ) + } + /* + The other machines on the tailnet. + * + * Emitted empty since this was written, so the peer list in the UI — tap to copy an IP, + * long-press for the MagicDNS name — has never had anything to render. ipnstate.Status keys + * them by public key under "Peer". ExitNodeOption / ExitNode ride along because choosing an + * exit node needs both: who offers, and who is in use. + */ + val peers: List> = o.optJSONObject("Peer")?.let { pm -> + pm.keys().asSequence().mapNotNull { k -> + pm.optJSONObject(k)?.let { p -> + val pIpsArr = p.optJSONArray("TailscaleIPs") + val pIps = if (pIpsArr != null) (0 until pIpsArr.length()).map { pIpsArr.getString(it) } else emptyList() + val pDns = p.optString("DNSName").removeSuffix(".").takeIf { it.isNotEmpty() } + val pHost = p.optString("HostName").takeIf { it.isNotEmpty() } + mapOf( + // StableID is durable; the map key is the public key and changes on rekey. + "id" to (p.optString("ID").takeIf { it.isNotEmpty() } ?: pDns ?: pHost ?: k), + "name" to (pDns ?: pHost ?: ""), + "hostName" to pHost, + "ips" to pIps, + "online" to p.optBoolean("Online", false), + "os" to p.optString("OS").takeIf { it.isNotEmpty() }, + "lastSeen" to p.optString("LastSeen").takeIf { it.isNotEmpty() }, + "exitNodeOption" to p.optBoolean("ExitNodeOption", false), + "exitNode" to p.optBoolean("ExitNode", false), + ) + } + }.sortedWith( + // Online first, then by name: map order is whatever the JSON parser produced. + compareByDescending> { it["online"] as? Boolean ?: false } + .thenBy { (it["name"] as? String ?: "").lowercase() }, + ).toList() + } ?: emptyList() + + /* + The chosen exit node's health, as the core sees it. + + Three outcomes, and they need different things from the user: + - object, Online true working + - object, Online false the node is registered but not reachable — wait, or pick another + - absent, with an exit node set in prefs: the node is no longer on this tailnet at all, + so waiting will never help and the choice has to be remade + Traffic already fails closed in the middle case (no packets escape around the tunnel), which + is the safe behaviour but indistinguishable from a dead connection without this. + */ + val exitNodeOnline: Boolean? = + o.optJSONObject("ExitNodeStatus")?.optBoolean("Online", false) + + Log.i(TAG, "status: backend=$backend ips=$ips peers=${peers.size} authUrl=${authUrl != null} exitNodeOnline=$exitNodeOnline") + emitStatus( + state, self = self, peers = peers, authUrl = authUrl, + controlUrl = controlUrlForStatus(), exitNodeOnline = exitNodeOnline, + ) + + /* + The exit-node choice changed under a live tunnel, so the routes are now wrong. + * + * Nothing else notices: the preference lives in the core, the routes live in the tun, and + * only a re-establish can reconcile them. Checked here rather than pushed from the module so + * a change made anywhere — including by the core itself — is picked up. + */ + val wantExitNode = getSharedPreferences("officer_net", MODE_PRIVATE) + .getString(KEY_EXIT_NODE, "").orEmpty().isNotEmpty() + val wantSubnets = runCatching { + val arr = JSONArray(Tailcore.subnetRoutes()) + (0 until arr.length()).map { arr.getString(it) } + }.getOrDefault(emptyList()) + if (backend == "Running" && (wantExitNode != tunHasExitNode || wantSubnets.toSet() != tunSubnets.toSet())) { + Log.i(TAG, "routes changed (exit=$wantExitNode/$tunHasExitNode subnets=$wantSubnets/$tunSubnets) — rebuilding the tun") + running.set(false) + try { Tailcore.stop() } catch (e: Exception) { Log.w(TAG, "stop before exit-node rebuild: ${e.message}") } + val keep = buildList { + ips.firstOrNull { it.contains(".") }?.let { add(it to 32) } + ips.firstOrNull { it.contains(":") }?.let { add(it to 128) } + } + bringUp(keep.ifEmpty { null }, searchDomain, controlUrl, hostname, null, wantExitNode, wantSubnets) + return + } + + if (backend == "Running" && !tunHasRealIp && ips.isNotEmpty()) { + val v4 = ips.firstOrNull { it.contains(".") } + val v6 = ips.firstOrNull { it.contains(":") } + val suffix = o.optString("MagicDNSSuffix", "") + .ifEmpty { o.optJSONObject("CurrentTailnet")?.optString("MagicDNSSuffix", "") ?: "" } + .ifEmpty { null } + getSharedPreferences("officer_net", MODE_PRIVATE).edit() + .apply { v4?.let { putString(KEY_NODE_IPV4, it) }; v6?.let { putString(KEY_NODE_IPV6, it) } } + .apply() + Log.i(TAG, "reconfiguring tun with real ip v4=$v4 v6=$v6 suffix=$suffix") + running.set(false) + try { Tailcore.stop() } catch (e: Exception) { Log.w(TAG, "stop before reconfigure: ${e.message}") } + val real = buildList { v4?.let { add(it to 32) }; v6?.let { add(it to 128) } } + // Carries the exit-node choice through: rebuilding for the real IP must not quietly drop + // the default route and strand traffic that was going through the exit node. + bringUp(real, suffix, controlUrl, hostname, null, tunHasExitNode, tunSubnets) + return + } + } catch (e: Exception) { + Log.w(TAG, "status poll: ${e.message}") + } + try { Thread.sleep(2000) } catch (_: InterruptedException) { break } + } + } + + private fun teardown(logout: Boolean) { + running.set(false) + try { Tailcore.stop() } catch (e: Exception) { Log.w(TAG, "Tailcore.stop: ${e.message}") } + if (logout) { + getSharedPreferences("officer_net", MODE_PRIVATE).edit() + .remove(KEY_REGISTERED).remove(KEY_NODE_IPV4).remove(KEY_NODE_IPV6).apply() + // The identity itself is `tailscaled.state`, written by the CORE into filesDir — clearing the + // prefs above only forgets that we were registered, not the node we registered AS. Without + // this, "Forget this device" left a fully valid identity on disk and the next login silently + // came back as the same machine. Deleted after Tailcore.stop() above, never before: the store + // holds the file open and would write the in-memory identity straight back. + listOf("tailscaled.state").forEach { name -> + runCatching { java.io.File(filesDir, name).delete() } + .onFailure { Log.w(TAG, "wipe $name: ${it.message}") } + } + } + emitStatus("stopped") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) stopForeground(STOP_FOREGROUND_REMOVE) + } + + override fun onDestroy() { + markRunStopped() + teardown(logout = false) + super.onDestroy() + } + + /* + How the LAST tunnel run ended. + + Same inference as the iOS extension, and for the same reason: the failures worth knowing about kill + the process rather than throwing something catchable. A low-memory kill runs no handler at all, and + `ForegroundServiceDidNotStartInTimeException` — which took this app down once already — is thrown by + the system into the service, not into any code of ours. + + So it is inferred, not caught. Starting marks the run `running`; `onDestroy` marks it `stopped`. A + start that finds the previous run still `running` means that run never reached `onDestroy`. + + Unlike iOS this file is directly readable by the app: same process, same `filesDir`, no App Group + and no message channel needed. + + It holds a timestamp and nothing else — no address, no login, no tailnet name. Deliberate: this is + the diagnostic meant to leave the device, so it is given nothing worth leaking. + */ + private fun lastExitFile() = java.io.File(filesDir, "last-exit.json") + + private fun markRunStarted() { + val now = System.currentTimeMillis() + var previous: JSONObject? = null + runCatching { + val f = lastExitFile() + if (f.exists()) { + val prev = JSONObject(f.readText()) + previous = if (prev.optString("state") == "running") { + JSONObject() + .put("kind", "abnormal") + .put("startedAt", prev.optLong("startedAt")) + .put("detectedAt", now) + } else { + prev.optJSONObject("previous") + } + } + }.onFailure { Log.w(TAG, "last-exit read: ${it.message}") } + + if (previous?.optString("kind") == "abnormal") { + Log.w(TAG, "PREVIOUS RUN ENDED ABNORMALLY (no onDestroy) — started ${previous?.optLong("startedAt")}") + } + runCatching { + lastExitFile().writeText( + JSONObject() + .put("state", "running") + .put("startedAt", now) + // Carried forward so a reconnect the app did not witness cannot lose the report. + .put("previous", previous ?: JSONObject.NULL) + .toString(), + ) + }.onFailure { Log.w(TAG, "last-exit write: ${it.message}") } + } + + private fun markRunStopped() { + runCatching { + val f = lastExitFile() + // Preserve a not-yet-collected report: an orderly stop must not erase the crash before it. + val carried = if (f.exists()) JSONObject(f.readText()).opt("previous") else null + f.writeText( + JSONObject() + .put("state", "stopped") + .put("at", System.currentTimeMillis()) + .put("previous", carried ?: JSONObject.NULL) + .toString(), + ) + }.onFailure { Log.w(TAG, "last-exit stop: ${it.message}") } + } + + private fun startForegroundNotification() { + val nm = getSystemService(NotificationManager::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + nm.createNotificationChannel(NotificationChannel(CHANNEL_ID, "OffTail VPN", NotificationManager.IMPORTANCE_LOW)) + } + val notif: Notification = Notification.Builder(this, CHANNEL_ID) + .setContentTitle("OffTail") + .setContentText("Mesh VPN active") + .setSmallIcon(android.R.drawable.ic_lock_lock) + .setOngoing(true) + .build() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) + } else { + startForeground(NOTIF_ID, notif) + } + } + + /** + * The DNS servers of the real (non-VPN) network. + * + * `activeNetwork` is useless once our own tunnel is up — it returns the VPN. So walk every network + * and take the one that has internet and is NOT a VPN. + * + * Falls back to Cloudflare/Google only if that finds nothing, because no resolver at all means no + * name resolution for any app on the device while the tunnel is up. A public fallback is a + * privacy trade, so it is last resort and logged. + */ + /** + * The phone's own DNS servers that fall inside the ranges this tun claims. + * + * Only these need excluding — a resolver on a normal LAN address is untouched by our routes and + * needs no special handling. + */ + /** + * The subnets the phone is directly attached to, as IpPrefixes. + * + * Taken from the non-VPN networks' link addresses, so it follows the phone from Wi-Fi to Wi-Fi + * rather than assuming 192.168/16. Link-local and loopback are skipped — they are never routed + * through a VPN anyway, and excluding them just adds noise. + */ + @android.annotation.TargetApi(Build.VERSION_CODES.TIRAMISU) + private fun localSubnets(): List { + val cm = getSystemService(android.net.ConnectivityManager::class.java) ?: return emptyList() + val out = mutableListOf() + runCatching { + for (n in cm.allNetworks) { + val caps = cm.getNetworkCapabilities(n) ?: continue + if (caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN)) continue + cm.getLinkProperties(n)?.linkAddresses?.forEach { la -> + val a = la.address + if (a.isLoopbackAddress || a.isLinkLocalAddress || a.isAnyLocalAddress) return@forEach + // The prefix, not the host address: excluding a single /32 would leave the rest of the LAN + // inside the tunnel. + runCatching { out.add(android.net.IpPrefix(a, la.prefixLength)) } + } + } + } + return out.distinct() + } + + private fun systemDnsInsideTailnetRange(): List { + val cm = getSystemService(android.net.ConnectivityManager::class.java) ?: return emptyList() + val out = mutableListOf() + runCatching { + for (n in cm.allNetworks) { + val caps = cm.getNetworkCapabilities(n) ?: continue + if (caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN)) continue + cm.getLinkProperties(n)?.dnsServers?.forEach { addr -> + val ip = addr.hostAddress ?: return@forEach + // The file's isInTailnetRange covers the v4 CGNAT range; the v6 ULA is checked here so + // that helper keeps its single, documented meaning. + if (isInTailnetRange(ip) || ip.startsWith("fd7a:115c:a1e0:")) out.add(ip) + } + } + } + return out.distinct() + } + + /** + * Keep car projection out of the tunnel. + * + * Android Auto is a phone talking to a head unit over USB or the car's own Wi-Fi, and it is a poor + * fit for a VPN in every direction: the link is local, the head unit's network usually has no route + * to the internet, and the projection stack is not built to have its traffic and its name resolution + * quietly redirected. The one Android user of this app uninstalled it because it interfered with a + * Peugeot head unit, and while that is unconfirmed, there is nothing to gain by routing a car dock + * through a mesh network. + * + * An excluded app bypasses the tun entirely — its traffic AND its DNS — which is what makes this + * cover the case even if the resolver probe above turns out not to be the whole story. + * + * Scoped to a named list rather than a general per-app setting on purpose. A settings screen listing + * every installed app is a real feature and worth building; this is the case we have evidence for, + * and it should not wait for the general one. + * + * NameNotFoundException for an app that is not installed is the ordinary case and not worth a line + * in the log — most phones have some of these, few have all. + */ + private fun excludeCarProjection(builder: android.net.VpnService.Builder) { + val packages = listOf( + "com.google.android.projection.gearhead", // Android Auto + "com.google.android.apps.automotive.templates.host", // Automotive host, on some head units + ) + for (pkg in packages) { + try { + builder.addDisallowedApplication(pkg) + Log.i(TAG, "excluded $pkg from the tunnel — car projection does not belong in a mesh") + } catch (_: Exception) { + } + } + } + + private fun upstreamDnsServers(): List { + val cm = getSystemService(android.net.ConnectivityManager::class.java) ?: return fallbackDns() + val out = mutableListOf() + try { + for (n in cm.allNetworks) { + val caps = cm.getNetworkCapabilities(n) ?: continue + if (caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN)) continue + if (!caps.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET)) continue + cm.getLinkProperties(n)?.dnsServers?.forEach { addr -> + val ip = addr.hostAddress ?: return@forEach + /* + * A resolver inside 100.64.0.0/10 is CIRCULAR and must never be an upstream. + * + * This router hands out 100.90.1.1 and 100.100.1.1 over DHCP — tailnet addresses. They are + * only reachable THROUGH the tunnel, so handing them to the core as the upstream it should + * forward to means asking the tunnel to bootstrap itself. The core answered + * "no upstream resolvers set" and returned SERVFAIL for every public name, while + * IP-literal traffic kept working — so the tailnet tested clean and browsers showed no + * internet at all. + * + * They were not filtered because the list was merely non-empty, so the public fallback + * below never triggered. isInTailnetRange already existed for exactly this shape of check + * and was not being applied here. + */ + if (isInTailnetRange(ip)) { + Log.i(TAG, "upstream DNS $ip is inside the tunnel — skipping, it cannot resolve itself") + return@forEach + } + if (ip !in out) out.add(ip) + } + } + } catch (e: Exception) { + Log.w(TAG, "upstreamDnsServers: ${e.message}") + } + if (out.isEmpty()) { + Log.w(TAG, "no upstream resolvers found — falling back to public DNS") + return fallbackDns() + } + + /* + A resolver that EXISTS is not a resolver that ANSWERS, and the difference is the whole bug. + + The fallback above only fires when the network hands us nothing at all. A car head unit, a hotel + portal or a router with a broken forwarder all hand out an address that looks perfectly usable and + then answers nothing — we accepted it, gave it to the core as its upstream, and every public name + on the phone died. Not just in this app: Android has no split-DNS, so the tun's resolver is the + system's resolver, and one dud address taken on trust takes the whole device's DNS with it. + + That is the shape of the report from the one Android user we have — OffScale uninstalled because + it "interfered" with an Android Auto head unit. Unconfirmed as the cause, and cheap to rule out. + + So ask them. A resolver that cannot answer one query in 700ms is not one to hand the whole device + to, and if none of them can, the public servers are a better bet than a confident silence. + */ + val answering = out.filter { resolverAnswers(it) } + if (answering.isEmpty()) { + Log.w(TAG, "upstream resolvers $out did not answer — falling back to public DNS") + return fallbackDns() + } + if (answering.size != out.size) { + Log.i(TAG, "upstream resolvers: keeping $answering, dropping ${out - answering.toSet()} (no answer)") + } + return answering + } + + /** + * One real DNS query, to find out whether this address resolves anything. + * + * Deliberately a hand-rolled UDP query rather than InetAddress.getByName: the platform resolver + * decides for itself which server to use, so it would test Android's configuration rather than THIS + * address, and answer from cache besides. The wire format here is the minimum that is still a valid + * query — a header with one question, and `a.root-servers.net` as the name, which every functioning + * resolver on earth can answer and no forwarder special-cases. + * + * Called before establish(), so the socket goes out over the real network rather than the tun. 700ms + * once: this runs on the path that brings the tunnel up, and a slow resolver is a bad upstream for a + * device whose every lookup will go through it anyway. + */ + private fun resolverAnswers(ip: String, timeoutMs: Int = 700): Boolean { + return try { + val query = java.io.ByteArrayOutputStream() + val d = java.io.DataOutputStream(query) + d.writeShort(0x4f53) // id — any value; we only check that something came back + d.writeShort(0x0100) // standard query, recursion desired + d.writeShort(1) // one question + d.writeShort(0); d.writeShort(0); d.writeShort(0) + for (label in listOf("a", "root-servers", "net")) { + d.writeByte(label.length); d.write(label.toByteArray()) + } + d.writeByte(0) // end of name + d.writeShort(1) // A + d.writeShort(1) // IN + val payload = query.toByteArray() + + java.net.DatagramSocket().use { sock -> + // Our own socket, on the path that establishes the tunnel — protect it for the same reason + // the core's sockets are protected, or it can loop back into a tun that is coming up. + runCatching { protect(sock) } + sock.soTimeout = timeoutMs + sock.send(java.net.DatagramPacket(payload, payload.size, java.net.InetAddress.getByName(ip), 53)) + val reply = ByteArray(512) + sock.receive(java.net.DatagramPacket(reply, reply.size)) + // Anything at all is enough. A SERVFAIL still proves something is listening and willing to + // talk, which is the question being asked — grading the answer is a different job. + true + } + } catch (e: Exception) { + Log.i(TAG, "upstream DNS $ip did not answer in ${timeoutMs}ms: ${e.javaClass.simpleName}") + false + } + } + + /** Cloudflare and Google, primary and secondary. Used when the network offers nothing usable. */ + private fun fallbackDns() = listOf("1.1.1.1", "1.0.0.1", "8.8.8.8", "8.8.4.4") + + /** True when an IPv4 literal falls inside 100.64.0.0/10, the range this tunnel routes. */ + private fun isInTailnetRange(ip: String): Boolean { + val parts = ip.split(".").mapNotNull { it.toIntOrNull() } + if (parts.size != 4) return false + return parts[0] == 100 && parts[1] in 64..127 + } + + // Enumerate the device's network interfaces via java.net.NetworkInterface (a path Android permits, + // unlike Go's netlink net.Interfaces) as a JSON array the Go core parses (gocore/interfaces_droid.go). + private fun enumerateInterfacesJson(): String { + val arr = JSONArray() + try { + for (nif in Collections.list(NetworkInterface.getNetworkInterfaces())) { + val o = JSONObject() + o.put("name", nif.name) + o.put("index", nif.index) + o.put("mtu", try { nif.mtu } catch (_: Exception) { 0 }) + o.put("up", try { nif.isUp } catch (_: Exception) { false }) + o.put("loopback", try { nif.isLoopback } catch (_: Exception) { false }) + o.put("pointToPoint", try { nif.isPointToPoint } catch (_: Exception) { false }) + o.put("multicast", try { nif.supportsMulticast() } catch (_: Exception) { false }) + nif.hardwareAddress?.let { mac -> + o.put("mac", mac.joinToString(":") { b -> "%02x".format(b) }) + } + val addrs = JSONArray() + for (ia in nif.interfaceAddresses) { + val host = ia.address?.hostAddress ?: continue + addrs.put("$host/${ia.networkPrefixLength}") + } + o.put("addrs", addrs) + arr.put(o) + } + } catch (e: Exception) { + Log.w(TAG, "enumerateInterfaces: ${e.message}") + } + return arr.toString() + } + + /** + * The CURRENT profile's control server, read from the core rather than from our own prefs. + * + * The stored value is one global for many profiles, so after a switch or a LoginTo it names the + * tailnet just left. Asking the core cannot drift. Falls back to the stored value only before the + * profile list can be read. + */ + private fun controlUrlForStatus(): String? = + runCatching { + val arr = org.json.JSONArray(Tailcore.listProfiles()) + (0 until arr.length()).map { arr.getJSONObject(it) } + .firstOrNull { it.optBoolean("current") } + ?.optString("controlUrl") + ?.takeIf { it.isNotEmpty() } + }.getOrNull() + ?: getSharedPreferences("officer_net", MODE_PRIVATE).getString(KEY_CONTROL_URL, null) + + private fun emitStatus( + state: String, + self: Map? = null, + peers: List> = emptyList(), + error: String? = null, + /** Where to send the user to authorise this node. Only set while state is needsLogin. */ + authUrl: String? = null, + /** Which control plane this node talks to — Tailscale or a self-hosted Headscale. */ + controlUrl: String? = null, + /** Whether the chosen exit node is reachable. Null when none is set, or when it has left the tailnet. */ + exitNodeOnline: Boolean? = null, + ) { + val snap = mapOf( + "state" to state, "self" to self, "peers" to peers, "error" to error, + "authUrl" to authUrl, "controlUrl" to controlUrl, "exitNodeOnline" to exitNodeOnline, + ) + lastStatus = snap + statusSink?.invoke(snap) + } +} diff --git a/modules/officer-net/app.plugin.js b/modules/officer-net/app.plugin.js new file mode 100644 index 0000000..e24eeb5 --- /dev/null +++ b/modules/officer-net/app.plugin.js @@ -0,0 +1,2 @@ +// Root plugin entry so `plugins: ["officer-net", {...}]` resolves. Real logic in ./plugin/app.plugin.js. +module.exports = require('./plugin/app.plugin.js'); diff --git a/modules/officer-net/expo-module.config.json b/modules/officer-net/expo-module.config.json new file mode 100644 index 0000000..4e5c6dd --- /dev/null +++ b/modules/officer-net/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["apple", "android"], + "apple": { + "modules": ["OfficerNetModule"] + }, + "android": { + "modules": ["expo.modules.officernet.OfficerNetModule"] + } +} diff --git a/modules/officer-net/gocore/dns.go b/modules/officer-net/gocore/dns.go new file mode 100644 index 0000000..80eea8e --- /dev/null +++ b/modules/officer-net/gocore/dns.go @@ -0,0 +1,91 @@ +package tailcore + +import ( + "encoding/json" + "log" + "net/netip" + "sync" + + "tailscale.com/net/dns" + "tailscale.com/util/dnsname" +) + +// MagicDNS — the piece that makes short names like `alpha` resolve. +// +// The resolver itself lives in the Tailscale core and answers on 100.100.100.100; netstack routes +// queries to it, but only if a dns.Manager exists. Ours never created one, so that address answered +// nothing and pointing the OS at it black-holed every lookup. See OFFTAIL_ANALYSIS_2026-08-02.md. +// +// The manager needs an OSConfigurator. On desktop that writes /etc/resolv.conf or talks to +// NetworkManager; on mobile the OS applies DNS itself (VpnService.Builder / NEPacketTunnelNetworkSettings), +// so the platform-specific ones are wrong here — manager_darwin.go would try to rewrite the phone's +// resolv.conf. Hence this no-op. +// +// One method is NOT a no-op and is the whole reason this file is subtle: GetBaseConfig supplies the +// UPSTREAM resolvers the tailnet resolver forwards non-tailnet queries to. Return nothing and only +// tailnet names resolve while the rest of the internet goes dark — the exact failure we just spent an +// evening removing. The platform hands them in via SetUpstreamDNS before Start. + +var ( + upstreamMu sync.Mutex + upstreamDNS []netip.Addr +) + +// SetUpstreamDNS gives the core the resolvers of the underlying network, as a JSON array of IP +// strings. Call BEFORE Start. Mirrors SetAndroidInterfaces: gomobile-friendly, JSON in, nothing out. +// +// The platform already knows these — Android reads them off the non-VPN network's LinkProperties, +// iOS gets them from the system — and the core cannot discover them itself once the tunnel is up, +// because by then it would only see its own. +func SetUpstreamDNS(jsonArray string) { + var ips []string + if err := json.Unmarshal([]byte(jsonArray), &ips); err != nil { + log.Printf("[tailcore] SetUpstreamDNS: %v", err) + return + } + var addrs []netip.Addr + for _, s := range ips { + if a, err := netip.ParseAddr(s); err == nil { + addrs = append(addrs, a) + } + } + upstreamMu.Lock() + upstreamDNS = addrs + upstreamMu.Unlock() + log.Printf("[tailcore] upstream DNS = %v", addrs) +} + +func baseResolvers() []netip.Addr { + upstreamMu.Lock() + defer upstreamMu.Unlock() + return append([]netip.Addr(nil), upstreamDNS...) +} + +// mobileOSConfigurator applies nothing: the OS already has the DNS settings, handed to it by +// VpnService.Builder / NEPacketTunnelNetworkSettings alongside the routes. +type mobileOSConfigurator struct{} + +func (mobileOSConfigurator) SetDNS(dns.OSConfig) error { return nil } + +// False: we cannot split DNS at the OS level on mobile, so the core must handle every query itself — +// answering tailnet names and forwarding the rest to the base config below. Claiming true would have +// the core assume the OS routes only tailnet names to it, and everything else would be dropped. +func (mobileOSConfigurator) SupportsSplitDNS() bool { return false } + +func (mobileOSConfigurator) GetBaseConfig() (dns.OSConfig, error) { + return dns.OSConfig{Nameservers: baseResolvers()}, nil +} + +func (mobileOSConfigurator) Close() error { return nil } + +// searchDomainsFor is a convenience for callers that want the tailnet suffix as a search domain. +func searchDomainsFor(suffix string) []dnsname.FQDN { + if suffix == "" { + return nil + } + f, err := dnsname.ToFQDN(suffix) + if err != nil { + return nil + } + return []dnsname.FQDN{f} +} diff --git a/modules/officer-net/gocore/go.mod b/modules/officer-net/gocore/go.mod new file mode 100644 index 0000000..249adcd --- /dev/null +++ b/modules/officer-net/gocore/go.mod @@ -0,0 +1,55 @@ +module officer.dev/tailcore + +go 1.26.5 + +require ( + github.com/tailscale/wireguard-go v0.0.0-20260715223240-2e01ba5b00f0 + tailscale.com v1.102.0 +) + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/akutz/memconn v0.1.0 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/creachadair/msync v0.8.1 // indirect + github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/gaissmai/bart v0.26.1 // indirect + github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/jsimonetti/rtnetlink v1.4.1 // indirect + github.com/klauspost/compress v1.19.1 // indirect + github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect + github.com/mdlayher/socket v0.5.0 // indirect + github.com/mitchellh/go-ps v1.0.0 // indirect + github.com/pires/go-proxyproto v0.8.1 // indirect + github.com/safchain/ethtool v0.3.0 // indirect + github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // indirect + github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect + github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd // indirect + github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect + github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect + github.com/x448/float16 v0.8.4 // indirect + go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect + golang.org/x/mobile v0.0.0-20260803200217-62cee1672c8e // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.48.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + golang.zx2c4.com/wireguard/windows v0.5.3 // indirect + gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect +) + +tool golang.org/x/mobile/cmd/gobind diff --git a/modules/officer-net/gocore/go.sum b/modules/officer-net/gocore/go.sum new file mode 100644 index 0000000..23ce62b --- /dev/null +++ b/modules/officer-net/gocore/go.sum @@ -0,0 +1,141 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= +filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= +github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= +github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= +github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02 h1:bXAPYSbdYbS5VTy92NIUbeDI1qyggi+JYh5op9IFlcQ= +github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c= +github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok= +github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/creachadair/mds v0.25.13 h1:PsSUHV6zsfPd29k4kvm1rMoee1YFia7JyNGeMPmDcPM= +github.com/creachadair/mds v0.25.13/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs= +github.com/creachadair/msync v0.8.1 h1:QRd8si3qZ2Q4TaDL7tS/MG/lFE3YND7U7J9fy42eAFM= +github.com/creachadair/msync v0.8.1/go.mod h1:dt0bscS09J8Ie3AdccK9JpCb7LfStaDGlAmDLukOlY4= +github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc= +github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g= +github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= +github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= +github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc h1:8WFBn63wegobsYAX0YjD+8suexZDga5CctH4CCTx2+8= +github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= +github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= +github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo= +github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= +github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= +github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= +github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao= +github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= +github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689 h1:0psnKZ+N2IP43/SZC8SKx6OpFJwLmQb9m9QyV9BC2f8= +github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689/go.mod h1:OGmRfY/9QEK2P5zCRtmqfbCF283xPkU2dvVA4MvbvpI= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= +github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g= +github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4= +github.com/jsimonetti/rtnetlink v1.4.1 h1:JfD4jthWBqZMEffc5RjgmlzpYttAVw1sdnmiNaPO3hE= +github.com/jsimonetti/rtnetlink v1.4.1/go.mod h1:xJjT7t59UIZ62GLZbv6PLLo8VFrostJMPBAheR6OM8w= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= +github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= +github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= +github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= +github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= +github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= +github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= +github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= +github.com/studio-b12/gowebdav v0.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ= +github.com/studio-b12/gowebdav v0.13.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE= +github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d h1:JcGKBZAL7ePLwOhUdN8qGQZlP5GueEiIZwY7R62pejE= +github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= +github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd h1:Rf9uhF1+VJ7ZHqxrG8pJ6YacmHvVCmByDmGbAWCc/gA= +github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= +github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= +github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= +github.com/tailscale/wireguard-go v0.0.0-20260715223240-2e01ba5b00f0 h1:CnIEL2n7Xql6Ux1k+Vu5S5ubDHCT/kxFgkKCY8FjefU= +github.com/tailscale/wireguard-go v0.0.0-20260715223240-2e01ba5b00f0/go.mod h1:6SerzcvHWQchKO2BfNdmquA77CHSECZuFl+D9fp4RnI= +github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= +github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5 h1:Mn1OzFmF0ZKX/ZayHz/UdnWHufPp1wlD9lZ5U8LRDFY= +golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5/go.mod h1:YX+n47s+53POxN3dx9cIGxG3hGUm/lD64hvrRJFbcSA= +golang.org/x/mobile v0.0.0-20260730202154-c700fe717e6e h1:v5PGTbI7uer7FhpDeGryGytfbI8t7CDWkKukozvIsNU= +golang.org/x/mobile v0.0.0-20260730202154-c700fe717e6e/go.mod h1:YX+n47s+53POxN3dx9cIGxG3hGUm/lD64hvrRJFbcSA= +golang.org/x/mobile v0.0.0-20260803200217-62cee1672c8e h1:hi1b7Lmv+xvnrjeEv8BoDmU7H/FAOuH7hEHSvjMl2VM= +golang.org/x/mobile v0.0.0-20260803200217-62cee1672c8e/go.mod h1:YX+n47s+53POxN3dx9cIGxG3hGUm/lD64hvrRJFbcSA= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w= +golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= +golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= +golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= +gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 h1:Zy8IV/+FMLxy6j6p87vk/vQGKcdnbprwjTxc8UiUtsA= +gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= +howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= +howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= +tailscale.com v1.102.0 h1:yAYrykMZWlgp7Qq2ECDrDnxeySWBhNXNMkXdDmVMyrE= +tailscale.com v1.102.0/go.mod h1:NdndXfkSklVPbRUZMAzzRuDNaR5B+YWGbAyE1HpSQZg= diff --git a/modules/officer-net/gocore/interfaces_droid.go b/modules/officer-net/gocore/interfaces_droid.go new file mode 100644 index 0000000..1fbaeb5 --- /dev/null +++ b/modules/officer-net/gocore/interfaces_droid.go @@ -0,0 +1,116 @@ +//go:build linux + +package tailcore + +import ( + "encoding/json" + "net" + "net/netip" + "strings" + "sync/atomic" + + "tailscale.com/net/netmon" +) + +// Android interface enumeration bridge. +// +// Android (SDK 30+) blocks Go's net.Interfaces()/net.Interface.Addrs() — they make raw netlink +// RTM_GETLINK/GETADDR syscalls the app sandbox denies, surfacing as "route ip+net: netlinkrib: +// permission denied" during wgengine bring-up. Tailscale's netmon anticipates this +// (net/netmon/state.go: "Android SDK 30 no longer permits Go's net.Interfaces to work") and exposes +// RegisterInterfaceGetter so the host app can supply interfaces via a permitted path. +// +// We use a push model: the Kotlin VpnService enumerates interfaces with java.net.NetworkInterface (which +// Android DOES permit) and pushes a JSON snapshot here — once before Tailcore.start and again whenever +// connectivity changes. Our registered getter just parses the latest snapshot. AltAddrs is populated so +// netmon.Interface.Addrs() returns them directly and never falls back to the netlink path. + +var androidIfaceJSON atomic.Value // string; latest snapshot pushed from Kotlin + +type jsonIface struct { + Name string `json:"name"` + Index int `json:"index"` + MTU int `json:"mtu"` + Up bool `json:"up"` + Loopback bool `json:"loopback"` + P2P bool `json:"pointToPoint"` + Multicast bool `json:"multicast"` + MAC string `json:"mac"` + Addrs []string `json:"addrs"` // "10.0.0.2/24", "fe80::1%wlan0/64" +} + +func init() { + netmon.RegisterInterfaceGetter(androidInterfaces) +} + +// SetAndroidInterfaces is called from Kotlin with a JSON array snapshot of the device's network +// interfaces. Must be called before Tailcore.start (netmon reads interfaces during bring-up) and on +// each connectivity change to keep endpoint discovery current. +func SetAndroidInterfaces(jsonStr string) { + androidIfaceJSON.Store(jsonStr) +} + +func androidInterfaces() ([]netmon.Interface, error) { + s, _ := androidIfaceJSON.Load().(string) + if s == "" { + // No snapshot yet — return empty rather than falling back to the (denied) netlink path. + return nil, nil + } + var raw []jsonIface + if err := json.Unmarshal([]byte(s), &raw); err != nil { + return nil, err + } + out := make([]netmon.Interface, 0, len(raw)) + for _, r := range raw { + var flags net.Flags + if r.Up { + flags |= net.FlagUp | net.FlagRunning + } + if r.Loopback { + flags |= net.FlagLoopback + } + if r.P2P { + flags |= net.FlagPointToPoint + } + if r.Multicast { + flags |= net.FlagMulticast + } + var mac net.HardwareAddr + if r.MAC != "" { + mac, _ = net.ParseMAC(r.MAC) + } + ni := &net.Interface{ + Index: r.Index, + MTU: r.MTU, + Name: r.Name, + HardwareAddr: mac, + Flags: flags, + } + var altAddrs []net.Addr + for _, a := range r.Addrs { + pfx, err := netip.ParsePrefix(stripIPZone(a)) + if err != nil { + continue + } + altAddrs = append(altAddrs, &net.IPNet{ + IP: pfx.Addr().AsSlice(), + Mask: net.CIDRMask(pfx.Bits(), pfx.Addr().BitLen()), + }) + } + out = append(out, netmon.Interface{Interface: ni, AltAddrs: altAddrs}) + } + return out, nil +} + +// stripIPZone removes an IPv6 scope zone from a CIDR string ("fe80::1%wlan0/64" → "fe80::1/64") so +// netip.ParsePrefix accepts it. Java's InterfaceAddress hands us link-local v6 with a %zone suffix. +func stripIPZone(s string) string { + host, suffix := s, "" + if slash := strings.LastIndexByte(s, '/'); slash >= 0 { + host, suffix = s[:slash], s[slash:] + } + if pct := strings.IndexByte(host, '%'); pct >= 0 { + host = host[:pct] + } + return host + suffix +} diff --git a/modules/officer-net/gocore/maketun_apple.go b/modules/officer-net/gocore/maketun_apple.go new file mode 100644 index 0000000..89e4a81 --- /dev/null +++ b/modules/officer-net/gocore/maketun_apple.go @@ -0,0 +1,17 @@ +//go:build darwin + +package tailcore + +import ( + "os" + + wgtun "github.com/tailscale/wireguard-go/tun" +) + +// makeTUN wraps the OS-provided utun fd (handed over by NEPacketTunnelProvider) as a wireguard-go +// device. Pass mtu=0 so CreateTUNFromFile does NOT setMTU — inside a Network Extension that ioctl is +// EPERM ("operation not permitted"); iOS owns the utun MTU via NEPacketTunnelNetworkSettings.mtu. +// (wireguard-go only calls setMTU when mtu > 0.) +func makeTUN(fd int32) (wgtun.Device, error) { + return wgtun.CreateTUNFromFile(os.NewFile(uintptr(fd), "tun"), 0) +} diff --git a/modules/officer-net/gocore/maketun_droid.go b/modules/officer-net/gocore/maketun_droid.go new file mode 100644 index 0000000..41615e4 --- /dev/null +++ b/modules/officer-net/gocore/maketun_droid.go @@ -0,0 +1,18 @@ +//go:build linux + +package tailcore + +import ( + wgtun "github.com/tailscale/wireguard-go/tun" +) + +// makeTUN wraps the OS-provided VpnService fd as a wireguard-go device using the Android-safe entry +// point. The Linux CreateTUNFromFile would createNetlinkSocket() + setMTU() — both CAP_NET_ADMIN, which +// an unprivileged Android app is DENIED (fails as "tun from fd: permission denied"). Android's OS owns +// routes + MTU via VpnService.Builder, so the core must not touch them. CreateUnmonitoredTUNFromFD skips +// netlink + MTU and uses only the read-only TUNGETIFF ioctl, which a VpnService fd permits — the same +// path the official tailscale-android client takes. +func makeTUN(fd int32) (wgtun.Device, error) { + dev, _, err := wgtun.CreateUnmonitoredTUNFromFD(int(fd)) + return dev, err +} diff --git a/modules/officer-net/gocore/protect.go b/modules/officer-net/gocore/protect.go new file mode 100644 index 0000000..5c0f642 --- /dev/null +++ b/modules/officer-net/gocore/protect.go @@ -0,0 +1,65 @@ +//go:build android + +package tailcore + +/* +Give the Tailscale core a way to keep its own sockets out of its own tunnel. + +WHY THIS EXISTS + +A VPN has a bootstrap problem: the core's WireGuard sockets must reach the internet, but the tunnel +they create captures the internet. Left alone, the encrypted packets are routed back into the tunnel +that produced them and loop forever. + +Android's answer is `VpnService.protect(fd)`, which marks ONE socket as belonging outside the tunnel. +This app has been using a much blunter substitute — `addDisallowedApplication(packageName)`, which +excludes the ENTIRE app from its own VPN. That does stop the loop, and it also means nothing this app +does can ever travel through the tunnel it is holding open: not an in-app browser, not Officer's calls +to the platform, not the core reaching a service that only exists on the tailnet. + +`SetProtector` narrows it to what actually needs excluding. The core protects the sockets it dials +out; everything else the app does is free to use the tunnel like any other app's traffic. + +WHAT THIS DOES NOT DO + +It does not decide whether an address inside the tailnet is reachable — that is tsdial's business and +goes over WireGuard rather than any OS socket. Do not expect this to make an in-tailnet DNS resolver +start answering; that was a separate claim and it was never verified. + +Android only. iOS Network Extensions are excluded from their own tunnel by the OS, with no equivalent +call and no equivalent problem — hence the build tag. +*/ + +import ( + "errors" + + "tailscale.com/net/netns" +) + +// Protector is implemented on the Java side by the VpnService, which is the only object that can call +// protect(). gomobile generates a Java interface from this; int32 maps to Java's int, matching +// VpnService.protect(int). +type Protector interface { + // Protect marks a socket as belonging outside the tunnel. False means the call failed, which + // Android reports as a plain boolean with no reason attached. + Protect(fd int32) bool +} + +// SetProtector installs the callback, or removes it when p is nil. +// +// Must be called BEFORE Start: the core dials as soon as it comes up, and a socket created before the +// hook is registered is an unprotected one that will loop. +func SetProtector(p Protector) { + if p == nil { + netns.SetAndroidProtectFunc(nil) + return + } + netns.SetAndroidProtectFunc(func(fd int) error { + if p.Protect(int32(fd)) { + return nil + } + // Returning an error here fails the dial, which is correct: an unprotected socket would loop + // back into the tunnel, and a hung connection is harder to diagnose than a refused one. + return errors.New("VpnService.protect failed") + }) +} diff --git a/modules/officer-net/gocore/tailcore.go b/modules/officer-net/gocore/tailcore.go new file mode 100644 index 0000000..ab8e4d3 --- /dev/null +++ b/modules/officer-net/gocore/tailcore.go @@ -0,0 +1,509 @@ +// Package tailcore is the Go shim that gomobile-binds the real Tailscale core (tailscale.com) into a +// native framework (iOS .xcframework / Android .aar) for OffTail. Everything the NE / VpnService needs +// from the core is exposed here as gomobile-friendly functions (simple params + return types). +// +// This is the WHOLE-DEVICE path: Start() takes the OS tun file descriptor handed over by the platform +// (iOS NEPacketTunnelProvider's utun, Android VpnService.establish()) and drives it through wgengine + +// LocalBackend, pointed at a self-hosted Headscale. Modeled directly on tsnet.Server.start() — the +// canonical in-repo node bring-up — but with a real fd-backed tun instead of netstack-only. +// +// STATUS: compiles + binds; the control-plane bring-up (register with Headscale via the pre-auth key) +// is wired. The last mile is the native ROUTER/DNS bridge — routes + DNS must be applied on the OS side +// (NEPacketTunnelNetworkSettings / VpnService.Builder), which needs on-device testing. Until that's in, +// a nil Go router means packets reach the tun but the OS isn't told which routes to send through it. +package tailcore + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "sync" + + "tailscale.com/ipn" + "tailscale.com/ipn/ipnlocal" + "tailscale.com/ipn/store" + "tailscale.com/tailcfg" + "tailscale.com/net/tsdial" + "tailscale.com/tsd" + "tailscale.com/types/logid" + "tailscale.com/types/opt" + "tailscale.com/version" + "tailscale.com/wgengine" + "tailscale.com/wgengine/netstack" +) + +var ( + mu sync.Mutex + backend *ipnlocal.LocalBackend + cancel context.CancelFunc + // Kept alongside `cancel` so operations started AFTER Start — LoginTo's interactive login — share + // the tunnel's lifetime and are cancelled with it, rather than running on a detached Background. + runCtx context.Context +) + +// Version returns the embedded Tailscale core version — a no-network call proving the core is linked in. +func Version() string { return version.Long() } + +// Ping is a trivial liveness check for the native bridge. +func Ping() string { return "tailcore-ok" } + +// Start brings up a whole-device Tailscale node on the OS tun file descriptor, pointed at a Headscale +// control server, registering with a pre-auth key. gomobile-friendly signature (int32/string/error). +func Start(tunFD int32, controlURL, authKey, hostname, stateDir string) error { + mu.Lock() + defer mu.Unlock() + if backend != nil { + return nil // already running + } + + // Give Tailscale's logpolicy a writable directory for its log-state/ID. On Android (GOOS=android) + // logpolicy.LogsDir() matches none of its OS cases and its fallbacks all fail — DefaultTailscaledStateFile + // is empty, UserCacheDir errors (no $HOME), cwd is "/", and MkdirTemp("") fails (no writable /tmp) — so it + // PANICS ("no safe place found to store log state"). TS_LOGS_DIR is the first thing it honors; point it at + // the per-app state dir the platform handed us (Android filesDir / iOS Application Support). Harmless on + // iOS, where a cache dir already exists. + _ = os.MkdirAll(stateDir, 0o700) + _ = os.Setenv("TS_LOGS_DIR", stateDir) + + // Log to stderr → surfaces in the device's unified log (Console.app, process OffTailTunnel) so we can + // watch the core register/connect. TODO: quiet or gate this once the tunnel is proven. + logf := func(format string, args ...any) { log.Printf("[tailcore] "+format, args...) } + + // Wrap the OS-provided tun fd (NEPacketTunnelProvider's utun on iOS, VpnService.establish() on + // Android). The wrapping differs per-OS — iOS must skip setMTU, Android must skip netlink — so it + // lives behind build-tagged makeTUN (maketun_apple.go / maketun_droid.go). + dev, err := makeTUN(tunFD) + if err != nil { + return fmt.Errorf("tun from fd: %w", err) + } + + sys := tsd.NewSystem() + dialer := &tsdial.Dialer{Logf: logf} + dialer.SetBus(sys.Bus.Get()) + + eng, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{ + Tun: dev, + EventBus: sys.Bus.Get(), + Dialer: dialer, + SetSubsystem: sys.Set, + ControlKnobs: sys.ControlKnobs(), + HealthTracker: sys.HealthTracker.Get(), + Metrics: sys.UserMetricsRegistry(), // required by NewUserspaceEngine + // Router stays nil: the OS applies routes itself (NE settings / VpnService.Builder). + // + // DNS is NOT nil, and that is the fix for MagicDNS. Left nil, the engine substitutes a fake + // no-op OSConfigurator ("using fake (no-op) DNS configurator") and builds its dns.Manager + // around it — so the resolver at 100.100.100.100 had no base config to forward non-tailnet + // queries to. Ours returns the platform's real resolvers from GetBaseConfig; see dns.go. + // + // Passed here rather than constructed separately: the engine creates and registers the + // Manager itself, and calling sys.Set on a second one panics with "*dns.Manager is already + // set". + DNS: mobileOSConfigurator{}, + }) + if err != nil { + return fmt.Errorf("wgengine: %w", err) + } + sys.Set(eng) + + ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper()) + if err != nil { + return fmt.Errorf("netstack: %w", err) + } + sys.Tun.Get().Start() + sys.Set(ns) + + st, err := store.New(logf, filepath.Join(stateDir, "tailscaled.state")) + if err != nil { + return fmt.Errorf("store: %w", err) + } + sys.Set(st) + + lb, err := ipnlocal.NewLocalBackend(logf, logid.PublicID{}, sys, 0) + if err != nil { + return fmt.Errorf("localbackend: %w", err) + } + + // Start netstack against the LocalBackend. netstack.Create() only INSTALLS its tstun hooks (incl. the + // PreFilterPacketOutboundToWireGuardNetstackIntercept); ns.Start() is what marks netstack ready, wires + // its LocalBackend, and launches the inject goroutine. WITHOUT this call the outbound intercept is live + // but netstack isn't running, so every outbound packet is dropped and no peer is ever materialized in + // wireguard-go (peers stay in the netmap + magicsock but never reach the engine — disco works, data + // doesn't). tsnet and tailscaled both call ns.Start(lb); omitting it was the whole-device data-path bug. + // Defaults (ProcessLocalIPs/ProcessSubnets = false) are correct here: this is a real-tun whole-device + // node, so tailnet traffic must flow through the OS tun to peers, not be terminated in netstack. + if err := ns.Start(lb); err != nil { + return fmt.Errorf("netstack start: %w", err) + } + + ctx, cancelFn := context.WithCancel(context.Background()) + cancel = cancelFn + runCtx = ctx + + /* + NEVER rewrite an existing profile's control server. + + The app keeps ONE stored control URL, but every profile has its own — and this Start runs on + whichever profile happens to be current, on every single tunnel start. So a restart while the app + held a different tailnet's URL silently rewrote the current profile to point at the wrong control + server: a Tailscale profile ended up carrying headscale.pastilhas.eu, mislabelled and unable to + reach its own tailnet. + + A profile's control server is set once, when the profile is created (here for the first one, + LoginTo for the rest). After that the stored value wins and the caller's is ignored. + */ + effectiveControlURL := controlURL + // Hostname is the SAME class of bug: one app-level value, applied to whichever profile is current, + // on every start. Each profile advertises its own node name, and a restart would rename the node + // on one tailnet to whatever was last typed for another. + effectiveHostname := hostname + if p := lb.Prefs(); p.Valid() { + if p.ControlURL() != "" { + effectiveControlURL = p.ControlURL() + } + if p.Hostname() != "" { + effectiveHostname = p.Hostname() + } + } + + if err := lb.Start(ipn.Options{ + UpdatePrefs: &ipn.Prefs{ + ControlURL: effectiveControlURL, + WantRunning: true, + Hostname: effectiveHostname, + // Do stateful packet filtering (double negative: NoStatefulFiltering=false ⇒ ON) so replies to + // THIS node's outbound connections are allowed back through the netmap's inbound filter. Without + // it, phone→service traffic goes out but every reply is dropped (the connection just hangs) unless + // the ACL happens to also grant the reverse direction. tailscaled's NewPrefs defaults this OFF + // (NoStatefulFiltering=true); a minimal bring-up like ours must opt in explicitly. + NoStatefulFiltering: opt.NewBool(false), + // Accept the DNS config the control plane sends — MagicDNS. Same trap as the line above: + // tailscaled's NewPrefs() defaults this ON, but we build Prefs as a struct literal so it + // gets Go's zero value, false. The node then tells Headscale it does not want DNS, and the + // netmap arrives with {DefaultResolvers:[] Routes:{} SearchDomains:[] Hosts:0} — which is + // why `alpha` never resolved no matter what the server was configured to send. Headscale's + // own docs call this out: MagicDNS only helps a node with accept-dns on (CorpDNS true). + CorpDNS: true, + }, + AuthKey: authKey, + }); err != nil { + cancelFn() + return fmt.Errorf("localbackend start: %w", err) + } + if lb.State() == ipn.NeedsLogin { + if err := lb.StartLoginInteractive(ctx); err != nil { + cancelFn() + return fmt.Errorf("login: %w", err) + } + } + + backend = lb + return nil +} + +// ── Exit nodes ───────────────────────────────────────────────────────────────────────────────── +// +// Routing ALL traffic through another node on the tailnet. The single most-used control in the real +// Tailscale app after the on/off switch, and the reason a prefs-editing verb had to exist at all: +// everything else here is set once at Start, but an exit node is chosen and changed while running. + +// SetExitNode routes all traffic through the peer with this stable node id. An empty id turns it off. +// +// EditPrefs rather than Start: this changes a preference on a RUNNING node, and restarting to apply +// it would drop the tunnel for something the core can do live. `ExitNodeIP` is deliberately not used +// — an IP can be reassigned, while the stable id survives the node changing address. +func SetExitNode(stableID string) error { + mu.Lock() + defer mu.Unlock() + lb := backend + if lb == nil { + return fmt.Errorf("not started") + } + mp := &ipn.MaskedPrefs{ExitNodeIDSet: true} + mp.ExitNodeID = tailcfg.StableNodeID(stableID) + // Allowing LAN access alongside an exit node is what people expect: routing everything through a + // remote node should not cut off the printer in the same room. + mp.ExitNodeAllowLANAccessSet = true + mp.ExitNodeAllowLANAccess = true + if _, err := lb.EditPrefs(mp); err != nil { + return fmt.Errorf("set exit node: %w", err) + } + return nil +} + +// SetAcceptRoutes turns on using subnet routes other nodes advertise. +// +// Off by default in Tailscale, and the reason a node can see its peers but not the network behind +// one of them — which looks like a broken route rather than a preference. +func SetAcceptRoutes(on bool) error { + mu.Lock() + defer mu.Unlock() + lb := backend + if lb == nil { + return fmt.Errorf("not started") + } + mp := &ipn.MaskedPrefs{RouteAllSet: true} + mp.RouteAll = on + if _, err := lb.EditPrefs(mp); err != nil { + return fmt.Errorf("set accept routes: %w", err) + } + return nil +} + +// SubnetRoutes returns the subnet routes this node should carry, as a JSON array of CIDR strings. +// +// Empty unless RouteAll is on — accepting routes is opt-in, and a tun that claimed them regardless +// would capture traffic the user never asked us to carry. +// +// These come from peers' PrimaryRoutes: the prefixes each node is currently the primary router for. +// The tailnet's own ranges are filtered out because the tun already claims those, and re-adding them +// as subnet routes would be redundant at best. +// +// It exists because accepting a route in the CORE is only half of it. The OS decides what enters the +// tunnel, so unless the tun claims these prefixes the traffic never reaches us — the same gap that +// made choosing an exit node do nothing observable. +func SubnetRoutes() string { + mu.Lock() + defer mu.Unlock() + if backend == nil { + return "[]" + } + p := backend.Prefs() + if !p.Valid() || !p.RouteAll() { + return "[]" + } + st := backend.Status() + seen := map[string]bool{} + out := []string{} + for _, ps := range st.Peer { + if ps == nil || ps.PrimaryRoutes == nil { + continue + } + for _, r := range ps.PrimaryRoutes.All() { + // Skip the tailnet's own space and default routes: the first is already claimed, and the + // second is an exit node advertising itself, which is a separate choice the user makes + // explicitly rather than something to inherit by turning on subnet routes. + if r.Bits() == 0 { + continue + } + a := r.Addr() + if a.Is4() && a.As4()[0] == 100 && a.As4()[1] >= 64 && a.As4()[1] <= 127 { + continue + } + cidr := r.String() + if !seen[cidr] { + seen[cidr] = true + out = append(out, cidr) + } + } + } + b, err := json.Marshal(out) + if err != nil { + return "[]" + } + return string(b) +} + +// Prefs returns the live preferences as JSON, so the app can show what is actually set rather than +// what it last asked for. gomobile binds simple types only, hence a string. +func Prefs() string { + mu.Lock() + defer mu.Unlock() + lb := backend + if lb == nil { + return "{}" + } + p := lb.Prefs() + if !p.Valid() { + return "{}" + } + out := map[string]any{ + "exitNodeID": string(p.ExitNodeID()), + "acceptRoutes": p.RouteAll(), + "corpDNS": p.CorpDNS(), + } + b, err := json.Marshal(out) + if err != nil { + return "{}" + } + return string(b) +} + +// NOTE ON LOCKING +// +// These accessors used to take the mutex only to COPY the backend pointer, then release it and call +// methods on that copy. That is a use-after-shutdown waiting to happen: Stop() calls Shutdown() and +// nils the pointer under the same mutex, so a caller that had already copied it goes on to use a +// backend that is being torn down. Android polls Status() every two seconds and a profile switch +// calls Stop() — the two race, and the result is a native crash with no Java stack, intermittently. +// +// iOS never showed it because the extension is a separate process torn down whole. +// +// So the short operations below hold the lock for the WHOLE call. They are microseconds of work; +// serialising them against Stop costs nothing. LoginTo is deliberately excluded — it blocks on +// StartLoginInteractive, and holding the mutex across a network round trip would block Stop instead. + +// ── Profiles ─────────────────────────────────────────────────────────────────────────────────── +// +// Several tailnets on one device, switched between: a self-hosted Headscale and a login.tailscale.com +// account, say. The core has supported this the whole time — LocalBackend keeps a profile per login, +// each with its own control URL, node key and prefs — it was simply never exposed. +// +// gomobile only binds simple types, so these take and return strings; the list is JSON. + +// ProfileInfo is the gomobile-safe shape of a login profile. `ipn.LoginProfileView` cannot cross the +// binding, and most of it is uninteresting to a picker. +type profileInfo struct { + ID string `json:"id"` + Name string `json:"name"` // the login name, e.g. andre@example.com + Tailnet string `json:"tailnet"` // NetworkProfile.DomainName — which tailnet this is + Current bool `json:"current"` + // ControlURL is which control plane this profile talks to, and it is the ONLY authoritative way to + // say whether a stored login is Tailscale or a self-hosted Headscale. Inferring it from the tailnet + // name does not work: a personal Tailscale tailnet is named after the login email, and a Headscale + // namespace can be called anything at all. + ControlURL string `json:"controlUrl"` +} + +// ListProfiles returns every stored login as JSON, with the active one flagged. +func ListProfiles() string { + mu.Lock() + defer mu.Unlock() + lb := backend + if lb == nil { + return "[]" + } + currentID := lb.CurrentProfile().ID() + out := []profileInfo{} + for _, p := range lb.ListProfiles() { + out = append(out, profileInfo{ + ID: string(p.ID()), + Name: p.Name(), + Tailnet: p.NetworkProfile().DomainName, + Current: p.ID() == currentID, + ControlURL: p.ControlURL(), + }) + } + b, err := json.Marshal(out) + if err != nil { + return "[]" + } + return string(b) +} + +// SwitchProfile makes an existing login the active one. The node re-registers under that identity; +// the caller is responsible for tearing the tun down and back up around it. +func SwitchProfile(id string) error { + mu.Lock() + defer mu.Unlock() + lb := backend + if lb == nil { + return fmt.Errorf("not started") + } + return lb.SwitchProfile(ipn.ProfileID(id)) +} + +// NewProfile begins an additional login, leaving the existing ones intact. The node lands in +// NeedsLogin, which is the point: Status().AuthURL then carries where to send the user. +/* +LoginTo joins ANOTHER tailnet without restarting the tunnel. + +NewProfile alone is not enough. It creates a blank profile on the running backend, but the control +URL and the pre-auth key only reach the core through Start — and on iOS Start only happens when the +Network Extension launches. Recycling the tunnel to trigger it *loses the blank profile*: an +unregistered profile is not persisted, so on reload the core falls back to the previous one and +Start then overwrites ITS control URL. The result is a second tailnet silently replacing the first, +same profile id and all. + +Doing both here, against the live backend, is the only ordering where neither step undoes the other: +branch to a new profile, then start it with its own control URL and key. An empty authKey lands in +NeedsLogin and begins interactive login, exactly as the initial Start does. +*/ +func LoginTo(controlURL, authKey, hostname string) error { + mu.Lock() + lb := backend + ctxLocal := runCtx + mu.Unlock() + if lb == nil { + return fmt.Errorf("not started") + } + if err := lb.NewProfile(); err != nil { + return fmt.Errorf("new profile: %w", err) + } + // Same prefs as the initial Start — see the comments there for why CorpDNS and stateful + // filtering must be set explicitly rather than left at Go's zero values. + if err := lb.Start(ipn.Options{ + UpdatePrefs: &ipn.Prefs{ + ControlURL: controlURL, + WantRunning: true, + Hostname: hostname, + NoStatefulFiltering: opt.NewBool(false), + CorpDNS: true, + }, + AuthKey: authKey, + }); err != nil { + return fmt.Errorf("start profile: %w", err) + } + if lb.State() == ipn.NeedsLogin { + if ctxLocal == nil { + ctxLocal = context.Background() + } + if err := lb.StartLoginInteractive(ctxLocal); err != nil { + return fmt.Errorf("login: %w", err) + } + } + return nil +} + +func NewProfile() error { + mu.Lock() + defer mu.Unlock() + lb := backend + if lb == nil { + return fmt.Errorf("not started") + } + return lb.NewProfile() +} + +// DeleteProfile forgets a login. Deleting the ACTIVE profile is allowed by the core and leaves the +// node logged out, so callers should switch away first if that is not what they meant. +func DeleteProfile(id string) error { + mu.Lock() + defer mu.Unlock() + lb := backend + if lb == nil { + return fmt.Errorf("not started") + } + return lb.DeleteProfile(ipn.ProfileID(id)) +} + +// Stop tears the node down. +func Stop() { + mu.Lock() + defer mu.Unlock() + if cancel != nil { + cancel() + cancel = nil + } + if backend != nil { + backend.Shutdown() + backend = nil + } +} + +// Status returns the node status as JSON (self IPs, peers, backend state) — drives the RN UI. +func Status() string { + mu.Lock() + defer mu.Unlock() + if backend == nil { + return `{"BackendState":"Stopped"}` + } + b, err := json.Marshal(backend.Status()) + if err != nil { + return `{"BackendState":"Error"}` + } + return string(b) +} diff --git a/modules/officer-net/index.ts b/modules/officer-net/index.ts new file mode 100644 index 0000000..834ac00 --- /dev/null +++ b/modules/officer-net/index.ts @@ -0,0 +1,4 @@ +// The JS surface for this native module lives in the app, at src/vpn/officer-net.ts, which reaches it +// with requireNativeModule('OfficerNet'). This file is intentionally empty — it exists only to mark +// the directory as a module for Expo autolinking. +export {}; diff --git a/modules/officer-net/ios/OfficerNet.podspec b/modules/officer-net/ios/OfficerNet.podspec new file mode 100644 index 0000000..0fbdfc5 --- /dev/null +++ b/modules/officer-net/ios/OfficerNet.podspec @@ -0,0 +1,33 @@ +Pod::Spec.new do |s| + s.name = 'OfficerNet' + s.version = '0.1.0' + s.summary = 'OffTail VPN engine — Tailscale core bridge' + s.description = 'Embeds the gomobile-built Tailscale core (libtailscale) as OfficerNet.xcframework and drives it behind an NEPacketTunnelProvider. Run scripts/build-libtailscale-ios.sh to populate libs/ before pod install. Talks directly to a self-hosted Headscale control server.' + s.author = 'André Padez' + s.homepage = 'https://pastilhas.dev' + s.license = { :type => 'BSD-3-Clause' } + s.platforms = { :ios => '15.1' } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + # The gomobile-built Tailscale core. Produced once by scripts/build-libtailscale-ios.sh and vendored + # under libs/ (gitignored). Vendored ONLY when present — until the build script runs, the pod compiles + # as a stub (the Swift references no libtailscale symbols directly), so the app still builds + installs + # with an inert engine. Once the xcframework exists it links automatically on the next pod install. + # TODO(libtailscale): ship OfficerNet.xcframework here (arm64 device + arm64 sim slices). + if File.directory?(File.join(__dir__, 'libs', 'TailCore.xcframework')) + s.vendored_frameworks = 'libs/TailCore.xcframework' + end + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } + + # Only the RN-process module compiles into this pod (the main app). PacketTunnelProvider.swift compiles + # into the Network Extension target instead — the config plugin (plugin/withNetworkExtensionTarget.js) + # copies it into ios/OffTailTunnel/ and adds it to that target's sources, along with TailCore.xcframework. + s.source_files = 'OfficerNetModule.swift' +end diff --git a/modules/officer-net/ios/OfficerNetModule.swift b/modules/officer-net/ios/OfficerNetModule.swift new file mode 100644 index 0000000..237b5fa --- /dev/null +++ b/modules/officer-net/ios/OfficerNetModule.swift @@ -0,0 +1,740 @@ +import ExpoModulesCore +import NetworkExtension +import TailCore // the vendored gomobile Tailscale core (packages/officer-net/ios/libs/TailCore.xcframework) + +// OffTail VPN engine — iOS RN-process module. Parity with android/.../OfficerNetModule.kt and the JS +// bridge in packages/core/src/services/officer-net.ts (requireNativeModule('OfficerNet')). +// +// This module runs in the *main app process*. It does NOT touch packets. Its whole job is to configure +// and drive a NETunnelProviderManager, which in turn starts/stops the Network Extension target +// (PacketTunnelProvider.swift) that actually hosts the Tailscale core (libtailscale). The two processes +// share config/keys through the App Group. See TAILSCALE_VPN.md → "Architecture". +// +// The libtailscale core is NOT linked into this process — it lives in the NE. So there are no +// `// TODO(libtailscale):` call-sites here; the TODOs here are all about NE plumbing. The core call- +// sites live in PacketTunnelProvider.swift. + +/** + The Network Extension target that embeds PacketTunnelProvider.swift + the Tailscale core. + + DERIVED from the host app's bundle id, never hardcoded. It was `dev.officer.mobile.net` — Officer's + extension — which is correct for Officer and wrong for every other host. The standalone app ships + `dev.officer.tail.net`, so it was asking iOS to start an extension that is not part of it: the start + command is accepted, nothing matches, and the tunnel silently never launches. No crash, no trace, a + dead toggle. + + It appeared to work only because Officer is installed on the same device, so there WAS a + `dev.officer.mobile.net` for iOS to find — the standalone app was driving Officer's extension. The + moment the configuration was rebuilt from scratch that stopped being honoured, which is why this + surfaced as "it used to connect and now it will not". + + The value is read from `OfficerNetExtensionBundleId` in Info.plist, which the Expo plugin writes from + each app's own `neBundleId` prop — apps/tail already declared `dev.officer.tail.net` correctly, and + this file simply never looked. Deriving from the app's bundle id is the fallback for a host that + predates the plist key, and it matches the plugin's own `.net` convention. + */ +private var neBundleId: String { + if let declared = Bundle.main.object(forInfoDictionaryKey: "OfficerNetExtensionBundleId") as? String, + !declared.isEmpty { + return declared + } + return (Bundle.main.bundleIdentifier ?? "dev.officer.mobile") + ".net" +} + +// Local scratch store (UserDefaults.standard) for the config the NE needs (controlUrl/hostname/authKey); +// it's copied into the manager's providerConfiguration, which is how the NE actually receives it. No App +// Group — see the plugin's withIosEntitlements for why. +// Keys shared with providerConfiguration. +private let kControlUrl = "controlUrl" +private let kHostname = "hostname" +private let kAuthKey = "authKey" // single-use Headscale pre-auth key; consumed by the core on first up(). +private let kRegistered = "registered" // set by the NE once the node has a persisted identity. + +/// One-shot guard so a checked continuation is resumed exactly once when both a timeout and a real +/// response can race (see neCoreStatus). +private final class ResumeOnce { + private let lock = NSLock() + private var done = false + func take() -> Bool { + lock.lock(); defer { lock.unlock() } + if done { return false } + done = true + return true + } +} + +public class OfficerNetModule: Module { + // The lazily-loaded/created tunnel manager. One per app; NE state persists in the system prefs. + private var manager: NETunnelProviderManager? + private var statusObserver: NSObjectProtocol? + + public func definition() -> ModuleDefinition { + Name("OfficerNet") + + // Native → JS status/peer stream. Mirrors the `onStatus` event the JS bridge subscribes to. + Events("onStatus") + + // Proof-of-life: the embedded Tailscale core's version, read straight from the vendored gomobile + // framework — this runs real tailscale.com code inside the app. (Called here in the main process for + // the demo; the shipping design hosts the core in the NE.) No network. + AsyncFunction("coreVersion") { () -> String in + return TailcoreVersion() + } + + // ── Profiles ───────────────────────────────────────────────────────────────────────────── + // + // Everything here round-trips through the extension, because that is where the core runs. An + // empty reply means success; a non-empty one is the error text. + + AsyncFunction("listProfiles") { () async -> String in + return await neCommand("profiles:list") ?? "[]" + } + + AsyncFunction("switchProfile") { (id: String) async throws in + guard let err = await neCommand("profiles:switch:\(id)", timeout: 15) else { + throw NSError(domain: "officer-net", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "The VPN service did not respond. Connect first, then try again.", + ]) + } + if !err.isEmpty { throw NSError(domain: "officer-net", code: 1, userInfo: [NSLocalizedDescriptionKey: err]) } + } + + /* + nil is NOT success. + + `neCommand` returns nil when the message never got a reply — no live session, or the 1.5s + timeout. Treating that as success meant `newProfile()` could return cleanly having done nothing + at all, and the caller would go on to `configure` + `up` believing a fresh profile existed. The + result is a second tailnet silently REPLACING the first instead of joining it. + + Same shape as the startTunnel optional-chaining bug: a failure that returns quietly is worse + than one that throws. + */ + AsyncFunction("newProfile") { () async throws in + guard let err = await neCommand("profiles:new", timeout: 15) else { + throw NSError(domain: "officer-net", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "The VPN service did not respond. Connect first, then try again.", + ]) + } + if !err.isEmpty { throw NSError(domain: "officer-net", code: 1, userInfo: [NSLocalizedDescriptionKey: err]) } + } + + /// Join another tailnet without restarting the tunnel. See LoginTo in tailcore.go for why both + /// steps have to happen inside the core. + AsyncFunction("loginToNewProfile") { (controlUrl: String, authKey: String?, hostname: String?) async throws in + let payload: [String: Any] = [ + "controlUrl": controlUrl, + "authKey": authKey ?? "", + "hostname": hostname ?? "", + ] + let json = try JSONSerialization.data(withJSONObject: payload) + guard let err = await neCommand("profiles:login:\(json.base64EncodedString())", timeout: 20) else { + throw NSError(domain: "officer-net", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "The VPN service did not respond. Connect first, then try again.", + ]) + } + if !err.isEmpty { throw NSError(domain: "officer-net", code: 1, userInfo: [NSLocalizedDescriptionKey: err]) } + } + + // ── Live preferences ──────────────────────────────────────────────────────────────────── + // + // Applied with EditPrefs on the running node, so choosing an exit node does not drop the tunnel. + + AsyncFunction("setExitNode") { (stableId: String?) async throws in + guard let err = await neCommand("prefs:exitnode:\(stableId ?? "")", timeout: 15) else { + throw NSError(domain: "officer-net", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "The VPN service did not respond. Connect first, then try again.", + ]) + } + if !err.isEmpty { throw NSError(domain: "officer-net", code: 1, userInfo: [NSLocalizedDescriptionKey: err]) } + } + + AsyncFunction("setAcceptRoutes") { (on: Bool) async throws in + guard let err = await neCommand("prefs:routes:\(on ? 1 : 0)", timeout: 15) else { + throw NSError(domain: "officer-net", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "The VPN service did not respond. Connect first, then try again.", + ]) + } + if !err.isEmpty { throw NSError(domain: "officer-net", code: 1, userInfo: [NSLocalizedDescriptionKey: err]) } + } + + AsyncFunction("prefs") { () async -> String in + return await neCommand("prefs:get") ?? "{}" + } + + /* + Collect a pending report about how the LAST tunnel run ended, or "" if it ended cleanly. + + Needs a LIVE extension, because the marker lives in the extension's container and this channel is + the only way to it. That is a real limitation: a tunnel that dies and is never started again keeps + its report to itself. Collecting on the next successful connect is the trade — it costs the last + crash on a permanently-broken install and keeps the app from having to hold an App Group + entitlement solely to read one file. + + Also a TAKE, not a get: the extension drops it once handed over, so a crash is reported once + rather than on every launch forever. + */ + AsyncFunction("lastExitReport") { () async -> String in + return await neCommand("diag:lastexit") ?? "" + } + + AsyncFunction("deleteProfile") { (id: String) async throws in + guard let err = await neCommand("profiles:delete:\(id)", timeout: 15) else { + throw NSError(domain: "officer-net", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "The VPN service did not respond. Connect first, then try again.", + ]) + } + if !err.isEmpty { throw NSError(domain: "officer-net", code: 1, userInfo: [NSLocalizedDescriptionKey: err]) } + } + + OnCreate { + // Observe NEVPNStatusDidChange so state transitions (connecting/connected/…) push to JS without + // polling. Peer-level detail comes from the NE via the App Group / status(), emitted on change. + self.statusObserver = NotificationCenter.default.addObserver( + forName: .NEVPNStatusDidChange, object: nil, queue: .main + ) { [weak self] _ in + guard let self = self else { return } + Task { self.sendEvent("onStatus", try await self.buildStatus()) } + } + } + + OnDestroy { + if let o = self.statusObserver { NotificationCenter.default.removeObserver(o) } + } + + // Point the node at a Headscale control server. Stored for the NE to pick up on up(). Does not + // start anything. `hostname` overrides the node's advertised name (defaults to the device name). + AsyncFunction("configure") { (controlUrl: String, hostname: String?) in + let defs = Optional(UserDefaults.standard) + defs?.set(controlUrl, forKey: kControlUrl) + defs?.set(hostname, forKey: kHostname) + // Also stamp it onto the manager's protocol config so the NE has it even before it reads the group. + let mgr = try await self.loadOrCreateManager() + try await self.applyProviderConfig(mgr) + } + + // Register the node with a Headscale pre-auth key. We only stash the key here; the actual + // registration happens inside the core on the next up() (the core owns the node/WireGuard keys and + // persists them to the Keychain). The key is single-use and short-lived. + AsyncFunction("loginWithAuthKey") { (authKey: String) in + Optional(UserDefaults.standard)?.set(authKey, forKey: kAuthKey) + let mgr = try await self.loadOrCreateManager() + try await self.applyProviderConfig(mgr) + } + + // Whether a node identity already exists on this device. Written by up() in THIS process — the + // NE also writes it, but into a mismatched App Group, so that copy never arrives. See up(). + AsyncFunction("isRegistered") { () -> Bool in + Optional(UserDefaults.standard)?.bool(forKey: kRegistered) ?? false + } + + /** + Whether the OS has granted this app the right to run a tunnel. + + On iOS the grant IS the saved VPN configuration: you cannot write one without the user approving + the system prompt, and removing the profile in Settings deletes it again. So "does a configuration + of ours exist" is exactly the question, and `loadAllFromPreferences` is the authority. + + Distinct from `isRegistered`, which asks whether a node identity exists on some tailnet — that can + be true while consent is absent, and vice versa. The first-run flow needs them separate. + + ENABLED, not merely present — reversed 2026-08-07. + + This used to answer "does a configuration exist", on the reasoning that a user who switched the + VPN off in Settings had still granted permission, and that re-enabling does not prompt again. Both + of those are true, and the result was still wrong: switch the VPN off in iOS Settings and the app + came back saying "you're already set up on this device, turn the switch on to reconnect" over a + tunnel the OS had disabled. Technically accurate, and useless — the one thing that would fix it + was the one thing the screen never offered. + + Nothing in this app ever sets `isEnabled = false`; every path here sets it true. So a disabled + configuration means the USER disabled it, in Settings or by letting another VPN take the slot, and + in both cases the honest answer to "are we authorised to run" is no, with Allow VPN as the way + back. Allowing re-enables the existing configuration and does not prompt, exactly as before — the + difference is that the screen now offers it. + + Still distinct from `isRegistered`, which asks whether a node identity exists on some tailnet: + that can be true while consent is absent, and vice versa. The first-run flow needs them separate. + + Never cache this — a profile can be deleted or disabled at any moment, and a stale `true` leaves a + connect toggle that silently does nothing. + */ + AsyncFunction("isAuthorized") { () async -> Bool in + let all = (try? await NETunnelProviderManager.loadAllFromPreferences()) ?? [] + return all.contains { $0.isEnabled } + } + + /** + Ask for tunnel authorisation on its own, without connecting. + + Saving the configuration is what raises the system prompt (plus Face ID / passcode). A refusal + surfaces as a thrown `NEVPNError`, which is a normal answer here rather than a failure, so it maps + to `false` and the caller simply keeps showing "Allow VPN". + + Deliberately does NOT set `onDemandRules` or start the session — that is `up()`'s job. At this + point in the first-run flow no control URL has been chosen, so there is nothing to connect to, and + an on-demand rule here would have the system endlessly retry a tunnel pointed at nowhere. + */ + AsyncFunction("requestAuthorization") { () async -> Bool in + do { + let mgr = try await self.loadOrCreateManager() + try await self.applyProviderConfig(mgr) + mgr.isEnabled = true + try await mgr.saveToPreferences() + try await mgr.loadFromPreferences() // re-load so the handle stays valid after save + return true + } catch { + // Drop the cached manager: it may describe a configuration that was never written. + self.manager = nil + return false + } + } + + // Bring the tunnel up: ensure the manager exists + is enabled, then start the NE session. The core + // registers (if needed) and connects inside the NE. + AsyncFunction("up") { () in + let mgr = try await self.loadOrCreateManager() + try await self.applyProviderConfig(mgr) + mgr.isEnabled = true + /* + On-demand: the system keeps the tunnel up, including across reboots. + + Without it the VPN comes back DOWN after a restart and stays down until the app is opened and + Connect tapped — which is what Andre hit: sites behind the tailnet failed until he noticed. + + A single "connect always" rule. `down()` turns this OFF before stopping, otherwise the system + would immediately bring the tunnel back and a manual disconnect would be unhonourable — off + has to mean off until the user says otherwise. + */ + mgr.onDemandRules = [NEOnDemandRuleConnect()] + mgr.isOnDemandEnabled = true + try await mgr.saveToPreferences() + try await mgr.loadFromPreferences() // re-load so the session handle is valid after save + /* + Start the tunnel, and do NOT fail silently. + + This was `try (mgr.connection as? NETunnelProviderSession)?.startTunnel(...)`. Optional chaining + on a failed cast makes the whole statement a no-op that throws nothing, so a tap on the toggle + did exactly nothing, reported nothing, and left the UI on "not connected" — indistinguishable + from a lost registration. + + The stale-configuration case is the one that actually bites, and it bites on every reinstall: + iOS keeps the saved configuration but invalidates it against the new build's extension, so + loadAllFromPreferences returns it happily, saveToPreferences reports "unchanged", and + startTunnel then refuses. The recovery is to remove the configuration and write a fresh one — + which is why this retries rather than surfacing an error the user cannot act on. + */ + func start(_ m: NETunnelProviderManager) throws { + guard let session = m.connection as? NETunnelProviderSession else { + throw NSError(domain: "OfficerNet", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "VPN configuration is not usable (no provider session)", + ]) + } + try session.startTunnel(options: nil) + } + + /* + Wait out a teardown before starting. + + stopVPNTunnel() is asynchronous: it returns at once and the session sits in .disconnecting. + A startTunnel issued in that window is dropped by iOS, silently — which is why switching a + profile (switch, then down, then up) reliably ended DISCONNECTED and needed the toggle pressed + by hand to actually apply. + */ + if mgr.connection.status == .disconnecting { + for _ in 0..<40 { + if mgr.connection.status != .disconnecting { break } + try? await Task.sleep(nanoseconds: 100_000_000) + } + } + + do { + try start(mgr) + } catch { + // Rebuild the configuration from scratch and try once more. Anything still failing after this + // is a real error and is allowed to propagate to JS. + try? await mgr.removeFromPreferences() + self.manager = nil + let fresh = try await self.loadOrCreateManager() + try await self.applyProviderConfig(fresh) + fresh.isEnabled = true + fresh.onDemandRules = [NEOnDemandRuleConnect()] + fresh.isOnDemandEnabled = true + try await fresh.saveToPreferences() + try await fresh.loadFromPreferences() + try start(fresh) + } + /* + Remember that this device has a node identity. + + The NE writes the same flag into an App Group — but into `group.pastilhas.officer.mobile`, + while the app is entitled to `group.dev.officer.mobile`. Different name, so the write goes + nowhere and `isRegistered` always answered false. The app then showed the enrolment form and + asked for another pre-auth key, on a device whose identity was already valid and persisted: + Andre was minting Headscale keys to answer a question the app was wrong about. + + Recorded here, in the app's own storage, because the honest fix needs App Groups enabled on + dev.officer.mobile.net in the Apple portal and a wrong move there breaks code-signing for the + whole app. See _local_todos/OFFTAIL.md. + + "startTunnel did not throw" is a slightly weaker claim than "the core has an identity", but the + core only gets that far with a usable key or an existing identity, so in practice they coincide. + */ + UserDefaults.standard.set(true, forKey: kRegistered) + } + + /* + Bring the tunnel down and KEEP it down. Node identity is untouched. + + On-demand is disabled first and saved before stopping: with it still on, the system treats a + stopped tunnel as something to fix and reconnects within seconds, so a manual disconnect would + not stick. Turning it off is what makes "off" mean off until the user reconnects — and it + survives reboots the same way "on" does. + */ + AsyncFunction("down") { () in + let mgr = try await self.loadOrCreateManager() + mgr.isOnDemandEnabled = false + try? await mgr.saveToPreferences() + mgr.connection.stopVPNTunnel() + } + + // Current status snapshot: connection state from the manager + self/peer detail from the NE. + AsyncFunction("status") { () async throws -> [String: Any?] in + try await self.buildStatus() + } + + // Debug: the NE's diagnostic ring buffer (bring-up flow), fetched via provider message. Reliable — + // unlike idevicesyslog, which drops NE os_log lines under burst load. + AsyncFunction("coreDiag") { () async -> String in + if self.manager == nil { + self.manager = try? await NETunnelProviderManager.loadAllFromPreferences().first + } + guard let session = self.manager?.connection as? NETunnelProviderSession else { return "(no session)" } + return await self.neDiag(session) + } + + // Forget the node identity + keys (full reset): tear down, tell the NE to wipe the core's keychain + // identity, remove the manager, and clear stored config. + AsyncFunction("logout") { () in + /* + Order matters, and getting it wrong is what made logout cosmetic. + + The node identity is `tailscaled.state` in the EXTENSION's container — nothing in this process + can reach it, so removing the VPN configuration and our own UserDefaults left a fully valid + node on disk. Logging out and reinstalling came back as the same machine. + + So: ask the running extension to wipe it FIRST, while there is still a session to talk to, and + only then stop the tunnel and remove the configuration. Tearing the tunnel down first would + kill the only channel that can do the deletion. + */ + if let mgr = try? await self.loadOrCreateManager() { + /* + The identity can only be deleted by the extension, and the extension only exists while the + tunnel runs. Logging out from a DISCONNECTED state therefore wiped nothing — and because the + app-side flags below clear regardless, the UI showed a convincing first run over an identity + that was still on disk. The next "fresh" sign-in then reconnected instantly as the same node. + + So bring the tunnel up briefly, purely to have someone to give the order to. Ugly, but the + alternative is a reset that quietly does not reset, which is worse than slow. + */ + let status = mgr.connection.status + if status != .connected && status != .connecting { + try? await self.applyProviderConfig(mgr) + mgr.isEnabled = true + // No on-demand rule here: this tunnel exists to be torn down a second later, and an + // on-demand rule would have the system dutifully bring it straight back. + mgr.isOnDemandEnabled = false + try? await mgr.saveToPreferences() + try? await mgr.loadFromPreferences() + try? (mgr.connection as? NETunnelProviderSession)?.startTunnel(options: nil) + // Up to ~5s. A node with a stored identity needs no network to reach `connected`, and the + // wipe only needs the process alive, not a working tailnet. + for _ in 0..<20 { + if mgr.connection.status == .connected || mgr.connection.status == .disconnected { break } + try? await Task.sleep(nanoseconds: 250_000_000) + } + } + _ = await self.neCommand("wipe") + mgr.connection.stopVPNTunnel() + try? await mgr.removeFromPreferences() + } + self.manager = nil + let defs = Optional(UserDefaults.standard) + [kControlUrl, kHostname, kAuthKey, kRegistered].forEach { defs?.removeObject(forKey: $0) } + } + } + + // MARK: - NETunnelProviderManager plumbing + + /// Load the existing OffTail manager or create a fresh one. There is at most one per app. + private func loadOrCreateManager() async throws -> NETunnelProviderManager { + if let m = manager { return m } + let all = try await NETunnelProviderManager.loadAllFromPreferences() + let m = all.first ?? NETunnelProviderManager() + manager = m + return m + } + + /// Write the NETunnelProviderProtocol (which NE target + control server) and providerConfiguration + /// (controlUrl/hostname/authKey the NE reads at startTunnel). serverAddress is display-only for iOS. + private func applyProviderConfig(_ mgr: NETunnelProviderManager) async throws { + let defs = Optional(UserDefaults.standard) + let controlUrl = defs?.string(forKey: kControlUrl) ?? "" + + let proto = (mgr.protocolConfiguration as? NETunnelProviderProtocol) ?? NETunnelProviderProtocol() + proto.providerBundleIdentifier = neBundleId + // Display-only on iOS: the NE gets the real control URL through providerConfiguration below, so + // this string is free to be branding rather than a hostname. It is the likelier of the two + // candidates for the second line iOS shows under the VPN entry in Settings. + proto.serverAddress = "Officer Suite" + var cfg: [String: Any] = [:] + if let v = defs?.string(forKey: kControlUrl) { cfg[kControlUrl] = v } + if let v = defs?.string(forKey: kHostname) { cfg[kHostname] = v } + if let v = defs?.string(forKey: kAuthKey) { cfg[kAuthKey] = v } + proto.providerConfiguration = cfg + mgr.protocolConfiguration = proto + // The name iOS shows for this VPN configuration in Settings. Was "OffTail", which predates the + // rename and is the string users actually see in Settings > General > VPN. + mgr.localizedDescription = "OffScale" + } + + /// Map the NE connection state to the JS VpnState, and merge in self/peer detail the NE publishes to + /// the App Group. The rich peer list originates from the Tailscale core inside the NE. + private func buildStatus() async throws -> [String: Any?] { + // Ensure we're reading the live system VPN state even after an app relaunch / backgrounding, when + // `self.manager` may not be cached yet — otherwise status falsely reads "stopped" while connected. + if manager == nil { + manager = try? await NETunnelProviderManager.loadAllFromPreferences().first + } + let state: String + switch manager?.connection.status ?? .invalid { + case .connecting, .reasserting: state = "connecting" + case .connected: state = "connected" + case .disconnecting, .disconnected, .invalid: state = "stopped" + @unknown default: state = "stopped" + } + + // Pull self/peer detail straight from the core inside the NE via a provider message — this works + // cross-process without an App Group (the NE's handleAppMessage returns TailcoreStatus() JSON, in + // Tailscale's ipnstate shape: Self.TailscaleIPs / Self.DNSName / Peer{}). Only meaningful once the + // tunnel is up. + var selfNode: [String: Any?]? = nil + var peers: [[String: Any?]] = [] + var authUrl: String? = nil + var backendState = state + // Kept outside the block below so the return value can read the exit-node health from it. + var coreStatus: [String: Any]? = nil + if state == "connected" || state == "connecting", + let session = manager?.connection as? NETunnelProviderSession, + let fetched = await neCoreStatus(session) { + coreStatus = fetched + let core = fetched + if let s = core["Self"] as? [String: Any] { + let ips = (s["TailscaleIPs"] as? [String]) ?? [] + // MagicDNS names arrive fully qualified with a trailing dot ("host.tailnet.ts.net.") — correct + // as DNS, but it reads like a typo in a UI. + let dns = (s["DNSName"] as? String).map { $0.hasSuffix(".") ? String($0.dropLast()) : $0 } + let host = s["HostName"] as? String + + /* + The account this node was registered under. + + `ipnstate.Status` carries a `User` map keyed by the numeric user id, and `Self.UserID` points + into it — so the login email has been in this JSON all along, just never read. It is the one + piece that answers "which account is this?", which matters as soon as more than one tailnet + can be signed in. + */ + var login: String? + let uid = (s["UserID"] as? NSNumber)?.stringValue ?? (s["UserID"] as? String) + if let uid, let users = core["User"] as? [String: Any], + let u = users[uid] as? [String: Any] { + login = (u["LoginName"] as? String) ?? (u["DisplayName"] as? String) + } + + let tailnet = (core["CurrentTailnet"] as? [String: Any])?["Name"] as? String + + /* + The other machines on the tailnet. + + `peers` was declared and returned empty from the day this was written, so the peer list in the + UI — tap to copy an IP, long-press for the MagicDNS name — has never had anything to render. + ipnstate.Status keys them by public key under "Peer". + + ExitNodeOption / ExitNode ride along because picking an exit node needs both: which peers offer + to be one, and which one is currently in use. + */ + if let peerMap = core["Peer"] as? [String: Any] { + peers = peerMap.values.compactMap { raw in + guard let p = raw as? [String: Any] else { return nil } + let ips = (p["TailscaleIPs"] as? [String]) ?? [] + let dns = (p["DNSName"] as? String).map { $0.hasSuffix(".") ? String($0.dropLast()) : $0 } + let host = p["HostName"] as? String + return [ + // StableID is the durable identifier; the public key is the map key and changes on rekey. + "id": (p["ID"] as? String) ?? dns ?? host ?? UUID().uuidString, + "name": (dns ?? host ?? "") as Any?, + "hostName": host as Any?, + "ips": ips, + "online": (p["Online"] as? Bool) ?? false, + "os": p["OS"] as Any?, + "lastSeen": p["LastSeen"] as Any?, + "exitNodeOption": (p["ExitNodeOption"] as? Bool) ?? false, + "exitNode": (p["ExitNode"] as? Bool) ?? false, + ] + } + // Online first, then by name: a tailnet of any size is unreadable in map order, which is + // whatever the JSON decoder produced. + .sorted { + let (a, b) = (($0["online"] as? Bool) ?? false, ($1["online"] as? Bool) ?? false) + if a != b { return a } + return (($0["name"] as? String) ?? "") .localizedCaseInsensitiveCompare(($1["name"] as? String) ?? "") == .orderedAscending + } + } + + /* + When does this device's key expire, and has it already? + + A node key expires on the tailnet's own schedule — often 180 days — and when it does the + device silently stops working until re-authorised. Reporting it lets the app warn first + rather than presenting expiry as a mysterious disconnection. + */ + let keyExpiry = s["KeyExpiry"] as? String + let expired = (s["Expired"] as? Bool) ?? false + + selfNode = [ + "keyExpiry": keyExpiry as Any?, + "expired": expired, + // Prefer the short host name for display; the fully-qualified one is still available. + "name": (host ?? dns) as Any?, + "magicDNSName": dns as Any?, + "ips": ips, + "hostName": host as Any?, + "os": s["OS"] as Any?, + "loginName": login as Any?, + "tailnet": tailnet as Any?, + ] + } + + // The NE being up says nothing about whether the NODE is authorised. `state` above comes from + // NEVPNConnection, which reports "connected" the moment the tunnel exists — while the core can + // still be sitting in NeedsLogin waiting for a human. Android reads BackendState directly and + // so must this, or interactive login is invisible on iOS. + /* + Every backend state is mapped, not just NeedsLogin. + + Handling only NeedsLogin left `Starting` and `NoState` falling through as "connected", which is + the window right after startTunnel — the tunnel exists, the core has not finished coming up, and + nothing is authorised. The UI showed a connected toggle with no data for several seconds, then + jumped to a sign-in prompt. From the outside that reads as "it connected, then changed its mind". + + `Running` is the only state that earns "connected". + */ + switch core["BackendState"] as? String { + case "NeedsLogin": + backendState = "needsLogin" + // Straight from ipnstate.Status.AuthURL — already in this JSON, never read until now. + authUrl = (core["AuthURL"] as? String).flatMap { $0.isEmpty ? nil : $0 } + case "Running": + backendState = "connected" + case "Starting", "NoState", "Stopped": + backendState = "connecting" + default: + break // unknown state: leave the NE-derived value rather than inventing one + } + } + + return [ + "state": backendState, + "self": selfNode as Any?, + "peers": peers, + "error": nil, + "authUrl": authUrl as Any?, + // Which control plane this node is talking to. The app knows it because it configured it, but + // sending it with the status keeps "who am I connected to" answerable from one object rather + // than correlating two — and it is what distinguishes Tailscale from a self-hosted Headscale. + "controlUrl": Optional(UserDefaults.standard)?.string(forKey: kControlUrl) as Any?, + /* + The exit node's own health, straight from the core. + + When an exit node goes offline the traffic stops rather than escaping around the tunnel — the + safe failure — but the app went on saying "Connected" with no explanation. Reported so the + screen can say what is actually happening. + */ + "exitNodeOnline": (coreStatus?["ExitNodeStatus"] as? [String: Any])?["Online"] as Any?, + /* + Our configuration exists but iOS has deselected it — which is what happens when another VPN + app (the official Tailscale client, say) takes the slot, since only one can be active. + + Reported so the UI can SAY that. Without it, being superseded looks identical to a plain + failure to connect: the toggle sits at "Not connected" with no reason given, and the honest + explanation — something else is using the tunnel — is invisible. Last-connected-wins is fine + as a rule; silently losing is not. + */ + "supersededByOtherVpn": (manager != nil && manager?.isEnabled == false), + ] + } + + /// Ask the running NE for the core's status JSON (self IPs + backend state) via a provider message. + /// Returns the decoded ipnstate object, or nil if the tunnel isn't up / doesn't answer promptly. + /** + Send a raw command to the NE and get its reply as a string. + + The generalisation of `neCoreStatus`, needed because the core lives in the extension: profile + operations act on the running LocalBackend, and the app process does not have one. Calling + `TailcoreListProfiles()` here would compile and return "[]" forever. + */ + /// - Parameter timeout: 1.5s suits a status read. Profile MUTATIONS need far longer — creating or + /// switching a profile makes the backend re-key and fetch a fresh netmap, which does not finish in + /// a second and a half. Timing out there returns nil, and nil used to be read as success. + private func neCommand(_ command: String, timeout: TimeInterval = 1.5) async -> String? { + guard let session = manager?.connection as? NETunnelProviderSession else { return nil } + return await withCheckedContinuation { (cont: CheckedContinuation) in + let resumed = ResumeOnce() + DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { if resumed.take() { cont.resume(returning: nil) } } + do { + try session.sendProviderMessage(Data(command.utf8)) { resp in + guard resumed.take() else { return } + cont.resume(returning: resp.flatMap { String(data: $0, encoding: .utf8) }) + } + } catch { + if resumed.take() { cont.resume(returning: nil) } + } + } + } + + private func neCoreStatus(_ session: NETunnelProviderSession) async -> [String: Any]? { + await withCheckedContinuation { (cont: CheckedContinuation<[String: Any]?, Never>) in + let resumed = ResumeOnce() + // Guard against a hung continuation if the NE never calls back. + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { if resumed.take() { cont.resume(returning: nil) } } + do { + try session.sendProviderMessage(Data("status".utf8)) { resp in + guard resumed.take() else { return } + if let resp = resp, let obj = try? JSONSerialization.jsonObject(with: resp) as? [String: Any] { + cont.resume(returning: obj) + } else { + cont.resume(returning: nil) + } + } + } catch { + if resumed.take() { cont.resume(returning: nil) } + } + } + } + + /// Ask the running NE for its diagnostic ring buffer (a plain string) via a provider message. + private func neDiag(_ session: NETunnelProviderSession) async -> String { + await withCheckedContinuation { (cont: CheckedContinuation) in + let resumed = ResumeOnce() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { if resumed.take() { cont.resume(returning: "(diag timeout)") } } + do { + try session.sendProviderMessage(Data("diag".utf8)) { resp in + guard resumed.take() else { return } + cont.resume(returning: resp.flatMap { String(data: $0, encoding: .utf8) } ?? "(no diag)") + } + } catch { + if resumed.take() { cont.resume(returning: "(diag err: \(error.localizedDescription))") } + } + } + } +} diff --git a/modules/officer-net/ios/PacketTunnelProvider.swift b/modules/officer-net/ios/PacketTunnelProvider.swift new file mode 100644 index 0000000..0141a62 --- /dev/null +++ b/modules/officer-net/ios/PacketTunnelProvider.swift @@ -0,0 +1,784 @@ +import NetworkExtension +import TailCore // the vendored gomobile Tailscale core (Tailcore{Start,Stop,Status,…}) +import os.log + +// OffTail VPN data plane — iOS. This is the Network Extension: a SEPARATE executable bundled inside the +// same app (NE App ID `dev.officer.mobile.net`), running in its own process. It owns the utun device and +// hands its raw file descriptor to the embedded Tailscale core (TailCore.xcframework), which then does +// all packet I/O + WireGuard/magicsock/DERP itself — the same fd-handoff pattern WireGuard-iOS uses, so +// there's no Swift packet loop. +// +// This file compiles into the NE target (added as a manual Xcode step — see plugin/README.md) AND into +// the OfficerNet pod, so its TailcoreStart call is compile-checked in every app build. +// +// ⚠️ MEMORY BUDGET: iOS gives a packet tunnel a HARD ceiling (~50 MB; jetsam-killed above it). The core +// runs WireGuard in USERSPACE via netstack (no kernel WireGuard on iOS). Keep the peer set small. See +// TAILSCALE_VPN.md → "Memory budget". +// +// LAST MILE (needs on-device testing + a reachable Headscale): the network settings below use a +// placeholder address/route. The REAL per-node IP, routes, and MagicDNS come from the core's netmap +// (poll TailcoreStatus / add a netmap callback) and must be applied via setTunnelNetworkSettings — the +// router/DNS bridge. Until that's wired from the live netmap, routing won't be correct. + +private let appGroup = "group.pastilhas.officer.mobile" +private let log = OSLog(subsystem: "dev.officer.mobile.net", category: "PacketTunnel") + +class PacketTunnelProvider: NEPacketTunnelProvider { + + // In-NE diagnostic ring buffer, surfaced to the app via handleAppMessage("diag"). idevicesyslog drops + // os_log lines under burst load, so this is the reliable way to see the bring-up flow on-device. + private var diag: [String] = [] + private let diagLock = NSLock() + private func d(_ s: String) { + diagLock.lock() + diag.append(s) + if diag.count > 80 { diag.removeFirst() } + diagLock.unlock() + // .default, NOT .info: idevicesyslog drops OS_LOG_TYPE_INFO, so an .info line reads exactly like + // code that never ran — which is how the identity wipe was nearly misdiagnosed as not executing. + os_log("DIAG %{public}@", log: log, type: .default, s) + } + + override func startTunnel(options: [String: NSObject]?, + completionHandler: @escaping (Error?) -> Void) { + os_log("startTunnel", log: log, type: .info) + d("startTunnel") + markRunStarted() + + // 1. Read config the main app stashed (providerConfiguration wins; App Group is the fallback). + let proto = protocolConfiguration as? NETunnelProviderProtocol + let cfg = proto?.providerConfiguration ?? [:] + let defs = UserDefaults(suiteName: appGroup) + let controlUrl = (cfg["controlUrl"] as? String) ?? defs?.string(forKey: "controlUrl") ?? "" + let hostname = (cfg["hostname"] as? String) ?? defs?.string(forKey: "hostname") ?? "" + let authKey = (cfg["authKey"] as? String) ?? defs?.string(forKey: "authKey") ?? "" + guard !controlUrl.isEmpty else { + completionHandler(NSError(domain: "OfficerNet", code: 1, + userInfo: [NSLocalizedDescriptionKey: "No controlUrl configured"])) + return + } + + // 2. Apply tunnel network settings so iOS creates the utun and starts routing into it. + // TODO(netmap): replace the placeholder IP/route with the core's assigned node IP + routes from + // TailcoreStatus (100.64.0.0/10 + advertised subnets) and MagicDNS — the router/DNS last mile. + // tunnelRemoteAddress MUST be a valid IP literal, not a URL — it's informational for a mesh VPN + // (there's no single server). Passing the control URL here fails with "Invalid tunnelRemoteAddress" + // and the tunnel dies before the core starts. Use a placeholder IP. + // + // KNOWN node IP → apply the REAL settings once, here, before the core starts. That is the whole + // fix: the previous flow applied a placeholder, started the core, then re-applied real settings + // on a live tunnel, and re-applying is a documented iOS teardown trap. The identity persists in + // the state dir so the IP is stable — only the very first connect on a device lacks one. + let known = savedIPs() + let settings: NEPacketTunnelNetworkSettings + if known.isEmpty { + d("no saved node IP — first connect, using placeholder and refining after the netmap") + settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1") + let ipv4 = NEIPv4Settings(addresses: ["100.64.0.1"], subnetMasks: ["255.192.0.0"]) + ipv4.includedRoutes = [NEIPv4Route(destinationAddress: "100.64.0.0", subnetMask: "255.192.0.0")] + settings.ipv4Settings = ipv4 + settings.mtu = 1280 + } else { + d("saved node IP \(known) — applying real settings up front, no swap") + settings = tunnelSettings(selfIPs: known, magicDNSSuffix: nil) + } + + setTunnelNetworkSettings(settings) { [weak self] error in + guard let self = self else { return } + if let error = error { + os_log("setTunnelNetworkSettings failed: %{public}@", log: log, type: .error, error.localizedDescription) + self.d("placeholder setTunnelNetworkSettings FAILED: \(error.localizedDescription)") + completionHandler(error) + return + } + self.d("placeholder settings applied") + + // 3. Hand the utun fd to the Tailscale core and bring the node up against Headscale. The core reads + // /writes the fd itself — no Swift packet loop. The auth key is single-use; drop it after. + guard let fd = self.utunFileDescriptor() else { + self.d("utun fd NOT FOUND") + completionHandler(NSError(domain: "OfficerNet", code: 2, + userInfo: [NSLocalizedDescriptionKey: "Could not locate the utun file descriptor"])) + return + } + self.d("utun fd = \(fd)") + // Persist the node identity in a STABLE dir (Application Support), not NSTemporaryDirectory — else + // the core loses its keys on every restart and registers a brand-new node each connect. + let stateDir = self.stateDirectory() + // gomobile exposes Start as a C function returning BOOL with an NSError out-param (not throwing). + // NO upstream DNS here, deliberately — unlike Android. matchDomains below scopes the capture + // to the tailnet suffix, so only short names ever reach the core's resolver and it never has + // to forward anything. Android has no matchDomains equivalent, must route ALL DNS at the core, + // and therefore does need TailcoreSetUpstreamDNS. + var startErr: NSError? + let ok = TailcoreStart(fd, controlUrl, authKey, hostname, stateDir, &startErr) + if ok { + self.d("TailcoreStart OK — waiting for netmap IP, then completing") + defs?.set(true, forKey: "registered") // an identity now exists on this device + defs?.removeObject(forKey: "authKey") // single-use pre-auth key — don't keep it + if !known.isEmpty { + // Settings are already correct. Complete NOW: a provider that sits on its completion + // handler risks being torn down, and there is nothing left to wait for. + self.d("real settings already applied — completing immediately") + completionHandler(nil) + // Still watch the netmap, purely to keep the saved IP current if Headscale reassigns it. + self.refreshSavedIPs() + } else { + // First connect only. Complete on the placeholder rather than blocking for up to 15s — + // the previous version waited before completing, which is its own teardown risk — and + // refine once the netmap lands so the NEXT connect takes the fast path above. + self.d("first connect — completing on placeholder, refining in background") + completionHandler(nil) + self.refineSettingsFromNetmap() + } + } else { + let e = startErr ?? NSError(domain: "OfficerNet", code: 3, + userInfo: [NSLocalizedDescriptionKey: "TailcoreStart failed"]) + os_log("TailcoreStart failed: %{public}@", log: log, type: .error, e.localizedDescription) + self.d("TailcoreStart FAILED: \(e.localizedDescription)") + completionHandler(e) + } + } + } + + override func stopTunnel(with reason: NEProviderStopReason, + completionHandler: @escaping () -> Void) { + os_log("stopTunnel: %{public}d", log: log, type: .info, reason.rawValue) + d("stopTunnel reason=\(reason.rawValue)") + markRunStopped(reason: reason.rawValue) + TailcoreStop() + completionHandler() + } + + /// Answer app-messages from the main process. "diag" → the NE's diagnostic ring buffer (reliable, since + /// idevicesyslog drops lines); anything else → the core status JSON (self IPs + peers). + /** + The app's only channel to the core. + + On iOS the Tailscale core runs in THIS process, not the app's — so anything the app wants from + `LocalBackend` has to come through here. That is invisible until you try to add a feature: calling + `TailcoreListProfiles()` from the app compiles perfectly and returns "[]", because the app's copy + of the package has a nil backend. Android has no such split, which is why its module can call the + Go directly and this one cannot. + + The protocol is a string command. It stays deliberately dumb — a verb, optionally a colon and an + argument — because it is a private channel between two halves of the same app, and a real + serialisation format here would be ceremony. + */ + override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) { + let msg = String(data: messageData, encoding: .utf8) ?? "" + + if msg == "diag" { + diagLock.lock(); let out = diag.joined(separator: "\n"); diagLock.unlock() + completionHandler?(out.data(using: .utf8)) + return + } + + /* + Destroy the node identity, for real. + + `logout()` in the app could only ever remove the VPN configuration and its own UserDefaults. The + identity is `tailscaled.state`, written by the CORE inside THIS process's container — so nothing + the app can reach touches it, and it survived logout and reinstall alike. That is why signing out + and installing again silently came back as the same node. + + Only the extension can do this, and only with the core stopped: `store.New` holds the file open, + and deleting it underneath a running backend leaves the identity in memory to be written back. + Stop first, then delete, and let the app tear the tunnel down afterwards. + */ + if msg == "wipe" { + d("wipe: stopping core and deleting persisted identity") + TailcoreStop() + let fm = FileManager.default + let dir = URL(fileURLWithPath: stateDirectory()) + // The whole directory, not just tailscaled.state: the cached node IPs and MagicDNS suffix are + // derived from the identity, and leaving them behind reapplies settings for a node that no + // longer exists. + for name in ["tailscaled.state", "node-ips.json", "magicdns-suffix.txt"] { + try? fm.removeItem(at: dir.appendingPathComponent(name)) + } + d("wipe: done") + completionHandler?("ok".data(using: .utf8)) + return + } + + // Preferences that change while running: the exit node and route acceptance. Everything else the + // core is configured with is set once at Start; these two are chosen and changed live. + /* + Hand over a pending abnormal-exit report, once. + + The app cannot read this file itself: the marker lives in the EXTENSION's container, and the two + do not share an App Group (see the group.pastilhas.officer.mobile vs group.dev.officer.mobile + mismatch documented above). This channel is the only way across, which is also why it is a `take` + rather than a `get` — collected means reported, and reporting the same crash on every launch would + be worse than not reporting it. + */ + if msg == "diag:lastexit" { + let report = takeLastExitReport() + if !report.isEmpty { d("handing over last-exit report: \(report)") } + completionHandler?(report.data(using: .utf8)) + return + } + + if msg.hasPrefix("prefs:exitnode:") { + let id = String(msg.dropFirst("prefs:exitnode:".count)) + d("prefs:exitnode \(id.isEmpty ? "(none)" : id)") + var err: NSError? + TailcoreSetExitNode(id, &err) + d("prefs:exitnode -> \(err?.localizedDescription ?? "ok")") + /* + Re-apply the tunnel settings, because the ROUTES changed. + + The preference lives in the core and the routes live in the tun; only this reconciles them. + Without it, selecting an exit node sets a preference and the OS carries on sending everything + but tailnet traffic straight out over Wi-Fi — the exit node never sees a packet, and on and off + look identical. + + Applied on the live tunnel rather than restarting it: the same path the netmap refine already + uses, and far less disruptive than dropping the connection to change a route. + */ + if err == nil { + let ips = savedIPs() + if !ips.isEmpty { + applySettings(selfIPs: ips, magicDNSSuffix: savedSuffix()) + } else { + d("prefs:exitnode: no saved IPs yet — routes will follow on the next netmap") + } + } + completionHandler?((err?.localizedDescription ?? "").data(using: .utf8)) + return + } + + if msg.hasPrefix("prefs:routes:") { + let on = msg.hasSuffix(":1") + d("prefs:routes \(on)") + var err: NSError? + TailcoreSetAcceptRoutes(on, &err) + d("prefs:routes -> \(err?.localizedDescription ?? "ok")") + // Same reasoning as the exit node: the preference is in the core, the routes are in the tun, + // and only re-applying reconciles them. + if err == nil { + let ips = savedIPs() + if !ips.isEmpty { applySettings(selfIPs: ips, magicDNSSuffix: savedSuffix()) } + } + completionHandler?((err?.localizedDescription ?? "").data(using: .utf8)) + return + } + + if msg == "prefs:get" { + completionHandler?(TailcorePrefs().data(using: .utf8)) + return + } + + if msg == "profiles:list" { + let json = TailcoreListProfiles() + // Traced because an empty list is ambiguous from the app side: it looks identical whether the + // core has no profiles, the message timed out, or the reply failed to parse. The trace says + // which, and costs nothing. + d("profiles:list -> \(json.count) chars: \(json.prefix(300))") + completionHandler?(json.data(using: .utf8)) + return + } + + /* + Join another tailnet on the LIVE backend. + + Payload is base64 JSON because a control URL and a pre-auth key both contain characters that a + `:`-delimited command cannot carry safely. + + This exists because NewProfile + restart cannot work: restarting drops the unregistered blank + profile and Start then overwrites the surviving one, so a second tailnet replaced the first + under the same profile id. Doing both steps inside the core, without a restart, is the only + ordering where neither undoes the other. + */ + if msg.hasPrefix("profiles:login:") { + let b64 = String(msg.dropFirst("profiles:login:".count)) + guard let data = Data(base64Encoded: b64), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + d("profiles:login -> bad payload") + completionHandler?("bad payload".data(using: .utf8)) + return + } + let controlUrl = (obj["controlUrl"] as? String) ?? "" + let authKey = (obj["authKey"] as? String) ?? "" + let hostname = (obj["hostname"] as? String) ?? "" + d("profiles:login control=\(controlUrl) key=\(authKey.isEmpty ? "none" : "yes")") + var err: NSError? + TailcoreLoginTo(controlUrl, authKey, hostname, &err) + d("profiles:login -> \(err?.localizedDescription ?? "ok")") + // The new profile has its own node IP; the cached one belongs to the tailnet we just left. + if err == nil { self.forgetCachedNetmap() } + completionHandler?((err?.localizedDescription ?? "").data(using: .utf8)) + return + } + + if msg == "profiles:new" { + // Errors come back as a string rather than a thrown NSError: the channel carries Data, and the + // app only needs to know whether it worked and why not. + d("profiles:new — creating a blank profile") + var err: NSError? + TailcoreNewProfile(&err) + d("profiles:new -> \(err?.localizedDescription ?? "ok")") + completionHandler?((err?.localizedDescription ?? "").data(using: .utf8)) + return + } + + if msg.hasPrefix("profiles:switch:") { + var err: NSError? + let switchID = String(msg.dropFirst("profiles:switch:".count)) + d("profiles:switch \(switchID)") + TailcoreSwitchProfile(switchID, &err) + d("profiles:switch -> \(err?.localizedDescription ?? "ok")") + if err == nil { self.forgetCachedNetmap() } + completionHandler?((err?.localizedDescription ?? "").data(using: .utf8)) + return + } + + if msg.hasPrefix("profiles:delete:") { + var err: NSError? + let deleteID = String(msg.dropFirst("profiles:delete:".count)) + d("profiles:delete \(deleteID)") + TailcoreDeleteProfile(deleteID, &err) + d("profiles:delete -> \(err?.localizedDescription ?? "ok")") + completionHandler?((err?.localizedDescription ?? "").data(using: .utf8)) + return + } + + let status = TailcoreStatus() + completionHandler?(status.data(using: .utf8)) + } + + // MARK: - State dir + netmap-driven settings + + /** + The node's tailnet IPs, remembered from the last successful netmap. + + Kept in the NE's OWN state dir, not an App Group. The NE currently has no App Group entitlement at + all, and adding one needs App Groups enabled on the `dev.officer.mobile.net` App ID in the Apple + portal — requesting an entitlement the App ID does not carry fails code-signing for the WHOLE app. + Nothing else needs to read this, so the extension's own container is both sufficient and free. + */ + + /* + Forget the cached node IP and MagicDNS suffix. + + Both describe the tailnet the node is currently in, but they live in one per-DEVICE file. Change + profile and they become another tailnet's answers — and startTunnel trusts them, applying "real + settings up front, no swap". After a switch the tun would come up holding an address that belongs + to the network you just left. Clearing forces the slower first-connect path, which refines from the + netmap and is correct. + */ + private func forgetCachedNetmap() { + let fm = FileManager.default + try? fm.removeItem(at: savedIPsURL) + try? fm.removeItem(at: savedSuffixURL) + d("cleared cached node IP + MagicDNS suffix (profile changed)") + } + + private var savedIPsURL: URL { + URL(fileURLWithPath: stateDirectory()).appendingPathComponent("node-ips.json") + } + + /* + How the LAST tunnel run ended — the only crash reporting a packet tunnel can honestly do. + + The interesting failures in this app live in the extension, and they are the ones no reporter + catches. iOS gives a packet tunnel a hard memory ceiling (~50 MB) and jetsams it on breach: the + process is killed outright, so no signal handler, no exception handler and no in-process crash + library ever runs. A reporter embedded here would also be competing with the Tailscale core for the + very budget that gets it killed. + + So this infers rather than catches. `startTunnel` marks the run as `running`; `stopTunnel` marks it + `stopped` with the OS's own reason. If a run STARTS and finds the previous one still marked + `running`, that run never reached `stopTunnel` — killed, crashed, or the device rebooted. The + distinction between a jetsam kill and a crash is not available to us, and pretending otherwise would + be worse than saying "ended abnormally". + + It carries no identifiers by construction — a timestamp and a numeric reason code, nothing derived + from the tailnet, the login or any address. That is deliberate: this is the one diagnostic intended + to leave the device, and the cheapest way to keep a crash report free of secrets is to give it + nothing to leak. + */ + private var lastExitURL: URL { + URL(fileURLWithPath: stateDirectory()).appendingPathComponent("last-exit.json") + } + + private func iso(_ d: Date = Date()) -> String { + let f = ISO8601DateFormatter() + return f.string(from: d) + } + + /// Called at the top of startTunnel. Returns what happened to the previous run, and claims this one. + @discardableResult + private func markRunStarted() -> [String: Any]? { + let fm = FileManager.default + var previous: [String: Any]? = nil + if let data = try? Data(contentsOf: lastExitURL), + let prev = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if (prev["state"] as? String) == "running" { + // Never reached stopTunnel. Jetsam, crash or reboot — indistinguishable from in here. + previous = [ + "kind": "abnormal", + "startedAt": prev["startedAt"] as? String ?? "", + "detectedAt": iso(), + ] + } else { + previous = [ + "kind": "clean", + "reason": prev["reason"] as? Int ?? -1, + "at": prev["at"] as? String ?? "", + ] + } + } + let now: [String: Any] = [ + "state": "running", + "startedAt": iso(), + // Carried forward so the app can still collect it after a reconnect it did not witness. + "previous": previous as Any, + ] + if let data = try? JSONSerialization.data(withJSONObject: now) { + try? data.write(to: lastExitURL, options: .atomic) + } + _ = fm + if let previous, (previous["kind"] as? String) == "abnormal" { + d("PREVIOUS RUN ENDED ABNORMALLY (no stopTunnel) — started \(previous["startedAt"] ?? "?")") + } + return previous + } + + /// Called from stopTunnel. Records an orderly shutdown so the next start does not report a crash. + private func markRunStopped(reason: Int) { + var payload: [String: Any] = ["state": "stopped", "reason": reason, "at": iso()] + // Preserve any not-yet-collected report, so an orderly stop cannot erase the crash before it. + if let data = try? Data(contentsOf: lastExitURL), + let prev = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let carried = prev["previous"] { + payload["previous"] = carried + } + if let data = try? JSONSerialization.data(withJSONObject: payload) { + try? data.write(to: lastExitURL, options: .atomic) + } + } + + /// The pending abnormal-exit report, if any. Cleared once handed over, so it is reported once. + private func takeLastExitReport() -> String { + guard let data = try? Data(contentsOf: lastExitURL), + var obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return "" } + guard let previous = obj["previous"] as? [String: Any] else { return "" } + obj["previous"] = nil + if let out = try? JSONSerialization.data(withJSONObject: obj) { + try? out.write(to: lastExitURL, options: .atomic) + } + guard let data = try? JSONSerialization.data(withJSONObject: previous), + let s = String(data: data, encoding: .utf8) else { return "" } + return s + } + + private var savedSuffixURL: URL { + URL(fileURLWithPath: stateDirectory()).appendingPathComponent("magicdns-suffix.txt") + } + + /// Remembered alongside the node IP: the fast path applies settings before any netmap arrives, so + /// without this a reconnect would silently drop MagicDNS until the next refresh. + private func savedSuffix() -> String? { + try? String(contentsOf: savedSuffixURL, encoding: .utf8) + } + + private func saveSuffix(_ s: String) { + try? s.write(to: savedSuffixURL, atomically: true, encoding: .utf8) + } + + private func savedIPs() -> [String] { + guard let data = try? Data(contentsOf: savedIPsURL), + let ips = try? JSONSerialization.jsonObject(with: data) as? [String] else { return [] } + return ips + } + + private func saveIPs(_ ips: [String]) { + guard let data = try? JSONSerialization.data(withJSONObject: ips) else { return } + try? data.write(to: savedIPsURL, options: .atomic) + } + + /// A stable per-extension directory for the core's persisted node identity/keys. + private func stateDirectory() -> String { + let fm = FileManager.default + let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory()) + let dir = base.appendingPathComponent("offtail", isDirectory: true) + try? fm.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.path + } + + /// Poll the core until it has a netmap (self Tailscale IPs), then re-apply the tunnel settings with the + /// real per-node address + tailnet routes + MagicDNS. The initial settings in startTunnel are just + /// placeholders to bring the utun up; this makes routing actually correct. + private func refineSettingsFromNetmap() { + d("refine: start polling") + Task { + // Poll up to ~60s at 0.5s — the netmap can be slow (esp. right after control comes back up). The + // placeholder tun address (100.64.0.1) MUST be replaced with the real node IP or outbound packets + // carry the wrong source and replies never come back. + var lastBackend = "" + for i in 0..<120 { + if let data = TailcoreStatus().data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + let backend = (obj["BackendState"] as? String) ?? "?" + if backend != lastBackend { self.d("refine: poll \(i) backend=\(backend)"); lastBackend = backend } + if let selfNode = obj["Self"] as? [String: Any], + let ips = selfNode["TailscaleIPs"] as? [String], !ips.isEmpty { + self.d("refine: got self IPs after \(i) polls (backend=\(backend)): \(ips)") + self.applySettings(selfIPs: ips, magicDNSSuffix: obj["MagicDNSSuffix"] as? String) + return + } + } + try? await Task.sleep(nanoseconds: 500_000_000) + } + self.d("refine: GAVE UP — no self IPs after 120 polls; tun stuck on placeholder") + } + } + + /** + The tunnel settings for a set of node IPs. Pure — builds, does not apply. + + Extracted so `startTunnel` and the refine path cannot drift: the whole bug class here is one code + path applying different settings than another. + */ + /** + Whether an exit node is in use, so the tun knows whether to claim everything. + + Read from the core rather than remembered, because the preference can change from the app at any + time and the routes have to follow it. + */ + /** + RFC1918 and link-local, kept out of a default-route tunnel. + + Fixed ranges rather than the phone's actual subnet: NEPacketTunnelNetworkSettings is built before + the tunnel is up and enumerating interfaces from inside the extension is unreliable. Excluding all + private space is broader than strictly needed and errs the right way — a LAN address should never + be carried to an exit node on the other side of the world. + */ + /** + Subnet routes the core has accepted, as NEIPv4Routes. + + Accepting a route in the core is only half of it: the OS decides what enters the tunnel, so unless + the tun claims these prefixes the traffic never arrives and "use subnet routes" does nothing — + the same gap that made choosing an exit node inert. Empty unless the preference is on. + + IPv4 only for now: advertised v6 subnets are rare and untested here, and claiming a prefix wrongly + is worse than not claiming it. + */ + private func acceptedSubnetRoutes() -> [NEIPv4Route] { + guard let data = TailcoreSubnetRoutes().data(using: .utf8), + let cidrs = try? JSONSerialization.jsonObject(with: data) as? [String] else { return [] } + return cidrs.compactMap { cidr in + let parts = cidr.split(separator: "/") + guard parts.count == 2, let bits = Int(parts[1]), bits > 0, bits <= 32, + parts[0].contains(".") else { return nil } + // Prefix length to dotted mask, which is the only form NEIPv4Route takes. + let mask = (0..<4).map { i -> String in + let b = max(0, min(8, bits - i * 8)) + return String(256 - (1 << (8 - b)) % 256) + }.joined(separator: ".") + return NEIPv4Route(destinationAddress: String(parts[0]), subnetMask: mask) + } + } + + private func privateIPv4Ranges() -> [NEIPv4Route] { + [ + NEIPv4Route(destinationAddress: "10.0.0.0", subnetMask: "255.0.0.0"), + NEIPv4Route(destinationAddress: "172.16.0.0", subnetMask: "255.240.0.0"), + NEIPv4Route(destinationAddress: "192.168.0.0", subnetMask: "255.255.0.0"), + NEIPv4Route(destinationAddress: "169.254.0.0", subnetMask: "255.255.0.0"), + ] + } + + private func exitNodeActive() -> Bool { + guard let data = TailcorePrefs().data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let id = obj["exitNodeID"] as? String else { return false } + return !id.isEmpty + } + + private func tunnelSettings(selfIPs: [String], magicDNSSuffix: String?) -> NEPacketTunnelNetworkSettings { + // tunnelRemoteAddress MUST be a valid IP literal, not a URL — it is informational for a mesh VPN + // (there is no single server). A control URL here fails with "Invalid tunnelRemoteAddress" and + // the tunnel dies before the core starts. + let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1") + settings.mtu = 1280 + + /* + With an exit node, the tun must claim EVERYTHING. + + Routing only the tailnet ranges is correct for ordinary use and useless for an exit node: iOS + hands us tailnet packets and sends the rest straight out over Wi-Fi, so the exit node never sees + the traffic it exists to carry. Selecting one changed the core's preference and nothing else. + */ + let allTraffic = exitNodeActive() + // Advertised subnets the user has accepted. Empty unless the toggle is on; skipped entirely with + // an exit node, whose default route already covers them. + let subnets = allTraffic ? [] : acceptedSubnetRoutes() + d("tun routes: \(allTraffic ? "DEFAULT (exit node active)" : "tailnet ranges + \(subnets.count) subnet route(s)")") + + let v4 = selfIPs.filter { $0.contains(".") } + let v6 = selfIPs.filter { $0.contains(":") } + if !v4.isEmpty { + let ipv4 = NEIPv4Settings(addresses: v4, subnetMasks: v4.map { _ in "255.255.255.255" }) + ipv4.includedRoutes = allTraffic + ? [NEIPv4Route.default()] + : [NEIPv4Route(destinationAddress: "100.64.0.0", subnetMask: "255.192.0.0")] // 100.64.0.0/10 + + subnets + // The default route swallows the LAN as well, so the printer, the NAS and the router's own UI + // would go dark. Prefs already set ExitNodeAllowLANAccess; this is the tun honouring it, which + // is the half that decides where packets actually go. + if allTraffic { + ipv4.excludedRoutes = privateIPv4Ranges() + } + settings.ipv4Settings = ipv4 + } + if !v6.isEmpty { + let ipv6 = NEIPv6Settings(addresses: v6, networkPrefixLengths: v6.map { _ in NSNumber(value: 128) }) + ipv6.includedRoutes = allTraffic + ? [NEIPv6Route.default()] + : [NEIPv6Route(destinationAddress: "fd7a:115c:a1e0::", networkPrefixLength: 48)] // Tailscale ULA + settings.ipv6Settings = ipv6 + } + /* + DNS — scoped to the tailnet normally, everything once an exit node is carrying the traffic. + + 100.100.100.100 is the core's own resolver, which now exists: a dns.Manager is created before + netstack.Create, where previously there was none and this address answered nothing. + + WITHOUT an exit node, matchDomains keeps the capture to tailnet names, which is why iOS never + lost the internet the way Android did — every other name keeps using the system resolvers + directly. Android has no matchDomains equivalent and has to route ALL DNS at the core. + + WITH an exit node that scoping becomes a leak, and a quiet one. Ordinary names go to whatever + resolver the Wi-Fi handed out, which is usually the router — and the router is on the LAN, which + `privateIPv4Ranges()` deliberately excludes from the tunnel so the printer and the NAS keep + working. So every domain the user visits is still sent, in the clear, to the network they chose + an exit node to get away from. The packets go through the exit node; the names never did. + + Claiming ALL names fixes it: resolution moves to the core, which is the only component that knows + an exit node exists, and its forwarded queries travel over WireGuard rather than out the Wi-Fi. + The catch-all is an empty matchDomains entry — `[""]` — which is Apple's documented way to say + "this tunnel resolves everything", not a placeholder. + + The trade is real and worth stating: with an exit node, a core resolver that cannot forward now + takes ALL name resolution down instead of just tailnet names — the exact failure Android hit. + That is the right trade only because it is scoped to the case where the user has explicitly asked + for every packet to leave through another machine. Off the exit node, the safe scoping returns. + */ + let suffix = magicDNSSuffix ?? savedSuffix() + if allTraffic { + let dns = NEDNSSettings(servers: ["100.100.100.100"]) + dns.matchDomains = [""] + settings.dnsSettings = dns + if let suffix, !suffix.isEmpty { saveSuffix(suffix) } + d("DNS: ALL names via core (exit node active)") + } else if let suffix, !suffix.isEmpty { + let dns = NEDNSSettings(servers: ["100.100.100.100"]) + dns.matchDomains = [suffix] + settings.dnsSettings = dns + saveSuffix(suffix) + d("MagicDNS on for .\(suffix)") + } + return settings + } + + /** + Keep the saved node IP AND the MagicDNS suffix current, without touching a live tunnel's settings. + + The suffix matters as much as the IP: the fast path applies settings before any netmap exists, so + it can only enable MagicDNS from what a previous connect wrote down. Recording only the IP — which + this did — meant `savedSuffix()` stayed empty forever on a device that always took the fast path, + and short names never resolved no matter what the core was capable of. + + Written for the NEXT connect rather than re-applied now: re-applying settings on a live tunnel is + the teardown trap this whole file exists to avoid. + */ + private func refreshSavedIPs() { + Task { + for _ in 0..<60 { + if let data = TailcoreStatus().data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let suffix = obj["MagicDNSSuffix"] as? String, !suffix.isEmpty, suffix != self.savedSuffix() { + self.d("saved MagicDNS suffix -> \(suffix); next connect will use it") + self.saveSuffix(suffix) + } + if let selfNode = obj["Self"] as? [String: Any], + let ips = selfNode["TailscaleIPs"] as? [String], !ips.isEmpty { + if ips != self.savedIPs() { + self.d("saved node IP changed -> \(ips); next connect will use it") + self.saveIPs(ips) + } + return + } + } + try? await Task.sleep(nanoseconds: 500_000_000) + } + } + } + + private func applySettings(selfIPs: [String], magicDNSSuffix: String?, then: ((Error?) -> Void)? = nil) { + d("applySettings IPs=\(selfIPs) suffix=\(magicDNSSuffix ?? "-")") + // Remembered so the NEXT connect can apply the right settings up front and skip the two-phase + // swap entirely — the identity is stable across connects, so this IP is too. + saveIPs(selfIPs) + let settings = tunnelSettings(selfIPs: selfIPs, magicDNSSuffix: magicDNSSuffix) + let v4 = selfIPs.filter { $0.contains(".") } + let v6 = selfIPs.filter { $0.contains(":") } + d("refine: calling setTunnelNetworkSettings v4=\(v4) v6=\(v6)") + setTunnelNetworkSettings(settings) { [weak self] err in + if let err = err { + os_log("refine settings failed: %{public}@", log: log, type: .error, err.localizedDescription) + self?.d("refine: setTunnelNetworkSettings FAILED: \(err.localizedDescription)") + } else { + os_log("applied netmap settings v4=%{public}@ v6=%{public}@", log: log, type: .info, v4.description, v6.description) + self?.d("refine: APPLIED real settings v4=\(v4) v6=\(v6) ✓") + } + // On the first-connect path this is startTunnel's completionHandler: the tunnel goes "connected" + // only now, already carrying the real settings. + then?(err) + } + } + + // MARK: - utun fd + + /// The utun fd this Network Extension owns. The RELIABLE way is NEPacketTunnelFlow's underlying socket + /// fd via KVC — that is unambiguously OUR tun (the one the OS routes into). WireGuard-iOS uses this. + /// + /// The old approach scanned open fds for the first "utun"-named control socket, but a device can have + /// MORE than one utun — iCloud Private Relay, a personal Tailscale/VPN — and the scan could return the + /// wrong one. The core would then read/write a tun the OS never routes into: control plane works (the + /// core uses its own sockets, so it registers + gets a netmap + IP), but no app traffic ever flows. + private func utunFileDescriptor() -> Int32? { + // NOTE: do NOT use packetFlow.value(forKeyPath: "socket.fileDescriptor") — on iOS 18 that key path + // throws an Objective-C NSUnknownKeyException, which Swift can't catch, crashing the whole NE before + // TailcoreStart. Scan the open fds for the utun control socket instead (WireGuard-iOS's technique). + // + // EVERY match is logged, not just the first one taken. A phone routinely has more than one utun — + // iCloud Private Relay, a personal VPN, a leftover — and picking the wrong one gives a control + // plane that works perfectly (the core registers, gets a netmap and an IP, because it uses its own + // sockets) while no app traffic ever flows. That failure is indistinguishable from a dozen others + // unless the candidates are on the record, so: log them all, take the first, and say which. + var buf = [Int8](repeating: 0, count: 256) + var found: [(Int32, String)] = [] + for fd: Int32 in 0...1024 { + var len = socklen_t(buf.count) + // SYSPROTO_CONTROL (2), UTUN_OPT_IFNAME (2): getsockopt succeeds only on a utun control socket. + let ret = buf.withUnsafeMutableBytes { getsockopt(fd, 2, 2, $0.baseAddress, &len) } + if ret == 0 && strncmp(buf, "utun", 4) == 0 { + found.append((fd, String(cString: buf))) + } + } + guard let chosen = found.first else { + os_log("utun fd: no utun socket found", log: log, type: .error) + d("utun fd: NONE FOUND") + return nil + } + let all = found.map { "\($0.1)=fd\($0.0)" }.joined(separator: " ") + os_log("utun candidates: %{public}@ — taking %{public}d", log: log, type: .info, all, chosen.0) + d("utun candidates: \(all) — taking \(chosen.1)=fd\(chosen.0)\(found.count > 1 ? " ⚠️ MORE THAN ONE" : "")") + return chosen.0 + } +} diff --git a/modules/officer-net/package.json b/modules/officer-net/package.json new file mode 100644 index 0000000..84b51e4 --- /dev/null +++ b/modules/officer-net/package.json @@ -0,0 +1,10 @@ +{ + "name": "officer-net", + "version": "0.1.0", + "description": "OffTail VPN engine — Tailscale core (gomobile/libtailscale) native bridge", + "main": "index.ts", + "private": true, + "dependencies": { + "@expo/config-plugins": "^57.0.0" + } +} diff --git a/modules/officer-net/plugin/README.md b/modules/officer-net/plugin/README.md new file mode 100644 index 0000000..460e255 --- /dev/null +++ b/modules/officer-net/plugin/README.md @@ -0,0 +1,54 @@ +# officer-net Expo config plugin + +Wires the OS-level capabilities the VPN needs into the app at prebuild. Enabled from `app.json`: + +```js +plugins: [ + ["officer-net", { + appGroup: "group.pastilhas.officer.offscale", + neBundleId: "dev.officer.offscale.net", + enableNetworkExtension: true, + }], +] +``` + +Props (all optional; defaults shown): + +| prop | default | what it controls | +|--------------------------|------------------------------------|---------------------------------------------------------| +| `appGroup` | `group.pastilhas.officer.mobile` | iOS App Group shared by the app and the NE | +| `neBundleId` | `dev.officer.mobile.net` | the Network Extension target's bundle id | +| `specialUseSubtype` | VPN mesh tunnel justification | Android `FOREGROUND_SERVICE_SPECIAL_USE` purpose | +| `enableNetworkExtension` | `false` | add the iOS entitlement and synthesise the NE target | + +`enableNetworkExtension` is **off by default** because the entitlement fails device code-signing until +the Apple provisioning below exists. With it off the app signs, installs and renders; the engine is +inert. The repo's own `app.json` turns it on. + +## What the plugin does + +- **iOS** — adds `com.apple.developer.networking.networkextension` (`packet-tunnel-provider`) to the + main app, and writes `OfficerNetExtensionBundleId` into `Info.plist`. That key is **load-bearing**, + not a breadcrumb: the Swift module reads it to fill `providerBundleIdentifier`, which is how iOS + knows which extension to launch. A hardcoded value there means the start command is accepted, nothing + matches, and the tunnel silently never starts. +- **iOS, under `enableNetworkExtension`** — synthesises the Network Extension Xcode target + (`withNetworkExtensionTarget.js`): a new target compiling this package's + `ios/PacketTunnelProvider.swift`, its `Info.plist` (`NSExtensionPointIdentifier = + com.apple.networkextension.packet-tunnel`), its own entitlements, `NetworkExtension.framework`, the + vendored `TailCore.xcframework`, and the app's *Embed App Extensions* phase. Doing this by hand after + every `expo prebuild --clean` is what this replaces. +- **Android** — adds `FOREGROUND_SERVICE` and `FOREGROUND_SERVICE_SPECIAL_USE`, and registers + `OfficerVpnService` with `BIND_VPN_SERVICE`, `foregroundServiceType="specialUse"` and the + `android.net.VpnService` intent-filter. + +## Apple provisioning prerequisites (one-time, in the Apple Developer portal) + +- A separate **App ID for the extension** (`neBundleId`) with the **Network Extensions** capability. +- The **Network Extensions** capability on the main app ID. +- Provisioning profiles for both, with those capabilities. + +`xcodebuild -allowProvisioningUpdates` cannot create these while Xcode has no account signed in: it +falls back to the wildcard team profile and fails with *"Provisioning profile 'iOS Team Provisioning +Profile: \*' doesn't include the Network Extensions capability"*. Sign in under **Xcode → Settings → +Accounts** first. Android needs none of this. diff --git a/modules/officer-net/plugin/app.plugin.js b/modules/officer-net/plugin/app.plugin.js new file mode 100644 index 0000000..a0be758 --- /dev/null +++ b/modules/officer-net/plugin/app.plugin.js @@ -0,0 +1,141 @@ +// Expo config plugin for officer-net. Wires the OS-level capabilities the VPN engine needs into the +// consuming app at prebuild time. Native code lives in the package; this plugin touches manifests, +// entitlements and Info.plist, plus (under enableNetworkExtension) the iOS Network Extension target. +// +// Usage in app.json: +// plugins: [ +// ["officer-net", { appGroup: "group.pastilhas.officer.offscale", +// neBundleId: "dev.officer.offscale.net", +// enableNetworkExtension: true }] +// ] +// +// iOS is the hard part: a packet tunnel requires a SECOND native target (the Network Extension) that +// embeds PacketTunnelProvider.swift. withNetworkExtensionTarget.js synthesises it — see +// plugin/README.md for what that needs from the Apple Developer portal first. + +const { + withEntitlementsPlist, + withInfoPlist, + withAndroidManifest, + AndroidConfig, + createRunOncePlugin, +} = require('@expo/config-plugins'); +const { withNetworkExtensionTarget } = require('./withNetworkExtensionTarget'); + +const DEFAULTS = { + // App Group IDs are globally unique across ALL Apple accounts, so this uses your own domain + // (group.pastilhas.*) to guarantee it's free + yours. + appGroup: 'group.pastilhas.officer.mobile', + neBundleId: 'dev.officer.mobile.net', + // Android FOREGROUND_SERVICE_SPECIAL_USE (API 34+) needs a stated purpose in the manifest. + specialUseSubtype: 'VPN mesh tunnel (Tailscale/Headscale) for OffTail', + // The iOS Network Extension entitlement + App Group require Apple provisioning (a registered App + // Group, an NE App ID, the Network Extensions capability) that must exist BEFORE the app can sign. + // Until that's set up, adding the entitlement fails device code-signing — so it's OFF by default. + // Flip to true once provisioning is in place (see ../README.md). With it off the app still signs and + // installs, the VPN screen renders, and the engine is inert. + enableNetworkExtension: false, +}; + +// ── iOS: main-app entitlements ────────────────────────────────────────────── +// The main app needs the Network Extensions capability (packet-tunnel-provider) to create/drive the +// NETunnelProviderManager. NO App Group: config reaches the NE via the manager's providerConfiguration +// and status comes back via handleAppMessage — so there's no App-Group entitlement to provision (which +// -allowProvisioningUpdates won't auto-assign anyway, especially on Xcode-managed App IDs). +function withIosEntitlements(config) { + return withEntitlementsPlist(config, (cfg) => { + cfg.modResults['com.apple.developer.networking.networkextension'] = ['packet-tunnel-provider']; + return cfg; + }); +} + +// Record the NE bundle id in the main app's Info.plist. This is LOAD-BEARING, not a breadcrumb: the +// Swift module reads it to fill providerBundleIdentifier, which is how iOS knows which extension to +// launch. It used to be described as "purely informational" while the module used a hardcoded +// dev.officer.mobile.net — so every host but Officer pointed at an extension it does not ship, and +// the tunnel silently never started. Keep this in step with each app's `neBundleId` prop. +function withIosInfo(config, props) { + return withInfoPlist(config, (cfg) => { + cfg.modResults.OfficerNetExtensionBundleId = props.neBundleId; + return cfg; + }); + // TODO: create the Network Extension target here (or as a separate withXcodeProject mod): + // - add a new PBXNativeTarget of productType com.apple.product-type.app-extension + // - embed PacketTunnelProvider.swift + NetworkExtension.framework + // - set its bundle id = props.neBundleId, its own entitlements (networkextension + + // the same App Group), and an Info.plist with NSExtension → + // NSExtensionPointIdentifier = com.apple.networkextension.packet-tunnel + // NSExtensionPrincipalClass = $(PRODUCT_MODULE_NAME).PacketTunnelProvider + // - add it to the app's "Embed App Extensions" build phase + // This is intentionally NOT automated yet — see plugin/README.md for the manual Xcode steps. +} + +// ── Android: manifest ─────────────────────────────────────────────────────── +// Adds the foreground-service permissions and registers OfficerVpnService with the BIND_VPN_SERVICE +// permission + the android.net.VpnService intent-filter (what makes it a system VPN provider). +function withAndroidVpn(config, props) { + config = AndroidConfig.Permissions.withPermissions(config, [ + 'android.permission.FOREGROUND_SERVICE', + 'android.permission.FOREGROUND_SERVICE_SPECIAL_USE', + // Reconnect after a reboot if the user had the tunnel on. See BootReceiver. + 'android.permission.RECEIVE_BOOT_COMPLETED', + ]); + + return withAndroidManifest(config, (cfg) => { + const app = AndroidConfig.Manifest.getMainApplicationOrThrow(cfg.modResults); + app.service = app.service || []; + + const name = 'expo.modules.officernet.OfficerVpnService'; + if (!app.service.some((s) => s.$?.['android:name'] === name)) { + app.service.push({ + $: { + 'android:name': name, + 'android:permission': 'android.permission.BIND_VPN_SERVICE', + 'android:foregroundServiceType': 'specialUse', + 'android:exported': 'false', + }, + 'intent-filter': [{ action: [{ $: { 'android:name': 'android.net.VpnService' } }] }], + // API 34+ requires declaring the special-use subtype/justification. + property: [ + { + $: { + 'android:name': 'android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE', + 'android:value': props.specialUseSubtype, + }, + }, + ], + }); + } + // Boot receiver: brings the tunnel back after a restart, but only when the user left it on. + // exported=true is required — the sender is the system, not us. + app.receiver = app.receiver || []; + const receiver = 'expo.modules.officernet.BootReceiver'; + if (!app.receiver.some((r) => r.$?.['android:name'] === receiver)) { + app.receiver.push({ + $: { 'android:name': receiver, 'android:exported': 'true', 'android:enabled': 'true' }, + 'intent-filter': [ + { + action: [{ $: { 'android:name': 'android.intent.action.BOOT_COMPLETED' } }], + }, + ], + }); + } + return cfg; + }); +} + +const withOfficerNet = (config, userProps = {}) => { + const props = { ...DEFAULTS, ...userProps }; + // The NE entitlement + App Group + the NE target only go on once Apple provisioning exists — otherwise + // device signing fails. Gated so the inert-shell builds stay signable. + if (props.enableNetworkExtension) { + config = withIosEntitlements(config, props); + config = withNetworkExtensionTarget(config, props); + } + config = withIosInfo(config, props); + config = withAndroidVpn(config, props); + return config; +}; + +const pkg = require('../package.json'); +module.exports = createRunOncePlugin(withOfficerNet, pkg.name, pkg.version); diff --git a/modules/officer-net/plugin/withNetworkExtensionTarget.js b/modules/officer-net/plugin/withNetworkExtensionTarget.js new file mode 100644 index 0000000..2ae62f7 --- /dev/null +++ b/modules/officer-net/plugin/withNetworkExtensionTarget.js @@ -0,0 +1,163 @@ +// Adds the iOS Network Extension (Packet Tunnel Provider) target to the app's Xcode project at prebuild +// time, so it survives `expo prebuild` (a hand-added target would be wiped). Mirrors the proven pattern +// expo-share-intent uses to add its share-extension target (addPbxGroup → addTarget "app_extension" → +// addBuildPhase → build settings), plus the two things a VPN NE needs that a share extension doesn't: +// the packet-tunnel Info.plist + entitlements, and linking the vendored TailCore.xcframework (the +// gomobile Tailscale core) into the NE target — the core runs in the NE process. +// +// Only runs when props.enableNetworkExtension is true (needs Apple provisioning first; see README). + +const { withXcodeProject, withDangerousMod } = require('@expo/config-plugins'); +const fs = require('fs'); +const path = require('path'); + +const EXT_NAME = 'OffTailTunnel'; // Xcode target + group name (bundle id is set separately) + +// The NE's Info.plist: declares it a packet-tunnel provider whose principal class is our PacketTunnelProvider. +const infoPlist = () => ` + + + + CFBundleName$(PRODUCT_NAME) + CFBundleDisplayNameOffTail Tunnel + CFBundleExecutable$(EXECUTABLE_NAME) + CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion6.0 + CFBundlePackageTypeXPC! + CFBundleShortVersionString$(MARKETING_VERSION) + CFBundleVersion$(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifiercom.apple.networkextension.packet-tunnel + NSExtensionPrincipalClass$(PRODUCT_MODULE_NAME).PacketTunnelProvider + + + +`; + +// The NE's entitlements: just the packet-tunnel capability. No App Group — the app↔NE channel is +// providerConfiguration (config in) + handleAppMessage (status out), so nothing to provision. +const entitlements = () => ` + + + + com.apple.developer.networking.networkextension + packet-tunnel-provider + + +`; + +// Pull DEVELOPMENT_TEAM from the main-app target (same helper expo-share-intent uses). +function mainAppDevTeam(pbx) { + const configs = pbx.pbxXCBuildConfigurationSection(); + for (const key in configs) { + const bs = configs[key].buildSettings; + if (!bs || !bs.PRODUCT_NAME) continue; + const name = bs.PRODUCT_NAME.replace(/"/g, ''); + if (name.includes('Extension') || name.includes('Widget') || name.includes('Tunnel')) continue; + if (bs.DEVELOPMENT_TEAM) return bs.DEVELOPMENT_TEAM.replace(/"/g, ''); + } + return null; +} + +// Stage 1 (dangerous mod): write the NE's own files (provider source, Info.plist, entitlements) into +// ios// before the Xcode-project mod references them. +function withNeFiles(config, props) { + return withDangerousMod(config, [ + 'ios', + (cfg) => { + const iosRoot = cfg.modRequest.platformProjectRoot; + const extDir = path.join(iosRoot, EXT_NAME); + fs.mkdirSync(extDir, { recursive: true }); + // Copy our authoritative provider source out of the package into the target folder. + const src = path.join(__dirname, '..', 'ios', 'PacketTunnelProvider.swift'); + fs.copyFileSync(src, path.join(extDir, 'PacketTunnelProvider.swift')); + fs.writeFileSync(path.join(extDir, `${EXT_NAME}-Info.plist`), infoPlist()); + fs.writeFileSync(path.join(extDir, `${EXT_NAME}.entitlements`), entitlements()); + return cfg; + }, + ]); +} + +// Stage 2 (Xcode project): create the target, its group + build phases, link TailCore, set build settings. +function withNeTarget(config, props) { + return withXcodeProject(config, (cfg) => { + const proj = cfg.modResults; + if (proj.pbxTargetByName(EXT_NAME)) { + console.log(`[officer-net] ${EXT_NAME} target already exists — skipping.`); + return cfg; + } + + const sourceFiles = ['PacketTunnelProvider.swift']; + const configFiles = [`${EXT_NAME}-Info.plist`, `${EXT_NAME}.entitlements`]; + + // Group holding the NE files, hung off the project root group. + const group = proj.addPbxGroup([...sourceFiles, ...configFiles], EXT_NAME, EXT_NAME); + const groups = proj.hash.project.objects.PBXGroup; + Object.keys(groups).forEach((key) => { + if (typeof groups[key] === 'object' && groups[key].name === undefined && groups[key].path === undefined) { + proj.addToPbxGroup(group.uuid, key); + } + }); + + // addTarget bug workaround (single-target projects lack these sections). + const objects = proj.hash.project.objects; + objects.PBXTargetDependency = objects.PBXTargetDependency || {}; + objects.PBXContainerItemProxy = objects.PBXContainerItemProxy || {}; + + const target = proj.addTarget(EXT_NAME, 'app_extension', EXT_NAME); + proj.addBuildPhase(sourceFiles, 'PBXSourcesBuildPhase', 'Sources', target.uuid); + proj.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', target.uuid); + proj.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', target.uuid); + + // Link the vendored gomobile Tailscale core (TailCore.xcframework) into the NE. The `xcode` lib has + // no .xcframework extension mapping, so we MUST force lastKnownFileType='wrapper.xcframework' (else it + // types it 'unknown' and Xcode never resolves the per-slice module → "no such module 'TailCore'"). + // Path is relative to SOURCE_ROOT (the ios/ project dir) and includes a dirname so the search path + // is registered. Located in the package's libs/ (../../../ from apps/*/ios up to packages/). + proj.addFramework('../../../packages/officer-net/ios/libs/TailCore.xcframework', { + target: target.uuid, + customFramework: true, + lastKnownFileType: 'wrapper.xcframework', + sourceTree: 'SOURCE_ROOT', + embed: false, + link: true, + sign: false, + }); + + const devTeam = mainAppDevTeam(proj); + // Framework search path → the package's libs dir (from ios/: up to the monorepo, into packages). + const fwSearch = '"$(PROJECT_DIR)/../../../packages/officer-net/ios/libs"'; + + const configs = proj.pbxXCBuildConfigurationSection(); + for (const key in configs) { + const bs = configs[key].buildSettings; + if (!bs || bs.PRODUCT_NAME !== `"${EXT_NAME}"`) continue; + bs.INFOPLIST_FILE = `"${EXT_NAME}/${EXT_NAME}-Info.plist"`; + bs.CODE_SIGN_ENTITLEMENTS = `"${EXT_NAME}/${EXT_NAME}.entitlements"`; + bs.CODE_SIGN_STYLE = 'Automatic'; + bs.PRODUCT_BUNDLE_IDENTIFIER = `"${props.neBundleId}"`; + bs.GENERATE_INFOPLIST_FILE = 'NO'; + bs.CLANG_ENABLE_MODULES = 'YES'; + bs.SWIFT_VERSION = '5.0'; + bs.SWIFT_EMIT_LOC_STRINGS = 'YES'; + bs.TARGETED_DEVICE_FAMILY = '"1,2"'; + bs.IPHONEOS_DEPLOYMENT_TARGET = '16.4'; + bs.ENABLE_BITCODE = 'NO'; + bs.MARKETING_VERSION = `"${config.version || '1.0.0'}"`; + bs.CURRENT_PROJECT_VERSION = `"${config.ios?.buildNumber || '1'}"`; + bs.FRAMEWORK_SEARCH_PATHS = ['"$(inherited)"', fwSearch]; + if (devTeam) bs.DEVELOPMENT_TEAM = devTeam; + } + + if (devTeam) { + proj.addTargetAttribute('DevelopmentTeam', devTeam); + proj.addTargetAttribute('DevelopmentTeam', devTeam, proj.pbxTargetByName(EXT_NAME)); + } + + console.log(`[officer-net] Added Network Extension target ${EXT_NAME} (${props.neBundleId}).`); + return cfg; + }); +} + +module.exports = { withNetworkExtensionTarget: (config, props) => withNeTarget(withNeFiles(config, props), props), EXT_NAME }; diff --git a/modules/officer-net/scripts/build-libtailscale-android.sh b/modules/officer-net/scripts/build-libtailscale-android.sh new file mode 100755 index 0000000..a55bb09 --- /dev/null +++ b/modules/officer-net/scripts/build-libtailscale-android.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Build the OffTail Tailscale core into an Android .aar via gomobile, from the Go shim in ../gocore. +# Output: modules/officer-net/android/libs/tailcore.aar (gitignored; consumed by android/build.gradle). +# Requires: Go + gomobile + gobind on PATH, Android SDK + NDK. Proven pair with the iOS build (v1.102.0). +set -euo pipefail +TAILSCALE_REF="${TAILSCALE_REF:-v1.102.0}" +export ANDROID_HOME="${ANDROID_HOME:-/opt/homebrew/share/android-commandlinetools}" +export ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-$ANDROID_HOME/ndk/26.3.11579264}" +export PATH="$(go env GOPATH)/bin:$PATH" +HERE="$(cd "$(dirname "$0")/.." && pwd)" +GOCORE="$HERE/gocore"; OUT="$HERE/android/libs/tailcore.aar" +cd "$GOCORE" +[ -f go.mod ] || go mod init officer.dev/tailcore +go get "tailscale.com@${TAILSCALE_REF}"; go get -tool golang.org/x/mobile/cmd/gobind; go get golang.org/x/mobile/bind +mkdir -p "$HERE/android/libs" +echo "→ gomobile bind -target=android/arm64 (compiles the core for Android)" +gomobile bind -target=android/arm64 -androidapi 24 -o "$OUT" ./ +[ -f "$OUT" ] || { echo "✗ no aar produced" >&2; exit 1; } +# Gradle does NOT consume the .aar — build.gradle has `api files('libs/tailcore.jar')`, and the native +# code ships from src/main/jniLibs. Producing the .aar alone therefore has NO EFFECT on the app, and +# the failure is silent: everything builds, installs and runs the PREVIOUS core. That cost two hours +# on 2026-08-02, with a jar three days older than the .aar beside it. +echo "→ refreshing libs/tailcore.jar + jniLibs/libgojni.so from the .aar (what Gradle actually uses)" +LIBS="$HERE/android/libs" +JNI="$HERE/android/src/main/jniLibs/arm64-v8a" +mkdir -p "$JNI" +unzip -o -q "$OUT" classes.jar -d "$LIBS" && mv -f "$LIBS/classes.jar" "$LIBS/tailcore.jar" +unzip -o -q -j "$OUT" "jni/arm64-v8a/libgojni.so" -d "$JNI" +echo "✔ built $OUT"; ls -la "$OUT" "$LIBS/tailcore.jar" "$JNI/libgojni.so" diff --git a/modules/officer-net/scripts/build-libtailscale-ios.sh b/modules/officer-net/scripts/build-libtailscale-ios.sh new file mode 100755 index 0000000..9c7ec29 --- /dev/null +++ b/modules/officer-net/scripts/build-libtailscale-ios.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Build the OffTail Tailscale core into an iOS .xcframework via gomobile, from the Go shim in ../gocore. +# Output: modules/officer-net/ios/libs/TailCore.xcframework (gitignored; the podspec vendors it when present). +# +# Unlike bw-vault (which fetches a prebuilt Bitwarden release asset), Tailscale publishes no third-party +# gomobile framework — WE produce it once with `gomobile bind`, then vendor it. PROVEN working on +# Go 1.26 + Xcode 26: binds tailscale.com (tsnet/ipn/wgengine/magicsock/netstack) to arm64 + sim. +# +# Requires: Go + gomobile + gobind on PATH: +# go install golang.org/x/mobile/cmd/gomobile@latest +# go install golang.org/x/mobile/cmd/gobind@latest +# +# Keep TAILSCALE_REF paired with the Headscale version you run — Headscale tracks a specific Tailscale +# client protocol version, and a core built far ahead of it will fail to register. +set -euo pipefail + +TAILSCALE_REF="${TAILSCALE_REF:-v1.102.0}" + +HERE="$(cd "$(dirname "$0")/.." && pwd)" # modules/officer-net +GOCORE="$HERE/gocore" +OUT="$HERE/ios/libs/TailCore.xcframework" + +export PATH="$(go env GOPATH)/bin:$PATH" +command -v gomobile >/dev/null 2>&1 || { echo "✗ gomobile not on PATH — go install golang.org/x/mobile/cmd/gomobile@latest"; exit 1; } + +cd "$GOCORE" +[ -f go.mod ] || go mod init officer.dev/tailcore +echo "→ pinning tailscale.com@${TAILSCALE_REF}" +go get "tailscale.com@${TAILSCALE_REF}" +# gomobile (Go 1.26) needs x/mobile as a tool dep AND bind imported, or `go mod tidy` prunes it and the +# bind fails with "missing golang.org/x/mobile dependency". +go get -tool golang.org/x/mobile/cmd/gobind +go get golang.org/x/mobile/bind + +echo "→ gomobile bind -target=ios (compiles the whole Tailscale core; a few minutes)" +mkdir -p "$HERE/ios/libs" +rm -rf "$OUT" +gomobile bind -target=ios -o "$OUT" ./ + +[ -d "$OUT" ] || { echo "✗ bind produced no framework" >&2; exit 1; } +echo "✔ built $OUT"; du -sh "$OUT" diff --git a/nativewind-env.d.ts b/nativewind-env.d.ts new file mode 100644 index 0000000..1625edf --- /dev/null +++ b/nativewind-env.d.ts @@ -0,0 +1,3 @@ +/// + +declare module '*.css'; diff --git a/package.json b/package.json new file mode 100644 index 0000000..75ddeda --- /dev/null +++ b/package.json @@ -0,0 +1,37 @@ +{ + "name": "offscale-mobile", + "version": "1.0.0", + "license": "MIT", + "private": true, + "main": "src/index.tsx", + "scripts": { + "start": "expo start", + "ios": "expo run:ios", + "android": "expo run:android", + "prebuild": "./scripts/gen-adaptive-icon.sh && expo prebuild", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@expo/vector-icons": "15.1.1", + "@react-native-async-storage/async-storage": "2.2.0", + "expo": "57.0.8", + "expo-clipboard": "57.0.1", + "expo-dev-client": "57.0.9", + "expo-modules-core": "57.0.7", + "expo-status-bar": "57.0.1", + "expo-system-ui": "57.0.1", + "nativewind": "4.2.6", + "officer-net": "file:./modules/officer-net", + "react": "19.2.3", + "react-native": "0.86.0", + "react-native-safe-area-context": "5.7.0", + "react-native-svg": "15.15.4" + }, + "devDependencies": { + "@babel/core": "7.29.7", + "@types/react": "19.2.17", + "babel-preset-expo": "57.0.4", + "tailwindcss": "3.4.19", + "typescript": "6.0.3" + } +} diff --git a/scripts/build-android.sh b/scripts/build-android.sh new file mode 100755 index 0000000..8cf1345 --- /dev/null +++ b/scripts/build-android.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# +# Clean-build the release APK and install it on the attached device. +# +# scripts/build-android.sh # build + install +# ABIS=arm64-v8a,x86_64 scripts/… # add an emulator ABI +# MINIFY=1 scripts/… # turn R8 on (see the note below — not the default) +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LOGS="${TMPDIR:-/tmp}offscale-build" +mkdir -p "$LOGS" +log="$LOGS/android.log" +: >"$log" +cd "$ROOT" || exit 1 + +# The vendored Tailscale core is built by hand, per platform, and forgetting it fails much later as a +# missing symbol rather than a missing rebuild — Android says "Unresolved reference 'loginTo'" at +# Kotlin compile time. Do it here instead of relying on anyone remembering. +. "$ROOT/scripts/ensure-core.sh" android + +echo "logs: $log" + +# `bun run prebuild`, NOT `bunx expo prebuild`: the package.json script runs gen-adaptive-icon.sh +# first, and calling expo directly silently skips it — which ships an icon the Android launcher mask +# crops. Trailing args append to that script, and `expo prebuild` is its last command. +if ! bun run prebuild --platform android --clean --no-install >>"$log" 2>&1; then + echo "PREBUILD FAILED — $log"; exit 1 +fi + +# Build for phones only. +# +# Expo's generated gradle.properties asks for `armeabi-v7a,arm64-v8a,x86,x86_64`, and the native +# libraries are most of an APK — for an app this shape, roughly 65 MB of it is lib/x86 and lib/x86_64, +# Intel builds for emulators that no phone will ever execute, and another 21 MB is armeabi-v7a for +# 32-bit devices. The Tailscale core is a large .so and gets built once per ABI, so this matters more +# here than in an app without one. +# +# Overridden here rather than in gradle.properties because `expo prebuild --clean` on the line above +# regenerates that file every single build, so an edit there survives exactly nothing. +# +# ABIS=… to override — an emulator needs x86_64 and will refuse an APK without it. +ABIS="${ABIS:-arm64-v8a}" + +# MINIFY=1 turns R8 on. OFF by default, and that default is the honest one. +# +# R8 deletes every class nothing reaches from an entry point. It decides "nothing reaches this" by +# reading the code — so anything reached by REFLECTION looks dead and gets deleted. React Native and +# Expo both do that, and so does the gomobile binding layer. The build still succeeds; the app crashes +# on the device. This cannot be verified by looking at a file size, and it must not become the default +# until a build with it has been opened on a real phone, connected to a tailnet, and used. +MINIFY_ARGS=() +if [[ "${MINIFY:-0}" == "1" ]]; then + MINIFY_ARGS=(-Pandroid.enableMinifyInReleaseBuilds=true) +fi + +# assembleRelease, always. A debug build needs Metro attached and behaves differently from what ships, +# so testing it answers the wrong question. +if ! (cd android && ./gradlew :app:assembleRelease -PreactNativeArchitectures="$ABIS" \ + "${MINIFY_ARGS[@]}" --console=plain) >>"$log" 2>&1; then + echo "GRADLE FAILED — $log"; exit 1 +fi + +apk="$(find android/app/build/outputs/apk/release -name '*.apk' | head -1)" +[[ -z "$apk" ]] && { echo "NO APK"; exit 1; } + +if ! adb install -r "$apk" >>"$log" 2>&1; then + # Almost always the cable, not the build. The APK is on disk either way: rerun just the install + # rather than the whole thing. + echo "INSTALL FAILED (device attached?) — apk at $apk"; exit 1 +fi +echo "OK" diff --git a/scripts/build-ios.sh b/scripts/build-ios.sh new file mode 100755 index 0000000..329a7ec --- /dev/null +++ b/scripts/build-ios.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# +# Clean-build the Release .app and install it on the cabled iPhone. +# +# scripts/build-ios.sh # build + install +# DEVICE= scripts/build-ios.sh # pick a specific device +# NO_INSTALL=1 scripts/build-ios.sh # build only; leave the phone alone +# +# NO_INSTALL exists because installing restarts the app, and a long-running pass on the phone dies +# with it. The .app path is printed so it can be installed by hand: +# xcrun devicectl device install app --device +# +# xcodebuild + devicectl rather than `expo run:ios`: the expo installer has hung on this setup, and +# this way the build and the install fail separately and legibly. +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LOGS="${TMPDIR:-/tmp}offscale-build" +mkdir -p "$LOGS" +log="$LOGS/ios.log" +: >"$log" +cd "$ROOT" || exit 1 + +# The vendored Tailscale core is built by hand, per platform, and forgetting it fails much later as a +# missing symbol rather than a missing rebuild — iOS says "cannot find 'TailcoreListProfiles' in +# scope", long after the Go compiled cleanly. Do it here instead of relying on anyone remembering. +. "$ROOT/scripts/ensure-core.sh" ios + +# The iPhone to install on, unless one is named explicitly. Matched by UDID shape rather than column +# position: the Model column has a variable number of words, so counting fields from either end picks +# up part of the model name instead. +# +# Three things this has to get right, all learned the hard way: +# +# - devicectl says "connected" for a cabled device on some Xcode versions and "available (paired)" +# on others. Both are installable. Matching only "connected" exits with "No connected iPhone" +# while a cabled phone sits there working fine. +# - It must be an iPhone. A paired iPad is "available" too and sorts first, so widening the state +# match alone silently retargets the whole build at the iPad. +# - "unavailable" CONTAINS "available", so a bare /available/ matches a phone that is paired but not +# plugged in. Picking one does not fail here — it fails fifteen lines into xcodebuild with "Unable +# to find a destination matching the provided destination specifier", which reads like a broken +# project rather than an unplugged cable. +# +# With more than one iPhone paired there is no correct guess, so it refuses and lists them. +if [[ -z "${DEVICE:-}" ]]; then + cands="$(xcrun devicectl list devices 2>/dev/null | awk ' + /\(iPhone[0-9]/ && (/connected/ || /(^|[^n])available/) { + for (i = 1; i <= NF; i++) if ($i ~ /^[0-9A-F]{8}-[0-9A-F]{4}-/) { print $i; break } + }')" + n="$(printf '%s' "$cands" | grep -c . || true)" + if [[ "$n" -eq 1 ]]; then + DEVICE="$cands" + elif [[ "$n" -gt 1 ]]; then + echo "!! More than one iPhone is paired — refusing to guess. Pass DEVICE=:" >&2 + xcrun devicectl list devices 2>/dev/null | awk '/\(iPhone[0-9]/ && (/connected/ || /(^|[^n])available/)' >&2 + exit 1 + fi +fi + +# No device is not fatal: `generic/platform=iOS` builds an unsigned-to-no-particular-device arm64 +# .app, which is what a build server wants. Passing `id=` instead fails deep inside +# xcodebuild with a destination error that reads like a broken project. +if [[ -z "${DEVICE:-}" ]]; then + echo "device: none — building for generic iOS, no install" + destination="generic/platform=iOS" + NO_INSTALL=1 +else + echo "device: $DEVICE" + destination="id=$DEVICE" +fi +echo "logs: $log" + +# `bun run prebuild`, NOT `bunx expo prebuild`: the package.json script runs gen-adaptive-icon.sh +# first, and calling expo directly silently skips it — which ships an icon the Android launcher mask +# crops. Trailing args append to that script, and `expo prebuild` is its last command. +if ! bun run prebuild --platform ios --clean --no-install >>"$log" 2>&1; then + echo "PREBUILD FAILED — $log"; exit 1 +fi +if ! (cd ios && pod install) >>"$log" 2>&1; then + echo "POD INSTALL FAILED — $log"; exit 1 +fi + +ws="$(find ios -maxdepth 1 -name '*.xcworkspace' | head -1)" +scheme="$(basename "$ws" .xcworkspace)" +derived="$LOGS/derived" + +# A SwiftPM binary artifact that was extracted by an interrupted build stays half-unpacked, and +# xcodebuild then fails with "There is no Info.plist found at .../Some.xcframework". It reads like a +# corrupt download and is not — the zip in ~/Library/Caches/org.swift.swiftpm is fine; only this +# extraction of it is short. Any .xcframework missing its Info.plist means the whole SourcePackages +# tree is suspect, so it goes and is re-extracted from the (good) cache. Cheap: no re-download. +if [[ -d "$derived/SourcePackages/artifacts" ]]; then + while IFS= read -r fw; do + [[ -f "$fw/Info.plist" ]] && continue + echo "half-extracted $(basename "$fw") — clearing SourcePackages" + rm -rf "$derived/SourcePackages" + break + done < <(find "$derived/SourcePackages/artifacts" -maxdepth 4 -name '*.xcframework' -type d 2>/dev/null) +fi + +# Wipe old products first. A rename leaves the PREVIOUS bundle behind in the same derivedDataPath, +# and picking "a" .app then installs the stale one. +rm -rf "$derived/Build/Products" + +# Release, always. Bare Debug needs Metro attached and behaves differently from what ships, so +# testing it answers the wrong question. +# +# DEVELOPMENT_TEAM is also in app.json as ios.appleTeamId, so prebuild writes it into the pbxproj; +# passed here too so the script still works against an ios/ dir that predates that. +# +# A Network Extension needs its own provisioning profile, and -allowProvisioningUpdates cannot create +# one while Xcode has no account signed in — it falls back to the wildcard team profile and fails with +# "Provisioning profile ... doesn't include the Network Extensions capability". Fix is one interactive +# step: Xcode -> Settings -> Accounts -> sign in to the team, then rebuild. +if ! xcodebuild -workspace "$ws" -scheme "$scheme" -configuration Release \ + -destination "$destination" -derivedDataPath "$derived" \ + DEVELOPMENT_TEAM="${DEVELOPMENT_TEAM:-RGHC4NJC77}" CODE_SIGN_STYLE=Automatic \ + -allowProvisioningUpdates build >>"$log" 2>&1; then + echo "XCODEBUILD FAILED — $log"; exit 1 +fi + +# Newest by mtime, belt-and-braces with the wipe above. +appPath="$(find "$derived/Build/Products" -maxdepth 2 -name '*.app' -print0 2>/dev/null \ + | xargs -0 stat -f '%m %N' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-)" +[[ -z "$appPath" ]] && { echo "NO .app"; exit 1; } + +if [[ -n "${NO_INSTALL:-}" ]]; then + echo "BUILT, not installed — $appPath"; exit 0 +fi + +# Reinstalling invalidates the saved VPN configuration: iOS hands it back from loadAllFromPreferences, +# reports "unchanged" on save, then refuses to start it. The app removes and rewrites it on failure, +# which is self-healing — but it is why the first tunnel start after an install can take two attempts. +if ! xcrun devicectl device install app --device "$DEVICE" "$appPath" >>"$log" 2>&1; then + echo "INSTALL FAILED (device attached?) — app at $appPath"; exit 1 +fi +echo "OK ($scheme)" diff --git a/scripts/ensure-core.sh b/scripts/ensure-core.sh new file mode 100755 index 0000000..b342b08 --- /dev/null +++ b/scripts/ensure-core.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# +# Rebuild the vendored Tailscale core when the Go source is newer than the built artefact. +# +# . scripts/ensure-core.sh ios # or: android +# +# WHY THIS EXISTS +# +# Changing modules/officer-net/gocore requires rebuilding the core by hand, per platform, and nothing +# enforces it. Forgetting is silent until link or compile time, and the error names a symbol rather +# than the cause: +# +# Android e: Unresolved reference 'loginTo' +# iOS error: cannot find 'TailcoreSubnetRoutes' in scope +# +# iOS is the nastier of the two: the Go compiles cleanly and the failure arrives minutes later, in +# Swift, against a vendored xcframework whose headers are simply older than the source beside them. +# +# It compares mtimes rather than hashes: gomobile takes minutes, and a spurious rebuild is far cheaper +# than a stale one. The artefact is touched on success so an unchanged tree does not rebuild twice. +set -uo pipefail + +_ec_platform="${1:?usage: ensure-core.sh }" +_ec_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +_ec_gocore="$_ec_root/modules/officer-net/gocore" + +case "$_ec_platform" in + ios) + _ec_artifact="$_ec_root/modules/officer-net/ios/libs/TailCore.xcframework" + _ec_script="$_ec_root/modules/officer-net/scripts/build-libtailscale-ios.sh" + ;; + android) + _ec_artifact="$_ec_root/modules/officer-net/android/libs/tailcore.aar" + _ec_script="$_ec_root/modules/officer-net/scripts/build-libtailscale-android.sh" + ;; + *) + echo "!! ensure-core: unknown platform '$_ec_platform'" >&2 + return 1 2>/dev/null || exit 1 + ;; +esac + +# Newest input: any Go source, plus the module files that pin tailscale.com itself. +_ec_newest_src="$(find "$_ec_gocore" -type f \( -name '*.go' -o -name 'go.mod' -o -name 'go.sum' \) \ + -exec stat -f '%m' {} + 2>/dev/null | sort -rn | head -1)" + +if [[ -e "$_ec_artifact" ]]; then + # The xcframework is a directory, and its own mtime is what this script touches. + _ec_artifact_mtime="$(stat -f '%m' "$_ec_artifact" 2>/dev/null || echo 0)" +else + _ec_artifact_mtime=0 +fi + +if [[ -z "${_ec_newest_src:-}" ]]; then + echo "ensure-core: no Go sources found — skipping (is gocore missing?)" >&2 +elif (( _ec_newest_src > _ec_artifact_mtime )); then + if (( _ec_artifact_mtime == 0 )); then + echo "==> core artefact missing for $_ec_platform — building it (a few minutes)" + else + echo "==> gocore is newer than the built core for $_ec_platform — rebuilding (a few minutes)" + fi + # gomobile lives in ~/go/bin, which is not on PATH in every shell. + if PATH="$PATH:$HOME/go/bin" "$_ec_script"; then + touch "$_ec_artifact" 2>/dev/null || true + echo "==> core rebuilt for $_ec_platform" + else + echo "!! core rebuild FAILED for $_ec_platform — the app build would link a stale core, stopping." >&2 + return 1 2>/dev/null || exit 1 + fi +else + echo "==> core is up to date for $_ec_platform" +fi diff --git a/scripts/gen-adaptive-icon.sh b/scripts/gen-adaptive-icon.sh new file mode 100755 index 0000000..74cba0d --- /dev/null +++ b/scripts/gen-adaptive-icon.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# +# Regenerates the Android adaptive-icon foreground from the iOS icon. +# +# scripts/gen-adaptive-icon.sh # check, and generate if missing +# scripts/gen-adaptive-icon.sh --force # regenerate even if one exists but differs +# +# Android launcher masks (round, squircle, teardrop — the launcher decides) crop the icon. The art is +# a full-bleed render on a black backdrop, so a mask ate its edges. The fix is to shrink the art inside +# the same canvas and let the transparent margin absorb whatever the mask takes. +# +# Only Android needs this. iOS draws icon.png full-bleed with its own rounding, so insetting there +# would just add a black border — icon.png is never touched. +# +# Runs from the `prebuild` npm script, because `expo prebuild` is what turns these assets into +# android/app/src/main/res/*.webp. The generated PNG is also committed, so a machine without +# ImageMagick still builds a correct icon — this regenerates it, it is not the only source. That is +# also why calling `expo prebuild` directly instead of `bun run prebuild` is a mistake: it skips this +# silently. +set -euo pipefail + +# Android's adaptive-icon spec: the outer 1/3 of the foreground is reserved for mask and parallax, +# leaving a guaranteed-visible circle of ~66%. 72% sits just above that — as large as the art can be +# drawn while no supported mask clips it. +readonly INSET_PERCENT=72 + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ICON="$ROOT/assets/icon.png" +ADAPTIVE="$ROOT/assets/adaptive-icon.png" + +FORCE=false +for a in "$@"; do [[ "$a" == "--force" ]] && FORCE=true; done + +if ! command -v magick >/dev/null 2>&1; then + echo "!! ImageMagick not found — skipping adaptive-icon generation." >&2 + echo "!! The committed adaptive-icon.png is used as-is. brew install imagemagick" >&2 + exit 0 +fi + +# True when `existing` already IS `icon` inset — compared by pixel content, not by bytes and not by +# geometry. +# +# Geometry alone is not enough: checking only that the art fills ~72% of the canvas passes an adaptive +# icon built from SUPERSEDED art, so swapping in new icon.png art leaves the Android icon silently +# stale. Bytes are not enough in the other direction: re-encoding an identical image can differ, which +# would rewrite a megabyte on every prebuild. RMSE of the decoded pixels is exact here, so the +# threshold only absorbs a future encoder change. +matches_icon() { + local existing="$1" icon="$2" size="$3" art="$4" + local tmp="$existing.expected.png" + magick "$icon" -resize "${art}x${art}" -background none -gravity center \ + -extent "${size}x${size}" "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; } + + # Differing canvas sizes make compare fail outright, which is itself a mismatch. + local rmse + rmse="$(magick compare -metric RMSE "$existing" "$tmp" null: 2>&1 || true)" + rmse="${rmse%% *}" + rm -f "$tmp" + + awk -v v="$rmse" 'BEGIN { exit !(v ~ /^[0-9.]+$/ && v < 100) }' +} + +[[ -f "$ICON" ]] || { echo "!! no assets/icon.png" >&2; exit 1; } + +# Same canvas as the source; only the art inside it shrinks. Rounded, not truncated. +size="$(magick identify -format '%w' "$ICON")" +art=$(((size * INSET_PERCENT + 50) / 100)) + +if [[ -f "$ADAPTIVE" ]] && matches_icon "$ADAPTIVE" "$ICON" "$size" "$art"; then + echo "adaptive icon: ok" +elif [[ -f "$ADAPTIVE" ]] && [[ "$FORCE" != true ]]; then + # Deliberately does NOT overwrite. An existing adaptive icon that does not match icon.png is + # ambiguous: it is either stale art (regenerate) or art framed on purpose. Say so and let a human + # decide; --force applies the regeneration. + echo "adaptive icon: DIFFERS from icon.png — not overwritten (--force to regenerate)" +else + magick "$ICON" -resize "${art}x${art}" \ + -background none -gravity center -extent "${size}x${size}" "$ADAPTIVE" + echo "adaptive icon: regenerated (${size} canvas, ${art} art)" +fi + +# The adaptive background sits behind the inset art, so it must match the art's own backdrop or a seam +# shows at the edges. This only reports — app.json is easy to edit by hand and silently rewriting it +# is worse than saying what to change. +corner="#$(magick "$ICON" -format '%[hex:p{0,0}]' info: | tr '[:lower:]' '[:upper:]')" +configured="$(grep -oE 'backgroundColor.{0,4}#[0-9A-Fa-f]{6}' "$ROOT/app.json" 2>/dev/null \ + | grep -oE '#[0-9A-Fa-f]{6}' | head -1 | tr '[:lower:]' '[:upper:]' || true)" +if [[ -z "$configured" ]]; then + echo " no android.adaptiveIcon.backgroundColor set — use $corner" +elif [[ "$corner" != "$configured" ]]; then + echo " backgroundColor is $configured, art corner is $corner" +fi diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..9696472 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,66 @@ +import './global.css'; + +import { useState } from 'react'; +import { View } from 'react-native'; +import { StatusBar } from 'expo-status-bar'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; + +import { AppSplash } from './ui/AppSplash'; +import { ThemeProvider, useTheme } from './ui/useTheme'; +import { VpnScreen } from './vpn/VpnScreen'; + +const appIcon = require('../assets/icon.png'); +const wordmark = require('../assets/wordmark.png'); + +/** + * OffScale opens straight into the VPN screen. There is no sign-in, and there is nothing to sign in + * to: this app has no backend of its own and never contacts one. + * + * The only hosts it ever talks to are the control server you point it at — your own Headscale, or + * Tailscale's — and, if you tapped an invite link, the enrolment sidecar named inside that link. That + * is not an accident of configuration; it is the design. A VPN client that needed some other service + * to be reachable in order to authenticate would be a bootstrap deadlock, because the tunnel is + * frequently the thing that makes that service reachable in the first place. + * + * So: do not wire an account, a telemetry endpoint or a "check for updates" call into this shell. + */ +const Root = ({ onShowCredits }: { onShowCredits: () => void }) => { + const { palette } = useTheme(); + + return ( + + {/* Follows the theme, and that happens to be right for the wallpaper too: the status bar now + sits on the photograph rather than on palette.bg, and the two wallpapers are 0.77 and 0.12 + mean luminance across that strip — bright in light mode, dark in dusk. */} + + + + ); +}; + +export const App = () => { + const [showSplash, setShowSplash] = useState(true); // launch intro + const [showCredits, setShowCredits] = useState(false); // replayed intro (hold-until-tap) + + return ( + + {/* Dark, with no choice offered. The app is drawn on the dusk photograph — wordmark, cards and + tab bar were all picked against it — so a light mode would only ever be a worse version of + it. The Appearance control is hidden to match; a switch that changes nothing is worse than + no switch. */} + + setShowCredits(true)} /> + {showSplash ? ( + setShowSplash(false)} + /> + ) : null} + {showCredits ? setShowCredits(false)} /> : null} + + + ); +}; diff --git a/src/assets/landscape1.webp b/src/assets/landscape1.webp new file mode 100644 index 0000000..7d8744d Binary files /dev/null and b/src/assets/landscape1.webp differ diff --git a/src/assets/landscape_dusk.webp b/src/assets/landscape_dusk.webp new file mode 100644 index 0000000..21d04ae Binary files /dev/null and b/src/assets/landscape_dusk.webp differ diff --git a/src/global.css b/src/global.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/src/global.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..9b5d412 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,5 @@ +import { registerRootComponent } from 'expo'; + +import { App } from './App'; + +registerRootComponent(App); diff --git a/src/settings/SettingsPrimitives.tsx b/src/settings/SettingsPrimitives.tsx new file mode 100644 index 0000000..867f32c --- /dev/null +++ b/src/settings/SettingsPrimitives.tsx @@ -0,0 +1,67 @@ +import type { ReactNode } from 'react'; +import { View, Text, Pressable } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +import { useTheme } from '../ui/useTheme'; + +/* + The pieces a settings page is built from, kept separate from the page itself so a screen can + assemble its own block of rows out of the same parts rather than restyling one. +*/ + +/** + * A settings row: a label on the left, and on the right either a supplied control or a chevron. + * Exported so each app builds its own rows out of the same part rather than restyling one. + */ +export const SettingsRow = ({ + label, + icon, + onPress, + children, +}: { + label: string; + /** Trailing icon, used when the row is an action rather than a control. */ + icon?: keyof typeof Ionicons.glyphMap; + onPress?: () => void; + /** A control (switch, segmented picker) rendered on the right instead of an icon. */ + children?: ReactNode; +}) => { + const { palette } = useTheme(); + const rowStyle = { borderColor: palette.border, backgroundColor: palette.surface }; + + const body = ( + <> + {label} + {children ?? (icon ? : null)} + + ); + + if (!onPress) { + return ( + + {body} + + ); + } + + return ( + + {body} + + ); +}; + +/** A labelled block of rows. Stacked inside the settings Card with the same gap as everything else. */ +export const SettingsSection = ({ title, children }: { title: string; children: ReactNode }) => { + const { palette } = useTheme(); + return ( + + {title} + {children} + + ); +}; diff --git a/src/settings/SettingsScreen.tsx b/src/settings/SettingsScreen.tsx new file mode 100644 index 0000000..82802e5 --- /dev/null +++ b/src/settings/SettingsScreen.tsx @@ -0,0 +1,185 @@ +import type { ReactNode } from 'react'; +import type { ImageSourcePropType } from 'react-native'; +import { View, Text, Pressable, ScrollView, Image } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { useTheme } from '../ui/useTheme'; +import { Card } from '../ui/Card'; +import { Wallpaper } from '../ui/AppBackdrop'; +import { useUIScale, scaled } from '../ui/useUIScale'; +import { WORDMARK_ASPECT } from '../ui/AppSplash'; +import { SettingsRow, SettingsSection } from './SettingsPrimitives'; + +// Re-exported so a screen building its own rows imports the parts from the same place it imports +// the page, rather than reaching one directory deeper for each. +export { SettingsRow, SettingsSection } from './SettingsPrimitives'; + +export type SettingsScreenProps = { + /** Shown in the header when no wordmark is supplied, and the wordmark's accessibility label. */ + title: string; + onClose: () => void; + /** + * Replays the launch splash. Comes from the app shell because that is what owns the icon and + * wordmark art. + */ + onShowCredits?: () => void; + /** Standalone owns the top inset; embedded in a host the container has already paid it. */ + embedded?: boolean; + /** This app's own settings, rendered above the About block. */ + children?: ReactNode; + /** + * The end-of-list action, inside About. + * + * There is no default. In an app with an account this is where "Sign out" goes; OffScale has no + * account — deliberately, it never talks to any server but the control plane you point it at — so + * the caller passes its own, and "Forget all connections" is what it passes. + */ + footer?: ReactNode; + /** + * Draw the suite wallpaper behind the page instead of a flat `palette.bg`. + * + * The whole app floats on the landscape, so a settings page painted flat would be the one screen + * that steps out of it. + */ + wallpaper?: boolean; + /** Permanent strip below the content, matching the app's frame. Same reasoning as AppBackdrop's. */ + bottomStrip?: ReactNode; + /** + * Voxel wordmark for the header, in place of the title text. + * + * Passed in rather than required here: the art belongs to the app shell. `wordmarkLight` is the + * dark-lettered cut — the same rule as AppBackdrop, since this header sits on a translucent surface + * over the same photograph, and white-"Off" is gone against the day one. + */ + wordmark?: ImageSourcePropType; + wordmarkLight?: ImageSourcePropType; +}; + +/** + * The settings page behind the top-right gear. + * + * ## What this deliberately does NOT have + * + * No server list, no sign-in, no sign-out, and no account of any kind. OffScale is a Tailscale / + * Headscale client and nothing else: the only host it ever contacts is the control server you give + * it, plus the invite sidecar named in an invite link you tapped. A settings page offering to switch + * between accounts would be offering to switch between things this app never talks to. + * + * No Appearance control either. The app is pinned to dark (`ThemeProvider lockedMode`) because it is + * drawn on the dusk photograph — the wordmark, cards and tab bar were all picked against it — so the + * switch would still render, still write a preference, and change nothing. A control that does + * nothing is worse than an absent one: the user concludes the app is broken rather than that the + * option does not exist. + * + * No tabs. The tabbed variant exists for pages with several named sections to choose between; this + * one has the app's own section and About, and a row of two pills is chrome rather than navigation. + */ +export const SettingsScreen = ({ + title, + onClose, + onShowCredits, + embedded, + children, + footer, + wallpaper = false, + bottomStrip, + wordmark, + wordmarkLight, +}: SettingsScreenProps) => { + const { palette } = useTheme(); + const insets = useSafeAreaInsets(); + // Same choice AppBackdrop makes, and for the same reason: the backing is the photograph, not the palette. + const mark = palette.mode === 'dark' ? wordmark : (wordmarkLight ?? wordmark); + const ui = useUIScale(); + const markHeight = scaled(26, ui.scale); + + const body = ( + + {/* + Deliberately identical to AppBackdrop's header — same padding, same wordmark height, no bar + behind it. + + Settings opens over the app rather than beside it, so any difference in this row reads as the + page JUMPING: a logo two points shorter, or sitting on a bordered surface where the frame has + bare wallpaper, is two small mismatches and one visible shift. + + The inset is paid HERE rather than by the container, again matching the frame: paying it + outside pushes the whole page down and leaves the header a different height from the one it + replaces. + */} + + {mark ? ( + + ) : ( + {title} + )} + + {/* Filled, like the frame's own controls: a bare glyph on a photograph disappears wherever the + photograph is bright, and this one has to be findable to leave the page at all. */} + + + + + + + + + + {/* gap-7 between sections against gap-2 within one: the ratio is what makes a section read + as a group rather than as a run of evenly spaced rows. */} + + {children} + {/* About last, because it is the way out of the page. */} + + {onShowCredits ? ( + + ) : null} + {footer} + + + + + + {/* Settings is a full screen of its own rather than something drawn inside AppBackdrop, so it + inherits nothing and the host has to pass its strip again. A credit that disappears on one + screen is not "always visible". */} + {bottomStrip} + + ); + + return wallpaper ? {body} : body; +}; + +/** The top-right gear the main screen carries. */ +export const SettingsButton = ({ onPress }: { onPress: () => void }) => { + const { palette } = useTheme(); + return ( + + + + ); +}; diff --git a/src/ui/AppBackdrop.tsx b/src/ui/AppBackdrop.tsx new file mode 100644 index 0000000..6b4f94b --- /dev/null +++ b/src/ui/AppBackdrop.tsx @@ -0,0 +1,270 @@ +import { Ionicons } from '@expo/vector-icons'; +import type { ReactNode } from 'react'; +import type { ImageSourcePropType } from 'react-native'; +import { View, Text, Image, ImageBackground, Linking, Pressable } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { useTheme } from './useTheme'; +import { useUIScale, scaled } from './useUIScale'; +import { TailscaleMark } from './TailscaleMark'; +import { WORDMARK_ASPECT } from './AppSplash'; + +const background = require('../assets/landscape1.webp'); +const backgroundDusk = require('../assets/landscape_dusk.webp'); + +/** + * The suite wallpaper, themed. + * + * Split out so anything that needs to sit ON the landscape — the app frame, a full-screen settings + * page — draws the same image from one place rather than each requiring its own copy and drifting. + */ +export const Wallpaper = ({ children }: { children: ReactNode }) => { + const { palette } = useTheme(); + return ( + + {children} + + ); +}; + +/** + * The frame the app is drawn in. + * + * ## Why this exists in core rather than in each app + * + * The wallpaper continues behind the header and the content floats on it, so the app reads as a place + * rather than as a form on a flat background. There is no navigation chrome in the frame — no back + * button, no close — because there is nowhere else in this app to go. + * + * ## The wordmark is theme-dependent, and not for the reason you would guess + * + * It is not about the palette — the header sits on the PHOTOGRAPH, not on `palette.bg`. The two + * wallpapers differ enormously in brightness where a header lands: the dusk one measures 0.11 mean + * luminance in that corner, the day one 0.74. The wordmarks are white-"Off" + yellow-"Scale", which + * is legible on the first and gone on the second. So light mode takes `wordmarkLight` (dark "Off", + * from `_Text_Light.png`), cut from the same shared window so the letters do not change size + * when the theme flips. + * + * An app that passes only `wordmark` gets it in both themes, which is correct where the art already + * carries its own contrast. + */ +export const AppBackdrop = ({ + title, + wordmark, + wordmarkLight, + wordmarkHeight = 26, + actions, + footer, + children, +}: { + /** Fallback when no wordmark is supplied, and the accessibility label when one is. */ + title: string; + /** Voxel wordmark for a DARK backing. Replaces the title text when present. */ + wordmark?: ImageSourcePropType; + /** Wordmark for a LIGHT backing. Falls back to `wordmark` when absent. */ + wordmarkLight?: ImageSourcePropType; + wordmarkHeight?: number; + /** Trailing header controls — refresh, settings. Drawn on the photograph, so keep them light. */ + actions?: ReactNode; + /** + * A permanent strip below the content — credits, a status line, whatever an app needs always visible. + * + * Passed in rather than built in. OffScale's credit to Tailscale and Headscale used to be hardcoded + * here, which would have handed every other app that adopted this frame a credit to projects it does + * not use. Most apps will want nothing, and will credit in Credits instead. + * + * Rendered outside the scroll view, so it never moves and nothing can scroll under it. + */ + footer?: ReactNode; + children: ReactNode; +}) => { + const insets = useSafeAreaInsets(); + const { palette } = useTheme(); + const { scale } = useUIScale(); + const dark = palette.mode === 'dark'; + const mark = dark ? wordmark : (wordmarkLight ?? wordmark); + // The header scales too, or the app grows around a logo that stayed phone-sized. + const markHeight = scaled(wordmarkHeight, scale); + + return ( + + + {mark ? ( + + ) : ( + // Shadowed: it is sitting on a photograph, and a thin stroke disappears against the + // bright parts of one. + + {title} + + )} + + + {actions} + + + {/* + No sheet. The cards sit directly on the photograph. + + A raised panel painted in palette.bg would be covering the wallpaper it had just drawn — the + sheet is a signal that you opened something ON TOP of somewhere else, and there is nowhere + else here. + + It works because `palette.surface` is translucent in light mode by design — rgba(255,255,255, + 0.6), meant for exactly this — and opaque in dark, where the dusk photograph is dark enough to + carry an opaque card without either disappearing. + */} + {children} + + {footer} + + ); +}; + +/** + * A permanent strip at the bottom of the app, crediting the open-source projects it is built on. + * + * Shaped like a tab bar on purpose, and deliberately not one: same height, same seat at the bottom of + * the screen, no icons and nothing to press. It reads as part of the furniture rather than as a banner + * someone forgot to dismiss, and it occupies the slot before anything else claims it. + * + * PLACEHOLDER. The wording is a stand-in and the full BSD-3-Clause notice for the Tailscale core still + * belongs in Credits — a licence obligation is not discharged by a line of small type. What this is + * for is the acknowledgement being VISIBLE, everywhere, rather than filed two screens deep. + * + * Lives in the frame rather than in a screen so it cannot be lost: every state of the app — permission + * gate, setup, dashboard, an error — is drawn inside this, so all of them carry it without knowing. + * + * It sits OUTSIDE the scroll view, so it does not move. The screens already reserve bottom padding + * well beyond this height, so nothing is covered. + */ +const OFFICER_URL = 'https://officer.dev'; + +/** Where each credited project lives. Opening these is the whole point of crediting them. */ +const PROJECTS = [ + { name: 'Tailscale', url: 'https://tailscale.com' }, + { name: 'Headscale', url: 'https://headscale.net' }, +] as const; + +export const AttributionBar = () => { + const insets = useSafeAreaInsets(); + const { palette } = useTheme(); + const { scale, maxWidth } = useUIScale(); + return ( + + {/* + Small and wide-tracked, but NOT dimmed. + + Hierarchy here comes from size, weight and letter-spacing. Reaching for a fainter colour is the + habit that keeps making things unreadable — a caption should be quieter than what it + introduces, and 10pt against 15pt bold already is. + */} + + BUILT ON OPEN SOURCE + + + {/* + Name, mark, name — evenly spaced across the strip. + + The mark sits BETWEEN them rather than beside its own name, which makes it the separator the + row needed. A punctuation dot did that job before and had to be styled down so it would not + read as a third name; this one earns the position instead of merely occupying it. + + No open-in-new glyph. Two of three items being links is obvious from the fact that they are + names of projects, and the glyphs were adding clutter to a strip whose whole job is to be + unobtrusive. + */} + {/* px-20 rather than px-6: narrowing the row is what pulls the names in toward the mark. On a + 440pt screen it takes the gap either side of the mark from roughly 68pt to 31 — about half, + which is close enough to the group reading as one unit rather than three strangers. */} + + + + + + + {/* + Where this app comes from, under what it is built on. + + A nested Text takes its own onPress and keeps the sentence as one line of prose — wrapping the + link in a Pressable would break "part of the … family" into three laid-out pieces that have to + be re-joined by eye. + */} + + part of the{' '} + void Linking.openURL(OFFICER_URL).catch(() => {})} + accessibilityRole="link" + accessibilityLabel={`officer.dev — opens ${OFFICER_URL}`} + style={{ color: palette.accent, fontWeight: '700' }} + > + officer.dev + {' '} + family + + + ); +}; + +/** White rather than the accent: the names are a credit, not a call to action. */ +const ProjectLink = ({ project }: { project: (typeof PROJECTS)[number] }) => { + const { scale } = useUIScale(); + return ( + void Linking.openURL(project.url).catch(() => {})} + accessibilityRole="link" + accessibilityLabel={`${project.name} — opens ${project.url}`} + hitSlop={12} + className="active:opacity-60" + > + {project.name} + + ); +}; + +/** + * A header control sitting on the wallpaper. + * + * Filled rather than a bare glyph: a thin icon stroke on a photograph vanishes wherever the + * photograph is bright, and the day wallpaper is bright exactly where the header is. + */ +export const BackdropAction = ({ children }: { children: ReactNode }) => { + const { palette } = useTheme(); + return ( + + {children} + + ); +}; diff --git a/src/ui/AppSplash.tsx b/src/ui/AppSplash.tsx new file mode 100644 index 0000000..ae95a70 --- /dev/null +++ b/src/ui/AppSplash.tsx @@ -0,0 +1,108 @@ +import { useEffect, useRef } from 'react'; +import { Animated, Easing, Image, Pressable, StyleSheet, Text, useWindowDimensions } from 'react-native'; +import type { ImageSourcePropType } from 'react-native'; + +/** + * The launch splash — the app icon over black, with its name, held briefly then faded. + * + * It doubles as the CREDITS screen: pass `hold` to keep it on screen until tapped, which is what the + * Credits row in Settings does. + * + * All motion and the fade run on the native driver, so heavy startup JS cannot stutter the intro. + */ +export type AppSplashProps = { + /** The app's branding icon (its `assets/icon.png` art). */ + icon: ImageSourcePropType; + /** App name shown under the icon (e.g. "OffTailscale"), and the accessibility label for `wordmark`. */ + name: string; + /** + * Voxel wordmark art, which replaces the rendered name when supplied. + * Apps without one yet fall back to `name` as text, so this can land per app. + */ + wordmark?: ImageSourcePropType; + /** Optional one-line tagline under the name. */ + tagline?: string; + onDone: () => void; + zIndex?: number; + /** Credits mode: hold on screen (no auto-fade) until tapped, then call onDone. */ + hold?: boolean; +}; + +/** + * Every wordmark is cropped from its master to the SAME window, so the letter scale the masters were + * designed with is preserved between products — a longer name draws smaller letters, as intended. + * Which means one shared aspect, not a per-app one. + */ +export const WORDMARK_ASPECT = 1759 / 345; + +export const AppSplash = ({ icon, name, wordmark, tagline, onDone, zIndex = 100, hold = false }: AppSplashProps) => { + const { width, height } = useWindowDimensions(); + // One native-driven 0→1 timing over the whole intro; opacity + icon scale are INTERPOLATED from it. + const progress = useRef(new Animated.Value(0)).current; + // A gentle float loop so the icon isn't static while it holds. + const float = useRef(new Animated.Value(0)).current; + + useEffect(() => { + const anim = hold ? null : Animated.timing(progress, { toValue: 1, duration: 2600, useNativeDriver: true }); + anim?.start(({ finished }) => finished && onDone()); + // In hold mode, still play the intro scale-in once so the icon settles in. + const intro = hold ? Animated.timing(progress, { toValue: 0.5, duration: 500, useNativeDriver: true }) : null; + intro?.start(); + const loop = Animated.loop( + Animated.sequence([ + Animated.timing(float, { toValue: 1, duration: 1600, easing: Easing.inOut(Easing.sin), useNativeDriver: true }), + Animated.timing(float, { toValue: 0, duration: 1600, easing: Easing.inOut(Easing.sin), useNativeDriver: true }), + ]), + ); + loop.start(); + return () => { + anim?.stop(); + intro?.stop(); + loop.stop(); + }; + }, [progress, float, onDone, hold]); + + // Fade: hold at full, then fade near the end (auto mode only). + const opacity = hold ? 1 : progress.interpolate({ inputRange: [0, 0.82, 1], outputRange: [1, 1, 0] }); + // Icon scales up from 0.8→1 over the first third of the intro. + const scale = progress.interpolate({ inputRange: [0, 0.35, 1], outputRange: [0.8, 1, 1], extrapolate: 'clamp' }); + const size = Math.min(width * 0.8, 360); + const bob = float.interpolate({ inputRange: [0, 1], outputRange: [-size * 0.03, size * 0.03] }); + + return ( + + + + + {wordmark ? ( + + ) : ( + {name} + )} + {tagline ? ( + {tagline} + ) : null} + + © {new Date().getFullYear()} pastilhas.dev + + {hold ? ( + <> + Tap to close + {/* Full-screen tap catcher so a tap anywhere dismisses the credits. */} + + + ) : null} + + ); +}; diff --git a/src/ui/Card.tsx b/src/ui/Card.tsx new file mode 100644 index 0000000..3d2390f --- /dev/null +++ b/src/ui/Card.tsx @@ -0,0 +1,105 @@ +import type { ReactNode } from 'react'; +import { useState } from 'react'; +import type { LayoutChangeEvent, ViewStyle } from 'react-native'; +import { View } from 'react-native'; +import { useTheme } from './useTheme'; +import { lightPalette } from './theme'; + +const GRID_SIZE = 20; + +type GridProps = { width: number; height: number; color: string }; + +/** The 20px grid the web Card paints with layered linear-gradients. */ +const Grid = ({ width, height, color: GRID_COLOR }: GridProps) => { + const cols = Math.ceil(width / GRID_SIZE); + const rows = Math.ceil(height / GRID_SIZE); + + return ( + + {Array.from({ length: cols }, (_, i) => ( + + ))} + {Array.from({ length: rows }, (_, i) => ( + + ))} + + ); +}; + +/** `light` forces the light palette regardless of the app theme — used by the always-light sign-in screen. */ +type CardProps = { children: ReactNode; className?: string; style?: ViewStyle; light?: boolean }; + +/** + * Split `gap-*` out of the card's className and hand it to the CONTENT wrapper. + * + * A gap on the Card itself applies between the grid background and the inner view — never between the + * children, which is what everyone writing it meant. It reads as set and does nothing, and it is + * invisible until something inside grows enough to touch its neighbour. + * + * Nineteen call sites had a dead gap when this was written. Fixing it here rather than at each of them + * means the next `` also does what it says. + * + * Whole tokens only, so `gap-3`, `gap-x-2` and `gap-y-1.5` move while `flex-1` stays on the outside + * where it belongs. + */ +const splitGap = (className: string): { outer: string; content: string } => { + const tokens = className.split(/\s+/).filter(Boolean); + const gaps = tokens.filter((t) => /^gap(-[xy])?-/.test(t)); + return { outer: tokens.filter((t) => !gaps.includes(t)).join(' '), content: gaps.join(' ') }; +}; + +export const Card = ({ children, className = '', style, light }: CardProps) => { + const { palette: themed } = useTheme(); + const palette = light ? lightPalette : themed; + // Measured rather than assumed, so the grid covers a full-height card as well as a small one. + const { outer, content } = splitGap(className); + const [size, setSize] = useState({ width: 0, height: 0 }); + const onLayout = (e: LayoutChangeEvent) => { + const { width, height } = e.nativeEvent.layout; + if (width !== size.width || height !== size.height) setSize({ width, height }); + }; + + return ( + + + {/* Grows to fill a card that was given a height, so a flex-1 child (a FlatList, say) has room, + while still sizing to its content on a card that has none. + Not `flex-1`: RN's shorthand sets flexBasis to 0 rather than auto, so on a content-sized + card the wrapper starts at zero and finds no free space to grow into — collapsing it. */} + {children} + + ); +}; diff --git a/src/ui/Tabs.tsx b/src/ui/Tabs.tsx new file mode 100644 index 0000000..dd66563 --- /dev/null +++ b/src/ui/Tabs.tsx @@ -0,0 +1,86 @@ +import { Ionicons } from '@expo/vector-icons'; +import { View, Text, Pressable } from 'react-native'; + +import { useTheme } from './useTheme'; + +export type TabDef = { + key: K; + label: string; + icon: keyof typeof Ionicons.glyphMap; + /** Small count shown after the label — peers, connections. Hidden when 0 or undefined. */ + badge?: number; +}; + +/** + * A segmented control for a screen with a handful of sections. + * + * Pills rather than the dropdown Photos uses: that screen has six sections and a picker keeps the + * chrome down, but with three, a dropdown hides two of them behind a tap and states the obvious. + * Everything fits on one row here, so the sections are visible and one tap away. + * + * Sized for thumbs — 44pt is Apple's minimum target and the reason this is not a compact row of text. + * + * Deliberately NOT a navigator. These are sections of one screen sharing one view-model; a navigator + * would give each its own mount lifecycle and remount the tunnel status on every switch. + */ +export const Tabs = ({ + tabs, + value, + onChange, +}: { + tabs: readonly TabDef[]; + value: K; + onChange: (k: K) => void; +}) => { + const { palette } = useTheme(); + + return ( + + {tabs.map((t) => { + const active = t.key === value; + return ( + onChange(t.key)} + accessibilityRole="tab" + accessibilityState={{ selected: active }} + className="flex-1 flex-row items-center justify-center gap-1.5 rounded-xl active:opacity-70" + style={{ + minHeight: 44, + // The brand yellow carries its own ink, because palette.text is near-white in dark mode + // and unreadable on it. + backgroundColor: active ? palette.brand : 'transparent', + }} + > + + + {t.label} + + {t.badge ? ( + + {t.badge} + + ) : null} + + ); + })} + + ); +}; diff --git a/src/ui/TailscaleMark.tsx b/src/ui/TailscaleMark.tsx new file mode 100644 index 0000000..ffe7443 --- /dev/null +++ b/src/ui/TailscaleMark.tsx @@ -0,0 +1,49 @@ +import Svg, { Circle } from 'react-native-svg'; + +/** + * Tailscale's logo mark, square, without the wordmark beside it. + * + * ## Circles rather than the supplied paths + * + * The official SVG draws each dot as a bezier path — nine of them, ~200 characters each, all + * describing the same circle at a different position. Read the numbers and the mark is simply a 3×3 + * grid: radius 3.5, centres at 3.5, 14 and 24.5 on both axes, with four dots solid and five at 40%. + * + * Written that way it is legible, provably symmetric, and impossible to typo one dot out of alignment. + * The rendered result is identical — these are the same circles the paths described. + * + * ## Monochrome, and why not the accent + * + * The source is #232222, which is invisible on a dark surface. Tailscale's mark may be reproduced in a + * single flat colour, so it takes `color` and defaults to white. + * + * It deliberately does NOT default to our accent yellow, tempting as that is beside yellow text. This + * is somebody else's trademark, and the BSD-3 licence that covers their SOURCE says nothing about + * their logo. White is the reproduction they publish; tinting a trademark to match our palette is the + * kind of liberty that is fine right up until it is not. + */ +const SOLID: [number, number][] = [ + [3.5, 14], + [14, 14], + [14, 24.5], + [24.5, 14], +]; + +const FADED: [number, number][] = [ + [3.5, 3.5], + [14, 3.5], + [24.5, 3.5], + [3.5, 24.5], + [24.5, 24.5], +]; + +export const TailscaleMark = ({ size = 16, color = '#ffffff' }: { size?: number; color?: string }) => ( + + {FADED.map(([cx, cy]) => ( + + ))} + {SOLID.map(([cx, cy]) => ( + + ))} + +); diff --git a/src/ui/TextField.tsx b/src/ui/TextField.tsx new file mode 100644 index 0000000..540de75 --- /dev/null +++ b/src/ui/TextField.tsx @@ -0,0 +1,72 @@ +import { forwardRef } from 'react'; +import { TextInput, type TextInputProps } from 'react-native'; + +/** + * The one text input for the whole suite. + * + * ## The bug this exists to kill + * + * Text was clipped along the BOTTOM of every input — descenders on `g`, `y`, `p`, and accented + * capitals sliced off — and raising the height did not fix it. It only added space above the text, + * because the clipping was never happening in the container. + * + * The cause is `lineHeight`. Tailwind's type scale ships one with every size — `text-base` is + * `fontSize: 16, lineHeight: 24`, `text-sm` is `14/20` — and NativeWind faithfully emits both. On a + * `Text` that is exactly right. On a `TextInput` an explicit `lineHeight` constrains the glyph box + * itself, and anything the font draws below that box is cut. Container padding cannot reach inside it, + * which is why `paddingVertical: 12` bought nothing but a bigger gap at the top. + * + * So this component owns input typography and never sets `lineHeight`. Size the field with + * `minHeight`, not with vertical padding, and let the glyphs have the whole box. + * + * **Do not put a `text-*` class on one of these.** That is the bug coming back — the class carries a + * lineHeight with it. Pass `fontSize` through `style` if a specific size is genuinely needed. + * + * ## The rest of the recipe + * + * `paddingVertical: 0` — vertical padding fights `minHeight` for the same space and shrinks the + * content box, which is how the previous attempt clipped even at 48pt tall. + * + * `textAlignVertical: 'center'` — Android otherwise pins text to the top of a tall field, so a 48pt + * input looks like text stuck to its ceiling. iOS centres single-line input already and ignores this. + * + * `minHeight: 48` — Apple's minimum comfortable touch target, and enough for a 16pt face with room + * under the baseline. + * + * Multiline is a different shape and says so: text starts at the TOP, and vertical padding is + * correct there because the field is a box of text rather than a single centred line. + */ + +/** Single-line field height. 48 is the minimum comfortable touch target. */ +export const INPUT_MIN_HEIGHT = 48; +/** Multiline fields start here and grow. */ +export const INPUT_MULTILINE_MIN_HEIGHT = 96; + +export const TextField = forwardRef(({ style, multiline, ...rest }, ref) => ( + +)); + +TextField.displayName = 'TextField'; diff --git a/src/ui/theme.ts b/src/ui/theme.ts new file mode 100644 index 0000000..76015fe --- /dev/null +++ b/src/ui/theme.ts @@ -0,0 +1,192 @@ +/** + * The app-wide colour system. Every screen reads colours from here (via useTheme) instead of hardcoding + * hex — so light/dark is one switch. Tokens are SEMANTIC (what a colour is for), not literal, so the two + * palettes can diverge freely. Light is the original "duck" cream/green/teal look; dark is the "fin" + * palette (see the old tailwind config). The immersive full-screen media player is intentionally black in + * both themes (its own `player*` tokens); the mini dock follows the theme. + */ +export type ThemeMode = 'light' | 'dark'; +export type ThemePreference = 'light' | 'dark' | 'system'; + +export type Palette = { + mode: ThemeMode; + + // Surfaces + bg: string; // screen background + surface: string; // cards, inputs, list-row fills + surfaceAlt: string; // section headers, elevated panels + overlay: string; // modal scrim + + // Text + text: string; // primary + textMuted: string; // secondary + textFaint: string; // tertiary / placeholder + heading: string; // section / brand headings + + // Accent + accent: string; // primary action / active + accentText: string; // text/icon on an accent fill + + // Brand (the Officer yellow — the same fill in both themes, so it carries its own ink: + // palette.text is near-white in dark mode and unreadable on it.) + brand: string; // brand-yellow fill for a primary CTA / selected pill + brandText: string; // text/icon on a brand fill + + // Lines + border: string; // input & card borders + hairline: string; // 1px list separators + + /* + Status. + + `success` and `warning` complete the state vocabulary the web app already uses — see the platform's + `components/Data/tone.ts`, where five tones and no others is the whole point: when a view can reach + for any colour, green stops meaning "finished" and starts meaning "the author liked green here". + `danger` is the third of them and predates this; `neutral` is textMuted and `info` is accent. + + Same values as the web's tokens so a state reads identically on both, and like the web these do NOT + change between light and dark — a finished thing is the same green wherever you look at it. + */ + success: string; // finished, healthy, passing + warning: string; // in progress, needs attention, not yet failed + danger: string; // destructive text/icon + dangerText: string; // text on a danger fill + dangerBg: string; // error box fill + dangerBorder: string; // error box border + + // Icons + icon: string; // default icon tint + iconFaint: string; // disabled / placeholder icon + + // Media player (immersive; near-black in both themes) + playerBg: string; + playerText: string; + playerMuted: string; + playerFaint: string; + playerAccent: string; + playerHeart: string; // active heart + dockBg: string; // mini-player dock (follows theme) +}; + +export const lightPalette: Palette = { + mode: 'light', + bg: '#ede7de', + surface: 'rgba(255,255,255,0.6)', + surfaceAlt: '#d8d3c9', + overlay: 'rgba(0,0,0,0.4)', + text: '#14532D', + textMuted: 'rgba(20,83,45,0.6)', + textFaint: 'rgba(20,83,45,0.4)', + heading: '#166534', + accent: '#0E7490', + accentText: '#ffffff', + brand: '#F4C430', + brandText: '#0E7490', + border: 'rgba(20,83,45,0.2)', + hairline: 'rgba(20,83,45,0.08)', + // hsl(142.1 76.2% 36.3%) and hsl(43 96% 56%) — the web's --success and --warning, converted. + success: '#16A34A', + warning: '#FABD23', + danger: '#B91C1C', + dangerText: '#ffffff', + dangerBg: '#fef2f2', + dangerBorder: '#fca5a5', + icon: '#0E7490', + iconFaint: 'rgba(20,83,45,0.35)', + playerBg: '#000000', + playerText: '#ffffff', + playerMuted: 'rgba(255,255,255,0.6)', + playerFaint: 'rgba(255,255,255,0.3)', + playerAccent: '#0891B2', + playerHeart: '#EF4444', + dockBg: 'rgba(237,231,222,0.98)', +}; + +export const darkPalette: Palette = { + mode: 'dark', + bg: '#101418', + /* + Translucent, mirroring light mode. + + `#1b2027` at 60%. Light has always been rgba(255,255,255,0.6) — cards there were built to let the + wallpaper through — and dark was opaque, so the same card read as a panel on one theme and a pane + on the other. Over the dusk photograph (0.12 mean luminance) a dark card at 60% stays dark, so + near-white body text keeps its contrast. + + `surfaceAlt` deliberately stays opaque: it is the "elevated panel" token, and things that float + ABOVE content — dropdowns, tab bars — must not have that content reading through them. + */ + surface: 'rgba(27,32,39,0.6)', + surfaceAlt: '#242b34', + overlay: 'rgba(0,0,0,0.6)', + text: '#e8eaed', + /* + Secondary text, not faint text. + + Was #9aa4b2 — a mid grey that held up against an opaque #1b2027 card and stopped holding up the + moment the card became translucent over a photograph. Most of the writing on these screens is + `textMuted`: subtitles, peer rows, every explanatory line under a heading. It has to be readable + first and quiet second, and it was the other way round. + + Now the same near-white as `text`, at 75%, so the two read as a hierarchy rather than as one colour + and both sit on one hue instead of drifting blue-grey apart. + + It carries less than it used to. Body copy — anything the reader is meant to actually read — was + moved to `text`, because the honest answer to "why is this muted if it matters" is that it should + not be. What is left here is labels, timestamps and row metadata, which is what secondary means. + */ + textMuted: 'rgba(232,234,237,0.75)', + textFaint: 'rgba(232,234,237,0.55)', + heading: '#e8eaed', + /* + Dark mode is yellow, not blue. + + The accent was #00a4dc — Jellyfin's blue, inherited when the player was the first thing built here + and never revisited. It is the one colour on these screens that belongs to somebody else's product, + sitting beside a suite whose entire identity is a yellow duck. + + So `accent` is now the duck yellow, and everything keyed off it moves together: buttons, icons, + selected states, bullets, the emphasised word in the disclosure. + + Ink on yellow is `duck-forest` — #166534, taken from the platform's own tokens rather than invented + here, where it is already paired with duck-yellow. Roughly 4.4:1 against #F4C430: under the 4.5 + needed for body text, comfortably over the 3.0 for large bold type, which is all this is used for. + + Note WHICH forest. The platform lightens it to #4ade80 for dark mode, because there it sits on a + dark background. Ours sits on YELLOW — a light surface even in dark mode — so it wants the light + theme's dark forest. Taking the dark-mode value would be reading the token by name rather than by + the job it does. + + `accentText` and `brandText` share it, so a yellow button and a yellow tab pill carry the same ink. + They did not before. + + LIGHT MODE IS UNCHANGED, and that is now a decision rather than a deferral (2026-08-07): the + redesign is dark-only, across every app. Light keeps the teal accent and its own surfaces. Yellow on + cream never has to be solved, because light is not being restyled — and the sign-in screen, which is + deliberately always-light, is unaffected by any of this. + */ + accent: '#F4C430', + accentText: '#0891B2', + brand: '#F4C430', + brandText: '#0891B2', + border: 'rgba(255,255,255,0.14)', + hairline: 'rgba(255,255,255,0.08)', + // Unchanged from light, exactly as the web keeps --success and --warning identical across themes. + success: '#16A34A', + warning: '#FABD23', + danger: '#f87171', + dangerText: '#ffffff', + dangerBg: 'rgba(248,113,113,0.12)', + dangerBorder: 'rgba(248,113,113,0.4)', + icon: '#F4C430', + iconFaint: 'rgba(232,234,237,0.35)', + playerBg: '#000000', + playerText: '#ffffff', + playerMuted: 'rgba(255,255,255,0.6)', + playerFaint: 'rgba(255,255,255,0.3)', + playerAccent: '#00a4dc', + playerHeart: '#EF4444', + dockBg: '#1b2027', +}; + +export const paletteFor = (mode: ThemeMode): Palette => (mode === 'dark' ? darkPalette : lightPalette); diff --git a/src/ui/useLayout.ts b/src/ui/useLayout.ts new file mode 100644 index 0000000..72e33b5 --- /dev/null +++ b/src/ui/useLayout.ts @@ -0,0 +1,16 @@ +import { useWindowDimensions } from 'react-native'; + +/** + * Coarse layout class driven by the CURRENT window width (not the device), so it adapts to rotation and + * iPad split-view / Stage Manager resizing: a narrow window (phone, or a slim iPad multitasking pane) + * gets the single-column phone UI; a wide one gets the multi-pane tablet shell. + */ +export type LayoutKind = 'phone' | 'tablet'; + +/** Below this window width we use the phone layout; at or above it, the tablet shell. */ +export const TABLET_MIN_WIDTH = 720; + +export function useLayout(): LayoutKind { + const { width } = useWindowDimensions(); + return width >= TABLET_MIN_WIDTH ? 'tablet' : 'phone'; +} diff --git a/src/ui/useTheme.tsx b/src/ui/useTheme.tsx new file mode 100644 index 0000000..823e5b0 --- /dev/null +++ b/src/ui/useTheme.tsx @@ -0,0 +1,93 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { useColorScheme } from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +import { paletteFor, type Palette, type ThemeMode, type ThemePreference } from './theme'; + +const PREF_KEY = 'app.themePreference'; + +type ThemeValue = { + /** Resolved appearance actually in effect. */ + mode: ThemeMode; + palette: Palette; + /** The user's choice: 'system' follows the OS. */ + preference: ThemePreference; + setPreference: (p: ThemePreference) => void; +}; + +const ThemeContext = createContext(null); + +/** + * App-wide theme. One provider at the root of the app. + * + * `preference` is persisted, and 'system' resolves against the OS appearance live. `lockedMode` pins + * the result regardless — which is what this app uses, because it is drawn on a dark photograph and a + * light mode would only ever be a worse version of it. + */ +export const ThemeProvider = ({ + children, + defaultPreference = 'system', + lockedMode, +}: { + children: React.ReactNode; + /** + * What a device with no stored choice gets. + * + * A per-app default rather than a global one. OffScale opens onto a photograph and its whole look + * is built for the dusk one, so landing a first-run user in light mode shows them the weakest + * version of the app before they have any reason to go looking for a theme switch. Every other host + * still follows the OS. + * + * Only the DEFAULT. The moment someone picks a theme it is written to storage and this stops + * mattering — including if they pick 'system', which is a choice like any other and is why this + * cannot simply be "dark unless the OS says light". + */ + defaultPreference?: ThemePreference; + /** + * Pin the app to one appearance and take the choice away. + * + * For an app designed around a single look rather than one that adapts — OffScale is drawn on the + * dusk photograph, and its wordmark, cards and tab bar were all chosen against it. Offering a light + * mode there is offering a worse version of the app. + * + * Stronger than `defaultPreference`, which only decides where an unset device starts. This ignores + * both the OS and anything stored, so a preference saved before the app was locked cannot resurface. + * Hosts that set it should also hide the Appearance control, or the app shows a switch that does + * nothing. + */ + lockedMode?: ThemeMode; +}) => { + const system = useColorScheme(); // 'light' | 'dark' | null, updates live with the OS + const [preference, setPreferenceState] = useState(defaultPreference); + + useEffect(() => { + AsyncStorage.getItem(PREF_KEY) + .then((v) => { + if (v === 'light' || v === 'dark' || v === 'system') setPreferenceState(v); + }) + .catch(() => {}); + }, []); + + const setPreference = useCallback((p: ThemePreference) => { + setPreferenceState(p); + AsyncStorage.setItem(PREF_KEY, p).catch(() => {}); + }, []); + + const mode: ThemeMode = lockedMode ?? (preference === 'system' ? (system === 'dark' ? 'dark' : 'light') : preference); + const value = useMemo( + () => ({ mode, palette: paletteFor(mode), preference, setPreference }), + [mode, preference, setPreference], + ); + + return {children}; +}; + +/** + * Read the theme. Falls back to the light palette when no provider is mounted, so a stray component never + * crashes — but every app should mount ThemeProvider at its root. + */ +export const useTheme = (): ThemeValue => { + const ctx = useContext(ThemeContext); + if (ctx) return ctx; + return { mode: 'light', palette: paletteFor('light'), preference: 'system', setPreference: () => {} }; +}; diff --git a/src/ui/useUIScale.ts b/src/ui/useUIScale.ts new file mode 100644 index 0000000..5f2740f --- /dev/null +++ b/src/ui/useUIScale.ts @@ -0,0 +1,52 @@ +import { useLayout } from './useLayout'; + +/** + * How much bigger everything should be, and how wide the content column is allowed to get. + * + * ## Two separate problems, often confused + * + * A phone layout on an iPad is wrong in two ways at once, and fixing only one looks worse than fixing + * neither. + * + * **Everything is too small.** The iPad is held further away and has more pixels, so type and controls + * sized for a phone read as miniature. That wants a multiplier. + * + * **Everything is too wide.** A single column stretched across 1024pt gives line lengths nobody can + * read and buttons a metre wide. That wants a ceiling, not a multiplier — scaling a full-width layout + * up makes the stretch worse. + * + * So: scale the type and the controls, and CAP the column. The result is a phone-shaped app that looks + * deliberate on a tablet rather than blown up or stranded. + * + * ## Driven by the window, not the device + * + * `useLayout` reads the current window width, so an iPad in a slim Split View pane correctly gets the + * phone treatment — it IS a phone-width window at that moment. Nothing here asks what device it is + * running on, which is what keeps Stage Manager and rotation working without special cases. + * + * ## Why 1.25 and 620 + * + * 1.25 is enough to stop things looking miniature without turning the app into a mockup of itself; the + * cards keep their proportions because everything inside them moves together. 620pt is a comfortable + * measure for a column of prose at these sizes — wider and the disclosure bullets start running long. + */ +export type UIScale = { + /** Multiply sizes by this. 1 on a phone-width window. */ + scale: number; + /** Cap the content column at this, centred. Infinity on a phone, where the column IS the screen. */ + maxWidth: number; + isTablet: boolean; +}; + +export const useUIScale = (): UIScale => { + const layout = useLayout(); + const isTablet = layout === 'tablet'; + return { + scale: isTablet ? 1.25 : 1, + maxWidth: isTablet ? 620 : Infinity, + isTablet, + }; +}; + +/** Round a scaled size to a whole point. Half-points blur text on some densities. */ +export const scaled = (size: number, scale: number): number => Math.round(size * scale); diff --git a/src/vpn/VpnScreen.tsx b/src/vpn/VpnScreen.tsx new file mode 100644 index 0000000..433e244 --- /dev/null +++ b/src/vpn/VpnScreen.tsx @@ -0,0 +1,1640 @@ +import type { ReactNode } from 'react'; +import type { ImageSourcePropType } from 'react-native'; +import { useEffect, useState } from 'react'; +import { View, Text, ScrollView, Pressable, ActivityIndicator, KeyboardAvoidingView, Platform, Switch, Linking, Alert } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { useTheme } from '../ui/useTheme'; +import { + isVpnAvailable, + isTailscaleControl, + exitNodeTrouble, + keyExpiryStatus, + type VpnPeer, + type VpnState, +} from './officer-net'; +import { Card as BaseCard } from '../ui/Card'; +import { AppBackdrop, AttributionBar, BackdropAction } from '../ui/AppBackdrop'; +import { Tabs, type TabDef } from '../ui/Tabs'; +import { SettingsScreen, SettingsRow, SettingsSection } from '../settings/SettingsScreen'; +import { useUIScale, scaled } from '../ui/useUIScale'; +import { useVpnScreen, type VpnVM } from './useVpnScreen'; + +/** + * The shared card, with this screen's stacking restored. + * + * `ui/Card` is the design — a bordered, shadowed surface painted with the same 20px grid the + * web app uses — but it makes no assumption about how its children stack. Every card on this screen + * was a `gap-3` column, so that lives here instead of on thirty call sites. + */ +const Card = ({ children, className = '' }: { children: ReactNode; className?: string }) => ( + // The workaround that used to live here is gone: `Card` now routes gap-* to its own content + // wrapper, so writing the gap in the className does what it says. + {children} +); + +export type VpnScreenProps = { + /** + * Set when some outer container has already paid the top safe-area inset and drawn a header. Unset + * here — this app is the screen — but the flag is what keeps the header and inset logic in one place + * rather than duplicated by whatever renders it. + */ + embedded?: boolean; + /** Replays the launch splash, from the Credits row inside Settings. */ + onShowCredits?: () => void; + /** + * Voxel wordmark for the header, in place of a text title. + * + * Passed in rather than reached for, because the art belongs to the app shell (`assets/`) and this + * screen should not know where it lives. `wordmarkLight` is the dark-lettered cut for the light + * wallpaper; see AppBackdrop for why the choice is driven by the photograph rather than the palette. + */ + wordmark?: ImageSourcePropType; + wordmarkLight?: ImageSourcePropType; +}; + +const STATE_META: Record = { + stopped: { label: 'DISCONNECTED', color: '#9aa4b2', icon: 'shield-outline' }, + needsLogin: { label: 'SIGN IN REQUIRED', color: '#d97706', icon: 'log-in-outline' }, + connecting: { label: 'CONNECTING…', color: '#d97706', icon: 'sync' }, + connected: { label: 'CONNECTED', color: '#16a34a', icon: 'shield-checkmark' }, + reconnecting: { label: 'RECONNECTING…', color: '#d97706', icon: 'sync' }, + error: { label: 'ERROR', color: '#B91C1C', icon: 'warning-outline' }, +}; + +/** + * Text input metrics, shared so every field agrees. + * + * The fields were `py-3` with a 16px font: about 40pt tall, which leaves nothing below the baseline + * and clips descenders — a `j` or `p` loses its tail. Height is set explicitly rather than by padding + * so the box does not depend on how the platform measures the font, and 48 is also the minimum + * comfortable touch target. + */ + +/** + * An inline explanation of something the user has to know about, with the action that resolves it. + * + * `danger` for a state that is actively breaking things, `info` for one that will. The action is + * optional but preferred — a warning the user cannot act on is noise on a screen they opened to get + * connected. Where there is genuinely nothing to tap, the body says what to do instead. + */ +const Notice = ({ + tone, + icon, + title, + body, + action, +}: { + tone: 'danger' | 'info'; + icon: keyof typeof Ionicons.glyphMap; + title: string; + body: string; + action?: { label: string; onPress: () => void; busy?: boolean }; +}) => { + const { palette } = useTheme(); + const fg = tone === 'danger' ? palette.danger : palette.accent; + return ( + + + + {title} + {body} + {action ? ( + + {action.label} + + ) : null} + + + ); +}; + +/** + * Text that sits on the WALLPAPER rather than on a card. + * + * With the sheet gone, the handful of bare links are drawn straight onto a photograph, and the day + * wallpaper is bright almost everywhere — 60%-opacity muted text disappears into it. Same treatment + * the launcher's tile labels get: the shadow is always the opposite of the ink, which is what keeps + * an edge regardless of the pixel underneath. + */ +const useOnWallpaperText = () => { + const { palette } = useTheme(); + const dark = palette.mode === 'dark'; + return { + color: dark ? '#ffffff' : '#14532D', + textShadowColor: dark ? 'rgba(0,0,0,0.9)' : 'rgba(255,255,255,0.9)', + textShadowOffset: { width: 0, height: dark ? 1 : 0 }, + textShadowRadius: 3, + } as const; +}; + +const Row = ({ label, value }: { label: string; value: string }) => { + const { palette } = useTheme(); + return ( + + {label} + {value} + + ); +}; + +/** + * One machine on the tailnet, shaped after the Tailscale app's device list. + * + * Tapping copies the IP — the single most common thing anyone does on this screen, and previously + * you had to read it off and retype it. Long-press copies the MagicDNS name instead, now that short + * names actually resolve. + */ +const PeerRow = ({ peer, vm }: { peer: VpnPeer; vm: VpnVM }) => { + const { palette } = useTheme(); + const ip = peer.ips[0]; + const key = `peer:${peer.id}`; + const copied = vm.copiedKey === key; + + return ( + ip && vm.copy(key, ip)} + onLongPress={() => peer.name && vm.copy(key, peer.name)} + disabled={!ip} + className="flex-row items-center gap-3 rounded-xl border p-3 active:opacity-70" + style={{ borderColor: palette.border, backgroundColor: palette.surface }} + > + + + + {peer.name || peer.hostName || peer.id} + + + {[ip ?? '—', peer.os, !peer.online && peer.lastSeen ? `seen ${peer.lastSeen}` : null] + .filter(Boolean) + .join(' · ')} + + + + + ); +}; + +// The big connect button — the whole screen's focal point. +/** + * Status and action, as two separate things. + * + * They used to be one: a big unlabelled circle that was both the state readout AND the only way to + * connect. When disconnected it rendered on `palette.surface`, so it looked like a badge — Andre + * spent twenty minutes concluding, reasonably, that the screen had no connect control at all, and + * the only thing that looked pressable was the refresh icon. + * + * So: the circle now only reports state, and a full-width labelled button does the work. Modelled on + * the Tailscale app, which likewise never makes you guess what to tap. + */ +/** + * The connection control, shaped after Tailscale's own app: a switch, not a shield. + * + * It used to be a 96px status circle with a full-width button underneath — three elements saying one + * thing, and the circle was deliberately not pressable so it "could not be mistaken for the action", + * which is a comment admitting the layout invited a mistake. A switch is the control people already + * reach for, and its state and its affordance are the same object. + * + * The switch carries every transition `toggle` handles: connect, disconnect, and first-run enrolment. + * While any of those are in flight it is replaced by a spinner rather than disabled in place — a + * greyed switch still reads as "off", which is a lie during connecting. + */ +const ConnectSwitch = ({ vm }: { vm: VpnVM }) => { + const { palette } = useTheme(); + const meta = STATE_META[vm.status.state]; + /* + `switching` counts as working. + + Changing tailnet takes the tunnel down and brings it back, so for a second or two the status is + genuinely 'stopped' — and the screen said "Not connected" with the switch flicked off, which reads + as the app having fallen over mid-operation rather than as a step in one. + */ + const working = + vm.busy || vm.enrolling || vm.switching || vm.status.state === 'connecting' || vm.status.state === 'reconnecting'; + + const headline = vm.switching + ? 'Switching tailnet…' + : vm.enrolling + ? 'Enrolling…' + // Before `working`: the tunnel IS up while waiting for authorisation, so "Connecting…" would be + // wrong, and "Not set up" — which is what it used to say — is wrong in the other direction. + : vm.signingIn + ? (isTailscaleControl(vm.status.controlUrl) ? 'Waiting for Tailscale sign-in' : 'Waiting for sign-in') + : working + ? vm.connected + ? 'Disconnecting…' + : 'Connecting…' + : vm.connected + ? 'Connected' + : vm.registered + ? 'Not connected' + : 'Not set up'; + + /* + What you are connected AS — or, when you are not, what turning the switch on would restore. + + Disconnected, the app can read nothing: the core is not running, so there are no profiles, and + `registered` stays false on the Tailscale path. It therefore showed a bare "Not connected" with no + indication that a working tailnet was one tap away — which is precisely how you lose someone who + has just removed the VPN configuration in Settings. + + `lastSeen` records only THAT something was set up, not what — deliberately. While disconnected the + core is not running, so the profile list cannot be read, and naming one remembered network would be + confidently wrong the moment there are two. Cleared when everything is forgotten. + */ + const connectedAs = vm.switching + ? 'Reconnecting to the new tailnet' + : (vm.status.self?.magicDNSName ?? vm.status.self?.name); + const previously = vm.lastSeen?.tailnet ?? vm.lastSeen?.account; + const subtitle = connectedAs ?? ''; + + return ( + + + + + + + + {headline} + {subtitle ? ( + {subtitle} + ) : null} + + + {working ? ( + + + + ) : ( + + )} + + + {/* + Disconnected, with a tailnet we remember. + + This started life as a suffix on the subtitle above — one muted line, numberOfLines={1}, so + the only actionable words ("turn on to reconnect") were the ones that got ellipsised. An + instruction cannot live in a slot designed to truncate. It gets full width, wrapping, and + body-text contrast instead. + */} + {!vm.connected && !vm.enrolling && !vm.switching && previously ? ( + + + + You're already set up on this device. Turn the switch on to reconnect — no need to + sign in again. + + + ) : null} + + {/* Superseded, not broken. iOS allows one active VPN, so another app taking the slot deselects + ours — which without this line looks exactly like a failure to connect. Says what happened + and what turning the switch back on will do. */} + {vm.status.supersededByOtherVpn && !vm.connected ? ( + + + + Another VPN app is currently active. iOS allows one at a time — connecting here will take + over. + + + ) : null} + + {vm.status.authUrl ? ( + /* + * Interactive login. The control server has issued a URL and is waiting for a human to + * approve this node; opening it is the entire flow. Whatever identity provider sits behind + * it — Google, GitHub, an SSO tenant — authenticates against the CONTROL SERVER, so no + * credential and no token passes through this app. The node polls and comes up on its own. + */ + isTailscaleControl(vm.status.controlUrl) ? ( + + {/* Name the destination. Interactive sign-in only ever happens for Tailscale — a Headscale + node registers with a pre-auth key and never sees this — so an unlabelled "sign in" + leaves the user guessing which of their networks is asking, and whether it should be + asking at all. */} + + + {isTailscaleControl(vm.status.controlUrl) + ? 'Tailscale' + : (vm.status.controlUrl?.replace(/^https?:\/\//i, '').replace(/\/+$/, '') ?? 'This network')} + {' '} + needs to authorise this device in your browser. + + void Linking.openURL(vm.status.authUrl!)} + className="h-11 flex-row items-center justify-center gap-2 rounded-xl active:opacity-80" + style={{ backgroundColor: palette.accent }} + > + + + Sign in to Tailscale + + + + ) : ( + /* + A self-hosted Headscale has NO usable interactive login on a phone. + + Its auth URL renders a page telling you to run `headscale auth register …` on the server — + reasonable at a desk, useless from the device being registered, and the user cannot act on + it at all. Offering that link was worse than offering nothing: it looked like the way + forward and was a dead end. + + The only thing that works here is a fresh pre-auth key, so that is what this offers. + */ + + + + {vm.status.controlUrl?.replace(/^https?:\/\//i, '').replace(/\/+$/, '') ?? 'This server'} + {' '} + needs this device registered again. Headscale cannot do that in a browser — it needs a + new pre-auth key from whoever runs the server. + + vm.setShowManual(true)} + className="h-11 flex-row items-center justify-center gap-2 rounded-xl active:opacity-80" + style={{ backgroundColor: palette.accent }} + > + + + Enter a pre-auth key + + + + ) + ) : null} + + {/* Only the standalone hint survives here — the embedded one is the subtitle above, and saying + it twice on the same card reads as a bug. */} + {/* Also gated on not being connected. `registered` is a local flag the app writes, and on the + Tailscale path the node can be fully up while it is still false — which put "Set up, or add a + Headscale server" underneath a working connection. A node talking to its control plane does + not need setting up, whatever the flag says. */} + {!vm.registered && !vm.connected && !vm.enrolling && !vm.signingIn && !vm.embedded ? ( + + Set up, or add a Headscale server below. + + ) : null} + + ); +}; + +/** + * Which tailnet you are on, and how to be on a different one. + * + * The core has always kept a profile per login — its own control server, node key and prefs — so a + * self-hosted Headscale and a login.tailscale.com account can coexist. None of it was reachable. + * + * Shown as soon as there is one login, because the card is not only a picker: it names the tailnet + * you are actually on, which the switch above does not, and it carries the only route to adding a + * second. Hiding it until there are two would mean the way to get a second one is hidden until you + * have one. + */ +const ProfileSwitcher = ({ vm }: { vm: VpnVM }) => { + const { palette } = useTheme(); + const [renaming, setRenaming] = useState(null); + const [draft, setDraft] = useState(''); + + // Deliberately NOT `if (!profiles.length) return null`. Profiles can only be read while the core is + // running, so a disconnected node legitimately has none — and hiding the whole card then removed the + // switcher AND the add button at exactly the moment the user needed them to get back. + if (!vm.registered && !vm.tailnets.length) return null; + + return ( + + Tailnets + + {vm.tailnets.map((p) => ( + + !p.current && !vm.switching && void vm.switchProfile(p.id)} + disabled={p.current || vm.switching} + className="flex-row items-center gap-3 active:opacity-70" + > + + + + {vm.tailnetLabel(p)} + + {/* Which KIND of network, then the server's own words. The kind leads because with + several set up it is the first thing you need to tell them apart — and it cannot be + read off the names: a personal Tailscale tailnet is named after the login email, and + a Headscale namespace can be called anything. Omitted rather than guessed when the + core predates reporting a control URL. */} + + {[ + p.controlUrl ? (isTailscaleControl(p.controlUrl) ? 'Tailscale' : 'Headscale') : null, + p.tailnet, + p.name, + ] + .filter(Boolean) + .join(' · ') || 'unnamed'} + + + {vm.switching && !p.current ? : null} + { + setRenaming(renaming === p.id ? null : p.id); + setDraft(vm.labels[p.id] ?? ''); + }} + hitSlop={8} + className="active:opacity-60" + > + + + {/* + Forget just this one, as opposed to signing out of everything. + + Confirmed, because it is irreversible and the row is small enough to hit by accident — + and the warning names the tailnet, since "are you sure?" on a list of several is exactly + where people delete the wrong thing. Only offered while the core runs: DeleteProfile + needs the live backend, and a button that throws "not started" is worse than no button. + */} + + Alert.alert( + `Sign out of ${vm.tailnetLabel(p)}?`, + p.current + ? 'This device is removed from that tailnet and disconnects. Signing in again registers it as a new device.' + : 'This device is removed from that tailnet. Your other tailnets are untouched.', + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Sign out', style: 'destructive', onPress: () => void vm.signOutOf(p.id) }, + ], + ) + } + hitSlop={8} + className="active:opacity-60" + > + + + + + {renaming === p.id ? ( + + + { + vm.setTailnetLabel(p.id, draft); + setRenaming(null); + }} + className="h-9 items-center justify-center rounded-lg px-3 active:opacity-80" + style={{ backgroundColor: palette.accent }} + > + Save + + + ) : null} + + ))} + + {/* A registered node with no readable profiles means the core is not running, not that there are + no tailnets. Say which, rather than showing an empty card that reads like data loss. */} + {!vm.tailnets.length ? ( + + {vm.backendUp ? 'Reading tailnets…' : 'Connect to see your tailnets.'} + + ) : null} + + {/* + Opens the same chooser as first run. It does NOT touch the core: creating the profile happens + on submit, once a destination exists. Doing it on tap dropped the tunnel into a blank profile + that could never log in. + */} + !vm.switching && vm.beginAddProfile()} + disabled={vm.switching} + className="flex-row items-center justify-center gap-2 rounded-xl border border-dashed p-3 active:opacity-70" + style={{ borderColor: palette.border }} + > + + Add a tailnet + + + {/* Switching recycles the tunnel — the netmap, IPs and routes all change with the identity. */} + + Switching reconnects the tunnel. Adding one asks you to sign in. + + + ); +}; + +const ManualLogin = ({ vm }: { vm: VpnVM }) => { + const { palette } = useTheme(); + return ( + + Connect to Headscale + Point at your control server and paste a pre-auth key. + + + + + {vm.enrolling ? : Connect} + + {/* A pre-auth key sent over plain HTTP travels in the clear. The fallback is allowed — plenty of + home Headscales run without TLS behind a tunnel — but it is never silent. */} + {vm.insecure ? ( + + Connected over plain HTTP — this server has no TLS, so the pre-auth key was sent unencrypted. + + ) : null} + + ); +}; + +/** + * A pending invite, awaiting one confirmation. + * + * Deliberately NOT automatic. A link arrives through a chat app and can be forwarded by anyone, so + * joining on tap would let a stray message enrol someone's phone onto a stranger's network. It names + * the server and the account so the person can see what they are agreeing to before they agree. + * + * Rendered above everything else, because an invite is the reason the app was opened. + */ +const InviteCard = ({ vm }: { vm: VpnVM }) => { + const { palette } = useTheme(); + if (!vm.invite) return null; + const host = vm.invite.base.replace(/^https?:\/\//i, '').replace(/\/.*$/, ''); + + return ( + + + + You have been invited + + + + Join the network at {host}. No + sign-in needed — the invite carries everything. + + + + + void vm.acceptInvite()} + disabled={vm.inviteBusy} + className="h-11 flex-row items-center justify-center gap-2 rounded-xl active:opacity-90" + style={{ backgroundColor: palette.accent, opacity: vm.inviteBusy ? 0.6 : 1 }} + > + {vm.inviteBusy ? ( + + ) : ( + + )} + + {vm.inviteBusy ? 'Joining…' : 'Join this network'} + + + + + Not now + + + {/* The invite is single-use and its key is short-lived, so a failure here usually means asking + the admin for another rather than retrying. Say which, per reason. */} + {vm.inviteError ? ( + {vm.inviteError} + ) : null} + + ); +}; + +/** + * Step one of first run: the OS has to grant tunnel rights before anything else can happen. + * + * On its own screen rather than folded into Connect, because it is a different question — this is the + * operating system's permission, not a tailnet. It also has to come first for a structural reason: our + * Tailscale core runs INSIDE the tunnel process, so there is no backend to ask for a sign-in URL until + * a tunnel can exist at all. + */ +/** + * What this app collects, said before you use it. + * + * Required by App Store guideline 5.4: a VPN app must make "a clear declaration of what user data will + * be collected and how it will be used ON AN APP SCREEN prior to any user action to purchase or + * otherwise use the service." Not the privacy policy, not the store listing — a screen, before first + * use. So it sits under Allow VPN, which is the first action anyone takes. + * + * Shown on the permission gate only. Repeating it above a working tunnel would be noise, and the + * guideline asks for it *prior to* use rather than forever. + * + * The text is short because the truthful answer is short. If any of these four lines stops being + * true, this screen is the first thing that must change — it is a declaration, not marketing. + */ +/** + * One panel of the pre-use declaration: a heading with the load-bearing word enlarged, and a list. + * + * Shared by both cards so they cannot drift apart. Two panels saying different things in slightly + * different type would read as two moods rather than one promise. + */ +const DisclosurePanel = ({ + icon, + before, + emphasis, + after, + lines, +}: { + icon: keyof typeof Ionicons.glyphMap; + before: string; + /** The word that carries the meaning. Larger and in the accent, on the same line. */ + emphasis: string; + after: string; + lines: string[]; +}) => { + const { palette } = useTheme(); + const { scale } = useUIScale(); + return ( + + + + + {before} + {/* Weight rather than size. The heading is already `font-bold` (700), so the word that + carries the meaning goes to 900 and keeps the accent — it reads as emphasis inside one + sentence instead of two type sizes sharing a line. */} + {emphasis} + {after} + + + {/* gap-3 between items, and a line height above the font size within one — a bullet that wraps + to two lines must still read as one item, so the space BETWEEN items has to exceed the space + inside them. */} + + {lines.map((line) => ( + + • + + {line} + + + ))} + + + ); +}; + +/** + * What this app does and does not do, said before you use it. + * + * Required by App Store guideline 5.4: a VPN app must make "a clear declaration of what user data will + * be collected and how it will be used ON AN APP SCREEN prior to any user action to purchase or + * otherwise use the service." Not the privacy policy, not the store listing — a screen, before first + * use. So it sits under Allow VPN, which is the first action anyone takes. + * + * Shown on the permission gate only. Repeating it above a working tunnel would be noise, and the + * guideline asks for it *prior to* use rather than forever. + * + * Nothing here is mandated wording — 5.4 asks for a clear declaration and leaves the words to us. What + * is not optional is that every line stays TRUE. If one stops being true, this is the first thing that + * must change: it is a declaration, not marketing. + */ +const DataDisclosure = () => ( + <> + + + +); + +const AuthorizeGate = ({ vm }: { vm: VpnVM }) => { + const { palette } = useTheme(); + const { scale } = useUIScale(); + return ( + + {/* The button alone, and no shield glyph either — that one illustrated the word "VPN" to + someone already reading the word "VPN". + + It carried a heading saying "Allow the VPN" directly above a button saying + "Allow VPN", and a paragraph explaining a permission prompt that iOS is about to explain + itself — three statements of one thing, above two disclosure cards that are the screen's + actual content. + + What went with the paragraph was a genuine distinction: it read differently once a tailnet + existed, so someone who had removed the VPN configuration in Settings was told their network + was still set up rather than being left to assume they had lost it. `vm.lastSeen` still holds + that, and if this ever reads as alarming on a re-authorize, that sentence is what to bring + back — on its own, without the heading. */} + + + {vm.authorizing ? ( + + ) : ( + Allow VPN + )} + + + + ); +}; + +/** + * Step two: which network. **Both destinations are equals** — that is the point. + * + * Headscale is a first-class choice here, not an "advanced" disclosure. Burying self-hosting behind + * the hosted option is the specific thing that makes the official client tiresome for anyone running + * their own control server, and this app exists partly to not do that. + */ +const SetupChoice = ({ vm }: { vm: VpnVM }) => { + const { palette } = useTheme(); + const Option = ({ + icon, + title, + body, + onPress, + }: { + icon: keyof typeof Ionicons.glyphMap; + title: string; + body: string; + onPress: () => void; + }) => ( + + + + {title} + {body} + + + + ); + + return ( + + Choose a network + + You can add the other one later, and switch between them. + + + + + ); +}; + +/** + * Exit node, and subnet routes. + * + * Both are preferences on a RUNNING node, applied with EditPrefs, so neither drops the tunnel — which + * is why they can be switches rather than a reconnect flow. + * + * "Exit node" is the term Tailscale uses and the one people search for, so the card says that rather + * than "Routing", which means nothing to anyone who has not read the docs. + * + * The list of machines only appears once the switch is ON. Showing candidates under an off switch + * asks the user to reason about a choice that has no effect yet. + */ +const RoutingCard = ({ vm }: { vm: VpnVM }) => { + const { palette } = useTheme(); + const activeId = vm.prefs.exitNodeID || ''; + /* + The switch is "do I want one", which is not the same as "is one set". + * + * Turning it on cannot pick a machine for the user, so it reveals the list instead and the choice + * sets the pref. Seeded from whether an exit node is already in use, so reopening the screen shows + * the real state rather than a switch that forgot. + */ + const [wantExitNode, setWantExitNode] = useState(!!activeId); + // An exit node set elsewhere (or cleared) must move the switch — it reports the core, not a guess. + useEffect(() => { + if (activeId) setWantExitNode(true); + }, [activeId]); + + if (!vm.connected || !vm.canEditPrefs) return null; + + const candidates = vm.status.peers.filter((p) => p.exitNodeOption); + const active = candidates.find((p) => p.id === activeId); + const trouble = exitNodeTrouble(vm.status, vm.prefs); + + const toggle = (on: boolean) => { + setWantExitNode(on); + // Turning it off is a real change; turning it on only opens the list. + if (!on && activeId) void vm.setExitNode(undefined); + }; + + return ( + + + + + Use exit node + + {/* On with nothing chosen is the same as off, so say that rather than describe an + intent — a switch in the on position otherwise implies traffic is already going + somewhere it is not. */} + {/* Never claim traffic is flowing through a node that cannot carry it — that reassurance + is the exact thing that makes a dead exit node look like a broken app. */} + {active && !trouble + ? `All your internet traffic goes out through ${active.name || active.hostName}.` + : trouble + ? 'Your internet traffic is blocked until this is resolved.' + : wantExitNode + ? 'Nothing selected yet — pick a machine below.' + : 'Send all your internet traffic through another machine on your tailnet.'} + + + {vm.prefsBusy ? ( + + ) : ( + + )} + + + {/* + An exit node that stops working takes all internet traffic with it — deliberately. The tunnel + fails CLOSED rather than quietly falling back to the local network, which is the only safe + choice for something whose whole purpose is to not leak. But the safe behaviour and a broken + app look identical from the phone, so it has to be said out loud, with the way out attached. + */} + {trouble ? ( + { + setWantExitNode(false); + void vm.setExitNode(undefined); + }, + }} + /> + ) : null} + + {wantExitNode ? ( + candidates.length ? ( + + {candidates.map((p) => { + const on = p.id === activeId; + return ( + void vm.setExitNode(on ? undefined : p.id)} + disabled={vm.prefsBusy} + className="flex-row items-center gap-3 rounded-xl border p-3 active:opacity-70" + style={{ + borderColor: on ? palette.accent : palette.border, + backgroundColor: on ? palette.surfaceAlt : 'transparent', + opacity: vm.prefsBusy ? 0.6 : 1, + }} + > + + + + {p.name || p.hostName || p.id} + + + {[p.ips[0], p.os, p.online ? null : 'offline'].filter(Boolean).join(' · ')} + + + + ); + })} + + ) : ( + // Distinct from "you have no peers": a machine must be running with --advertise-exit-node + // AND approved, so silence here is a server-side state, not a fault in the app. + + No machine on this tailnet is offering to be an exit node yet. + + ) + ) : null} + + + + + Use subnet routes + + Reach networks that other machines share + + + {vm.prefsBusy ? null : ( + void vm.setAcceptRoutes(on)} + trackColor={{ false: palette.surfaceAlt, true: palette.accent }} + thumbColor="#ffffff" + /> + )} + + + ); +}; + +/** + * Who this device is on the mesh, in the order a person actually asks it. + * + * The card used to show the fully-qualified MagicDNS name as "Name" and both IPs crammed into one + * comma-joined line. Both are accurate and neither is readable: `iphone-test-2.tail57b36.ts.net.` is + * a DNS record, not a device name, and an IPv4 and an IPv6 sharing a row cannot be tapped, copied or + * even reliably picked apart by eye. + * + * So: the short host name is the headline, the FQDN is secondary, the network and account come next + * because "which tailnet am I on, as whom" is the question a second profile creates, and the + * addresses are separate copyable rows. + */ +/** + * Warn before this device's key expires. + * + * Not the pre-auth key used to register — that one is spent on first contact and is the tailnet + * admin's business. This is the node key the device holds afterwards, which expires on the tailnet's + * own schedule, and when it does the device stops carrying traffic until a human re-authorises it. + * + * Expiry ITSELF needs no card: the node drops to `needsLogin` and the existing sign-in path already + * handles it correctly for both kinds of tailnet. What was missing is the notice beforehand, because + * re-authorising is not something to discover on a phone on the day it breaks — it needs either a + * browser sign-in or a fresh key from whoever runs the server. + * + * There is deliberately no button. Renewing early means going through the same sign-in as a new + * registration, and offering a tap that quietly re-registers the device would be a worse surprise + * than the expiry. The body names the actual next step for the tailnet in question instead. + */ +const KeyExpiryNotice = ({ vm }: { vm: VpnVM }) => { + const expiry = keyExpiryStatus(vm.status.self); + // Once expired the node is in needsLogin and that path speaks for itself — do not say it twice. + if (!expiry || expiry.expired || !vm.connected) return null; + + const tailscale = isTailscaleControl(vm.status.controlUrl); + return ( + + ); +}; + +const ThisDevice = ({ vm }: { vm: VpnVM }) => { + const { palette } = useTheme(); + const self = vm.status.self; + if (!self) return null; + + /* + Take the control server from the CURRENT PROFILE, not from the app's stored value. + + status.controlUrl is whatever configure() last wrote — one global for many profiles. Switching + never calls configure(), so after a switch this card confidently named the tailnet you had just + left, while the row above it (sourced from the core) named the right one. Same class of bug as the + core rewriting a profile's ControlURL from that same global. + + The profile list is the core's own answer, so it cannot drift. The stored value stays only as a + fallback for the moment before the list has been read. + */ + const current = vm.tailnets.find((t) => t.current); + const controlUrl = current?.controlUrl || vm.status.controlUrl; + const tailscale = isTailscaleControl(controlUrl); + const host = controlUrl?.replace(/^https?:\/\//i, '').replace(/\/+$/, ''); + const ipv4 = self.ips.filter((ip) => ip.includes('.')); + const ipv6 = self.ips.filter((ip) => ip.includes(':')); + + const CopyRow = ({ label, value, k }: { label: string; value: string; k: string }) => ( + void vm.copy(k, value)} className="gap-0.5 active:opacity-60"> + + {label} + + + {value} + + + + ); + + return ( + + This device + + + + {self.hostName || self.name} + + {self.magicDNSName && self.magicDNSName !== (self.hostName || self.name) ? ( + {self.magicDNSName} + ) : null} + + + {/* Which network, said plainly. "tailscale.com" vs a Headscale host is the distinction that + matters once both can be signed in at once. */} + + + + {tailscale ? 'Tailscale' : 'Headscale'} + + {self.tailnet ? ( + + {self.tailnet} + + ) : null} + + + {/* Only a Tailscale login has an account behind it; a Headscale pre-auth key usually does not, + so an empty row here is a real state rather than missing data. */} + {self.loginName ? : null} + {host ? : null} + + {ipv4.map((ip) => ( + + ))} + {ipv6.map((ip) => ( + + ))} + + {/* Signing out belongs here as well as on the row above: this card is where you are looking when + you decide you are done with a connection, and making someone hunt for it elsewhere — or + switch to a tailnet first — is the friction this app exists to avoid. */} + + Alert.alert( + `Sign out of ${self.tailnet || (tailscale ? 'Tailscale' : 'Headscale')}?`, + 'This device is removed from that tailnet and disconnects. Signing in again registers it as a new device.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Sign out', + style: 'destructive', + onPress: () => void vm.signOutOf(vm.tailnets.find((t) => t.current)?.id ?? ''), + }, + ], + ) + } + disabled={vm.busy} + className="mt-1 flex-row items-center justify-center gap-2 rounded-xl border p-3 active:opacity-80" + style={{ borderColor: palette.dangerBorder, opacity: vm.busy ? 0.6 : 1 }} + > + {vm.busy ? ( + + ) : ( + + )} + + {vm.busy ? 'Signing out…' : 'Sign out of this tailnet'} + + + + ); +}; + +/** The Tailscale branch: no key to paste, just start and follow the sign-in URL the core produces. */ +const TailscaleLogin = ({ vm }: { vm: VpnVM }) => { + const { palette } = useTheme(); + return ( + + Connect to Tailscale + + Opens tailscale.com in your browser to authorise this device. Your sign-in happens there — + OffScale never sees your account details. + + + + {vm.enrolling ? ( + + ) : ( + Sign in to Tailscale + )} + + + ); +}; + +/** The dashboard's sections. Status is not among them — see the note where the tabs render. */ +type VpnTab = 'device' | 'routing' | 'peers'; + +export const VpnScreen = ({ embedded, onShowCredits, wordmark, wordmarkLight }: VpnScreenProps) => { + const { palette } = useTheme(); + const insets = useSafeAreaInsets(); + const vm = useVpnScreen(embedded); + const [settingsOpen, setSettingsOpen] = useState(false); + // Device first: on a working connection it answers "what am I on this tailnet", which is what the + // screen is usually opened for. Peers is the browsing one and costs a tap. + const [tab, setTab] = useState('device'); + const onWallpaper = useOnWallpaperText(); + const ui = useUIScale(); + + if (!isVpnAvailable()) { + return ( + + + The VPN engine isn't bundled in this build. + + ); + } + + if (settingsOpen) { + return ( + setSettingsOpen(false)} + onShowCredits={onShowCredits} + /* The whole app floats on the landscape; a settings page painted flat would be the one screen + that steps out of it. */ + wallpaper + wordmark={wordmark} + wordmarkLight={wordmarkLight} + bottomStrip={} + /* This app has no account, so there is no "Sign out". Forgetting every connection is the real + end-of-list action here, so it belongs in that position rather than duplicated halfway up + the page. */ + footer={ + <> + {/* + NOT "sign out" — that is per-connection and belongs beside each tailnet, which is where it + now lives. This app has no account of its own to sign out of, so an app-level sign out was + a category error: it described one identity while actually destroying all of them. + + What belongs here is the whole-app reset: forget every connection at once. + + Gated on anything indicating a node exists, NOT on `registered` alone. That flag is written + by the app and stays false on the Tailscale path, so a fully connected node offered no + reset at all — invisible precisely when it applied. + */} + {vm.registered || vm.connected || vm.profiles.length > 0 ? ( + { + await vm.logout(); + setSettingsOpen(false); + }} + disabled={vm.busy} + className="flex-row items-center justify-center gap-2 rounded-lg border p-3 active:opacity-80" + style={{ borderColor: palette.dangerBorder, backgroundColor: palette.dangerBg, opacity: vm.busy ? 0.6 : 1 }} + > + {vm.busy ? ( + + ) : ( + + )} + + {vm.busy ? 'Forgetting…' : 'Forget all connections'} + + + ) : null} + {vm.registered || vm.connected || vm.profiles.length > 0 ? ( + + Removes this device from every tailnet it has joined and returns OffScale to setup. Each + one disappears from its admin panel; signing in again registers a new device. To leave + just one, sign out of it from its row on the main screen. + + ) : null} + + } + > + + + + {vm.coreVersion ? `${vm.coreVersion} · embedded` : 'unavailable'} + + + + + ); + } + + const self = vm.status.self; + // Manual Headscale entry — a control URL plus a pre-auth key, typed or pasted. + /* + * The Headscale form is for enrolment, not reconnection. + * + * It used to appear whenever `registered` was false — and on iOS that flag was answered by a + * broken App Group read, so a device with a perfectly valid node identity was shown a form asking + * for a pre-auth key. Keys are single-use, so Andre minted a fresh one on Headscale every single + * round to answer a question the app had wrong. + * + * Now it also stays hidden whenever the tunnel is connected or connecting: whatever the flag says, + * a node that is talking to the control plane plainly does not need enrolling. `vm.showManual` + * still forces it open for the standalone/manual path. + */ + const busyOrUp = vm.connected || vm.status.state === 'connecting' || vm.status.state === 'reconnecting'; + const showManual = vm.showManual || (!vm.registered && !busyOrUp); + + /* + Tabs appear only on the DASHBOARD, never during setup. + + Every pre-connection state — permission gate, choosing a network, entering a key — is a single + linear task with one thing to do. Wrapping those in sections would offer a Peers tab to a device + that has never joined a tailnet, and a Routing tab with nothing to route. + */ + const setupFlow = vm.authorized === false || vm.needsSetup || vm.adding; + const showTabs = !setupFlow && !vm.signingIn; + + const TABS: readonly TabDef[] = [ + { key: 'device', label: 'Device', icon: 'phone-portrait-outline' }, + { key: 'routing', label: 'Routing', icon: 'git-network-outline' }, + { key: 'peers', label: 'Peers', icon: 'people-outline', badge: vm.status.peers.length }, + ]; + + const body = ( + + {/* No title here: the header is on the wallpaper above, drawn by AppBackdrop. */} + + {!vm.invite && vm.inviteError ? ( + + {vm.inviteError} + + ) : null} + + {/* + First run, three states, decided in this order: + 1. no OS permission → Allow VPN, and nothing else + 2. permission but no tailnet → choose Headscale or Tailscale (equals) + 3. otherwise → the normal dashboard + + The order is not cosmetic. Our Tailscale core runs inside the tunnel process, so there is no + backend to produce a sign-in URL until the OS has allowed a tunnel to exist — permission has + to precede the choice of network, not follow it. + */} + {vm.authorized === false ? ( + <> + + {/* Below the gate, so it is on screen before the first action anyone takes. See 5.4. */} + + + ) : vm.needsSetup || vm.adding ? ( + vm.choice == null ? ( + <> + + {/* Only when ADDING: first run has nothing to go back to, and offering "cancel" there + would leave the app on a screen with no working action. */} + {vm.adding ? ( + + + Cancel + + ) : null} + + ) : ( + <> + vm.setChoice(null)} className="flex-row items-center gap-1 active:opacity-60"> + + Choose a different network + + {vm.choice === 'headscale' ? : } + + ) + ) : ( + <> + + {/* Hidden mid sign-in. The list would be built from a placeholder status and would claim + membership of a tailnet this device has not joined yet. + + Kept ABOVE the tabs rather than inside one: which tailnet you are on changes the meaning + of every tab below it, and holding two tailnets at once is the thing this app does that + the official client makes hard. Burying it behind a tap would hide the feature. */} + {vm.signingIn ? null : } + + )} + + {vm.error ? ( + + {vm.error} + + ) : null} + + {/* + The sections. + + Status stays above them and is never a tab: it is the one thing the app exists to tell you, + and putting "am I connected" behind a tap would mean the answer depends on which tab you left + it on. Everything below is reference or configuration, which is what tabs are for. + + The tailnet switcher also stays out of the tabs — see the note where it renders. + */} + {showTabs ? : null} + + {showTabs && tab === 'device' ? ( + <> + + {self ? : null} + + ) : null} + + {showTabs && tab === 'routing' ? : null} + + {showTabs && tab === 'peers' ? ( + /* Gated on being CONNECTED, not on `registered` — that flag is written by the app and stays + false on the Tailscale path, so this list would have stayed hidden even once peers arrived. + Same flag that hid the sign-out button. */ + vm.connected && vm.status.peers.length > 0 ? ( + + {vm.status.peers.map((p) => ( + + ))} + + ) : ( + // A tab that renders nothing reads as broken. Say which of the two reasons it is. + + + {vm.connected + ? 'No other machines on this tailnet yet.' + : 'Connect to see the other machines on your tailnet.'} + + + ) + ) : null} + + {/* Suppressed during first run and behind the permission gate: those states render their own + Headscale form, and showing a second copy here would put two on the screen at once. */} + {/* `vm.showManual` forces it open even mid sign-in: for a Headscale node in needsLogin the form + is the ONLY way forward, so hiding it there strands the user. */} + {showManual && !vm.needsSetup && (!vm.signingIn || vm.showManual) && vm.authorized !== false ? ( + + ) : null} + + {/* + Deliberately NOT a Notice, and deliberately below the fold. + + By the time this renders the tunnel is up again — the trouble is over, and nothing is asked of + the user. Putting "the VPN crashed last time" at the top of a working screen trains people to + ignore the place real warnings appear. Its audience is whoever is debugging, and the console + line logged alongside it is what actually gets searched. + */} + {showTabs && tab !== 'device' ? null : vm.lastExit ? ( + + + Previous run (debug) + + + {`ended abnormally — no stop handler ran\nstarted: ${String(vm.lastExit.startedAt ?? '?')}\ndetected: ${String(vm.lastExit.detectedAt ?? '?')}`} + + + ) : null} + + {showTabs && tab !== 'device' ? null : vm.diag ? ( + + NE bring-up trace (debug) + + {vm.diag} + + + ) : null} + + {/* Core version and "Forget this device" moved into Settings — one is reference, the other is + destructive and almost never wanted, and neither earned a permanent place under the peers. + The NE trace above stays: it is a live aid while a connection is being attempted. */} + + ); + + /* + Embedded, some outer container has already drawn the wallpaper, the header and the sheet, so this + screen is only ever their contents and drawing its own frame would nest one inside another. + Standalone — which is how this app runs — the screen owns the frame. + */ + if (embedded) { + return ( + + {body} + + ); + } + + return ( + } + actions={ + + vm.refresh(true)} hitSlop={8} disabled={vm.refreshing} className="active:opacity-60"> + + {vm.refreshing ? ( + + ) : ( + + )} + + + setSettingsOpen(true)} hitSlop={8} className="active:opacity-60"> + + + + + + } + > + + {body} + + + ); +}; +import { TextField } from '../ui/TextField'; diff --git a/src/vpn/clipboard.ts b/src/vpn/clipboard.ts new file mode 100644 index 0000000..753fab9 --- /dev/null +++ b/src/vpn/clipboard.ts @@ -0,0 +1,32 @@ +import { requireOptionalNativeModule } from 'expo-modules-core'; + +/** + * Copy text, or report that we could not. + * + * ## The guard has to test the NATIVE module, not the import + * + * This used to `require('expo-clipboard')` inside a try/catch and assume that covered a host which + * ships without it. It does not, and the failure is worse than the one it was guarding against. + * + * A `require` that resolves proves only that the JavaScript is on disk. It can succeed while the + * NATIVE half was never linked into this binary — a stale prebuild, a pod that did not install — and + * calling `setStringAsync` then takes the process down. A JavaScript try/catch cannot catch that; the + * app is simply gone. + * + * `requireOptionalNativeModule` asks the question that actually matters: is the native side present in + * THIS binary. It returns null instead of throwing, so a host without clipboard support degrades to a + * copy that quietly does nothing, which is what the original comment intended all along. + * + * Returns false rather than throwing, so callers can say "copied" only when it was. + */ +export const copyToClipboard = async (text: string): Promise => { + if (!requireOptionalNativeModule('ExpoClipboard')) return false; + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mod = require('expo-clipboard') as { setStringAsync: (t: string) => Promise }; + await mod.setStringAsync(text); + return true; + } catch { + return false; + } +}; diff --git a/src/vpn/invite.ts b/src/vpn/invite.ts new file mode 100644 index 0000000..d2c206a --- /dev/null +++ b/src/vpn/invite.ts @@ -0,0 +1,264 @@ +/** + * Invite enrollment — parsing and redeeming a one-tap join link. + * + * An admin mints an invite on their Headscale sidecar and sends the link. Tapping it opens OffScale, + * which shows one confirmation and joins. The parts that matter here: + * + * **The link carries a claim token, never the pre-auth key.** A pre-auth key is a bearer credential, + * and a link sent over a messaging app is not a private channel — it is logged by the messenger, + * backed up, quoted in replies, and often fetched by a link-preview bot before the human taps it. The + * key exists only in the HTTPS response to this app, and only once: claiming marks the invite used. + * + * **Everything is in the fragment**, not the query string, so the token stays out of web-server + * access logs and `Referer` headers on the fallback page. + */ + +/** A parsed invite link. Opaque values — this app validates their shape, never their meaning. */ +export type Invite = { + version: number; + token: string; + /** + * The sidecar base URL to claim against, already decoded. + * + * It is a BASE URL and may include a path (`https://host/officer-api`), not a bare origin — the + * claim path is appended to it verbatim. Reconstructing it from scheme + host drops that prefix + * and hits a 404. + */ + base: string; + /** + * The device name the admin suggested, when the link carries one (`n=`). + * + * The same string comes back as `suggestedHostname` on claim, but a claim only happens at CONFIRM — + * one step after the screen that asks for a name. Carrying it in the link is what lets that field be + * prefilled. Absent on links minted before this existed, so keep the claim-time fallback. + */ + name?: string; +}; + +/** What the sidecar returns on a successful claim. */ +export type InviteClaim = { + controlUrl: string; + authKey: string; + user?: string; + /** The server's display name for the tailnet — the control-server domain, not the MagicDNS base. */ + tailnet?: string; + /** Only present when the invite carried a note. Treat as absent, not empty. */ + suggestedHostname?: string; + keyExpiresAt?: string; +}; + +export type InviteErrorCode = + | 'malformed' + | 'unsupported' + | 'insecure' + | 'unknown' + | 'used' + | 'expired' + | 'rateLimited' + | 'server' + | 'network'; + +export class InviteError extends Error { + code: InviteErrorCode; + constructor(code: InviteErrorCode, message: string) { + super(message); + this.code = code; + } +} + +/** Human wording per failure. Distinct on purpose: "invalid link" for everything is what makes people retry forever. */ +export const inviteErrorText = (code: InviteErrorCode, host?: string): string => { + switch (code) { + case 'malformed': + return 'This invite link is not readable. Ask for a new one.'; + case 'unsupported': + return 'This invite needs a newer version of OffScale.'; + case 'insecure': + return 'This invite points at an unencrypted server, so it was refused.'; + case 'unknown': + return 'This invite link is not valid. Ask for a new one.'; + case 'used': + return 'This invite has already been used. Ask for a new one.'; + case 'expired': + return 'This invite has expired. Ask for a new one.'; + case 'rateLimited': + return 'Too many attempts. Wait a moment and try again.'; + case 'server': + return 'The server could not complete this invite. Try again shortly.'; + case 'network': + return `Could not reach ${host ?? 'the server'}. Check your connection.`; + } +}; + +/** + * base64url -> string, without depending on `atob`. + * + * Hermes does not reliably provide `atob`, and a link that fails to decode would be indistinguishable + * from a corrupt one. Small enough to own. + */ +const decodeBase64Url = (input: string): string | null => { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + const normalised = input.replace(/-/g, '+').replace(/_/g, '/'); + let bits = 0; + let acc = 0; + let out = ''; + for (const ch of normalised) { + if (ch === '=') break; + const v = alphabet.indexOf(ch); + if (v < 0) return null; + acc = (acc << 6) | v; + bits += 6; + if (bits >= 8) { + bits -= 8; + out += String.fromCharCode((acc >> bits) & 0xff); + } + } + return out; +}; + +/** The one version of the link format this build understands. */ +const SUPPORTED_VERSION = 1; + +/** UTF-8 length of one code point. */ +const utf8Len = (cp: number): number => (cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4); + +/** + * Tidy a device name suggested by an invite link. + * + * Two things a plain `.slice(0, 63)` gets wrong, both only visible with a non-ASCII name — which this + * field invites, since it is a person's device. + * + * **63 is a limit in BYTES.** It is the DNS label maximum, and `slice` counts UTF-16 units. A + * 63-character name in Cyrillic or Japanese is well over 63 bytes once encoded, so the cap did not + * actually cap anything it was meant to. + * + * **`slice` can cut a code point in half.** Anything outside the BMP — an emoji, which people do put + * in device names — is a surrogate PAIR in JavaScript, and slicing between the halves leaves a lone + * surrogate: a string that is no longer valid Unicode and encodes to a replacement character + * downstream. Iterating with `for…of` walks code points, so a character is either kept whole or + * dropped. + * + * Control and zero-width characters go too. They are invisible in the field, so a name carrying them + * looks identical to one that does not while behaving differently — and this string arrives from a + * link somebody else composed. + * + * Deliberately NOT restricted to a hostname charset. The value is shown in an editable field and the + * control server has the final say on what it will accept; quietly rewriting someone's device name + * into something they did not type would be worse than letting them see it and change it. + */ +const cleanDeviceName = (raw: string | undefined): string | undefined => { + if (!raw) return undefined; + const stripped = raw.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028\u2029\ufeff]/g, '').trim(); + let out = ''; + let bytes = 0; + for (const ch of stripped) { + const n = utf8Len(ch.codePointAt(0) ?? 0); + if (bytes + n > 63) break; + out += ch; + bytes += n; + } + return out || undefined; +}; + +/** + * Read an invite out of a deep link, or throw a specific reason. + * + * Accepts both the custom scheme (`officer-offscale://join#…`) and the universal-link form + * (`https://host/join#…`), because the sidecar can switch between them with one env var and the app + * is expected to handle both without a release. + * + * Rejects before any network call — an unreadable link should never cause a request. + */ +export const parseInviteUrl = (url: string): Invite => { + const hash = url.indexOf('#'); + if (hash < 0) throw new InviteError('malformed', 'no fragment'); + + const params = new Map(); + for (const pair of url.slice(hash + 1).split('&')) { + const eq = pair.indexOf('='); + if (eq < 0) continue; + params.set(decodeURIComponent(pair.slice(0, eq)), decodeURIComponent(pair.slice(eq + 1))); + } + + const version = Number(params.get('v')); + if (!Number.isFinite(version)) throw new InviteError('malformed', 'no version'); + // A future version may mean anything at all, so refuse rather than guess at a partial reading. + if (version !== SUPPORTED_VERSION) throw new InviteError('unsupported', `version ${version}`); + + const token = params.get('t'); + if (!token) throw new InviteError('malformed', 'no token'); + + const encodedBase = params.get('s'); + if (!encodedBase) throw new InviteError('malformed', 'no sidecar'); + const base = decodeBase64Url(encodedBase); + if (!base) throw new InviteError('malformed', 'sidecar not decodable'); + + // The claim carries a bearer token out and a pre-auth key back. Plaintext is not negotiable here, + // unlike the Headscale control URL itself where an http fallback is allowed with a warning. + if (!/^https:\/\//i.test(base)) throw new InviteError('insecure', base); + + // Percent-encoded, not base64url like `s` — the loop above already decoded it, and it may be non-ASCII. + // A hint, never a requirement: a malformed or absent one is not a reason to refuse a valid invite. + const name = cleanDeviceName(params.get('n')); + + return { version, token, base: base.replace(/\/+$/, ''), ...(name ? { name } : {}) }; +}; + +/** Host only, for messages. */ +export const inviteHost = (invite: Invite): string => + invite.base.replace(/^https?:\/\//i, '').replace(/\/.*$/, ''); + +/** + * Redeem an invite. **One-shot** — the sidecar marks it used and mints the pre-auth key at this + * moment, so a second attempt with the same token fails by design. + * + * Call this at CONFIRM, never on link open: the key expires in five minutes, and first-run has to get + * through the OS VPN permission prompt first, which can easily take longer. Claiming early spends the + * invite on a flow that has not started yet. + */ +export const claimInvite = async (invite: Invite): Promise => { + let res: Response; + try { + res = await fetch(`${invite.base}/api/v1/enroll/claim`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ v: invite.version, token: invite.token }), + }); + } catch { + throw new InviteError('network', invite.base); + } + + if (!res.ok) { + // Branch on the status, not the body: the body carries { error } but the status is the contract. + switch (res.status) { + case 400: + throw new InviteError('malformed', 'rejected by server'); + case 403: + throw new InviteError('insecure', 'rejected by server'); + case 404: + throw new InviteError('unknown', 'no such invite'); + case 409: + throw new InviteError('used', 'already claimed'); + case 410: + throw new InviteError('expired', 'expired or revoked'); + case 429: + throw new InviteError('rateLimited', 'rate limited'); + default: + throw new InviteError('server', `HTTP ${res.status}`); + } + } + + const body = (await res.json().catch(() => null)) as Partial | null; + if (!body?.controlUrl || !body?.authKey) { + throw new InviteError('server', 'incomplete claim response'); + } + return { + controlUrl: body.controlUrl, + authKey: body.authKey, + user: body.user, + tailnet: body.tailnet, + // Absent whenever the invite had no note, so this must stay optional rather than defaulting to ''. + suggestedHostname: body.suggestedHostname || undefined, + keyExpiresAt: body.keyExpiresAt, + }; +}; diff --git a/src/vpn/officer-net.ts b/src/vpn/officer-net.ts new file mode 100644 index 0000000..a74e551 --- /dev/null +++ b/src/vpn/officer-net.ts @@ -0,0 +1,464 @@ +import { requireNativeModule } from 'expo-modules-core'; + +/** + * Thin bridge over the native `OfficerNet` module — the OffTail VPN engine. It embeds the Tailscale + * core (Go, via gomobile/libtailscale) and runs a real mesh node behind the OS VPN APIs (iOS + * NEPacketTunnelProvider, Android VpnService), talking directly to the user's self-hosted Headscale. + * + * This file is a bridge, not logic: no protocol, no key handling in JS. Node/WireGuard keys are + * generated and held by the native core (Keychain / Keystore) and never cross into JS. + * + * `requireNativeModule` is GUARDED rather than called at module scope, so a JS-only context — a unit + * test, Expo Go, a build where the module failed to link — reports the engine unavailable instead of + * throwing on import, before any screen has rendered enough to say why. + */ + +/** + * `needsLogin` is a STATE, not a failure. + * + * It was mapped to 'error' with the message "key rejected/expired?", which is a guess — the node + * reaches NeedsLogin whenever control wants a human to authorise it, which is the normal path for + * anything other than a pre-auth key. Tailscale's own SaaS login is only ever this state, so calling + * it an error made interactive login look broken rather than unimplemented. + */ +export type VpnState = 'stopped' | 'connecting' | 'connected' | 'reconnecting' | 'needsLogin' | 'error'; + +/** This device's node identity on the mesh. */ +export type VpnSelf = { + name: string; + ips: string[]; + magicDNSName?: string; + /** Short host name, without the tailnet suffix — what a person would call this device. */ + hostName?: string; + os?: string; + /** + * The account this node registered under, from `Status.User[Self.UserID].LoginName`. Present for a + * Tailscale login; a Headscale pre-auth key registration usually has no email behind it. + */ + loginName?: string; + /** The tailnet's own name, e.g. `tail57b36.ts.net` or a Headscale namespace. */ + tailnet?: string; + /** + * When this device's node key expires, as an ISO timestamp. + * + * Distinct from the pre-auth key used to register, which is spent immediately and is the admin's + * concern. This one is the device's own credential, expires on the tailnet's schedule (often 180 + * days), and when it does the device simply stops working. Without surfacing it, expiry arrives as + * an unexplained disconnection on a device that has been fine for months. + */ + keyExpiry?: string; + /** The node key has already expired. The node needs re-authorising before it will carry traffic. */ + expired?: boolean; +}; + +/** Another node on the tailnet. */ +export type VpnPeer = { + id: string; + name: string; + hostName?: string; + ips: string[]; + online: boolean; + lastSeen?: string; + os?: string; + /** This peer offers to act as an exit node. Only these can be selected as one. */ + exitNodeOption?: boolean; + /** This peer IS the current exit node. */ + exitNode?: boolean; +}; + +/** Live preferences that can change while the node is running. */ +export type VpnPrefs = { + /** Stable node id of the exit node in use, or empty for none. */ + exitNodeID?: string; + /** Whether subnet routes advertised by other nodes are used. Off by default in Tailscale. */ + acceptRoutes?: boolean; + corpDNS?: boolean; +}; + +export type VpnStatus = { + state: VpnState; + self?: VpnSelf; + peers: VpnPeer[]; + error?: string; + /** + * Where to send the user to authorise this node, when `state` is `needsLogin`. + * + * Comes straight from the core: `ipnstate.Status.AuthURL`, which has always been in the JSON the + * bridge already marshals — nothing was reading it. Opening it in a browser is the whole of + * interactive login; the identity provider (Google, GitHub, whatever the control server offers) + * talks to the CONTROL SERVER, never to us, so no credential or token passes through this app. + */ + authUrl?: string; + /** The control plane this node is talking to — what tells Tailscale apart from a self-hosted one. */ + controlUrl?: string; + /** + * Our VPN configuration exists but the OS has deselected it, because another VPN app took the slot + * — only one can be active at a time. Distinct from "failed to connect", and worth saying out loud: + * otherwise being superseded is indistinguishable from being broken. + */ + supersededByOtherVpn?: boolean; + /** + * Whether the chosen exit node is reachable, from `ipnstate.Status.ExitNodeStatus.Online`. + * + * Three cases, needing different things from the user — see `exitNodeTrouble`: + * `true` working + * `false` registered but unreachable: wait, or choose another + * `undefined` no exit node set, OR the chosen one has left the tailnet entirely + * + * Traffic already fails closed when an exit node dies — nothing escapes around the tunnel, which is + * the safe behaviour — but from the outside that is indistinguishable from a broken connection. + */ + exitNodeOnline?: boolean; +}; + +/** + * How long until this device's key expires, in whole days — or `expired`, or nothing worth saying. + * + * Only reports inside the warning window, so the screen stays quiet for the 170-odd days when there is + * nothing to do. The window is deliberately wide: re-authorising needs either a browser sign-in or a + * fresh pre-auth key from an admin, and on a phone that is not something to discover on the last day. + */ +export const KEY_EXPIRY_WARN_DAYS = 14; + +export const keyExpiryStatus = ( + self?: Pick, + now: number = Date.now(), +): { expired: true } | { expired: false; days: number } | undefined => { + if (!self) return undefined; + if (self.expired) return { expired: true }; + if (!self.keyExpiry) return undefined; + const at = Date.parse(self.keyExpiry); + // Tailscale writes a zero time for keys that never expire; it parses, but as year 1. + if (!Number.isFinite(at) || at <= 0) return undefined; + const days = Math.ceil((at - now) / 86_400_000); + if (days <= 0) return { expired: true }; + return days <= KEY_EXPIRY_WARN_DAYS ? { expired: false, days } : undefined; +}; + +/** What is wrong with the current exit node, if anything. */ +export type ExitNodeTrouble = 'offline' | 'missing'; + +/** + * Diagnose the exit node, given the status and the prefs that name it. + * + * The distinction matters because it changes the remedy: an offline node may come back, so waiting is + * reasonable; a node that has left the tailnet never will, and the choice has to be remade. The core + * populates `ExitNodeStatus` only when it can still find the peer in the netmap, so its absence + * alongside a set `exitNodeID` IS the "gone" signal. + */ +export const exitNodeTrouble = ( + status: Pick, + prefs?: Pick, +): ExitNodeTrouble | undefined => { + if (!prefs?.exitNodeID) return undefined; + // Only meaningful against a live netmap; while connecting there is nothing to judge. + if (status.state !== 'connected') return undefined; + if (status.exitNodeOnline === undefined) return 'missing'; + return status.exitNodeOnline ? undefined : 'offline'; +}; + +/** True when a control URL is Tailscale's own, rather than a self-hosted Headscale. */ +export const isTailscaleControl = (url?: string): boolean => + !!url && /(^|\.)tailscale\.com/i.test(url); + +/** + * One stored login. Several can exist at once — a self-hosted Headscale and a login.tailscale.com + * account, say — each with its own control server, node key and prefs. + */ +export type VpnProfile = { + id: string; + /** The login name, e.g. andre@example.com. */ + name: string; + /** Which tailnet this login belongs to. */ + tailnet: string; + current: boolean; + /** + * The control plane this login talks to — the only authoritative way to tell a Tailscale account + * from a self-hosted Headscale one. It cannot be inferred from the names: a personal Tailscale + * tailnet is named after the login email, and a Headscale namespace can be called anything. + */ + controlUrl?: string; +}; + +type OfficerNetNative = { + /** Point the node at a Headscale control server (call before login). */ + configure(controlUrl: string, hostname?: string): Promise; + /** Register the node with a Headscale pre-auth key. */ + loginWithAuthKey(authKey: string): Promise; + /** Whether a node identity already exists on this device (registered previously). */ + isRegistered(): Promise; + /** + * Whether the OS has granted this app tunnel rights. Optional: a host app built before these + * landed does not export them, and the caller treats their absence as "assume authorized" so the + * old connect-and-see flow still works. + */ + isAuthorized?(): Promise; + /** Ask for tunnel consent without connecting. Resolves false if the user declines. */ + requestAuthorization?(): Promise; + /** Bring the tunnel up / down. */ + up(): Promise<{ needsConsent?: boolean; granted?: boolean } | void>; + down(): Promise; + /** Stored logins as a JSON array — see `listProfiles` for the parsed form. */ + listProfiles(): Promise; + /** Make a stored login active. The netmap changes, so the caller recycles the tunnel around it. */ + switchProfile(id: string): Promise; + /** Begin an additional login; the node lands in `needsLogin` with an `authUrl`. */ + newProfile(): Promise; + /** + * Join ANOTHER tailnet on the running backend, without recycling the tunnel. Optional: older + * native builds do not have it, and the caller falls back to the (lossy) new-profile-then-restart + * path rather than failing outright. + */ + loginToNewProfile?(controlUrl: string, authKey?: string, hostname?: string): Promise; + /** Forget a login. Deleting the active one logs the node out. */ + deleteProfile(id: string): Promise; + /** Route all traffic through a peer, by stable node id. Empty/undefined turns it off. Optional: + * absent on native builds from before exit-node support. */ + setExitNode?(stableId?: string): Promise; + setAcceptRoutes?(on: boolean): Promise; + /** Live prefs as JSON — what IS set, not what was last requested. */ + prefs?(): Promise; + lastExitReport?(): Promise; + /** Current status snapshot. */ + status(): Promise; + /** Forget the node identity + keys (full reset). */ + logout(): Promise; + /** The embedded Tailscale core version — proof the gomobile core is linked in and executing. */ + coreVersion(): Promise; + /** Debug: the NE's diagnostic ring buffer (iOS bring-up flow). Optional (not on all platforms). */ + coreDiag?(): Promise; + // Expo EventEmitter surface (present when the module is built with Events). + addListener(event: string, cb: (s: VpnStatus) => void): { remove: () => void }; +}; + +let cached: OfficerNetNative | null | undefined; +const native = (): OfficerNetNative | null => { + if (cached !== undefined) return cached; + try { + cached = requireNativeModule('OfficerNet') as OfficerNetNative; + } catch { + cached = null; + } + return cached; +}; + +/** Whether the VPN engine is bundled in this build (Officer main app + OffTail; not the media apps). */ +export const isVpnAvailable = (): boolean => native() !== null; + +const mod = (): OfficerNetNative => { + const m = native(); + if (!m) throw new Error('OfficerNet native module is not available in this build'); + return m; +}; + +/** Point the engine at a Headscale control server. */ +export const configureVpn = (controlUrl: string, hostname?: string): Promise => + mod().configure(controlUrl, hostname); + +/** Register this device with a Headscale pre-auth key (manual fallback path). */ +export const loginVpnWithAuthKey = (authKey: string): Promise => mod().loginWithAuthKey(authKey); + +/* + There is deliberately no "sign in to get a key" call here, and there never was one that survived. + + An earlier version of this app could ask a companion server to mint a Headscale pre-auth key off an + account login. It is gone, and if it comes back it has to come back as something optional: a VPN + client that needs a working connection in order to authenticate is a bootstrap deadlock, because the + tunnel is frequently what makes that connection possible. Enrolment happens through a key you paste, + an invite link you tapped (see `invite.ts`, which talks to the sidecar named in the link and nothing + else), or Tailscale's own interactive login. +*/ + +/** The embedded Tailscale core version (proof-of-life for the vendored gomobile engine). */ +export const vpnCoreVersion = (): Promise => mod().coreVersion(); +/** Debug: the NE's diagnostic ring buffer (iOS). Empty string when unsupported. */ +export const vpnCoreDiag = (): Promise => mod().coreDiag?.() ?? Promise.resolve(''); +export const isVpnRegistered = (): Promise => mod().isRegistered(); + +/** + * Whether the OS has granted this app the right to run a tunnel. + * + * NOT the same question as `isVpnRegistered`, which asks whether a node identity exists on some + * tailnet. They disagree in both directions: a restored device can carry identity without consent, + * and consent survives a logout that destroys identity. + * + * **Never cache this.** Authorization is revocable — iOS lets the user delete the VPN profile in + * Settings and Android lets another VPN app take over — so it has to be re-read when the app comes + * back to the foreground. A stale `true` renders a connect toggle that silently does nothing. + * + * Defaults to `true` when the native build predates the verb, so the older connect-and-see flow is + * unaffected rather than blocked behind a gate it cannot satisfy. + */ +export const isVpnAuthorized = async (): Promise => { + const m = native(); + if (!m?.isAuthorized) return true; + try { + return await m.isAuthorized(); + } catch { + return false; + } +}; + +/** Ask for tunnel consent on its own, without connecting. False when declined or unavailable. */ +export const requestVpnAuthorization = async (): Promise => { + const m = native(); + if (!m?.requestAuthorization) return true; + try { + return await m.requestAuthorization(); + } catch { + return false; + } +}; +/** + * Ask for the tunnel. + * + * Returns what native reports rather than discarding it. `needsConsent` means Android wants its + * one-time VPN permission and had nowhere to show the dialog; `granted: false` means the user was + * asked and declined. Both were being thrown away, which left callers unable to tell "permission + * refused" from "starting". + * + * Resolving does NOT mean connected. It means the tunnel was asked to start — the state arrives + * afterwards on `onStatus`. + */ +export const vpnUp = (): Promise<{ needsConsent?: boolean; granted?: boolean } | void> => mod().up(); +export const vpnDown = (): Promise => mod().down(); +export const vpnStatus = (): Promise => mod().status(); +export const vpnLogout = (): Promise => mod().logout(); + +/** + * Stored logins, newest-agnostic order (whatever the core returns). + * + * Parsed here rather than at the bridge because the payload crosses as JSON: gomobile binds simple + * types only, so a list of structs has to be a string, and both platforms hand back the same one. + * A malformed reply yields an empty list rather than throwing — a broken switcher should not take + * the VPN screen down with it. + */ +export const vpnListProfiles = async (): Promise => { + try { + const raw = await mod().listProfiles(); + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as VpnProfile[]) : []; + } catch { + return []; + } +}; + +/** Whether this build can change prefs on a running node (exit node, accept routes). */ +export const canEditPrefs = (): boolean => typeof native()?.setExitNode === 'function'; + +/** + * Route all traffic through a peer, by STABLE NODE ID. + * + * Not by IP: an address can be reassigned, while the stable id survives the node changing address. + * Applied with EditPrefs on the running node, so choosing one does not drop the tunnel. + */ +export const vpnSetExitNode = (stableId?: string): Promise => { + const m = mod(); + if (!m.setExitNode) throw new Error('This build cannot select an exit node.'); + return m.setExitNode(stableId ?? ''); +}; + +/** Use subnet routes other nodes advertise. Off by default in Tailscale, which is why a node can see + * its peers but not the network behind one of them. */ +export const vpnSetAcceptRoutes = (on: boolean): Promise => { + const m = mod(); + if (!m.setAcceptRoutes) throw new Error('This build cannot change route acceptance.'); + return m.setAcceptRoutes(on); +}; + +/** Live prefs. Empty object when unavailable — a missing readout must not break the screen. */ +export const vpnPrefs = async (): Promise => { + const m = native(); + if (!m?.prefs) return {}; + try { + const parsed: unknown = JSON.parse(await m.prefs()); + return parsed && typeof parsed === 'object' ? (parsed as VpnPrefs) : {}; + } catch { + return {}; + } +}; + +/** + * How the previous tunnel run ended, when it did not end cleanly. + * + * `abnormal` means the tunnel process was killed without ever reaching its stop handler — on iOS + * almost always a jetsam kill against the ~50 MB ceiling a packet tunnel gets, on Android a low-memory + * kill or a foreground-service contract violation. It is inferred from a start-marker rather than + * caught, because none of those failures run any handler to catch them with. + * + * It carries no identifiers on purpose: two timestamps and a kind. This is the one diagnostic intended + * to leave the device, and the surest way to keep a crash report free of secrets is to put none in it. + */ +export type TunnelExit = { + kind: 'abnormal'; + /** When the run that died had started, epoch ms (Android) or ISO (iOS). */ + startedAt?: string | number; + detectedAt?: string | number; +}; + +/** + * Collect a pending abnormal-exit report, clearing it so it is reported once. + * + * Returns undefined when the last run ended cleanly, when the platform cannot answer, or — on iOS — + * when no extension is currently running to ask. That last case is a real gap: a tunnel that dies and + * is never started again keeps its report. Collecting on the next successful connect is the trade for + * not holding an App Group entitlement purely to read one file. + */ +export const takeTunnelExitReport = async (): Promise => { + const m = native(); + if (!m?.lastExitReport) return undefined; + try { + const raw = await m.lastExitReport(); + if (!raw) return undefined; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') return undefined; + const rec = parsed as Record; + // Only 'abnormal' is worth surfacing — a clean stop is carried in the same slot and is not news. + if (rec.kind !== 'abnormal') return undefined; + return { + kind: 'abnormal', + startedAt: rec.startedAt as string | number | undefined, + detectedAt: rec.detectedAt as string | number | undefined, + }; + } catch { + return undefined; + } +}; + +export const vpnSwitchProfile = (id: string): Promise => mod().switchProfile(id); +export const vpnNewProfile = (): Promise => mod().newProfile(); + +/** Whether the core can join a tailnet without a tunnel restart (see `vpnLoginToNewProfile`). */ +export const canLoginToNewProfile = (): boolean => typeof native()?.loginToNewProfile === 'function'; + +/** + * Join another tailnet on the LIVE backend. + * + * Branching to a new profile and starting it must happen together, inside the core. Doing them + * separately — NewProfile here, then a tunnel restart to deliver the control URL — loses the new + * profile: an unregistered one is not persisted, so the reload falls back to the previous profile + * and overwrites ITS control URL. The second tailnet then silently replaces the first, keeping the + * same profile id. + */ +export const vpnLoginToNewProfile = ( + controlUrl: string, + authKey?: string, + hostname?: string, +): Promise => { + const m = mod(); + if (!m.loginToNewProfile) throw new Error('This build cannot add a tailnet without reconnecting.'); + return m.loginToNewProfile(controlUrl, authKey, hostname); +}; +export const vpnDeleteProfile = (id: string): Promise => mod().deleteProfile(id); + +/** Subscribe to native status/peer updates. No-op (returns a no-op unsubscribe) when unavailable. */ +export const onVpnStatus = (cb: (s: VpnStatus) => void): (() => void) => { + const m = native(); + if (!m) return () => {}; + try { + const sub = m.addListener('onStatus', cb); + return () => sub.remove(); + } catch { + return () => {}; + } +}; diff --git a/src/vpn/useVpnScreen.ts b/src/vpn/useVpnScreen.ts new file mode 100644 index 0000000..1b1818e --- /dev/null +++ b/src/vpn/useVpnScreen.ts @@ -0,0 +1,1015 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { AppState, Linking } from 'react-native'; + +import { + isVpnAvailable, + isTailscaleControl, + isVpnAuthorized, + requestVpnAuthorization, + isVpnRegistered, + vpnCoreVersion, + configureVpn, + loginVpnWithAuthKey, + vpnUp, + vpnDown, + vpnStatus, + vpnLogout, + vpnCoreDiag, + onVpnStatus, + vpnListProfiles, + vpnSwitchProfile, + vpnNewProfile, + vpnLoginToNewProfile, + canLoginToNewProfile, + vpnDeleteProfile, + vpnSetExitNode, + vpnSetAcceptRoutes, + vpnPrefs, + takeTunnelExitReport, + type TunnelExit, + canEditPrefs, + type VpnStatus, + type VpnProfile, + type VpnPrefs, +} from './officer-net'; +import { copyToClipboard } from './clipboard'; +import { + claimInvite, + inviteErrorText, + inviteHost, + parseInviteUrl, + InviteError, + type Invite, +} from './invite'; + +const POLL_MS = 3000; + +/** Local, user-chosen tailnet names. Never sent anywhere — this is a display concern only. */ +const LABELS_KEY = 'vpn.tailnet.labels'; + +/** + * Marks the tailnet row synthesised from live status when the core reports no profiles. It has no + * profile behind it, so switching and per-profile deletion do not apply to it. + */ +export const SYNTHETIC_CURRENT_ID = '__current__'; + +/** + * The last connection we saw, kept locally so the DISCONNECTED screen can say something true. + * + * While the tunnel is down the core is not running, so there are no profiles to read, and + * `registered` is an app-written flag that stays false on the Tailscale path — meaning a + * fully-set-up app could not tell it was set up. It showed a bare "Not connected" with no hint that + * flipping the switch would restore everything, which is exactly as lost as it sounds. + */ +const LAST_SEEN_KEY = 'vpn.lastSeen'; +export type LastSeen = { tailnet?: string; account?: string; host?: string }; + +/** + * Tailscale's own control plane. An empty ControlURL already defaults to this inside the Tailscale + * library, but relying on that would make "which network am I joining" an accident of omission — + * spelling it out is what makes choosing Tailscale a deliberate branch rather than a fallback. + */ +export const TAILSCALE_CONTROL_URL = 'https://controlplane.tailscale.com'; + +/** + * Turn what someone types into a control URL. + * + * Users type `headscale.example.com`, not a scheme. Requiring one is exactly the kind of friction + * that makes self-hosting feel second-class, which is the thing this app exists to avoid — so the + * scheme is optional, and any scheme that IS typed is honoured rather than second-guessed. + * + * HTTPS is tried first and HTTP only as a fallback, never the reverse. `/key?v=2` is the probe rather + * than the root: it is the control-protocol discovery endpoint, so a 200 means an actual control + * server rather than merely a web server answering on that name. + * + * Returns the resolved base URL and whether it ended up unencrypted, so the caller can say so — a + * pre-auth key sent over plain HTTP travels in the clear, and that should be visible rather than + * silently accepted. + */ +export const resolveControlUrl = async ( + input: string, +): Promise<{ url: string; insecure: boolean } | null> => { + const raw = input.trim().replace(/\/+$/, ''); + if (!raw) return null; + + const explicit = /^https?:\/\//i.test(raw); + const host = raw.replace(/^https?:\/\//i, ''); + const candidates = explicit ? [raw] : [`https://${host}`, `http://${host}`]; + + for (const base of candidates) { + try { + const res = await fetch(`${base}/key?v=2`, { method: 'GET' }); + if (res.ok) return { url: base, insecure: base.startsWith('http://') }; + } catch { + /* unreachable over this scheme — try the next, or fall through */ + } + } + // Nothing answered. Hand back the user's best-effort URL rather than blocking: the server may + // simply not expose /key to unauthenticated callers, and the real registration attempt is a better + // error message than a probe failure invented here. + return { url: explicit ? raw : `https://${host}`, insecure: raw.startsWith('http://') }; +}; + +/** + * All of the VPN screen's state and every action it can take, in one hook. + * + * `embedded` only says whether an outer container owns the frame and the safe-area inset; it changes + * no behaviour that talks to the network. Everything here works the same either way. + */ +export const useVpnScreen = (embedded = false) => { + const available = isVpnAvailable(); + + const [registered, setRegistered] = useState(false); + /** Stored logins. Empty until the node is up — the core keeps them on the running backend. */ + const [profiles, setProfiles] = useState([]); + const [switching, setSwitching] = useState(false); + const [coreVersion, setCoreVersion] = useState(null); // embedded Tailscale core version + const [status, setStatus] = useState({ state: 'stopped', peers: [] }); + const [busy, setBusy] = useState(false); // connect/disconnect in flight + const [enrolling, setEnrolling] = useState(false); + const [error, setError] = useState(null); + + // Headscale inputs. + // Empty, not prefilled with our own server: this app ships to anyone, and a hardcoded control URL + // is somebody else's infrastructure staring at them. The placeholder carries the format instead. + const [controlUrl, setControlUrl] = useState(''); + const [authKey, setAuthKey] = useState(''); + const [nodeName, setNodeName] = useState(''); // optional device/hostname passed to the core on login + const [showManual, setShowManual] = useState(false); + + /** + * Whether the OS has granted tunnel rights. `null` means "not asked yet" and is deliberately + * distinct from `false`: rendering the "Allow VPN" gate during the first check would flash it at + * every launch for someone who authorised months ago. + */ + const [authorized, setAuthorized] = useState(null); + const [authorizing, setAuthorizing] = useState(false); + /** Which destination the user picked on first run; null means the choice has not been made. */ + const [choice, setChoice] = useState<'headscale' | 'tailscale' | null>(null); + /** Set when the Headscale control server only answered over plain HTTP. */ + const [insecure, setInsecure] = useState(false); + /** + * "Add a tailnet" is a UI state, NOT a core mutation. + * + * It used to call `NewProfile()` the instant the button was pressed, which switched the running + * backend to a blank profile with no control URL and no interactive login — so the tunnel dropped, + * no auth URL was ever produced, and because the switcher hides itself when the profile list is + * empty the user was left with no way back. Nothing touches the core now until a destination has + * actually been chosen and submitted. + */ + const [adding, setAdding] = useState(false); + /** User-chosen names per profile id, so a tailnet is not stuck with whatever the server calls it. */ + const [labels, setLabels] = useState>({}); + /** + * Live preferences — the exit node and route acceptance. + * + * Read from the core rather than remembered locally, so the screen shows what IS set rather than + * what was last asked for. That distinction has bitten this file repeatedly. + */ + const [prefs, setPrefs] = useState({}); + const [prefsBusy, setPrefsBusy] = useState(false); + + /** What we were connected to last time, for the disconnected screen to describe. */ + const [lastSeen, setLastSeen] = useState(null); + + /** + * A pending invite from a deep link, held UNCLAIMED until the user confirms. + * + * Claiming mints the pre-auth key and marks the invite used, and that key lives five minutes. First + * run has to get through the OS VPN permission prompt before anything can connect — easily longer + * than five minutes if someone wanders into Settings — so claiming on link open would spend the + * invite on a flow that had not started. The token waits here instead. + */ + const [invite, setInvite] = useState(null); + const [inviteName, setInviteName] = useState(''); + const [inviteBusy, setInviteBusy] = useState(false); + const [inviteError, setInviteError] = useState(null); + + const [refreshing, setRefreshing] = useState(false); // manual refresh in flight (button spinner) + const [diag, setDiag] = useState(''); // debug: NE bring-up trace (iOS) + // How the previous tunnel run ended, if it did not end cleanly. See takeTunnelExitReport. + const [lastExit, setLastExit] = useState(undefined); + // Copy feedback, keyed per row so two rows never both show a tick. + const [copiedKey, setCopiedKey] = useState(null); + const aliveRef = useRef(true); + + // `manual` = the top-right refresh button: re-test the connection (fetch fresh status) with a spinner. + // It deliberately does NOT re-check registration — that flips a connected node back to the enroll form + // (on iOS isVpnRegistered can't see the NE's flag without an App Group). The 3s background poll calls + // this without `manual` (no spinner flicker). Registration is owned by the connect/enroll/logout actions. + /** Copy a peer's IP (or name) and flash a tick on that row for a moment. */ + const copy = async (key: string, value: string) => { + if (!(await copyToClipboard(value))) return; + setCopiedKey(key); + setTimeout(() => setCopiedKey((k) => (k === key ? null : k)), 1500); + }; + + const refresh = async (manual = false) => { + if (!available) return; + if (manual) setRefreshing(true); + try { + const s = await vpnStatus().catch(() => null); + if (aliveRef.current && s) setStatus(s); + // Only pull the NE trace on a manual refresh (the 3s poll would clobber it), and ignore any + // status-JSON response (a crossed provider-message reply) so the short trace sticks. + if (manual) { + const dg = await vpnCoreDiag().catch(() => ''); + if (aliveRef.current && dg && !dg.trimStart().startsWith('{')) setDiag(dg); + } + } finally { + if (manual && aliveRef.current) setRefreshing(false); + } + }; + + /** + * Poll hard for a few seconds after asking the tunnel to start. + * + * The core takes a moment to reach NeedsLogin and produce an AuthURL, and the background poll is + * every 3s — so the sign-in prompt could sit invisible for most of that tick while the screen + * showed a connected-looking toggle and no data. Stops as soon as there is something to act on. + */ + const burstRefresh = async () => { + for (let i = 0; i < 16; i++) { + await new Promise((r) => setTimeout(r, 400)); + if (!aliveRef.current) return; + const s = await vpnStatus().catch(() => null); + if (!s) continue; + setStatus(s); + if (s.authUrl || s.state === 'connected' || s.state === 'error') return; + } + }; + + useEffect(() => { + aliveRef.current = true; + if (!available) return; + (async () => { + try { + setRegistered(await isVpnRegistered()); + } catch { + setRegistered(false); + } + try { + setCoreVersion(await vpnCoreVersion()); + } catch { + setCoreVersion(null); + } + refresh(); + // A relaunch onto an ALREADY-RUNNING tunnel still has to discover that fact. The core takes a + // few seconds to reach Running, and at a flat 3s tick the screen sat on "not connected" for + // ~10s over a tunnel that was up in 20ms — which reads exactly like the registration was lost. + void burstRefresh(); + })(); + const unsub = onVpnStatus((s) => aliveRef.current && setStatus(s)); + const timer = setInterval(refresh, POLL_MS); + return () => { + aliveRef.current = false; + unsub(); + clearInterval(timer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [available]); + + /** + * Re-read tunnel authorization on mount and every time the app comes back to the foreground. + * + * Never cached, because it is revocable while we are not looking: iOS lets the user delete the VPN + * profile in Settings, and on Android another VPN app taking over revokes ours. Both are done + * *outside* this app, so the only moment we can notice is on return — and a stale `true` leaves a + * connect toggle that silently does nothing, which is indistinguishable from a broken app. + */ + useEffect(() => { + if (!available) return; + const read = () => { + void isVpnAuthorized().then((ok) => { + if (aliveRef.current) setAuthorized(ok); + }); + }; + read(); + const sub = AppState.addEventListener('change', (s) => s === 'active' && read()); + return () => sub.remove(); + }, [available]); + + /** + * Deep links: pick up an invite whether the app was launched by one or was already running. + * + * Both paths are needed. `getInitialURL` covers a cold start, the listener covers a warm one, and a + * link that arrives while the app is backgrounded uses whichever fires first. + * + * A link that cannot be read is reported here and NEVER causes a request — an unreadable invite + * should not reach the network, and the reason is specific so the user is not left retrying a link + * that can never work. + */ + useEffect(() => { + const take = (url: string | null) => { + if (!url || !url.includes('#')) return; + try { + const parsed = parseInviteUrl(url); + setInvite(parsed); + /* + The admin's suggestion, shown in the field rather than applied behind the person's back — it is + the name their device will answer to on someone else's network, so it should be visible and + editable before they agree. + + Only fills an EMPTY field. iOS can deliver the same launch through both getInitialURL and the + url listener, and a link can arrive again while the screen is open — writing unconditionally + would replace whatever the person had typed in between with the admin's suggestion, which is + the one thing this is trying not to do. + */ + if (parsed.name) setInviteName((current) => current || parsed.name!); + setInviteError(null); + } catch (e) { + setInvite(null); + setInviteError( + e instanceof InviteError ? inviteErrorText(e.code) : 'This invite link is not readable.', + ); + } + }; + void Linking.getInitialURL().then(take).catch(() => {}); + const sub = Linking.addEventListener('url', ({ url }) => take(url)); + return () => sub.remove(); + }, []); + + const dismissInvite = () => { + setInvite(null); + setInviteError(null); + setInviteName(''); + }; + + /** + * Accept an invite: permission first, then claim, then join — in that order and no other. + * + * Permission comes BEFORE the claim because claiming spends the invite and starts a five-minute + * clock on the key. Asking iOS for VPN rights afterwards can take longer than that, and the user + * would be left holding a dead invite having done nothing wrong. + * + * The join itself reuses the same two paths as adding a tailnet by hand: a live backend branches to + * a new profile inside the core, an idle one configures and starts. Nothing invite-specific — an + * invite is a way to obtain a control URL and a key, not a different kind of membership. + */ + const acceptInvite = async () => { + if (!invite || inviteBusy) return; + setInviteBusy(true); + setInviteError(null); + try { + if (authorized !== true) { + const granted = await requestVpnAuthorization(); + setAuthorized(granted); + if (!granted) { + setInviteError('OffScale needs VPN permission before it can join a network.'); + return; + } + } + + const claim = await claimInvite(invite); + const hostname = (inviteName.trim() || claim.suggestedHostname || '').trim() || undefined; + + if (backendUp && canLoginToNewProfile()) { + await vpnLoginToNewProfile(claim.controlUrl, claim.authKey, hostname); + await configureVpn(claim.controlUrl, hostname); + } else { + await configureVpn(claim.controlUrl, hostname); + await loginVpnWithAuthKey(claim.authKey); + await vpnUp(); + } + + setRegistered(true); + dismissInvite(); + await burstRefresh(); + const fresh = await vpnListProfiles().catch(() => []); + if (fresh.length) setProfiles(fresh); + } catch (e) { + setInviteError( + e instanceof InviteError + ? inviteErrorText(e.code, inviteHost(invite)) + : e instanceof Error + ? e.message + : String(e), + ); + } finally { + setInviteBusy(false); + refresh(); + } + }; + + /** Ask for tunnel rights on their own, before any tailnet has been chosen. */ + const authorize = async () => { + if (authorizing) return; + setAuthorizing(true); + setError(null); + try { + const ok = await requestVpnAuthorization(); + setAuthorized(ok); + if (!ok) setError('Permission was declined. OffScale cannot connect without it.'); + } finally { + setAuthorizing(false); + } + }; + + const connected = status.state === 'connected' || status.state === 'reconnecting'; + + /** + * Is the core actually running? Broader than `connected`, and the distinction matters. + * + * Profiles live on the running LocalBackend, so they can only be read while the tunnel process is + * alive — but that includes `connecting` and `needsLogin`, not just `connected`. Keying the profile + * load on `connected` alone meant that a node sitting in `needsLogin` showed an empty tailnet list, + * which hid the switcher entirely and removed the only route back to a working profile. + */ + const backendUp = status.state !== 'stopped'; + + /** + * Mid sign-in: the node is up and waiting for a human to authorise it at the control server. + * + * This is a TRANSIENT state and was being treated as a settled one. Every card keyed on "no + * identity yet" fired at once — the Headscale form, the tailnet list, the device card — so an + * unfinished Tailscale sign-in showed three competing setup paths plus a device called `localhost`, + * which is the core's placeholder before the netmap arrives. It needs to be one screen: sign in. + */ + const signingIn = status.state === 'needsLogin' || !!status.authUrl; + + /** + * Load the stored logins once the node is up. + * + * They live on the running LocalBackend, so asking before it exists returns an empty list — which + * is indistinguishable from "you have one login", and would render a switcher that quietly claims + * you have nothing. Keyed on `connected` so it re-reads after a switch or a fresh sign-in. + */ + useEffect(() => { + if (!available || !backendUp) return; + let alive = true; + void vpnListProfiles().then((p) => { + if (alive) setProfiles(p); + }); + return () => { + alive = false; + }; + // Keyed on the identity too, not just the tunnel being up. Adding a tailnet changes who this + // node is without the tunnel ever going down, so a list read at connect time is stale the moment + // a second profile exists — which is why the switcher kept showing only the old one. + }, [available, backendUp, status.self?.tailnet, status.self?.magicDNSName]); + + /** Remember the live connection, and surface it again once the tunnel is down. */ + useEffect(() => { + AsyncStorage.getItem(LAST_SEEN_KEY) + .then((raw) => raw && setLastSeen(JSON.parse(raw) as LastSeen)) + .catch(() => {}); + }, []); + + useEffect(() => { + const self = status.self; + if (!self) return; + const next: LastSeen = { + tailnet: self.tailnet, + account: self.loginName, + host: self.hostName ?? self.name, + }; + setLastSeen(next); + AsyncStorage.setItem(LAST_SEEN_KEY, JSON.stringify(next)).catch(() => {}); + }, [status.self?.tailnet, status.self?.loginName, status.self?.hostName]); + + /* + Collect any abnormal-exit report, once the tunnel is up. + + Keyed on `backendUp` rather than run once at mount because on iOS the report lives in the + extension's container and only a RUNNING extension can hand it over — asking at launch would always + come back empty. Android could read it at any time; it follows the same key so both platforms + behave the same rather than one silently reporting earlier than the other. + + Collection clears it natively, so this cannot double-report; the state is for display only. + */ + useEffect(() => { + if (!available || !backendUp) return; + let alive = true; + void takeTunnelExitReport().then((r) => { + if (!alive || !r) return; + // Logged as well as shown: the debug card needs someone to open it, and this line is the thing + // to search for after a user says "it just stopped". + console.warn('[vpn] previous tunnel run ended abnormally', r); + setLastExit(r); + }); + return () => { + alive = false; + }; + }, [available, backendUp]); + + // Prefs are only readable while the core runs, and change under us when a switch lands — so this + // follows the same key as the profile list rather than loading once. + useEffect(() => { + if (!available || !backendUp) return; + let alive = true; + void vpnPrefs().then((p) => { + if (alive) setPrefs(p); + }); + return () => { + alive = false; + }; + }, [available, backendUp, status.self?.tailnet, status.self?.magicDNSName]); + + /** + * Route everything through a peer, or stop doing so. + * + * No tunnel recycle: EditPrefs applies on the running node, and the netmap change propagates by + * itself. That is the whole reason this is a prefs edit rather than a restart. + */ + const setExitNode = async (stableId?: string) => { + if (prefsBusy) return; + setPrefsBusy(true); + setError(null); + try { + await vpnSetExitNode(stableId); + setPrefs(await vpnPrefs()); + await burstRefresh(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setPrefsBusy(false); + refresh(); + } + }; + + const setAcceptRoutes = async (on: boolean) => { + if (prefsBusy) return; + setPrefsBusy(true); + setError(null); + try { + await vpnSetAcceptRoutes(on); + setPrefs(await vpnPrefs()); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setPrefsBusy(false); + refresh(); + } + }; + + /** Local tailnet names, loaded once. Purely cosmetic, so a read failure is not worth reporting. */ + useEffect(() => { + AsyncStorage.getItem(LABELS_KEY) + .then((raw) => { + if (!raw) return; + const parsed: unknown = JSON.parse(raw); + if (parsed && typeof parsed === 'object') setLabels(parsed as Record); + }) + .catch(() => {}); + }, []); + + /** Name a tailnet yourself. Empty clears it and falls back to whatever the control server reports. */ + const setTailnetLabel = (id: string, label: string) => { + setLabels((current) => { + const next = { ...current }; + const trimmed = label.trim(); + if (trimmed) next[id] = trimmed; + else delete next[id]; + AsyncStorage.setItem(LABELS_KEY, JSON.stringify(next)).catch(() => {}); + return next; + }); + }; + + /** + * What to show for a tailnet, best name first. + * + * A Headscale tailnet reports its DomainName, which is often just the namespace the node was + * registered under — "docker" — and that is the server's word rather than anything meaningful to + * the person holding the phone. A local label wins over it precisely for that reason. + */ + const tailnetLabel = (p: VpnProfile): string => + labels[p.id] || p.tailnet || p.name || 'Tailnet'; + + // The main toggle. Connected → down. Registered → up. Neither → open the manual enrolment form. + const toggle = async () => { + if (busy || enrolling) return; + setError(null); + if (connected) { + setBusy(true); + try { + await vpnDown(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + refresh(); + } + return; + } + if (registered) { + setBusy(true); + try { + await vpnUp(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + refresh(); + } + return; + } + // Not registered yet, so there is nothing to bring up: ask for a control URL and a key. + setShowManual(true); + }; + + // Manual path: point at a control server and paste a pre-auth key. + const loginManual = async () => { + if (enrolling || !controlUrl.trim() || !authKey.trim()) return; + setEnrolling(true); + setError(null); + try { + // Accepts a bare domain: the scheme is resolved (HTTPS first) rather than demanded of the user. + const resolved = await resolveControlUrl(controlUrl); + if (!resolved) return; + setInsecure(resolved.insecure); + // Only now, with a real destination in hand, does a second login get its own profile. Doing + // this before the user had entered anything is what stranded the old "Add a tailnet" button. + // + // `backendUp` is not optional. NewProfile() branches from the RUNNING LocalBackend and returns + // "not started" when there is none — which is exactly what a stale `adding` flag produced after + // a wipe: the core was stopped, so the very first sign-in threw before it could begin. + /* + Adding to a LIVE tunnel is a single core operation, not a sequence of app-level ones. + + Branching to a new profile and starting it against its own control URL must happen together + inside the core. Split apart — NewProfile here, then a tunnel restart to deliver the URL and + key — the new profile is lost: an unregistered profile is not persisted, so the reload falls + back to the previous one and overwrites its control URL. Headscale then replaced Tailscale + under the very same profile id. + */ + const branching = adding && backendUp && canLoginToNewProfile(); + if (branching) { + await vpnLoginToNewProfile(resolved.url, authKey.trim(), nodeName.trim() || undefined); + // LoginTo bypasses configure(), so the app's own record of the control URL would still name + // the PREVIOUS tailnet — which is how a Tailscale node ended up labelled Headscale. Sync it. + await configureVpn(resolved.url, nodeName.trim() || undefined); + } else { + await configureVpn(resolved.url, nodeName.trim() || undefined); + await loginVpnWithAuthKey(authKey.trim()); + } + setRegistered(true); + setAuthKey(''); + setShowManual(false); + setChoice(null); + setAdding(false); + /* + Recycle the tunnel when adding to a LIVE one. + + The control URL and pre-auth key only reach the core through TailcoreStart, which runs at + tunnel start. Adding a tailnet while already connected meant up() was a no-op on a running + session, so the key never arrived: the core sat on the fresh blank profile in NeedsLogin and + the screen asked for a sign-in that a pre-auth key exists precisely to avoid. + + switchProfile already does exactly this, for the same reason. + */ + // No recycle when branching: the core has already switched and started the new profile, and + // restarting would discard it — which is the bug this replaced. + if (!branching) await vpnUp(); + setProfiles(await vpnListProfiles().catch(() => [])); + await burstRefresh(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setEnrolling(false); + refresh(); + } + }; + + /** + * Join Tailscale's own network — interactive sign-in, no key to paste. + * + * Point the node at controlplane.tailscale.com and bring the tunnel up with NO auth key. The core + * reaches `NeedsLogin`, calls `StartLoginInteractive`, and `Status().AuthURL` then carries the + * sign-in URL, which the screen offers as a button. Whatever identity provider the control server + * offers — Google, GitHub, Microsoft — is a conversation between the user and Tailscale: no token, + * credential or OAuth secret ever passes through this app, which is why there is nothing here to + * "handle" about them. + */ + const connectTailscale = async () => { + if (enrolling) return; + setEnrolling(true); + setError(null); + setInsecure(false); + try { + const branching = adding && backendUp && canLoginToNewProfile(); + if (branching) { + // Same single-operation reasoning as the Headscale path; no key, so it lands in NeedsLogin + // with an AuthURL for the new tailnet. + await vpnLoginToNewProfile(TAILSCALE_CONTROL_URL, undefined, nodeName.trim() || undefined); + await configureVpn(TAILSCALE_CONTROL_URL, nodeName.trim() || undefined); + } else { + await configureVpn(TAILSCALE_CONTROL_URL, nodeName.trim() || undefined); + await vpnUp(); + } + setChoice(null); + setAdding(false); + // The sign-in URL is the whole point of this branch — do not make the user wait a poll tick. + await burstRefresh(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setEnrolling(false); + refresh(); + } + }; + + const logout = async () => { + setBusy(true); + setError(null); + try { + await vpnLogout(); + setRegistered(false); + setStatus({ state: 'stopped', peers: [] }); + /* + Re-read authorisation, because THIS is the action that revokes it. + + logout() calls removeFromPreferences(), which deletes the VPN configuration — so the OS grant + is gone the moment it returns. But `authorized` was only refreshed on mount and on foreground, + so the screen carried on believing it was authorised and offered the network chooser over a + configuration it had just deleted. Backgrounding and returning "fixed" it, which is the tell. + + The one action that revokes the grant has to be the one that re-checks it. + */ + setAuthorized(await isVpnAuthorized()); + + // Back to a true first run: no profiles to add to, so a leftover `adding` would show a Cancel + // button with nothing behind it — and, before the backendUp guard, threw "not started". + setProfiles([]); + setAdding(false); + setChoice(null); + // Forgetting everything must forget this too, or the setup screen would claim a connection that + // no longer exists. + setLastSeen(null); + AsyncStorage.removeItem(LAST_SEEN_KEY).catch(() => {}); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + /** + * Switch to another stored login. + * + * The tunnel is recycled around it deliberately. Switching re-registers the node under a different + * identity, so the netmap — the IPs, the routes, the DNS — all change underneath a live tun. Going + * down and up is both correct and honest: the UI shows a reconnect because a reconnect is what + * happens. + */ + const switchProfile = async (id: string) => { + setSwitching(true); + setError(null); + try { + /* + Switch FIRST, on the live backend, then recycle. + + It used to go down -> switch -> up, but SwitchProfile is a message to the extension, and down() + is what kills the extension. The two raced: the switch sometimes landed, and when it did not it + threw before up() ever ran — leaving the profile changed and the tunnel off, needing a manual + reconnect. + + The recycle still has to happen, and after the switch rather than instead of it: the tun holds + the old tailnet's address, and re-applying settings on a LIVE tunnel is the iOS teardown trap + that startTunnel's fast path exists to avoid. Restarting re-enters startTunnel, which applies + the new profile's settings cleanly — the cached node IP having been cleared by the switch. + */ + await vpnSwitchProfile(id); + await vpnDown().catch(() => {}); + // up() waits out the teardown itself — stopVPNTunnel is asynchronous, and a start issued while + // the session is still .disconnecting is silently dropped, which left the switch applied but + // the tunnel off. + await vpnUp(); + await burstRefresh(); + // Keep the previous list if the read comes back empty. During the restart the core is briefly + // unreadable, and blanking the switcher then makes it look like the tailnets were lost — right + // at the moment the user is already unsure whether the switch worked. + const fresh = await vpnListProfiles().catch(() => []); + if (fresh.length) setProfiles(fresh); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSwitching(false); + refresh(); + } + }; + + /** + * Begin an additional login. Leaves existing ones intact and lands the node in `needsLogin`, so + * the status carries an `authUrl` for the sign-in button to open. + */ + /** + * Start adding a tailnet. Opens the same chooser as first run and touches nothing else. + * + * Deliberately does NOT call `NewProfile()` here. Doing so switched the live backend to a blank + * profile the moment the button was pressed: the tunnel dropped, no auth URL appeared because + * nothing had called `StartLoginInteractive`, and with the profile list empty the switcher hid + * itself — stranding the user with a dead toggle and no route back. The core is only touched once + * a destination has been chosen and the form submitted. + */ + const beginAddProfile = () => { + setChoice(null); + setControlUrl(''); + setAuthKey(''); + setInsecure(false); + setError(null); + setAdding(true); + }; + + /** Back out of adding without having touched the core. */ + const cancelAdd = () => { + setAdding(false); + setChoice(null); + setError(null); + }; + + /** + * Forget ONE tailnet, leaving the others alone. + * + * Narrower than signing out, which destroys the identity on every tailnet at once. Deleting the + * ACTIVE profile logs the node out of it, so the tunnel is taken down first rather than left running + * against a login that no longer exists. + * + * Requires a running core: DeleteProfile branches off the live LocalBackend and returns + * "not started" without one — the same trap that made the first "Add a tailnet" throw. The caller + * keeps the control out of reach while disconnected, and this is the second line of defence. + */ + const removeProfile = async (id: string) => { + if (!backendUp) { + setError('Connect first — tailnets can only be changed while the core is running.'); + return; + } + setSwitching(true); + setError(null); + try { + const wasCurrent = profiles.find((p) => p.id === id)?.current ?? false; + const remaining = profiles.filter((p) => p.id !== id); + /* + Delete on the LIVE backend, before any teardown. + + This had the same ordering bug as switching: down() kills the extension, and DeleteProfile is a + message TO the extension. Taking the tunnel down first raced the delete. + */ + await vpnDeleteProfile(id); + + /* + Signing out of the CURRENT tailnet should fall back to another one, not to nothing. + + Deleting the active profile leaves the core with no login, so it lands in NeedsLogin — and the + screen then asked for a sign-in while a perfectly good second tailnet sat there unused. If one + remains, switch to it and reconnect; only with none left is being disconnected correct. + */ + if (wasCurrent && remaining.length) { + await vpnSwitchProfile(remaining[0].id); + await vpnDown().catch(() => {}); + await vpnUp(); + await burstRefresh(); + } else if (wasCurrent) { + await vpnDown().catch(() => {}); + } + // Read back from the core rather than filtering locally: deleting the active profile changes + // which one is current, and guessing that would show the wrong tailnet as selected. Keep the + // previous list on an empty read — the core is briefly unreadable while it restarts. + const after = await vpnListProfiles().catch(() => []); + if (after.length || !remaining.length) setProfiles(after); + // The local label is ours, not the core's, so it has to be cleaned up separately or it would be + // silently inherited by a future profile that happened to reuse the id. + setLabels((current) => { + if (!(id in current)) return current; + const next = { ...current }; + delete next[id]; + AsyncStorage.setItem(LABELS_KEY, JSON.stringify(next)).catch(() => {}); + return next; + }); + // Only when nothing is left: with a fallback we are connected to it, and claiming otherwise + // would put the screen back into the setup state it just escaped. + if (wasCurrent && !remaining.length) { + setRegistered(false); + setStatus({ state: 'stopped', peers: [] }); + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSwitching(false); + refresh(); + } + }; + + /** + * First run: authorised, but no tailnet yet — show the two destinations. + * + * Keyed on `registered` AND `profiles`, because they answer the question at different times. + * `profiles` only populates once the backend is running, so on a cold launch it is empty even for + * someone with three tailnets; `registered` is the durable flag that survives that gap. + * + * Also requires the tunnel to be idle. Tapping "Sign in to Tailscale" starts a node that is not yet + * registered and has no profiles, so without this the flow would bounce straight back to the choice + * screen and never show the auth URL it was waiting for. + */ + const needsSetup = + authorized === true && !registered && profiles.length === 0 && status.state === 'stopped'; + + /** + * The tailnets to display — never empty while something is connected. + * + * `profiles` comes from the core's own profile list, and it can legitimately be empty while a + * connection is live (the core has not persisted a profile yet, or the cross-process read failed). + * The screen then showed "Reading tailnets…" forever above a working tunnel, which is both wrong + * and unhelpful: the connection plainly exists, we are looking straight at its account and IPs. + * + * So when the core reports nothing but the status does, synthesise the current entry from the + * status. It is marked with a sentinel id because it has no profile behind it — switching and + * per-profile deletion do not apply, and signing out of it means signing out entirely. + */ + const tailnets: VpnProfile[] = useMemo(() => { + if (profiles.length) return profiles; + // Only from a SETTLED connection. Mid sign-in the status carries a placeholder self (hostname + // `localhost`, no tailnet), and describing that as a tailnet you belong to is simply false. + if (!status.self || status.state !== 'connected') return []; + return [ + { + id: SYNTHETIC_CURRENT_ID, + name: status.self.loginName ?? status.self.hostName ?? '', + tailnet: status.self.tailnet ?? (isTailscaleControl(status.controlUrl) ? 'Tailscale' : 'Headscale'), + current: true, + controlUrl: status.controlUrl, + }, + ]; + }, [profiles, status.self, status.controlUrl]); + + /** + * Sign out of one tailnet. + * + * Two different operations behind one intent. A real profile is deleted, leaving the others alone. + * The synthesised entry — or the last remaining profile — has nothing narrower to delete, so it is + * a full logout: the node identity itself has to go, or signing back in returns the same device. + */ + const signOutOf = async (id: string) => { + if (id === SYNTHETIC_CURRENT_ID || profiles.length <= 1) return logout(); + return removeProfile(id); + }; + + return { + // Setup / authorization + authorized, + authorizing, + authorize, + needsSetup, + choice, + setChoice, + insecure, + connectTailscale, + embedded, + profiles, + switching, + switchProfile, + tailnets, + signOutOf, + lastSeen, + signingIn, + prefs, + prefsBusy, + setExitNode, + setAcceptRoutes, + canEditPrefs: canEditPrefs(), + invite, + inviteName, + setInviteName, + inviteBusy, + inviteError, + acceptInvite, + dismissInvite, + beginAddProfile, + cancelAdd, + adding, + backendUp, + tailnetLabel, + setTailnetLabel, + labels, + removeProfile, + copy, + copiedKey, + available, + registered, + coreVersion, + status, + connected, + busy, + enrolling, + refreshing, + error, + diag, + lastExit, + // manual + controlUrl, + setControlUrl, + authKey, + setAuthKey, + nodeName, + setNodeName, + showManual, + setShowManual, + // actions + toggle, + loginManual, + logout, + refresh, + }; +}; + +export type VpnVM = ReturnType; diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..76767ca --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,20 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./src/**/*.{js,jsx,ts,tsx}'], + presets: [require('nativewind/preset')], + theme: { + extend: { + colors: { + fin: { + bg: '#101418', + surface: '#1b2027', + elevated: '#242b34', + text: '#e8eaed', + muted: '#9aa4b2', + accent: '#00a4dc', + }, + }, + }, + }, + plugins: [], +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..21688bf --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["DOM", "ESNext"], + "jsx": "react-jsx", + "strict": true, + "allowJs": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "modules/officer-net/index.ts", "nativewind-env.d.ts"] +}