- 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)
390 lines
12 KiB
TypeScript
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')}`;
|
|
}
|