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.
87 lines
2.7 KiB
TypeScript
87 lines
2.7 KiB
TypeScript
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>
|
|
);
|
|
};
|