From 42a8223bfc7d604c3b7e2602aa281625ad601f57 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Fri, 15 May 2026 22:41:25 +0200 Subject: [PATCH] feat(native): auto-detect Mac activation via Supabase Realtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the manual "I've installed it" button in AddMacSheet with an auto-advancing waiting-pill. As soon as the backend flips status from pending → active (triggered by the DoH handshake from the AdGuard watcher), the sheet jumps to the success step automatically. - useProtectedDevicesRealtime hook subscribes to rebreak.protected_devices UPDATE events for the current user, with auto-reconnect on CHANNEL_ERROR - AddMacSheet listens only while in step 2 (download/install) - devices.tsx keeps a list-level subscription so the table refreshes even if the user dismissed the sheet before activation - i18n: waiting_install / waiting_hint / activated_toast (DE + EN) --- apps/rebreak-native/app/devices.tsx | 3 + .../components/devices/AddMacSheet.tsx | 77 +++++++++---------- .../hooks/useProtectedDevicesRealtime.ts | 72 +++++++++++++++++ apps/rebreak-native/locales/de.json | 5 +- apps/rebreak-native/locales/en.json | 5 +- 5 files changed, 120 insertions(+), 42 deletions(-) create mode 100644 apps/rebreak-native/hooks/useProtectedDevicesRealtime.ts diff --git a/apps/rebreak-native/app/devices.tsx b/apps/rebreak-native/app/devices.tsx index d18c61e..4029bb3 100644 --- a/apps/rebreak-native/app/devices.tsx +++ b/apps/rebreak-native/app/devices.tsx @@ -15,6 +15,7 @@ import { useTranslation } from 'react-i18next'; import { useColors } from '../lib/theme'; import { useDevicesStore, type UserDevice } from '../stores/devices'; import { useProtectedDevicesStore, type ProtectedDevice } from '../stores/protectedDevices'; +import { useProtectedDevicesRealtime } from '../hooks/useProtectedDevicesRealtime'; import { useUserPlan } from '../hooks/useUserPlan'; import { AppHeader } from '../components/AppHeader'; import { AddMacSheet } from '../components/devices/AddMacSheet'; @@ -422,6 +423,8 @@ export default function DevicesScreen() { loadProtected(); }, []); + useProtectedDevicesRealtime(); + const MAX_PROTECTED_DEVICES = 2; const activeProtectedCount = protectedDevices.filter((d) => d.status !== 'revoked').length; const atDeviceLimit = isLegend && activeProtectedCount >= MAX_PROTECTED_DEVICES; diff --git a/apps/rebreak-native/components/devices/AddMacSheet.tsx b/apps/rebreak-native/components/devices/AddMacSheet.tsx index 7d157b9..225470c 100644 --- a/apps/rebreak-native/components/devices/AddMacSheet.tsx +++ b/apps/rebreak-native/components/devices/AddMacSheet.tsx @@ -8,7 +8,7 @@ import { TextInput, View, } from 'react-native'; -import { useRef, useState } from 'react'; +import { useCallback, useState } from 'react'; import { Ionicons } from '@expo/vector-icons'; import { useTranslation } from 'react-i18next'; import * as Haptics from 'expo-haptics'; @@ -16,6 +16,7 @@ import { useColors } from '../../lib/theme'; import { FormSheet } from '../FormSheet'; import { RiveAvatar } from '../RiveAvatar'; import { useProtectedDevicesStore } from '../../stores/protectedDevices'; +import { useProtectedDevicesRealtime } from '../../hooks/useProtectedDevicesRealtime'; import { useRouter } from 'expo-router'; // TODO lyra-persona: review lyra_intro + step_* body strings for coach tone @@ -45,20 +46,28 @@ export function AddMacSheet({ const { t } = useTranslation(); const colors = useColors(); const router = useRouter(); - const { enroll, confirmInstalled, enrolling } = useProtectedDevicesStore(); + const { enroll, enrolling } = useProtectedDevicesStore(); const [step, setStep] = useState(1); const [label, setLabel] = useState('MacBook Pro'); const [labelError, setLabelError] = useState(''); const [enrollResult, setEnrollResult] = useState<{ deviceId: string; downloadUrl: string } | null>(null); - const [confirming, setConfirming] = useState(false); + + const handleActivated = useCallback(() => { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}); + setStep(3); + }, []); + + useProtectedDevicesRealtime( + step === 2 ? handleActivated : undefined, + step === 2, + ); function reset() { setStep(1); setLabel('MacBook Pro'); setLabelError(''); setEnrollResult(null); - setConfirming(false); } function handleClose() { @@ -86,20 +95,6 @@ export function AddMacSheet({ } } - async function handleConfirmInstalled() { - if (!enrollResult) return; - setConfirming(true); - try { - await confirmInstalled(enrollResult.deviceId); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}); - setStep(3); - } catch { - Alert.alert(t('common.error'), t('common.unknown_error')); - } finally { - setConfirming(false); - } - } - function handleDownload() { if (!enrollResult?.downloadUrl) return; Linking.openURL(enrollResult.downloadUrl).catch(() => {}); @@ -141,9 +136,7 @@ export function AddMacSheet({ {step === 2 && ( @@ -230,16 +223,12 @@ function Step1LabelContent({ function Step2OnboardingContent({ onDownload, - onConfirmInstalled, onNeedHelp, - confirming, colors, t, }: { onDownload: () => void; - onConfirmInstalled: () => void; onNeedHelp: () => void; - confirming: boolean; colors: ReturnType; t: (k: string) => string; }) { @@ -338,28 +327,36 @@ function Step2OnboardingContent({ - {/* Confirm installed */} - - {confirming ? ( - - ) : ( - - {t('devices.confirm_installed')} + + + + {t('devices.waiting_install')} - )} - + + + {t('devices.waiting_hint')} + + {/* Need help */} void; + +export function useProtectedDevicesRealtime( + onActivated?: OnActivated, + enabled: boolean = true, +) { + const onActivatedRef = useRef(onActivated); + onActivatedRef.current = onActivated; + + useEffect(() => { + if (!enabled) return; + let channel: RealtimeChannel | null = null; + let cancelled = false; + let reconnectTimer: ReturnType | null = null; + + async function subscribe() { + const { data } = await supabase.auth.getSession(); + const session = data.session; + if (!session?.access_token || cancelled) return; + + const userId = session.user.id; + + channel = supabase + .channel(`protected:${userId}:${Date.now()}`) + .on( + "postgres_changes", + { + event: "UPDATE", + schema: "rebreak", + table: "protected_devices", + filter: `user_id=eq.${userId}`, + }, + (payload: any) => { + const updated = payload.new; + useProtectedDevicesStore.getState().load(); + if (updated?.status === "active" && payload.old?.status === "pending") { + onActivatedRef.current?.(updated.id); + } + }, + ) + .subscribe((status) => { + if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") { + cleanup(); + if (reconnectTimer) clearTimeout(reconnectTimer); + reconnectTimer = setTimeout(() => { + if (!cancelled) subscribe(); + }, 3000); + } + }); + } + + function cleanup() { + if (channel) { + supabase.removeChannel(channel); + channel = null; + } + } + + subscribe(); + + return () => { + cancelled = true; + if (reconnectTimer) clearTimeout(reconnectTimer); + cleanup(); + }; + }, [enabled]); +} diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index fb99e61..bae6dec 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -878,9 +878,12 @@ "step_3_title": "Standard-Account nutzen", "step_3_body": "Wichtig: arbeite auf dem Mac mit einem Standard-Account, nicht Admin. Wenn du ein Notfall-Bedürfnis hast, das Profile zu entfernen, brauchst du das Admin-Passwort — das idealerweise jemand anderes hat (Partnerin, Freund, jemand dem du vertraust). Das ist die eigentliche Schutzschicht.", "step_4_title": "Fertig", - "step_4_body": "Sobald du installiert hast, klick \"Ich hab's installiert\" — dann zähl ich den Mac als geschütztes Gerät.", + "step_4_body": "Sobald du auf dem Mac im Browser oder in einer App eine Webseite öffnest, erkennen wir das automatisch und aktivieren den Schutz.", "download_button": "Profile auf Mac downloaden", "confirm_installed": "Ich hab's installiert ✓", + "waiting_install": "Warte auf Profile-Installation auf deinem Mac…", + "waiting_hint": "Sobald du im Browser oder einer App eine Webseite öffnest, aktivieren wir automatisch dein Gerät.", + "activated_toast": "Mac verbunden!", "need_help": "Brauche Hilfe", "success_title": "Mac geschützt!", "success_body": "Du kannst weitere Geräte hinzufügen wenn du willst.", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 6ca2388..c792920 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -878,9 +878,12 @@ "step_3_title": "Use a standard account", "step_3_body": "Important: use a standard account on the Mac, not admin. If you ever have an urgent need to remove the profile, you'll need the admin password — ideally held by someone you trust (partner, friend). That's the real protection layer.", "step_4_title": "Done", - "step_4_body": "Once you've installed it, tap \"I've installed it\" — then I'll count the Mac as a protected device.", + "step_4_body": "Once installed, just open any website in a browser or app on your Mac — we'll detect it automatically and activate protection.", "download_button": "Download profile to Mac", "confirm_installed": "I've installed it ✓", + "waiting_install": "Waiting for profile installation on your Mac…", + "waiting_hint": "As soon as you open a website in a browser or app, we'll automatically activate your device.", + "activated_toast": "Mac connected!", "need_help": "I need help", "success_title": "Mac protected!", "success_body": "You can add more devices whenever you like.",