Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 62 additions & 98 deletions app/(onboarding)/add-device.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,24 +50,53 @@ export default function AddDeviceScreen() {
return;
}

const addr = relayAddress.trim();
if (!addr) {
alert.warning('Relay Address Required', 'Enter the relay address shown in your terminal (e.g., 192.168.1.5:3000).');
return;
}

try {
let relayUrl = addr;
if (!relayUrl.startsWith('ws://') && !relayUrl.startsWith('wss://')) {
relayUrl = `ws://${relayUrl}`;
const addr = relayAddress.trim();

if (addr) {
let relayUrl = addr;
if (!relayUrl.startsWith('ws://') && !relayUrl.startsWith('wss://')) {
relayUrl = `ws://${relayUrl}`;
}
await pairingService.setRelayUrl(relayUrl);
} else {
// No relay address — try tunnel URL from Supabase via pairing code
const tunnelUrl = await wsService.fetchTunnelUrlByPairingCode(pairingCode.toUpperCase());
if (tunnelUrl) {
let wsUrl = tunnelUrl;
if (wsUrl.startsWith('https://')) {
wsUrl = wsUrl.replace('https://', 'wss://');
} else if (wsUrl.startsWith('http://')) {
wsUrl = wsUrl.replace('http://', 'ws://');
}
await pairingService.setRelayUrl(wsUrl);
} else {
await pairingService.setRelayUrl(null);
}
}
await pairingService.setRelayUrl(relayUrl);
await connectAndWait();

await pairDevice(pairingCode);
wsService.disconnect();
await wsService.connect();

if (!wsService.isConnected) {
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
unsub();
reject(new Error('Connection timed out'));
}, 10000);
const unsub = wsService.on('connected', () => {
clearTimeout(timeout);
unsub();
resolve();
});
});
}

await pairDevice(pairingCode.toUpperCase());
setIsPaired(true);
} catch (error) {
alert.error('Pairing Failed', 'Could not connect to CLI. Check the relay address and pairing code.');
} catch (error: any) {
const msg = error?.message || String(error);
alert.error('Pairing Failed', msg);
}
};

Expand Down Expand Up @@ -275,95 +304,30 @@ export default function AddDeviceScreen() {
</View>
</View>
) : (
<View>
<View style={{ marginBottom: 16 }}>
<Text style={{ color: theme.textSecondary, fontSize: 12, fontWeight: 'bold', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>
Pairing Code
<View style={{ backgroundColor: theme.backgroundSecondary, borderWidth: 1, borderColor: theme.border, borderRadius: 12, padding: 24 }}>
<View style={{ alignItems: 'center' }}>
<Text style={{ color: theme.textSecondary, fontSize: 14, textAlign: 'center', marginBottom: 24 }}>
Enter the pairing code shown in your terminal after running{'\n'}
<Text style={{ color: theme.primary, fontFamily: 'monospace' }}>forkoff pair</Text>
</Text>
<TextInput
placeholder="e.g., ABC12345"
placeholderTextColor={theme.textTertiary}
value={pairingCode}
onChangeText={(text) => setPairingCode(text.toUpperCase())}
autoCapitalize="characters"
maxLength={8}
<TouchableOpacity
onPress={() => router.push('/device/pair?method=code')}
style={{
backgroundColor: theme.backgroundSecondary,
borderWidth: 1,
borderColor: theme.border,
backgroundColor: theme.primary,
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 16,
color: theme.text,
fontSize: 18,
fontFamily: 'monospace',
textAlign: 'center',
letterSpacing: 4,
padding: 16,
width: '100%',
alignItems: 'center',
shadowColor: theme.primary,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.2,
shadowRadius: 12,
elevation: 5,
}}
/>
</View>

<View style={{ marginBottom: 16 }}>
<Text style={{ color: theme.textSecondary, fontSize: 12, fontWeight: 'bold', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>
Relay Address
</Text>
<TextInput
placeholder="e.g., 192.168.1.5:3000"
placeholderTextColor={theme.textTertiary}
value={relayAddress}
onChangeText={setRelayAddress}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
style={{
backgroundColor: theme.backgroundSecondary,
borderWidth: 1,
borderColor: theme.border,
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 16,
color: theme.text,
fontSize: 16,
fontFamily: 'monospace',
textAlign: 'center',
}}
/>
</View>

<View style={{ marginBottom: 24, backgroundColor: theme.backgroundSecondary, borderWidth: 1, borderColor: theme.border, borderRadius: 12, padding: 16 }}>
<Text style={{ color: theme.textSecondary, fontSize: 14, textAlign: 'center' }}>
First, install ForkOff CLI:{'\n'}
<Text style={{ color: theme.primary, fontFamily: 'monospace' }}>
npm i -g forkoff
</Text>
{'\n\n'}Then run:{'\n'}
<Text style={{ color: theme.primary, fontFamily: 'monospace' }}>
forkoff pair
</Text>
{'\n'}Enter the code and relay address shown
</Text>
>
<Text style={{ color: theme.textInverse, fontWeight: 'bold' }}>Enter Pairing Code</Text>
</TouchableOpacity>
</View>

<TouchableOpacity
onPress={handlePairWithCode}
disabled={pairingCode.length !== 8 || isLoading}
style={{
backgroundColor: theme.primary,
borderRadius: 12,
padding: 16,
alignItems: 'center',
shadowColor: theme.primary,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.2,
shadowRadius: 12,
elevation: 5,
opacity: pairingCode.length !== 8 || isLoading ? 0.5 : 1,
}}
>
<Text style={{ color: theme.textInverse, fontWeight: 'bold' }}>
{isLoading ? 'Pairing...' : 'Pair Device'}
</Text>
</TouchableOpacity>
</View>
)}

Expand Down
113 changes: 101 additions & 12 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useCallback, useState, useRef } from 'react';
import { AppState } from 'react-native';
import { Stack, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
Expand All @@ -15,6 +16,7 @@ import { useConnectionStore } from '@/stores/connection.store';
import { useVersionStore } from '@/stores/version.store';
import { useAchievementsStore } from '@/stores/achievements.store';
import { wsService } from '@/services/websocket.service';
import { pairingService } from '@/services/pairing.service';
import { notificationService } from '@/services/notification.service';
import { sentryService } from '@/services/sentry.service';
import { analyticsService } from '@/services/analytics.service';
Expand Down Expand Up @@ -155,14 +157,14 @@ function ThemedApp({

export default function RootLayout() {
const router = useRouter();
const { initialize, isReady, isPaired, mobileDeviceId } = useIdentityStore();
const { initialize, isReady, isPaired, mobileDeviceId, pairedDevices } = useIdentityStore();
const {
currentApproval,
hideApproval,
respondToApproval,
subscribeToApprovals,
} = useApprovalStore();
const { initialize: initializeConnection } = useConnectionStore();
const { initialize: initializeConnection, setServerConnected } = useConnectionStore();
const { needsUpdate } = useVersionStore();
const { recentUnlock, setRecentUnlock } = useAchievementsStore();
const [showSplash, setShowSplash] = useState(true);
Expand Down Expand Up @@ -226,17 +228,103 @@ export default function RootLayout() {

// Connect/disconnect WebSocket based on pairing state
useEffect(() => {
if (isReady) {
if (isPaired) {
wsService.connect();

// Register for push notifications when paired
notificationService.registerForPushNotifications();
} else {
wsService.disconnect();
async function connectWithTunnelUrl() {
if (isReady) {
if (isPaired) {
// Cold start: fetch current tunnel URL from Supabase before connecting
if (pairedDevices.length > 0) {
for (const device of pairedDevices) {
const tunnelUrl = await wsService.fetchCurrentTunnelUrl(device.id);
if (tunnelUrl) {
let wsUrl = tunnelUrl;
if (wsUrl.startsWith('https://')) {
wsUrl = wsUrl.replace('https://', 'wss://');
} else if (wsUrl.startsWith('http://')) {
wsUrl = wsUrl.replace('http://', 'ws://');
}
await pairingService.setRelayUrl(wsUrl);
}
}
}

wsService.connect();

// Register for push notifications when paired
notificationService.registerForPushNotifications();
} else {
wsService.disconnect();
wsService.unsubscribeTunnelUpdates();
}
}
}
connectWithTunnelUrl();
}, [isPaired, isReady, pairedDevices]);

// Subscribe to tunnel URL changes when paired (for auto-reconnect on tunnel restart)
useEffect(() => {
if (isPaired && isReady && pairedDevices.length > 0) {
for (const device of pairedDevices) {
wsService.subscribeToTunnelUpdates(device.id);
}

return () => {
wsService.unsubscribeTunnelUpdates();
};
}
}, [isPaired, isReady]);
}, [isPaired, isReady, pairedDevices]);

// Reconnect on app foreground when connection was lost
const appStateRef = useRef(AppState.currentState);
useEffect(() => {
const subscription = AppState.addEventListener('change', async (nextAppState) => {
if (appStateRef.current.match(/inactive|background/) && nextAppState === 'active') {
if (isPaired && isReady && pairedDevices.length > 0) {
// Retry reconnect up to 6 times (30 seconds total)
for (let attempt = 0; attempt < 6; attempt++) {
if (wsService.isConnected) {
setServerConnected(true);
break;
}

for (const device of pairedDevices) {
const tunnelUrl = await wsService.fetchCurrentTunnelUrl(device.id);
if (tunnelUrl) {
let wsUrl = tunnelUrl;
if (wsUrl.startsWith('https://')) {
wsUrl = wsUrl.replace('https://', 'wss://');
} else if (wsUrl.startsWith('http://')) {
wsUrl = wsUrl.replace('http://', 'ws://');
}
const currentUrl = await pairingService.getRelayUrl();
if (wsUrl !== currentUrl) {
await pairingService.setRelayUrl(wsUrl);
}
if (!wsService.isConnected) {
wsService.disconnect();
wsService.connect();
// Wait 5 seconds for connection result
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => resolve(), 5000);
const unsub = wsService.on('connected', () => {
clearTimeout(timeout);
unsub();
resolve();
});
});
// Force update connection state after attempt
setServerConnected(wsService.isConnected);
}
}
}
if (wsService.isConnected) break;
await new Promise(r => setTimeout(r, 5000));
}
}
}
appStateRef.current = nextAppState;
});
return () => subscription.remove();
}, [isPaired, isReady, pairedDevices]);

// Subscribe to approval events when WebSocket is connected
useEffect(() => {
Expand Down Expand Up @@ -313,9 +401,10 @@ export default function RootLayout() {
<ErrorBoundary>
<ThemeProvider>
<PostHogProvider
apiKey={process.env.EXPO_PUBLIC_POSTHOG_API_KEY || ''}
apiKey={process.env.EXPO_PUBLIC_POSTHOG_API_KEY || 'phc_disabled'}
options={{
host: 'https://us.i.posthog.com',
disabled: !process.env.EXPO_PUBLIC_POSTHOG_API_KEY,
}}
>
<PostHogBridge>
Expand Down
Loading