chahinebrini 77edd67cbe fix(magic): explicit imports + staging defaults + sheet height
- backend/api/magic/register: explicit import of MAGIC_DEVICE_LIMIT
  and createAdGuardClient (Nitro auto-import was missing them
  → ReferenceError → HTTP 500 on /api/magic/register)
- mac-app: default backendBaseUrl falls back to staging.rebreak.org
  (app.rebreak.org serves wrong TLS cert)
- native MagicSheet: fallback download/dmg URLs point to staging
- native settings: Magic sheet capped at detents=[0.85] so AppHeader
  stays visible
- bundles all in-flight Magic feature work (pair create/redeem,
  device endpoints, schema, adguard utils, mac-app, locales)
2026-06-03 08:25:02 +02:00

390 lines
12 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Linking,
Pressable,
ScrollView,
Share,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import * as Clipboard from 'expo-clipboard';
import type { ColorScheme } from '../../lib/theme';
import { apiFetch } from '../../lib/api';
type PairResponse = {
code: string;
expiresAt: string;
expiresInSeconds: number;
};
type MagicDevice = {
deviceId: string;
hostname: string;
model: string | null;
osVersion: string | null;
magicEnrolledAt: string;
};
type MagicInfo = {
latestVersion: string;
downloadUrl: string;
dmgUrl: string;
minMacosVersion: string;
};
/**
* MagicSheet — präsentiert die Rebreak-Magic-Pairing-Flow in einem
* TrueSheet (analog SubscriptionSheet). Wird aus settings.tsx getriggert.
*/
export function MagicSheet({ colors }: { colors: ColorScheme }) {
const [info, setInfo] = useState<MagicInfo | null>(null);
const [pair, setPair] = useState<PairResponse | null>(null);
const [pairLoading, setPairLoading] = useState(false);
const [pairError, setPairError] = useState<string | null>(null);
const [now, setNow] = useState(Date.now());
const [devices, setDevices] = useState<MagicDevice[] | null>(null);
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
(async () => {
try {
const i = await apiFetch<MagicInfo>('/api/magic/info');
setInfo(i);
} catch {
setInfo({
latestVersion: '0.1.0',
downloadUrl: 'https://staging.rebreak.org/download/rebreakmagic',
dmgUrl: 'https://staging.rebreak.org/downloads/RebreakMagic-latest.dmg',
minMacosVersion: '13.0',
});
}
loadDevices();
})();
return () => {
if (tickRef.current) clearInterval(tickRef.current);
};
}, []);
useEffect(() => {
if (pair) {
if (tickRef.current) clearInterval(tickRef.current);
tickRef.current = setInterval(() => setNow(Date.now()), 1000);
return () => {
if (tickRef.current) clearInterval(tickRef.current);
};
}
}, [pair]);
async function loadDevices() {
try {
const d = await apiFetch<MagicDevice[]>('/api/magic/devices');
setDevices(d);
} catch {
setDevices([]);
}
}
async function handleGenerateCode() {
setPairLoading(true);
setPairError(null);
try {
const res = await apiFetch<PairResponse>('/api/magic/pair/create', {
method: 'POST',
body: {},
});
setPair(res);
setNow(Date.now());
} catch (e: any) {
setPairError(e?.message ?? 'Fehler beim Generieren');
} finally {
setPairLoading(false);
}
}
async function handleCopyCode() {
if (!pair) return;
await Clipboard.setStringAsync(pair.code);
}
const remaining = useMemo(() => {
if (!pair) return 0;
const exp = new Date(pair.expiresAt).getTime();
return Math.max(0, Math.floor((exp - now) / 1000));
}, [pair, now]);
const codeExpired = pair !== null && remaining <= 0;
return (
<ScrollView
style={{ maxHeight: 640 }}
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 8, paddingBottom: 32 }}
showsVerticalScrollIndicator={false}
>
{/* Header */}
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<View
style={{
width: 44,
height: 44,
borderRadius: 12,
backgroundColor: '#007AFF22',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="sparkles" size={22} color="#007AFF" />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 20, fontFamily: 'Nunito_700Bold', color: colors.text }}>
Rebreak Magic
</Text>
<Text style={{ fontSize: 13, color: colors.textMuted, marginTop: 1 }}>
iPhone in 30 Sek. binden ohne Werks-Reset.
</Text>
</View>
</View>
{/* Step 1 — Download */}
<SectionTitle text="1. Mac-App herunterladen" colors={colors} />
<View style={cardStyle(colors)}>
<Text style={{ fontSize: 14, color: colors.text, marginBottom: 12 }}>
Auf deinem Mac öffnen (min. macOS {info?.minMacosVersion ?? '13.0'}).
</Text>
<PrimaryButton
icon="cloud-download-outline"
label="Download öffnen"
onPress={() => info && Linking.openURL(info.downloadUrl)}
/>
<TouchableOpacity
onPress={() => info && Share.share({ message: info.downloadUrl })}
style={{ marginTop: 10, alignSelf: 'flex-start' }}
>
<Text style={{ fontSize: 13, color: '#007AFF' }}>Link an meinen Mac senden</Text>
</TouchableOpacity>
</View>
{/* Step 2 — Pairing-Code */}
<SectionTitle text="2. Pairing-Code generieren" colors={colors} />
<View style={cardStyle(colors)}>
{!pair || codeExpired ? (
<>
<Text style={{ fontSize: 14, color: colors.text, marginBottom: 14 }}>
Erzeuge einen 6-stelligen Code und gib ihn in der Mac-App ein. Gültig 10
Minuten, nur einmal verwendbar.
</Text>
<PrimaryButton
icon="key-outline"
label={
pairLoading
? 'Generiere…'
: codeExpired
? 'Neuen Code erzeugen'
: 'Code erzeugen'
}
onPress={handleGenerateCode}
loading={pairLoading}
/>
{pairError && (
<Text style={{ marginTop: 10, color: colors.error, fontSize: 13 }}>{pairError}</Text>
)}
</>
) : (
<>
<Text
style={{
fontSize: 13,
color: colors.textMuted,
textAlign: 'center',
marginBottom: 12,
}}
>
In Mac-App eingeben:
</Text>
<Pressable
onPress={handleCopyCode}
style={{
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
paddingVertical: 18,
borderRadius: 14,
backgroundColor: colors.groupedBg,
marginBottom: 12,
}}
>
{pair.code.split('').map((d, i) => (
<View
key={i}
style={{
width: 38,
height: 52,
borderRadius: 8,
backgroundColor: colors.card,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ fontSize: 28, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{d}
</Text>
</View>
))}
</Pressable>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Ionicons name="time-outline" size={14} color={colors.textMuted} />
<Text style={{ fontSize: 13, color: colors.textMuted }}>
Läuft ab in {formatRemaining(remaining)}
</Text>
</View>
<TouchableOpacity onPress={handleCopyCode}>
<Text style={{ fontSize: 13, color: '#007AFF', fontWeight: '600' }}>Kopieren</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
onPress={() => {
setPair(null);
setPairError(null);
}}
style={{ marginTop: 14, alignSelf: 'center' }}
>
<Text style={{ fontSize: 13, color: colors.textMuted }}>Code verwerfen</Text>
</TouchableOpacity>
</>
)}
</View>
{/* Verbundene Macs */}
<SectionTitle text="Verbundene Macs" colors={colors} />
<View style={cardStyle(colors)}>
{devices === null ? (
<ActivityIndicator />
) : devices.length === 0 ? (
<Text style={{ fontSize: 14, color: colors.textMuted }}>
Noch keine Macs verbunden. Sobald du einen Pairing-Code einlöst und ein iPhone
bindest, erscheint es hier.
</Text>
) : (
devices.map((d, i) => (
<View
key={d.deviceId}
style={{
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
borderTopWidth: i === 0 ? 0 : 1,
borderTopColor: colors.border,
}}
>
<View
style={{
width: 36,
height: 36,
borderRadius: 9,
backgroundColor: colors.groupedBg,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
}}
>
<Ionicons name="laptop-outline" size={20} color={colors.text} />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 15, color: colors.text, fontWeight: '600' }}>
{d.hostname}
</Text>
{d.model && (
<Text style={{ fontSize: 12, color: colors.textMuted, marginTop: 1 }}>
{d.model}
</Text>
)}
</View>
</View>
))
)}
</View>
</ScrollView>
);
}
// ─── Helpers ───────────────────────────────────────────────────────────────
function cardStyle(colors: ColorScheme) {
return {
backgroundColor: colors.card,
borderRadius: 14,
padding: 16,
marginBottom: 16,
} as const;
}
function SectionTitle({ text, colors }: { text: string; colors: ColorScheme }) {
return (
<Text
style={{
fontSize: 12,
textTransform: 'uppercase',
letterSpacing: 0.4,
color: colors.textMuted,
marginBottom: 8,
marginLeft: 4,
fontFamily: 'Nunito_700Bold',
}}
>
{text}
</Text>
);
}
function PrimaryButton({
icon,
label,
onPress,
loading,
}: {
icon: React.ComponentProps<typeof Ionicons>['name'];
label: string;
onPress: () => void;
loading?: boolean;
}) {
return (
<TouchableOpacity
onPress={onPress}
disabled={loading}
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
backgroundColor: '#007AFF',
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 12,
opacity: loading ? 0.6 : 1,
}}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Ionicons name={icon} size={18} color="#fff" />
)}
<Text style={{ color: '#fff', fontSize: 15, fontFamily: 'Nunito_700Bold' }}>{label}</Text>
</TouchableOpacity>
);
}
function formatRemaining(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}