Skip to content

Mobile Client

The Bifrost Mobile Client is a cross-platform application built with React Native and Expo, providing VPN management on iOS and Android devices.

Important — the mobile app is a remote control, not an on-device tunnel. The Expo app manages a Bifrost client running elsewhere on your network over the REST API (see Connecting to Client). It does not establish an encrypted tunnel on the phone itself. The native PacketTunnelProvider (iOS) and BifrostVpnService (Android) files in the repository are non-functional placeholders: they are unbuildable skeletons and the current on-device path would forward raw packets without a WireGuard/Noise handshake (i.e. cleartext, not secure). For that reason the native VPN path is gated off and must not be relied on for confidentiality. A real on-device WireGuard tunnel is planned future work.

  • Home Screen: VPN connection status and quick toggle
  • Servers Screen: Server selection with latency indicators
  • Stats Screen: Real-time traffic statistics and connection details
  • Settings Screen: Configuration management and preferences
  • Node.js 18+
  • npm or yarn
  • Expo CLI
  • For iOS: Xcode 14+ (macOS only)
  • For Android: Android Studio with SDK
Terminal window
# Clone repository
git clone https://github.com/rennerdo30/bifrost-proxy.git
cd bifrost-proxy/mobile
# Install dependencies
npm install
# Start development server
npx expo start
Terminal window
# iOS Simulator (macOS only)
npx expo run:ios
# Android Emulator
npx expo run:android
# On physical device
npx expo start --tunnel
# Then scan QR code with Expo Go app

Using EAS Build (recommended):

Terminal window
# Install EAS CLI
npm install -g eas-cli
# Login to Expo account
eas login
# Link this project to your Expo account (writes extra.eas.projectId).
# The repository ships unlinked, so this is required before the first build.
eas init
# Build for iOS
eas build --platform ios
# Build for Android
eas build --platform android
# Build for both
eas build --platform all

The main dashboard showing connection status:

┌─────────────────────────────────────┐
│ Bifrost VPN │
├─────────────────────────────────────┤
│ │
│ ┌───────────────┐ │
│ │ │ │
│ │ 🔒 │ │
│ │ Connected │ │
│ │ │ │
│ └───────────────┘ │
│ │
│ [ Disconnect ] │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ ↑ Sent │ │ ↓ Received │ │
│ │ 1.2 GB │ │ 3.4 GB │ │
│ └─────────────┘ └─────────────┘ │
│ │
│ Server: us-west.example.com │
│ Connected: 2h 34m │
│ │
├─────────────────────────────────────┤
│ 🏠 Home │ 📡 Servers │ 📊 Stats │ ⚙️ │
└─────────────────────────────────────┘

Server selection with status indicators:

┌─────────────────────────────────────┐
│ Available Servers │
├─────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────┐│
│ │ 🟢 US West ✓ 45ms ││
│ │ us-west.example.com ││
│ │ HTTP ││
│ └─────────────────────────────────┘│
│ │
│ ┌─────────────────────────────────┐│
│ │ 🟢 Europe 120ms ││
│ │ eu.example.com ││
│ │ HTTP ││
│ └─────────────────────────────────┘│
│ │
│ ┌─────────────────────────────────┐│
│ │ 🟡 Asia Pacific 200ms ││
│ │ asia.example.com ││
│ │ SOCKS5 ││
│ └─────────────────────────────────┘│
│ │
│ ┌─────────────────────────────────┐│
│ │ 🔴 Development offline ││
│ │ dev.example.com ││
│ │ HTTP ││
│ └─────────────────────────────────┘│
│ │
├─────────────────────────────────────┤
│ 🏠 Home │ 📡 Servers │ 📊 Stats │ ⚙️ │
└─────────────────────────────────────┘

Status indicators:

  • 🟢 Online - Server available
  • 🟡 Busy - High load
  • 🔴 Offline - Server unavailable

Detailed connection statistics:

┌─────────────────────────────────────┐
│ Connection Stats │
├─────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ ↑ Sent │ │ ↓ Received │ │
│ │ 1.2 GB │ │ 3.4 GB │ │
│ └─────────────┘ └─────────────┘ │
│ │
│ Current Session │
│ ───────────────────────────────── │
│ Duration 2h 34m │
│ Total Data 4.6 GB │
│ VPN Status connected │
│ │
│ Traffic │
│ ───────────────────────────────── │
│ Packets Sent 1,204,551 │
│ Packets Received 3,410,882 │
│ Active Conns 42 │
│ │
│ Split Tunneling │
│ ───────────────────────────────── │
│ Tunneled 1,204 │
│ Bypassed 318 │
│ │
│ DNS │
│ ───────────────────────────────── │
│ Queries 8,120 │
│ Cache Hits 6,904 │
│ │
├─────────────────────────────────────┤
│ 🏠 Home │ 📡 Servers │ 📊 Stats │ ⚙️ │
└─────────────────────────────────────┘

Note: Every row above describes the remote Bifrost client the app controls, and each one maps to a field of vpn.VPNStats as returned by GET /api/v1/vpn/status. None of it indicates an encrypted tunnel running on the phone itself — see the warning at the top of this page.

The API exposes no tunnel protocol, encryption suite, MTU, local IP, gateway, DNS server list or interface name, so the Stats screen does not claim to show them. Session duration comes from uptime (a Go time.Duration, i.e. nanoseconds), not from a connection timestamp.

Configuration and preferences:

┌─────────────────────────────────────┐
│ Settings │
├─────────────────────────────────────┤
│ │
│ Connection │
│ ───────────────────────────────── │
│ Client auto-connect [ 🔘 ] │
│ Connect when the client starts │
│ │
│ VPN Mode [🔘 ] │
│ Route remote-client traffic │
│ │
│ Split Tunneling │
│ Manage app/domain/IP rules │
│ [Configure →] │
│ │
│ Bifrost Client │
│ ───────────────────────────────── │
│ ┌─────────────────────────────────┐│
│ │ 192.168.1.20:7383 [Save] ││
│ └─────────────────────────────────┘│
│ Plain host:port uses HTTP. │
│ Prefix https:// for TLS. │
│ ┌─────────────────────────────────┐│
│ │ •••••••••••••••••••••• [Save] ││
│ └─────────────────────────────────┘│
│ A token is stored on this device. │
│ [Clear] │
│ API Endpoint http://…:7383/api/v1 │
│ │
│ Notifications │
│ ───────────────────────────────── │
│ Client Notifications [🔘 ] │
│ Desktop alerts on the client │
│ │
│ Data & Privacy │
│ ───────────────────────────────── │
│ [ Clear Cached Data ] │
│ │
│ About │
│ ───────────────────────────────── │
│ Version 1.0.0 │
│ Server Status Connected │
│ VPN Status connected │
│ │
├─────────────────────────────────────┤
│ 🏠 Home │ 📡 Servers │ 📊 Stats │ ⚙️ │
└─────────────────────────────────────┘

The Client auto-connect and Client Notifications switches write the remote client’s tray configuration (tray.auto_connect, tray.show_notifications). They are labeled as remote settings because they have no effect on the phone: the app has no launch-time auto-connect and no push notifications. There is no Kill Switch — that would require a tunnel on the device, which this app deliberately does not implement.

The Split Tunneling screen edits the remote client’s policy, and the remote client is the source of truth:

  • On open (and on pull-to-refresh) the app reads GET /api/v1/vpn/split/rules and reconciles it with what the phone remembers. Rules the client currently enforces show as enabled; rules added from another client are imported rather than ignored.
  • A rule you remember locally but the client no longer enforces stays visible and disabled, so parking a rule for later does not lose it.
  • Toggling a rule off removes it from the client but keeps it on the phone; the trash action removes it from both.
  • If the client cannot be reached, the screen says so and falls back to the locally remembered rules instead of showing an empty policy.

Before the VPN is enabled, the app replaces the client’s active rules with the enabled portion of the local policy: stale remote rules are removed, missing ones added, and changed app paths re-created. This is fail-closed — if any single operation fails, the VPN is not enabled and the error names the rule and operation that failed. The previous behaviour only ever added rules, so a rule disabled on the phone stayed in force on the client.

The interface ships English and German. The language follows the device locale (a locale starting with de selects German; everything else falls back to English). Counts and durations use locale-aware formatting, so a German device reads 1.234 where an English one reads 1,234. Strings live in mobile/src/i18n.ts; the catalogue is typed, so a key missing a German translation fails the build rather than leaking English text.

The mobile client connects to a Bifrost client running on your network. Configure its address, and an API token if the client requires one, in Settings.

  1. Start the Bifrost client on your computer/server
  2. Note the API address (default: 192.168.x.x:7383)
  3. Enter this address in the mobile app Settings

A bare host:port is reached over plain HTTP. Prefix the address with https:// when the client sits behind TLS; the implicit port 443 may then be omitted. IPv6 literals must be bracketed, e.g. [fd00::1]:7383.

When the client config sets api.token, every request must carry it as a bearer credential or the client answers 401 Unauthorized. Enter the token in the Bifrost Client section of Settings; it is stored on the device with AsyncStorage, sent as Authorization: Bearer <token>, never displayed again after saving, and never written to a log. Use Clear to remove it.

The client API rejects every POST, PUT, DELETE and PATCH that does not carry X-Requested-With: XMLHttpRequest with 403. The app sends this header on every request, matching both web dashboards.

The mobile app uses these API endpoints:

Endpoint Description
GET /api/v1/health Reachability probe used by the Save buttons
GET /api/v1/status Client status
GET /api/v1/vpn/status VPN connection status (vpn.VPNStats)
POST /api/v1/vpn/enable Enable VPN
POST /api/v1/vpn/disable Disable VPN
GET /api/v1/servers The client’s servers: list (credentials excluded)
POST /api/v1/server/select Select server, body {"server": "<name>"}; switches the client’s live upstream connection and persists the choice
GET /api/v1/config Get configuration
PUT /api/v1/config Update configuration
GET /api/v1/vpn/split/rules Split tunnel rules
PUT /api/v1/vpn/split/mode Set split tunnel mode
POST/DELETE /api/v1/vpn/split/{apps,domains,ips} Split tunnel rule edits
DELETE /api/v1/debug/entries Clear cached debug entries

Servers are identified by name: ServerInfo carries name, address, protocol, is_default, latency_ms and status, and has no id field.

mobile/
├── src/
│ ├── components/ # Reusable UI components
│ ├── screens/ # Screen components
│ │ ├── HomeScreen.tsx
│ │ ├── ServersScreen.tsx
│ │ ├── StatsScreen.tsx
│ │ └── SettingsScreen.tsx
│ ├── services/ # API and utilities
│ │ └── api.ts
│ └── App.tsx # Main app component
├── app.json # Expo configuration
├── package.json
└── tsconfig.json
  • React Native: Cross-platform UI framework
  • Expo: Development and build tooling
  • React Query: Data fetching and caching
  • React Navigation: Screen navigation
  1. Create screen component in src/screens/
  2. Add to navigation in App.tsx
  3. Add tab bar icon if needed

The app uses React Query for data fetching:

import { useQuery, useMutation } from '@tanstack/react-query'
import { api } from '../services/api'
function MyScreen() {
const { data, isLoading, error } = useQuery({
queryKey: ['vpn-status'],
queryFn: api.getVPNStatus,
refetchInterval: 3000,
})
const mutation = useMutation({
mutationFn: api.enableVPN,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vpn-status'] })
},
})
// ...
}

The Bifrost Mobile Client is designed with accessibility in mind, supporting both VoiceOver (iOS) and TalkBack (Android) screen readers.

Feature Support
Screen Reader Supported across all five screens
Accessibility Labels Interactive elements are labelled, including the split-tunnel rule switches; toasts announce as alerts
Accessibility Roles Semantic roles for buttons, tabs, switches
Accessibility States Disabled, selected, checked states
Accessibility Hints Context-aware action hints
  1. Enable VoiceOver: Settings > Accessibility > VoiceOver
  2. Navigate using swipe gestures
  3. Double-tap to activate
  4. Use two-finger scroll for lists

Home Screen:

  • Connect/Disconnect button announces: “Connect to VPN, button” or “Disconnect from VPN, button”
  • Status text announces: “Connection status: Protected” or similar
  • Error alerts are announced immediately with role “alert”
  • Upload/Download cards announce: “Upload: 1.2 GB” and “Download: 3.4 GB”

Servers Screen:

  • Server items announce full context: “Server Name, address, protocol, status, selected/not selected”
  • Offline servers announce disabled state
  • Pull-to-refresh announces “Pull to refresh server list”

Stats Screen:

  • All stat rows announce label and value pairs: “Duration: 2h 34m”
  • StatusCard components announce: “Total Sent: 1.2 GB”, “Total Received: 3.4 GB”

Settings Screen:

  • Switch controls announce their label and current state
  • Configure button announces: “Configure split tunneling, button”
  • Text input announces: “Server address, Enter the server address in host:port format”

Tab Navigation:

  • Tab icons announce: “Home tab, selected” or “Settings tab”
  • Uses proper tab role with selected state
  1. Enable TalkBack: Settings > Accessibility > TalkBack
  2. Navigate by swiping left/right
  3. Double-tap to activate
  4. Use two-finger scroll for content

All behaviors match VoiceOver testing above with Android-specific variations:

  • importantForAccessibility="no" hides decorative elements
  • importantForAccessibility="no-hide-descendants" groups child elements
  • Swipe gestures map to Android TalkBack conventions
  • Focus indicators appear on currently selected element

The following accessibility props are used throughout the app:

// Button example
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel="Connect to VPN"
accessibilityHint="Double tap to establish VPN connection"
accessibilityState={{ disabled: isLoading }}
>
// Switch example
<Switch
accessibilityLabel="Client auto-connect toggle"
accessibilityHint="When enabled, the Bifrost client connects automatically on startup"
accessibilityState={{ checked: isEnabled }}
/>
// Grouped content example
<View
accessible={true}
accessibilityRole="summary"
accessibilityLabel={`${title}: ${value}`}
>
<Text importantForAccessibility="no">{title}</Text>
<Text importantForAccessibility="no">{value}</Text>
</View>
  1. Emoji Icons: Tab bar uses emoji icons which are hidden from screen readers; proper labels are provided instead
  2. Pull-to-Refresh: Custom accessibility labels describe the refresh action
  3. Dynamic Content: Status updates may not be immediately announced; users should navigate to the status area to hear updates
  4. Error States: Error messages use the alert role for immediate screen reader announcement
  1. Verify client is running and API is enabled
  2. Check network connectivity (same network)
  3. Ensure correct IP address and port
  4. Check firewall settings on client machine
  1. Clear app data and reinstall
  2. Check for conflicting VPN apps
  3. Update to latest version
  1. Increase refetch intervals in settings
  2. Disable notifications if not needed
  3. Disconnect when not in use
  1. Check network connection
  2. Verify client is running
  3. Pull down to refresh manually