init: OffScale — standalone MIT Tailscale/Headscale client

Extracted from the officer-suite monorepo into its own public repo. The app is
open source and free; the rest of the suite is not, so it could not stay in a
tree that ships an account layer.

What moved:

- apps/offscale/*                  -> repo root (App shell, app.json, assets)
- packages/officer-net/            -> modules/officer-net/, a `file:` dependency
- packages/core VPN closure        -> src/vpn/ (screen, hook, native bridge, invite)
- packages/core shared primitives  -> src/ui/ (theme, cards, tabs, backdrop, splash)
- scripts/build-{ios,android}.sh   -> scripts/, cut down to this one app

What deliberately did NOT move: the platform account layer. VpnScreen used to
render core's AppSettingsScreen, which drags in ServersSection -> servers.ts,
useAuth, api.ts, shared-session, install-guard, api-keys. None of that exists
here. src/settings/SettingsScreen.tsx is a fresh replacement rendering exactly
what VpnScreen passed it — the app's own sections plus About — with no Servers
tab, no Appearance control (the app is pinned to dark) and no sign-out.

The one platform reference left in the closure was a dead `import { request }
from './api'` in officer-net.ts, unused since enrollVpn() was removed. Gone. The
app now contacts nothing but the control server you point it at, its peers, and
the enrolment sidecar named inside an invite link you tapped.

Dependencies are pinned to the exact versions the monorepo has installed, not to
ranges: this repo starts from versions already proven to build, and an unpinned
install is a supply-chain decision nobody made. react-query, react-dom,
react-native-screens, expo-secure-store, track-player, expo-audio and
expo-file-system were all declared by the old app and imported by none of this
code, so they are not here.

NOTICE carries Tailscale's BSD-3 text, names the built artifacts that contain
their compiled code, and treats the trademark question separately — including
that src/ui/TailscaleMark.tsx reproduces their logo in the attribution strip.
This commit is contained in:
2026-08-14 20:32:17 +01:00
commit ef3582f2aa
68 changed files with 11534 additions and 0 deletions
+66
View File
@@ -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 (
<View className="flex-1" style={{ backgroundColor: palette.bg }}>
{/* 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. */}
<StatusBar style={palette.mode === 'dark' ? 'light' : 'dark'} />
<VpnScreen onShowCredits={onShowCredits} wordmark={wordmark} />
</View>
);
};
export const App = () => {
const [showSplash, setShowSplash] = useState(true); // launch intro
const [showCredits, setShowCredits] = useState(false); // replayed intro (hold-until-tap)
return (
<SafeAreaProvider>
{/* 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. */}
<ThemeProvider lockedMode="dark">
<Root onShowCredits={() => setShowCredits(true)} />
{showSplash ? (
<AppSplash
icon={appIcon}
wordmark={wordmark}
name="OffScale"
tagline="Your mesh. Part of the Officer suite."
onDone={() => setShowSplash(false)}
/>
) : null}
{showCredits ? <AppSplash icon={appIcon} wordmark={wordmark} name="OffScale" hold onDone={() => setShowCredits(false)} /> : null}
</ThemeProvider>
</SafeAreaProvider>
);
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+5
View File
@@ -0,0 +1,5 @@
import { registerRootComponent } from 'expo';
import { App } from './App';
registerRootComponent(App);
+67
View File
@@ -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 = (
<>
<Text className="text-base font-bold" style={{ color: palette.text }}>{label}</Text>
{children ?? (icon ? <Ionicons name={icon} size={20} color={palette.textFaint} /> : null)}
</>
);
if (!onPress) {
return (
<View className="flex-row items-center justify-between rounded-lg border p-3" style={rowStyle}>
{body}
</View>
);
}
return (
<Pressable
onPress={onPress}
className="flex-row items-center justify-between rounded-lg border p-3 active:opacity-80"
style={rowStyle}
>
{body}
</Pressable>
);
};
/** 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 (
<View className="gap-2">
<Text className="text-xs font-bold uppercase tracking-wide" style={{ color: palette.textMuted }}>{title}</Text>
{children}
</View>
);
};
+185
View File
@@ -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 = (
<View className="flex-1" style={{ backgroundColor: wallpaper ? 'transparent' : palette.bg }}>
{/*
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.
*/}
<View
className="flex-row items-center gap-2 px-4 pb-3"
style={{ paddingTop: embedded ? 8 : insets.top + 8 }}
>
{mark ? (
<Image
source={mark}
accessibilityLabel={title}
style={{ height: markHeight, width: markHeight * WORDMARK_ASPECT }}
resizeMode="contain"
/>
) : (
<Text className="text-lg font-bold" style={{ color: palette.text }}>{title}</Text>
)}
<View className="flex-1" />
{/* 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. */}
<Pressable onPress={onClose} hitSlop={8} className="active:opacity-60">
<View className="rounded-full p-1.5" style={{ backgroundColor: palette.overlay }}>
<Ionicons name="close" size={18} color="#ffffff" />
</View>
</Pressable>
</View>
<ScrollView
className="flex-1"
contentContainerStyle={{
padding: 16,
paddingBottom: wallpaper ? 24 : insets.bottom + 24,
maxWidth: ui.maxWidth,
width: '100%',
alignSelf: 'center',
}}
keyboardShouldPersistTaps="handled"
>
<Card>
{/* 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. */}
<View className="gap-7">
{children}
{/* About last, because it is the way out of the page. */}
<SettingsSection title="About">
{onShowCredits ? (
<SettingsRow label="Credits" icon="information-circle-outline" onPress={onShowCredits} />
) : null}
{footer}
</SettingsSection>
</View>
</Card>
</ScrollView>
{/* 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}
</View>
);
return wallpaper ? <Wallpaper>{body}</Wallpaper> : body;
};
/** The top-right gear the main screen carries. */
export const SettingsButton = ({ onPress }: { onPress: () => void }) => {
const { palette } = useTheme();
return (
<Pressable
onPress={onPress}
hitSlop={8}
accessibilityLabel="Settings"
className="h-10 w-10 items-center justify-center rounded-md active:opacity-60"
>
<Ionicons name="settings-outline" size={20} color={palette.icon} />
</Pressable>
);
};
+270
View File
@@ -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 (
<ImageBackground
source={palette.mode === 'dark' ? backgroundDusk : background}
resizeMode="cover"
className="flex-1"
>
{children}
</ImageBackground>
);
};
/**
* 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 `<Master>_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 (
<Wallpaper>
<View
className="flex-row items-center gap-2 px-4 pb-3"
style={{ paddingTop: insets.top + 8 }}
>
{mark ? (
<Image
source={mark}
accessibilityLabel={title}
style={{ height: markHeight, width: markHeight * WORDMARK_ASPECT }}
resizeMode="contain"
/>
) : (
// Shadowed: it is sitting on a photograph, and a thin stroke disappears against the
// bright parts of one.
<Text
className="text-lg font-bold"
style={{
color: '#ffffff',
textShadowColor: 'rgba(0,0,0,0.85)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 3,
}}
>
{title}
</Text>
)}
<View className="flex-1" />
{actions}
</View>
{/*
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.
*/}
<View className="flex-1">{children}</View>
{footer}
</Wallpaper>
);
};
/**
* 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 (
<View
className="items-center rounded-t-3xl border-t px-4 pt-3.5"
style={{
// Translucent and rounded at the top, the same language as the cards and the dock — so it
// reads as another surface in this app rather than a strip bolted to the bottom of it.
backgroundColor: palette.surface,
borderColor: palette.border,
paddingBottom: insets.bottom + 12,
}}
>
{/*
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.
*/}
<Text style={{ color: palette.text, fontSize: scaled(10, scale), fontWeight: '700', letterSpacing: 1.4, opacity: 0.9 }}>
BUILT ON OPEN SOURCE
</Text>
{/*
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. */}
<View
className="mt-2 w-full flex-row items-center justify-around px-20"
style={{ maxWidth, alignSelf: 'center' }}
>
<ProjectLink project={PROJECTS[0]} />
<TailscaleMark size={scaled(16, scale)} />
<ProjectLink project={PROJECTS[1]} />
</View>
{/*
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.
*/}
<Text className="mt-2.5" style={{ color: palette.text, fontSize: scaled(11, scale), opacity: 0.9 }}>
part of the{' '}
<Text
onPress={() => void Linking.openURL(OFFICER_URL).catch(() => {})}
accessibilityRole="link"
accessibilityLabel={`officer.dev — opens ${OFFICER_URL}`}
style={{ color: palette.accent, fontWeight: '700' }}
>
officer.dev
</Text>{' '}
family
</Text>
</View>
);
};
/** 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 (
<Pressable
onPress={() => void Linking.openURL(project.url).catch(() => {})}
accessibilityRole="link"
accessibilityLabel={`${project.name} — opens ${project.url}`}
hitSlop={12}
className="active:opacity-60"
>
<Text style={{ color: '#ffffff', fontSize: scaled(13, scale), fontWeight: '700' }}>{project.name}</Text>
</Pressable>
);
};
/**
* 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 (
<View className="rounded-full p-1.5" style={{ backgroundColor: palette.overlay }}>
{children}
</View>
);
};
+108
View File
@@ -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 (
<Animated.View
pointerEvents={hold ? 'auto' : 'none'}
style={[
StyleSheet.absoluteFill,
{ backgroundColor: '#000', alignItems: 'center', justifyContent: 'center', opacity, zIndex },
]}
>
<Animated.View style={{ transform: [{ translateY: bob }, { scale }] }}>
<Image source={icon} style={{ width: size, height: size, borderRadius: size * 0.22 }} resizeMode="cover" />
</Animated.View>
{wordmark ? (
<Image
source={wordmark}
accessibilityLabel={name}
style={{ marginTop: 24, width: size, height: size / WORDMARK_ASPECT }}
resizeMode="contain"
/>
) : (
<Text style={{ marginTop: 28, color: '#fff', fontSize: 26, fontWeight: '700', letterSpacing: 0.5 }}>{name}</Text>
)}
{tagline ? (
<Text style={{ marginTop: 8, color: 'rgba(255,255,255,0.6)', fontSize: 14 }}>{tagline}</Text>
) : null}
<Text style={{ position: 'absolute', bottom: 72, color: 'rgba(255,255,255,0.9)', fontSize: 15, letterSpacing: 0.5 }}>
© {new Date().getFullYear()} pastilhas.dev
</Text>
{hold ? (
<>
<Text style={{ position: 'absolute', bottom: 28, color: 'rgba(255,255,255,0.25)', fontSize: 11 }}>Tap to close</Text>
{/* Full-screen tap catcher so a tap anywhere dismisses the credits. */}
<Pressable onPress={onDone} style={StyleSheet.absoluteFill} />
</>
) : null}
</Animated.View>
);
};
+105
View File
@@ -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 (
<View pointerEvents="none" className="absolute inset-0 overflow-hidden">
{Array.from({ length: cols }, (_, i) => (
<View
key={`c${i}`}
style={{
position: 'absolute',
left: i * GRID_SIZE,
top: 0,
bottom: 0,
width: 1,
backgroundColor: GRID_COLOR,
}}
/>
))}
{Array.from({ length: rows }, (_, i) => (
<View
key={`r${i}`}
style={{
position: 'absolute',
top: i * GRID_SIZE,
left: 0,
right: 0,
height: 1,
backgroundColor: GRID_COLOR,
}}
/>
))}
</View>
);
};
/** `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 `<Card className="gap-4">` 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 (
<View
onLayout={onLayout}
className={`overflow-hidden rounded-xl border-2 p-5 ${outer}`}
style={{
...style,
backgroundColor: palette.surface,
borderColor: palette.border,
shadowColor: '#000',
shadowOpacity: 0.15,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 6,
}}
>
<Grid width={size.width} height={size.height} color={palette.hairline} />
{/* 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. */}
<View className={content} style={{ flexGrow: 1, flexBasis: 'auto' }}>{children}</View>
</View>
);
};
+86
View File
@@ -0,0 +1,86 @@
import { Ionicons } from '@expo/vector-icons';
import { View, Text, Pressable } from 'react-native';
import { useTheme } from './useTheme';
export type TabDef<K extends string> = {
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 = <K extends string>({
tabs,
value,
onChange,
}: {
tabs: readonly TabDef<K>[];
value: K;
onChange: (k: K) => void;
}) => {
const { palette } = useTheme();
return (
<View
className="flex-row gap-1 rounded-2xl p-1"
style={{ backgroundColor: palette.surfaceAlt }}
>
{tabs.map((t) => {
const active = t.key === value;
return (
<Pressable
key={t.key}
onPress={() => 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',
}}
>
<Ionicons
name={t.icon}
size={15}
color={active ? palette.brandText : palette.textMuted}
/>
<Text
numberOfLines={1}
className="text-xs"
style={{
color: active ? palette.brandText : palette.textMuted,
fontWeight: active ? '700' : '500',
}}
>
{t.label}
</Text>
{t.badge ? (
<Text
className="text-[10px]"
style={{ color: active ? palette.brandText : palette.textFaint, fontWeight: '700' }}
>
{t.badge}
</Text>
) : null}
</Pressable>
);
})}
</View>
);
};
+49
View File
@@ -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 }) => (
<Svg width={size} height={size} viewBox="0 0 28 28">
{FADED.map(([cx, cy]) => (
<Circle key={`f${cx}-${cy}`} cx={cx} cy={cy} r={3.5} fill={color} opacity={0.4} />
))}
{SOLID.map(([cx, cy]) => (
<Circle key={`s${cx}-${cy}`} cx={cx} cy={cy} r={3.5} fill={color} />
))}
</Svg>
);
+72
View File
@@ -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<TextInput, TextInputProps>(({ style, multiline, ...rest }, ref) => (
<TextInput
ref={ref}
multiline={multiline}
// Base first so a caller's `style` still wins — some screens set their own colour or size.
style={[
{
fontSize: 16,
...(multiline
? {
minHeight: INPUT_MULTILINE_MIN_HEIGHT,
textAlignVertical: 'top' as const,
paddingTop: 12,
paddingBottom: 12,
}
: {
minHeight: INPUT_MIN_HEIGHT,
textAlignVertical: 'center' as const,
paddingVertical: 0,
}),
},
style,
]}
{...rest}
/>
));
TextField.displayName = 'TextField';
+192
View File
@@ -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);
+16
View File
@@ -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';
}
+93
View File
@@ -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<ThemeValue | null>(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<ThemePreference>(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<ThemeValue>(
() => ({ mode, palette: paletteFor(mode), preference, setPreference }),
[mode, preference, setPreference],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
};
/**
* 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: () => {} };
};
+52
View File
@@ -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);
File diff suppressed because it is too large Load Diff
+32
View File
@@ -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<boolean> => {
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<boolean> };
await mod.setStringAsync(text);
return true;
} catch {
return false;
}
};
+264
View File
@@ -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<string, string>();
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<InviteClaim> => {
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<InviteClaim> | 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,
};
};
+464
View File
@@ -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<VpnSelf, 'keyExpiry' | 'expired'>,
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<VpnStatus, 'state' | 'exitNodeOnline'>,
prefs?: Pick<VpnPrefs, 'exitNodeID'>,
): 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<void>;
/** Register the node with a Headscale pre-auth key. */
loginWithAuthKey(authKey: string): Promise<void>;
/** Whether a node identity already exists on this device (registered previously). */
isRegistered(): Promise<boolean>;
/**
* 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<boolean>;
/** Ask for tunnel consent without connecting. Resolves false if the user declines. */
requestAuthorization?(): Promise<boolean>;
/** Bring the tunnel up / down. */
up(): Promise<{ needsConsent?: boolean; granted?: boolean } | void>;
down(): Promise<void>;
/** Stored logins as a JSON array — see `listProfiles` for the parsed form. */
listProfiles(): Promise<string>;
/** Make a stored login active. The netmap changes, so the caller recycles the tunnel around it. */
switchProfile(id: string): Promise<void>;
/** Begin an additional login; the node lands in `needsLogin` with an `authUrl`. */
newProfile(): Promise<void>;
/**
* 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<void>;
/** Forget a login. Deleting the active one logs the node out. */
deleteProfile(id: string): Promise<void>;
/** 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<void>;
setAcceptRoutes?(on: boolean): Promise<void>;
/** Live prefs as JSON — what IS set, not what was last requested. */
prefs?(): Promise<string>;
lastExitReport?(): Promise<string>;
/** Current status snapshot. */
status(): Promise<VpnStatus>;
/** Forget the node identity + keys (full reset). */
logout(): Promise<void>;
/** The embedded Tailscale core version — proof the gomobile core is linked in and executing. */
coreVersion(): Promise<string>;
/** Debug: the NE's diagnostic ring buffer (iOS bring-up flow). Optional (not on all platforms). */
coreDiag?(): Promise<string>;
// 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<void> =>
mod().configure(controlUrl, hostname);
/** Register this device with a Headscale pre-auth key (manual fallback path). */
export const loginVpnWithAuthKey = (authKey: string): Promise<void> => 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<string> => mod().coreVersion();
/** Debug: the NE's diagnostic ring buffer (iOS). Empty string when unsupported. */
export const vpnCoreDiag = (): Promise<string> => mod().coreDiag?.() ?? Promise.resolve('');
export const isVpnRegistered = (): Promise<boolean> => 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<boolean> => {
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<boolean> => {
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<void> => mod().down();
export const vpnStatus = (): Promise<VpnStatus> => mod().status();
export const vpnLogout = (): Promise<void> => 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<VpnProfile[]> => {
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<void> => {
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<void> => {
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<VpnPrefs> => {
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<TunnelExit | undefined> => {
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<string, unknown>;
// 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<void> => mod().switchProfile(id);
export const vpnNewProfile = (): Promise<void> => 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<void> => {
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<void> => 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 () => {};
}
};
File diff suppressed because it is too large Load Diff