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:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user