feat(magic): RebreakMagic device-binding + DNS profile
- backend: /api/magic/{register,devices,profile,release} + AdGuard provisioning + 24h cooldown
- prisma: magic_binding_fields migration (additive on UserDevice)
- mac-app: Phase 2 - Login + MacRegistration + Profile install
- marketing: landing section + /download/rebreakmagic + DMG
- lyra: forbidden phrases + RebreakMagic coach guidance
This commit is contained in:
parent
1dc4e4f9cd
commit
c1edef8abd
@ -74,6 +74,16 @@
|
|||||||
"mail_mock_rate": "Treffer",
|
"mail_mock_rate": "Treffer",
|
||||||
"mail_mock_accounts": "Verbundene Konten",
|
"mail_mock_accounts": "Verbundene Konten",
|
||||||
"mail_mock_rhythm": "Automatischer Scan-Rhythmus",
|
"mail_mock_rhythm": "Automatischer Scan-Rhythmus",
|
||||||
|
"magic_badge": "Lock-Modus für iPhone",
|
||||||
|
"magic_title": "RebreakMagic.",
|
||||||
|
"magic_subtitle": "Der Lock-Modus ohne Reset.",
|
||||||
|
"magic_desc": "Eine kleine Mac-Begleit-App, die dein iPhone in den Lock-Modus versetzt — ReBreak ist danach nicht mehr löschbar und der Filter nicht mehr abschaltbar. „Magic“, weil das normalerweise einen kompletten iPhone-Reset bedeutet. Bei uns: USB anschließen, ein Klick, ~2 Minuten — Fotos, Apps und Daten bleiben.",
|
||||||
|
"magic_feat_noreset": "Kein Werks-Reset, keine Datenmigration",
|
||||||
|
"magic_feat_speed": "~2 Minuten Setup via USB-Kabel",
|
||||||
|
"magic_feat_lock": "App nicht mehr löschbar, Filter nicht abschaltbar",
|
||||||
|
"magic_feat_trustee": "Entsperren nur über Trustee oder erneut RebreakMagic",
|
||||||
|
"magic_cta": "RebreakMagic für Mac laden",
|
||||||
|
"magic_note": "Optional. Empfohlen für Phasen mit hohem Bypass-Risiko.",
|
||||||
"final_title": "Fang jetzt an.",
|
"final_title": "Fang jetzt an.",
|
||||||
"final_desc": "Du bist nicht kaputt. Das System ist manipulativ. Wir helfen dir zurück.",
|
"final_desc": "Du bist nicht kaputt. Das System ist manipulativ. Wir helfen dir zurück.",
|
||||||
"final_cta": "Jetzt starten – kostenlos & anonym",
|
"final_cta": "Jetzt starten – kostenlos & anonym",
|
||||||
|
|||||||
@ -74,6 +74,16 @@
|
|||||||
"mail_mock_rate": "Hit rate",
|
"mail_mock_rate": "Hit rate",
|
||||||
"mail_mock_accounts": "Connected accounts",
|
"mail_mock_accounts": "Connected accounts",
|
||||||
"mail_mock_rhythm": "Automatic scan rhythm",
|
"mail_mock_rhythm": "Automatic scan rhythm",
|
||||||
|
"magic_badge": "Lock Mode for iPhone",
|
||||||
|
"magic_title": "RebreakMagic.",
|
||||||
|
"magic_subtitle": "Lock Mode without a reset.",
|
||||||
|
"magic_desc": "A small Mac companion app that puts your iPhone into Lock Mode — ReBreak can no longer be deleted and the filter can no longer be switched off. “Magic” because this normally requires a full iPhone factory reset. With us: plug in via USB, one click, ~2 minutes — photos, apps and data stay.",
|
||||||
|
"magic_feat_noreset": "No factory reset, no data migration",
|
||||||
|
"magic_feat_speed": "~2 minute setup via USB cable",
|
||||||
|
"magic_feat_lock": "App not removable, filter not switchable",
|
||||||
|
"magic_feat_trustee": "Unlock only via trustee or RebreakMagic again",
|
||||||
|
"magic_cta": "Download RebreakMagic for Mac",
|
||||||
|
"magic_note": "Optional. Recommended for phases with high bypass risk.",
|
||||||
"final_title": "Start now.",
|
"final_title": "Start now.",
|
||||||
"final_desc": "You're not broken. The system is manipulative. We help you back.",
|
"final_desc": "You're not broken. The system is manipulative. We help you back.",
|
||||||
"final_cta": "Start now – free & anonymous",
|
"final_cta": "Start now – free & anonymous",
|
||||||
|
|||||||
146
apps/marketing/app/pages/download/rebreakmagic.vue
Normal file
146
apps/marketing/app/pages/download/rebreakmagic.vue
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-950 text-white flex flex-col items-center justify-center px-4 py-16">
|
||||||
|
<div class="w-full max-w-md">
|
||||||
|
<!-- Logo / Brand -->
|
||||||
|
<div class="flex items-center gap-3 mb-10">
|
||||||
|
<div class="w-12 h-12 rounded-2xl bg-indigo-500 flex items-center justify-center">
|
||||||
|
<span class="text-2xl font-bold">R</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight">RebreakMagic</h1>
|
||||||
|
<p class="text-sm text-gray-400">Lock-Modus für iPhone — ohne Reset</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<h2 class="text-3xl font-extrabold mb-2">RebreakMagic für Mac</h2>
|
||||||
|
<p class="text-gray-400 mb-1 text-sm">
|
||||||
|
Version {{ version }} · Build {{ buildDate }}
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
class="inline-block bg-amber-500/15 text-amber-400 text-xs font-semibold px-2.5 py-1 rounded-full mb-6"
|
||||||
|
>
|
||||||
|
Beta — Companion-App für ReBreak iOS
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Why "Magic"? -->
|
||||||
|
<div class="bg-indigo-950/30 border border-indigo-800/30 rounded-2xl p-5 mb-6">
|
||||||
|
<p class="text-sm text-indigo-200 leading-relaxed">
|
||||||
|
<strong>Warum „Magic"?</strong> Normalerweise muss ein iPhone komplett zurückgesetzt werden,
|
||||||
|
um in den Lock-Modus (Supervised-Mode) zu wechseln — alle Daten weg. RebreakMagic schafft
|
||||||
|
das <strong>ohne Reset</strong>: Fotos, Apps, Chats, Settings bleiben. In ~2 Minuten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Download Button -->
|
||||||
|
<a
|
||||||
|
:href="dmgUrl"
|
||||||
|
class="block w-full bg-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 transition-colors text-white text-center font-bold text-lg py-4 rounded-2xl mb-4 shadow-lg shadow-indigo-900/40"
|
||||||
|
download
|
||||||
|
>
|
||||||
|
DMG herunterladen ({{ dmgSize }})
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- SHA256 -->
|
||||||
|
<p class="text-xs text-gray-500 text-center break-all mb-10">
|
||||||
|
SHA256: <span class="font-mono">{{ sha256 }}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Install Instructions -->
|
||||||
|
<div class="bg-gray-900 rounded-2xl p-6 mb-8">
|
||||||
|
<h3 class="font-bold text-base mb-4 text-white">Setup in 4 Schritten</h3>
|
||||||
|
<ol class="space-y-4">
|
||||||
|
<li class="flex gap-3">
|
||||||
|
<span class="w-7 h-7 shrink-0 rounded-full bg-indigo-600 flex items-center justify-center text-sm font-bold">1</span>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-sm text-white">DMG laden & öffnen</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-0.5">Doppelklick auf das DMG, dann RebreakMagic.app in den Programme-Ordner ziehen.</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="flex gap-3">
|
||||||
|
<span class="w-7 h-7 shrink-0 rounded-full bg-indigo-600 flex items-center justify-center text-sm font-bold">2</span>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-sm text-white">Erste Öffnung erlauben</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-0.5">
|
||||||
|
App ist (noch) unsigniert — Rechtsklick → Öffnen → Bestätigen. Macht macOS einmalig nötig.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="flex gap-3">
|
||||||
|
<span class="w-7 h-7 shrink-0 rounded-full bg-indigo-600 flex items-center justify-center text-sm font-bold">3</span>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-sm text-white">iPhone per USB anschließen</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-0.5">
|
||||||
|
Lightning/USB-C-Kabel an Mac. Auf dem iPhone „Vertrauen" tippen, falls gefragt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="flex gap-3">
|
||||||
|
<span class="w-7 h-7 shrink-0 rounded-full bg-indigo-600 flex items-center justify-center text-sm font-bold">4</span>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-sm text-white">Setup durchklicken</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-0.5">RebreakMagic führt dich durch 5 Schritte (~2 Min). Nach einem Neustart ist der Lock-Modus aktiv.</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Voraussetzungen -->
|
||||||
|
<div class="bg-gray-900 rounded-2xl p-6 mb-8">
|
||||||
|
<h3 class="font-bold text-base mb-3 text-white">Voraussetzungen</h3>
|
||||||
|
<ul class="space-y-2 text-xs text-gray-400">
|
||||||
|
<li>• macOS 13 Ventura oder neuer (Intel oder Apple Silicon)</li>
|
||||||
|
<li>• iPhone mit iOS 17 oder neuer</li>
|
||||||
|
<li>• USB-Kabel (Lightning oder USB-C, passend zum iPhone)</li>
|
||||||
|
<li>• ReBreak-App ist vor dem Setup aus dem App Store installiert</li>
|
||||||
|
<li>• Find-My-iPhone & Stolen-Device-Protection sind vor dem Setup ausgeschaltet (RebreakMagic prüft das im Pre-Flight)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Deaktivieren -->
|
||||||
|
<div class="bg-gray-900 rounded-2xl p-6 mb-8">
|
||||||
|
<h3 class="font-bold text-base mb-3 text-white">Wie wird der Lock wieder gelöst?</h3>
|
||||||
|
<p class="text-xs text-gray-400 leading-relaxed">
|
||||||
|
Drei Wege, geordnet nach Aufwand: 1) deine Vertrauensperson (Trustee) entsperrt aus der ReBreak-App.
|
||||||
|
2) iPhone erneut am Mac mit RebreakMagic anschließen und „Reset" wählen.
|
||||||
|
3) Werks-Reset des iPhones (letzter Notausweg — alle Daten weg).
|
||||||
|
Das ist Designprinzip: der Schutz soll genau dem Impuls standhalten, der ihn loswerden will.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Beta Notice -->
|
||||||
|
<div class="bg-amber-950/40 border border-amber-800/30 rounded-xl p-4 mb-8">
|
||||||
|
<p class="text-amber-300 text-xs leading-relaxed">
|
||||||
|
<strong>Beta-Hinweis:</strong> RebreakMagic ist in geschlossener Beta. Bei Problemen oder
|
||||||
|
Feedback bitte E-Mail an
|
||||||
|
<a href="mailto:support@rebreak.org" class="underline">support@rebreak.org</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<p class="text-center text-xs text-gray-600">
|
||||||
|
© {{ new Date().getFullYear() }} Rebreak ·
|
||||||
|
<NuxtLink to="/datenschutz" class="hover:text-gray-400">Datenschutz</NuxtLink>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Werte bei jedem Release manuell aktualisieren (oder per Script aus der DMG).
|
||||||
|
const version = "0.1.0";
|
||||||
|
const buildDate = "2026-06-01";
|
||||||
|
const dmgSize = "740 KB";
|
||||||
|
const sha256 = "7c4af6a17982d84cfbd3066fda1217b8dbf0209322ac7263fca50c8793849c36";
|
||||||
|
const dmgUrl = "/downloads/RebreakMagic-latest.dmg";
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: "RebreakMagic für Mac – Lock-Modus ohne Reset",
|
||||||
|
description:
|
||||||
|
"Companion-App für ReBreak iOS. Aktiviert den Lock-Modus deines iPhones in ~2 Minuten per USB-Kabel — ohne Werks-Reset, ohne Datenverlust.",
|
||||||
|
});
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: false,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -396,6 +396,63 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── RebreakMagic (Lock-Modus, optional) ─── -->
|
||||||
|
<section class="py-8 px-4">
|
||||||
|
<div v-motion :initial="{ opacity: 0, y: 50 }" :visible="{ opacity: 1, y: 0, transition: { duration: 700 } }"
|
||||||
|
class="max-w-6xl mx-auto grid lg:grid-cols-2 gap-16 items-center">
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 bg-primary-950/60 border border-primary-800/40 rounded-full px-3 py-1 text-xs text-primary-300 mb-6">
|
||||||
|
<UIcon name="i-heroicons-lock-closed" />
|
||||||
|
{{ $t('landing.magic_badge') }}
|
||||||
|
</div>
|
||||||
|
<h2 class="text-4xl md:text-5xl font-black text-highlighted leading-tight mb-6">
|
||||||
|
{{ $t('landing.magic_title') }}<br />
|
||||||
|
<span class="text-primary-400">{{ $t('landing.magic_subtitle') }}</span>
|
||||||
|
</h2>
|
||||||
|
<p class="text-lg text-muted leading-relaxed mb-8">
|
||||||
|
{{ $t('landing.magic_desc') }}
|
||||||
|
</p>
|
||||||
|
<ul class="space-y-3 text-sm text-default mb-8">
|
||||||
|
<li class="flex items-center gap-3">
|
||||||
|
<UIcon name="i-heroicons-check-circle" class="text-primary-400 shrink-0" />
|
||||||
|
{{ $t('landing.magic_feat_noreset') }}
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-3">
|
||||||
|
<UIcon name="i-heroicons-check-circle" class="text-primary-400 shrink-0" />
|
||||||
|
{{ $t('landing.magic_feat_speed') }}
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-3">
|
||||||
|
<UIcon name="i-heroicons-check-circle" class="text-primary-400 shrink-0" />
|
||||||
|
{{ $t('landing.magic_feat_lock') }}
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-3">
|
||||||
|
<UIcon name="i-heroicons-check-circle" class="text-primary-400 shrink-0" />
|
||||||
|
{{ $t('landing.magic_feat_trustee') }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<NuxtLink to="/download/rebreakmagic">
|
||||||
|
<UButton size="lg" class="px-6">
|
||||||
|
<UIcon name="i-heroicons-arrow-down-tray" />
|
||||||
|
{{ $t('landing.magic_cta') }}
|
||||||
|
</UButton>
|
||||||
|
</NuxtLink>
|
||||||
|
<p class="text-xs text-muted mt-3">{{ $t('landing.magic_note') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
class="w-72 h-72 rounded-3xl bg-linear-to-br from-primary-950/60 to-primary-900/20 border border-primary-800/20 flex items-center justify-center shadow-2xl shadow-primary-950/50 relative">
|
||||||
|
<UIcon name="i-heroicons-sparkles" class="text-primary-400 w-32 h-32" />
|
||||||
|
<div class="absolute bottom-4 left-4 right-4 bg-gray-950/80 backdrop-blur rounded-xl px-3 py-2 flex items-center gap-2">
|
||||||
|
<UIcon name="i-heroicons-computer-desktop" class="text-primary-400 text-sm" />
|
||||||
|
<span class="text-xs text-highlighted font-medium">RebreakMagic.app</span>
|
||||||
|
<span class="text-[10px] text-muted ml-auto">~2 min</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- ─── FINAL CTA ─── -->
|
<!-- ─── FINAL CTA ─── -->
|
||||||
<section class="py-16 px-4 pb-24 text-center relative">
|
<section class="py-16 px-4 pb-24 text-center relative">
|
||||||
<div class="absolute inset-0 bg-linear-to-t from-primary-950/20 to-transparent pointer-events-none" />
|
<div class="absolute inset-0 bg-linear-to-t from-primary-950/20 to-transparent pointer-events-none" />
|
||||||
|
|||||||
BIN
apps/marketing/public/downloads/RebreakMagic-latest.dmg
Normal file
BIN
apps/marketing/public/downloads/RebreakMagic-latest.dmg
Normal file
Binary file not shown.
174
apps/rebreak-magic-mac/PHASE2_SUMMARY.md
Normal file
174
apps/rebreak-magic-mac/PHASE2_SUMMARY.md
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
# ReBreak Magic Mac-App — Phase 2: Backend-Auth-Integration
|
||||||
|
|
||||||
|
## ✅ Implementierte Features
|
||||||
|
|
||||||
|
### 1. Auth-Stack (bereits implementiert)
|
||||||
|
- ✅ `AuthService.swift` — Supabase-Login + Keychain-Persistence
|
||||||
|
- ✅ `KeychainStore` — in AuthService integriert (Service: `org.rebreak.magic`)
|
||||||
|
- ✅ `MagicAPIClient.swift` — Backend-API-Client für `/api/magic/*`
|
||||||
|
- ✅ `MacDeviceDetector.swift` — IOPlatformUUID + hwModel via IOKit/sysctl
|
||||||
|
- ✅ `MacProfileInstaller.swift` — Profile-Download + Installation via `profiles` command
|
||||||
|
|
||||||
|
### 2. Login-UI (bereits implementiert)
|
||||||
|
- ✅ `LoginView.swift` — Email/Passwort-Login
|
||||||
|
- ✅ Error-Handling für InvalidCredentials
|
||||||
|
- ✅ Link zu "Noch kein Account? rebreak.org/signup"
|
||||||
|
- ✅ Integration in `ContentView.swift` via `model.showingLogin`
|
||||||
|
|
||||||
|
### 3. Mac-Registration-Flow (NEU implementiert)
|
||||||
|
- ✅ **Neuer WizardStep `.macRegistration`** (rawValue 0, vor .welcome)
|
||||||
|
- ✅ **`MacRegistrationView.swift`** — UI für Mac-Device-Registrierung:
|
||||||
|
- Zeigt Mac-Info (hostname, model, deviceId via IOPlatformUUID)
|
||||||
|
- Button "Mac registrieren" → ruft `model.registerMac()`
|
||||||
|
- Auto-Download + Installation des DNS-Filter-Profils
|
||||||
|
- Limit-Reached-Handling → öffnet `ManageBindingsView` (via `model.showingManageBindings`)
|
||||||
|
- Bei Erfolg: "Weiter → iPhone-Setup" button
|
||||||
|
- ✅ Integration in `ContentView.swift` switch-case
|
||||||
|
- ✅ `WizardModel.swift` erweitert:
|
||||||
|
- Initial-Step: `.macRegistration` statt `.welcome`
|
||||||
|
- `reset()` setzt auch `magicRegistration = nil` und `registrationError = nil`
|
||||||
|
|
||||||
|
### 4. Menu-Erweiterung
|
||||||
|
- ✅ Neues Command-Menu "Account" mit "Abmelden" (⌘⇧L)
|
||||||
|
- ✅ `handleLogout()` in WizardModel ruft `AuthService.signOut()` + reset
|
||||||
|
|
||||||
|
### 5. Workflow-Integration
|
||||||
|
**Neuer Flow:**
|
||||||
|
1. App startet → `authState` aus Keychain laden
|
||||||
|
2. Wenn kein Auth → `LoginView`
|
||||||
|
3. Nach Login → `.macRegistration` (Mac registrieren + Profil installieren)
|
||||||
|
4. Nach Mac-Setup → `.welcome` (iPhone-Detection wie bisher)
|
||||||
|
5. Rest unverändert: preflight → supervise → enroll → configure → done
|
||||||
|
|
||||||
|
## 📂 Neue Files
|
||||||
|
- ✅ `Sources/Views/MacRegistrationView.swift` (218 Zeilen)
|
||||||
|
|
||||||
|
## 📝 Geänderte Files
|
||||||
|
1. **`Sources/Models/WizardStep.swift`**
|
||||||
|
- Neuer Case `.macRegistration = 0`
|
||||||
|
- `.welcome` wurde von rawValue 0 → 1 (alle anderen +1)
|
||||||
|
- Titel: "Mac registrieren"
|
||||||
|
|
||||||
|
2. **`Sources/Models/WizardModel.swift`**
|
||||||
|
- Initial-Step: `var step: WizardStep = .macRegistration`
|
||||||
|
- `reset()` erweitert: setzt `magicRegistration = nil`, `registrationError = nil`
|
||||||
|
- Syntax-Fix: entfernte orphaned code-fragments
|
||||||
|
|
||||||
|
3. **`Sources/Views/ContentView.swift`**
|
||||||
|
- Switch-Case erweitert: `case .macRegistration: MacRegistrationView()`
|
||||||
|
|
||||||
|
4. **`Sources/Views/LoginView.swift`**
|
||||||
|
- macOS-Kompatibilität: `.textInputAutocapitalization(.never)` entfernt (iOS-only)
|
||||||
|
|
||||||
|
5. **`Sources/RebreakMagicApp.swift`**
|
||||||
|
- Neues Command-Menu "Account" mit "Abmelden"
|
||||||
|
|
||||||
|
## ⚙️ Config-Anforderungen
|
||||||
|
|
||||||
|
User muss `~/.config/rebreak-magic/config.json` erstellen (siehe `config.example.json`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"supabaseUrl": "https://your-project.supabase.co",
|
||||||
|
"supabaseAnonKey": "your-supabase-anon-key",
|
||||||
|
"backendBaseUrl": "https://staging.rebreak.org",
|
||||||
|
"mdmServer": "https://mdm.rebreak.org",
|
||||||
|
"mdmUser": "admin",
|
||||||
|
"mdmApiKey": "your-nanomdm-api-key"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benötigte Werte:**
|
||||||
|
- `supabaseUrl` + `supabaseAnonKey` — von Supabase-Dashboard
|
||||||
|
- `backendBaseUrl` — staging: `https://staging.rebreak.org`, prod: `https://api.rebreak.org`
|
||||||
|
- `mdmServer`, `mdmUser`, `mdmApiKey` — für iPhone-MDM-Commands (nur wenn iPhone-Setup durchgeführt wird)
|
||||||
|
|
||||||
|
## 🏗 Build-Status
|
||||||
|
|
||||||
|
✅ **BUILD SUCCEEDED**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/rebreak-magic-mac
|
||||||
|
xcodegen generate
|
||||||
|
xcodebuild -project RebreakMagic.xcodeproj -scheme RebreakMagic -configuration Debug build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Warnings (harmlos):**
|
||||||
|
- `no 'async' operations occur within 'await' expression` bei `MainActor.run` (expected, korrekt)
|
||||||
|
|
||||||
|
## 📋 Login-Flow-Ablauf
|
||||||
|
|
||||||
|
1. **App-Start**
|
||||||
|
- `WizardModel.init()` lädt `authSession = AuthService.shared.currentSession()`
|
||||||
|
- Falls `authSession == nil` → `showingLogin = true`
|
||||||
|
|
||||||
|
2. **LoginView**
|
||||||
|
- User gibt Email + Passwort ein
|
||||||
|
- `AuthService.signIn()` → Supabase `/auth/v1/token`
|
||||||
|
- Bei Erfolg: Session in Keychain speichern
|
||||||
|
- `model.handleLogin(session)` setzt `showingLogin = false`
|
||||||
|
|
||||||
|
3. **MacRegistrationView** (neuer Step)
|
||||||
|
- Liest Mac-Info via `MacDeviceDetector.detect()`
|
||||||
|
- Button "Mac registrieren" → `model.registerMac()`
|
||||||
|
- API: `POST /api/magic/register` mit `{ deviceId, hostname, model, osVersion }`
|
||||||
|
- Response: `{ dnsToken, profileUrl, existing }`
|
||||||
|
- Auto-Download: `MagicAPIClient.downloadProfile(token)` → tmp-File
|
||||||
|
- Auto-Install: `MacProfileInstaller.downloadAndInstall()` → `profiles install -path <url>`
|
||||||
|
- Bei Limit-Reached (409): öffnet `ManageBindingsView` (Sheet)
|
||||||
|
- Bei Erfolg: "Weiter → iPhone-Setup" → `model.advance()` zu `.welcome`
|
||||||
|
|
||||||
|
4. **Rest des Wizards** (unverändert)
|
||||||
|
- `.welcome` — iPhone-Detection
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
## 🔒 Security-Hinweise
|
||||||
|
|
||||||
|
- **Keychain-Service**: `org.rebreak.magic` (Account: User-Email)
|
||||||
|
- **JWT-Tokens**: Access + Refresh-Token in Keychain
|
||||||
|
- **Token-Refresh**: Auto-Refresh bei Expiry via `AuthService.refreshSessionIfNeeded()`
|
||||||
|
- **Config-File**: chmod 600 auf `~/.config/rebreak-magic/config.json` empfohlen
|
||||||
|
|
||||||
|
## 🚧 Bekannte Limitations / TODOs
|
||||||
|
|
||||||
|
1. **Profile-Installation benötigt User-Interaktion**
|
||||||
|
- `profiles install` auf macOS öffnet evtl. System-Settings-Dialog
|
||||||
|
- User muss Profil manuell bestätigen (macOS-Security-Policy)
|
||||||
|
- TODO: Anleitung in UI zeigen falls Installation fehlschlägt
|
||||||
|
|
||||||
|
2. **Keine Profile-Signierung**
|
||||||
|
- Unsigned Profiles triggern macOS-Warnung
|
||||||
|
- TODO: Apple-Developer-Cert für Profil-Signierung (Phase 3)
|
||||||
|
|
||||||
|
3. **Device-ID-Persistence**
|
||||||
|
- IOPlatformUUID ist Hardware-UUID, bleibt stabil
|
||||||
|
- Bei Mac-Hardware-Reset ändert sich UUID → neues Device im Backend
|
||||||
|
- Akzeptabel für jetzt
|
||||||
|
|
||||||
|
4. **Keine Tests**
|
||||||
|
- Unit-Tests für AuthService, MagicAPIClient kommen in Phase 3
|
||||||
|
|
||||||
|
5. **Offline-Handling**
|
||||||
|
- Bei Netzwerkfehler: Error-Message, aber kein Retry-Button
|
||||||
|
- User muss App neu starten oder "Erneut registrieren" clicken
|
||||||
|
|
||||||
|
## 📊 Code-Statistik
|
||||||
|
|
||||||
|
- **Neue Files**: 1 (`MacRegistrationView.swift`, 218 LOC)
|
||||||
|
- **Geänderte Files**: 5 (WizardStep, WizardModel, ContentView, LoginView, RebreakMagicApp)
|
||||||
|
- **Bestehende Services** (nicht geändert): AuthService, MagicAPIClient, MacDeviceDetector, MacProfileInstaller, ManageBindingsView
|
||||||
|
|
||||||
|
## 🎯 Nächste Schritte (optional für User)
|
||||||
|
|
||||||
|
1. **Config erstellen**: `cp config.example.json ~/.config/rebreak-magic/config.json` + Werte eintragen
|
||||||
|
2. **Supabase-Projekt**: URL + Anon-Key aus Supabase-Dashboard
|
||||||
|
3. **Test-Account**: User in Supabase anlegen (via Supabase-Dashboard oder Backend-Signup)
|
||||||
|
4. **App starten**: Xcode → Run, Login-Flow testen
|
||||||
|
5. **Mac-Registrierung testen**: Device-Limit-Check (max 3 Devices)
|
||||||
|
6. **Profil prüfen**: `profiles show -type configuration` → sollte `org.rebreak.protection.profile.*` enthalten
|
||||||
|
|
||||||
|
## ✨ Bonus-Feature (bereits implementiert)
|
||||||
|
|
||||||
|
- ✅ **ManageBindingsView** — zeigt eigene Magic-Bindings + Release-Button
|
||||||
|
- ✅ Erreichbar via Sheet wenn Limit-Reached
|
||||||
|
- ✅ 24h-Cooldown-Handling für Release-Requests
|
||||||
@ -1,14 +1,30 @@
|
|||||||
# Rebreak Magic (Mac)
|
# Rebreak Magic (Mac)
|
||||||
|
|
||||||
End-User-Wizard für Self-Binding eines iPhones an Rebreak. Macht in einem 5-Step-Flow:
|
End-User-Wizard für Self-Binding eines Macs + iPhones an Rebreak. Macht in einem 6-Step-Flow:
|
||||||
|
|
||||||
1. **Welcome** — Detect iPhone via USB (lockdownd)
|
1. **Mac Registration** — Mac im Backend registrieren + DNS-Filter-Profil installieren
|
||||||
2. **Pre-Flight** — Find-My-iPhone + Stolen-Device-Protection prüfen/ausschalten
|
2. **Welcome** — Detect iPhone via USB (lockdownd)
|
||||||
3. **Supervise** — `supervise-magic` Plist-Inject + Reboot (kein Erase)
|
3. **Pre-Flight** — Find-My-iPhone + Stolen-Device-Protection prüfen/ausschalten
|
||||||
4. **Enroll** — MDM-Enrollment-Profile auf iPhone installieren
|
4. **Supervise** — `supervise-magic` Plist-Inject + Reboot (kein Erase)
|
||||||
5. **Configure** — NanoMDM pusht: Lock-Profile + Take-Management + Settings(mdmSupervised=true)
|
5. **Enroll** — MDM-Enrollment-Profile auf iPhone installieren
|
||||||
|
6. **Configure** — NanoMDM pusht: Lock-Profile + Take-Management + Settings(mdmSupervised=true)
|
||||||
|
|
||||||
Resultat: iPhone supervised by "Rebreak", App nicht löschbar, NEFilter aktiv (kein User-Toggle in Settings).
|
Resultat:
|
||||||
|
- **Mac**: DNS-Filter aktiv (Gambling-Domains blockiert via DoH-ClientID)
|
||||||
|
- **iPhone**: supervised by "Rebreak", App nicht löschbar, NEFilter aktiv (kein User-Toggle in Settings)
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
✅ **Phase 2 abgeschlossen** — Backend-Auth-Integration + Mac-Registration
|
||||||
|
|
||||||
|
### Was ist neu in Phase 2?
|
||||||
|
|
||||||
|
- **Login-Gate**: User muss sich mit Rebreak-Account anmelden bevor Wizard startet
|
||||||
|
- **Mac-Device-Registration**: Jeder Mac wird im Backend registriert (max. 3 Devices pro Account)
|
||||||
|
- **DNS-Filter für Mac**: Installation eines personalisierten DNS-Filter-Profils (DoH-ClientID)
|
||||||
|
- **Managed Bindings**: UI zum Verwalten gebundener Geräte + 24h-Release-Cooldown
|
||||||
|
|
||||||
|
Siehe [PHASE2_SUMMARY.md](./PHASE2_SUMMARY.md) für Details.
|
||||||
|
|
||||||
## Warum "Magic"?
|
## Warum "Magic"?
|
||||||
|
|
||||||
@ -16,7 +32,14 @@ Normalerweise muss ein iPhone **komplett zurückgesetzt** werden um es zu superv
|
|||||||
|
|
||||||
Rebreak Magic macht das **ohne Reset** — deine Fotos, Apps, Settings bleiben. Das ist in der Branche unüblich und spart den Betroffenen massiv Zeit und Frust beim Onboarding.
|
Rebreak Magic macht das **ohne Reset** — deine Fotos, Apps, Settings bleiben. Das ist in der Branche unüblich und spart den Betroffenen massiv Zeit und Frust beim Onboarding.
|
||||||
|
|
||||||
**Pre-Requirement**: Rebreak-App muss VOR Wizard-Start aus TestFlight installiert sein. Wizard nutzt `InstallApplication` mit `ChangeManagementState: Managed` (kein ManifestURL nötig, kein ABM-Account). Auto-Install via MDM-Push ist Phase 2 (braucht ABM oder Manifest-Hosting).
|
**Pre-Requirement**:
|
||||||
|
- **Rebreak-Account** (Login via Supabase-Auth)
|
||||||
|
- **Rebreak-App** muss VOR Wizard-Start aus TestFlight installiert sein (nur für iPhone-Binding)
|
||||||
|
- **Config-File** mit Supabase + Backend-URLs (siehe Config-Section)
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
🚧 Phase 1 — Skelett. Nur lokal nutzbar (User+Olfa+Dev-iPhones).
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
|
|
||||||
@ -119,20 +142,47 @@ sips -z 64 64 /tmp/master-icon.png --out icon_32x32@2x.png
|
|||||||
sips -z 128 128 /tmp/master-icon.png --out icon_128x128.png
|
sips -z 128 128 /tmp/master-icon.png --out icon_128x128.png
|
||||||
sips -z 256 256 /tmp/master-icon.png --out icon_128x128@2x.png
|
sips -z 256 256 /tmp/master-icon.png --out icon_128x128@2x.png
|
||||||
sips -z 256 256 /tmp/master-icon.png --out icon_256x256.png
|
sips -z 256 256 /tmp/master-icon.png --out icon_256x256.png
|
||||||
sips -z 512 512 /tmp/master-icon.png --out icon_256x256@2x.png
|
**WICHTIG**: Seit Phase 2 braucht die App ein Config-File mit Supabase + Backend-URLs.
|
||||||
sips -z 512 512 /tmp/master-icon.png --out icon_512x512.png
|
|
||||||
sips -z 1024 1024 /tmp/master-icon.png --out icon_512x512@2x.png
|
|
||||||
```
|
|
||||||
|
|
||||||
## Config (lokal)
|
### Schritt 1: Config-File erstellen
|
||||||
|
|
||||||
NanoMDM-API-Key braucht die App für Step 5 (Configure). Lege ein lokales config-file an:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cat > ~/.config/rebreak-binder/config.json <<'EOF'
|
mkdir -p ~/.config/rebreak-magic
|
||||||
|
cp config.example.json ~/.config/rebreak-magic/config.json
|
||||||
|
chmod 600 ~/.config/rebreak-magic/config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritt 2: Config-Werte eintragen
|
||||||
|
|
||||||
|
Editiere `~/.config/rebreak-magic/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
{
|
{
|
||||||
|
"supabaseUrl": "https://your-project.supabase.co",
|
||||||
|
"supabaseAnonKey": "your-supabase-anon-key",
|
||||||
|
"backendBaseUrl": "https://staging.rebreak.org",
|
||||||
"mdmServer": "https://mdm.rebreak.org",
|
"mdmServer": "https://mdm.rebreak.org",
|
||||||
"mdmUser": "nanomdm",
|
"mdmUser": "admin",
|
||||||
|
"mdmApiKey": "your-nanomdm-api-key"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wo finde ich die Werte?**
|
||||||
|
|
||||||
|
| Key | Quelle |
|
||||||
|
|---|---|
|
||||||
|
| `supabaseUrl` | Supabase-Dashboard → Project Settings → API → Project URL |
|
||||||
|
| `supabaseAnonKey` | Supabase-Dashboard → Project Settings → API → `anon` public key |
|
||||||
|
| `backendBaseUrl` | Staging: `https://staging.rebreak.org`, Prod: `https://api.rebreak.org` |
|
||||||
|
| `mdmServer` | `https://mdm.rebreak.org` (rebreak-mdm-VM) |
|
||||||
|
| `mdmUser` | `admin` (NanoMDM-Basic-Auth-User) |
|
||||||
|
| `mdmApiKey` | `/root/.nanomdm_admin_pass` auf rebreak-mdm (32-char-hex) |
|
||||||
|
|
||||||
|
**Hinweis**: `mdmServer`, `mdmUser`, `mdmApiKey` werden nur für iPhone-Setup (Steps 5-6) benötigt. Falls du nur den Mac-DNS-Filter testen willst, kannst du Dummy-Werte eintragen.
|
||||||
|
|
||||||
|
### Alte Config (pre-Phase-2)
|
||||||
|
|
||||||
|
Falls du ein altes `~/.config/rebreak-binder/config.json` hast (nur MDM-Keys), lösche es und erstelle `~/.config/rebreak-magic/config.json` neu. Der alte Pfad wird nicht mehr verwendet
|
||||||
"mdmApiKey": "<32-char-hex from /root/.nanomdm_admin_pass on rebreak-mdm>"
|
"mdmApiKey": "<32-char-hex from /root/.nanomdm_admin_pass on rebreak-mdm>"
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
@ -182,14 +232,37 @@ killall Dock Finder
|
|||||||
|
|
||||||
Dann App neu starten.
|
Dann App neu starten.
|
||||||
|
|
||||||
## TODOs (post-Skelett)
|
## TODOs (post-Phase-2)
|
||||||
|
|
||||||
|
### Phase 3 (geplant)
|
||||||
|
- [ ] **Code-Signing + Notarization** (Developer-ID-Cert)
|
||||||
|
- [ ] **Unit-Tests** für AuthService, MagicAPIClient, MacDeviceDetector
|
||||||
|
- [ ] **Profile-Signierung** (Apple-Developer-Cert für DNS-Filter-Profil)
|
||||||
|
- [ ] **Offline-Retry-Logic** (Network-Error-Handling mit Retry-Button)
|
||||||
|
- [ ] **Mac-Supervision via UAMDM** (aktuell: nur DNS-Filter, keine Supervision)
|
||||||
|
|
||||||
|
### Backlog
|
||||||
- [ ] **Lock-Profile-Refactor**: `allowAppRemoval=false` GLOBAL raus aus `rebreak-content-filter-sideload.mobileconfig`. Per-App-Lock kommt über Managed-App-State (MDM `InstallApplication` mit `ChangeManagementState: Managed` → iOS deaktiviert App-Wackel-„X" automatisch für managed apps). Andere Apps bleiben löschbar (bessere UX).
|
- [ ] **Lock-Profile-Refactor**: `allowAppRemoval=false` GLOBAL raus aus `rebreak-content-filter-sideload.mobileconfig`. Per-App-Lock kommt über Managed-App-State (MDM `InstallApplication` mit `ChangeManagementState: Managed` → iOS deaktiviert App-Wackel-„X" automatisch für managed apps). Andere Apps bleiben löschbar (bessere UX).
|
||||||
- [ ] App-Versions-Mgmt: `InstallApplication`-Manifest-URL-Pointer auf latest IPA (siehe `ops/mdm/PHASES.md` Phase F.5)
|
- [ ] App-Versions-Mgmt: `InstallApplication`-Manifest-URL-Pointer auf latest IPA (siehe `ops/mdm/PHASES.md` Phase F.5)
|
||||||
- [ ] Trustee-Setup-Optional in DoneView (Email an Vertrauensperson)
|
- [ Auth-Stack** (Phase 2):
|
||||||
- [ ] 7-Tage-Cooldown-Persistenz (lokale SQLite oder Backend)
|
- Supabase-JWT-Login (`AuthService.swift`)
|
||||||
- [ ] Code-Signing + Notarization (Developer-ID)
|
- Keychain-Persistence (Service: `org.rebreak.magic`)
|
||||||
- [ ] Backend `/api/binder/*` Endpoints — Mac-App spricht heute MDM-Server direkt
|
- Auto-Refresh bei Token-Expiry
|
||||||
|
- **Backend-API-Client** (`MagicAPIClient.swift`):
|
||||||
|
- `/api/magic/register` — Mac-Device-Registration
|
||||||
|
- `/api/magic/devices` — List own bindings
|
||||||
|
- `/api/magic/devices/{id}/request-release` — 24h-Cooldown
|
||||||
|
- `/api/magic/profile.mobileconfig` — Personalisiertes DNS-Filter-Profil (DoH-ClientID)
|
||||||
|
- **Services** sind dünne Wrapper um:
|
||||||
|
- `ideviceinfo` (libimobiledevice) — Device-Detection
|
||||||
|
- `supervise-magic` Go-CLI — Supervise + Status-Check
|
||||||
|
- `cfgutil` (optional, Apple Configurator 2) — Silent Profile-Install
|
||||||
|
- NanoMDM HTTP-API (`mdm.rebreak.org`) — InstallProfile + Settings-Commands
|
||||||
|
- `profiles` command (macOS) — Mac-DNS-Filter-Installation
|
||||||
|
- [x] Mac-Device-Registration API
|
||||||
|
- [x] DNS-Filter-Profil-Installation
|
||||||
|
- [x] Manage-Bindings-UI (Device-Limit, Release-Cooldown)
|
||||||
|
- [x] Login-Gate vor Wizard
|
||||||
|
|
||||||
## Architektur
|
## Architektur
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,7 @@ enum DebugSupervisionMode: String, CaseIterable, Identifiable {
|
|||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class WizardModel {
|
final class WizardModel {
|
||||||
var step: WizardStep = .welcome
|
var step: WizardStep = .macRegistration
|
||||||
var device: DeviceState?
|
var device: DeviceState?
|
||||||
|
|
||||||
var supervisionLog: [String] = []
|
var supervisionLog: [String] = []
|
||||||
@ -48,6 +48,19 @@ final class WizardModel {
|
|||||||
var resetLockProfile: Bool = true
|
var resetLockProfile: Bool = true
|
||||||
var resetApp: Bool = true
|
var resetApp: Bool = true
|
||||||
|
|
||||||
|
// Auth + Magic State
|
||||||
|
var authSession: AuthSession?
|
||||||
|
var showingLogin: Bool = false
|
||||||
|
var showingManageBindings: Bool = false
|
||||||
|
var magicRegistration: MagicRegistration?
|
||||||
|
var registrationError: String?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Load existing session from keychain
|
||||||
|
authSession = AuthService.shared.currentSession()
|
||||||
|
showingLogin = (authSession == nil)
|
||||||
|
}
|
||||||
|
|
||||||
func advance() {
|
func advance() {
|
||||||
if let next = WizardStep(rawValue: step.rawValue + 1) {
|
if let next = WizardStep(rawValue: step.rawValue + 1) {
|
||||||
step = next
|
step = next
|
||||||
@ -58,8 +71,52 @@ final class WizardModel {
|
|||||||
step = s
|
step = s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Mac Registration
|
||||||
|
|
||||||
|
/// Registriert den aktuellen Mac im Backend.
|
||||||
|
/// Wirft MagicError.limitReached falls Device-Limit erreicht.
|
||||||
|
func registerMac() async throws {
|
||||||
|
registrationError = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
let macInfo = try MacDeviceDetector.detect()
|
||||||
|
|
||||||
|
let registration = try await MagicAPIClient.shared.register(
|
||||||
|
deviceId: macInfo.deviceId,
|
||||||
|
hostname: macInfo.hostname,
|
||||||
|
model: macInfo.model,
|
||||||
|
osVersion: macInfo.osVersion
|
||||||
|
)
|
||||||
|
|
||||||
|
magicRegistration = registration
|
||||||
|
|
||||||
|
} catch let error as MagicError {
|
||||||
|
// Bei limit_reached → öffne ManageBindingsView
|
||||||
|
if case .limitReached(_) = error {
|
||||||
|
showingManageBindings = true
|
||||||
|
}
|
||||||
|
registrationError = error.localizedDescription
|
||||||
|
throw error
|
||||||
|
} catch {
|
||||||
|
registrationError = error.localizedDescription
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleLogin(session: AuthSession) {
|
||||||
|
authSession = session
|
||||||
|
showingLogin = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleLogout() async {
|
||||||
|
await AuthService.shared.signOut()
|
||||||
|
authSession = nil
|
||||||
|
showingLogin = true
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
|
||||||
func reset() {
|
func reset() {
|
||||||
step = .welcome
|
step = .macRegistration
|
||||||
device = nil
|
device = nil
|
||||||
supervisionLog = []
|
supervisionLog = []
|
||||||
enrollmentLog = []
|
enrollmentLog = []
|
||||||
@ -70,6 +127,8 @@ final class WizardModel {
|
|||||||
showAdvancedLogs = false
|
showAdvancedLogs = false
|
||||||
cooldownEndsAt = nil
|
cooldownEndsAt = nil
|
||||||
resetStatus = nil
|
resetStatus = nil
|
||||||
|
magicRegistration = nil
|
||||||
|
registrationError = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func startDebugReset() {
|
func startDebugReset() {
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum WizardStep: Int, CaseIterable, Identifiable {
|
enum WizardStep: Int, CaseIterable, Identifiable {
|
||||||
case welcome = 0
|
case macRegistration = 0
|
||||||
|
case welcome
|
||||||
case preflight
|
case preflight
|
||||||
case supervise
|
case supervise
|
||||||
case enroll
|
case enroll
|
||||||
@ -12,6 +13,7 @@ enum WizardStep: Int, CaseIterable, Identifiable {
|
|||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .macRegistration: return "Mac registrieren"
|
||||||
case .welcome: return "iPhone verbinden"
|
case .welcome: return "iPhone verbinden"
|
||||||
case .preflight: return "Pre-Flight Check"
|
case .preflight: return "Pre-Flight Check"
|
||||||
case .supervise: return "Supervisieren"
|
case .supervise: return "Supervisieren"
|
||||||
|
|||||||
@ -13,6 +13,14 @@ struct RebreakMagicApp: App {
|
|||||||
.windowResizability(.contentSize)
|
.windowResizability(.contentSize)
|
||||||
.windowStyle(.titleBar)
|
.windowStyle(.titleBar)
|
||||||
.commands {
|
.commands {
|
||||||
|
CommandMenu("Account") {
|
||||||
|
Button("Abmelden") {
|
||||||
|
Task { await model.handleLogout() }
|
||||||
|
}
|
||||||
|
.keyboardShortcut("l", modifiers: [.command, .shift])
|
||||||
|
.disabled(model.authSession == nil)
|
||||||
|
}
|
||||||
|
|
||||||
CommandMenu("Aktionen") {
|
CommandMenu("Aktionen") {
|
||||||
Menu("Debug Supervision Mode") {
|
Menu("Debug Supervision Mode") {
|
||||||
Button(DebugSupervisionMode.none.title) {
|
Button(DebugSupervisionMode.none.title) {
|
||||||
|
|||||||
272
apps/rebreak-magic-mac/Sources/Services/AuthService.swift
Normal file
272
apps/rebreak-magic-mac/Sources/Services/AuthService.swift
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
/// Auth-Session mit Supabase JWT + Refresh-Token.
|
||||||
|
/// Stored in macOS Keychain für persistence über App-Restarts.
|
||||||
|
struct AuthSession: Codable {
|
||||||
|
let accessToken: String
|
||||||
|
let refreshToken: String
|
||||||
|
let userId: String
|
||||||
|
let expiresAt: Date
|
||||||
|
let email: String
|
||||||
|
|
||||||
|
var isExpired: Bool {
|
||||||
|
Date().addingTimeInterval(60) > expiresAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AuthError: Error, LocalizedError {
|
||||||
|
case invalidCredentials
|
||||||
|
case networkError(String)
|
||||||
|
case configMissing(String)
|
||||||
|
case keychainError(OSStatus)
|
||||||
|
case tokenExpired
|
||||||
|
case refreshFailed
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidCredentials:
|
||||||
|
return "Email oder Passwort falsch"
|
||||||
|
case .networkError(let msg):
|
||||||
|
return "Netzwerkfehler: \(msg)"
|
||||||
|
case .configMissing(let msg):
|
||||||
|
return "Konfiguration fehlt: \(msg)"
|
||||||
|
case .keychainError(let status):
|
||||||
|
return "Keychain-Fehler: \(status)"
|
||||||
|
case .tokenExpired:
|
||||||
|
return "Session abgelaufen. Bitte neu einloggen."
|
||||||
|
case .refreshFailed:
|
||||||
|
return "Token-Refresh fehlgeschlagen. Bitte neu einloggen."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supabase Auth-Response Schemas
|
||||||
|
private struct SupabaseAuthResponse: Codable {
|
||||||
|
let access_token: String
|
||||||
|
let refresh_token: String
|
||||||
|
let expires_in: Int
|
||||||
|
let user: SupabaseUser
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SupabaseUser: Codable {
|
||||||
|
let id: String
|
||||||
|
let email: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SupabaseRefreshResponse: Codable {
|
||||||
|
let access_token: String
|
||||||
|
let refresh_token: String
|
||||||
|
let expires_in: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AuthService — managt Supabase-Login + Keychain-Persistence.
|
||||||
|
///
|
||||||
|
/// Config aus ~/.config/rebreak-magic/config.json:
|
||||||
|
/// { "supabaseUrl": "https://xxx.supabase.co", "supabaseAnonKey": "..." }
|
||||||
|
@MainActor
|
||||||
|
final class AuthService {
|
||||||
|
static let shared = AuthService()
|
||||||
|
|
||||||
|
private let keychainService = "org.rebreak.magic"
|
||||||
|
private var cachedSession: AuthSession?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Config
|
||||||
|
|
||||||
|
private struct Config: Codable {
|
||||||
|
let supabaseUrl: String
|
||||||
|
let supabaseAnonKey: String
|
||||||
|
let backendBaseUrl: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let configPath: String = {
|
||||||
|
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||||
|
return "\(home)/.config/rebreak-magic/config.json"
|
||||||
|
}()
|
||||||
|
|
||||||
|
private func loadConfig() throws -> Config {
|
||||||
|
let url = URL(fileURLWithPath: Self.configPath)
|
||||||
|
guard FileManager.default.fileExists(atPath: Self.configPath) else {
|
||||||
|
throw AuthError.configMissing("~/.config/rebreak-magic/config.json nicht gefunden. Bitte erstellen mit supabaseUrl + supabaseAnonKey.")
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
return try JSONDecoder().decode(Config.self, from: data)
|
||||||
|
} catch {
|
||||||
|
throw AuthError.configMissing(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sign In
|
||||||
|
|
||||||
|
func signIn(email: String, password: String) async throws -> AuthSession {
|
||||||
|
let config = try loadConfig()
|
||||||
|
|
||||||
|
guard let url = URL(string: "\(config.supabaseUrl)/auth/v1/token?grant_type=password") else {
|
||||||
|
throw AuthError.configMissing("Ungültige supabaseUrl")
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue(config.supabaseAnonKey, forHTTPHeaderField: "apikey")
|
||||||
|
|
||||||
|
let body = ["email": email, "password": password]
|
||||||
|
request.httpBody = try JSONEncoder().encode(body)
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw AuthError.networkError("Keine HTTP-Response")
|
||||||
|
}
|
||||||
|
|
||||||
|
if httpResponse.statusCode == 400 {
|
||||||
|
throw AuthError.invalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
guard httpResponse.statusCode == 200 else {
|
||||||
|
let body = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
throw AuthError.networkError("HTTP \(httpResponse.statusCode): \(body)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let authResponse = try JSONDecoder().decode(SupabaseAuthResponse.self, from: data)
|
||||||
|
|
||||||
|
let session = AuthSession(
|
||||||
|
accessToken: authResponse.access_token,
|
||||||
|
refreshToken: authResponse.refresh_token,
|
||||||
|
userId: authResponse.user.id,
|
||||||
|
expiresAt: Date().addingTimeInterval(TimeInterval(authResponse.expires_in)),
|
||||||
|
email: authResponse.user.email ?? email
|
||||||
|
)
|
||||||
|
|
||||||
|
// Save to keychain
|
||||||
|
try saveToKeychain(session)
|
||||||
|
cachedSession = session
|
||||||
|
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sign Out
|
||||||
|
|
||||||
|
func signOut() async {
|
||||||
|
cachedSession = nil
|
||||||
|
deleteFromKeychain(account: cachedSession?.email ?? "session")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Current Session
|
||||||
|
|
||||||
|
func currentSession() -> AuthSession? {
|
||||||
|
if let cached = cachedSession {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load from keychain
|
||||||
|
if let loaded = loadFromKeychain() {
|
||||||
|
cachedSession = loaded
|
||||||
|
return loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Refresh Token
|
||||||
|
|
||||||
|
func refreshSessionIfNeeded() async throws -> AuthSession {
|
||||||
|
guard let session = currentSession() else {
|
||||||
|
throw AuthError.tokenExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
if !session.isExpired {
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh via Supabase
|
||||||
|
let config = try loadConfig()
|
||||||
|
|
||||||
|
guard let url = URL(string: "\(config.supabaseUrl)/auth/v1/token?grant_type=refresh_token") else {
|
||||||
|
throw AuthError.configMissing("Ungültige supabaseUrl")
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue(config.supabaseAnonKey, forHTTPHeaderField: "apikey")
|
||||||
|
|
||||||
|
let body = ["refresh_token": session.refreshToken]
|
||||||
|
request.httpBody = try JSONEncoder().encode(body)
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
|
||||||
|
throw AuthError.refreshFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshResponse = try JSONDecoder().decode(SupabaseRefreshResponse.self, from: data)
|
||||||
|
|
||||||
|
let newSession = AuthSession(
|
||||||
|
accessToken: refreshResponse.access_token,
|
||||||
|
refreshToken: refreshResponse.refresh_token,
|
||||||
|
userId: session.userId,
|
||||||
|
expiresAt: Date().addingTimeInterval(TimeInterval(refreshResponse.expires_in)),
|
||||||
|
email: session.email
|
||||||
|
)
|
||||||
|
|
||||||
|
try saveToKeychain(newSession)
|
||||||
|
cachedSession = newSession
|
||||||
|
|
||||||
|
return newSession
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Keychain
|
||||||
|
|
||||||
|
private func saveToKeychain(_ session: AuthSession) throws {
|
||||||
|
let data = try JSONEncoder().encode(session)
|
||||||
|
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: keychainService,
|
||||||
|
kSecAttrAccount as String: session.email,
|
||||||
|
kSecValueData as String: data
|
||||||
|
]
|
||||||
|
|
||||||
|
// Delete existing first
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
|
||||||
|
let status = SecItemAdd(query as CFDictionary, nil)
|
||||||
|
guard status == errSecSuccess else {
|
||||||
|
throw AuthError.keychainError(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadFromKeychain() -> AuthSession? {
|
||||||
|
// Try to load with a wildcard account search
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: keychainService,
|
||||||
|
kSecReturnData as String: true,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitOne
|
||||||
|
]
|
||||||
|
|
||||||
|
var item: CFTypeRef?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||||
|
|
||||||
|
guard status == errSecSuccess,
|
||||||
|
let data = item as? Data,
|
||||||
|
let session = try? JSONDecoder().decode(AuthSession.self, from: data) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteFromKeychain(account: String) {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: keychainService,
|
||||||
|
kSecAttrAccount as String: account
|
||||||
|
]
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
import Foundation
|
||||||
|
import IOKit
|
||||||
|
|
||||||
|
/// Device-Info für den aktuellen Mac.
|
||||||
|
/// Wird für /api/magic/register benötigt.
|
||||||
|
struct MacDeviceInfo {
|
||||||
|
let deviceId: String // IOPlatformUUID
|
||||||
|
let hostname: String // Host.current().localizedName
|
||||||
|
let model: String // hw.model via sysctl
|
||||||
|
let osVersion: String // ProductVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MacDeviceDetectorError: Error, LocalizedError {
|
||||||
|
case platformUUIDNotFound
|
||||||
|
case sysctlFailed(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .platformUUIDNotFound:
|
||||||
|
return "IOPlatformUUID nicht gefunden"
|
||||||
|
case .sysctlFailed(let msg):
|
||||||
|
return "sysctl fehlgeschlagen: \(msg)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erkennt Mac-Hardware-Infos via IOKit + sysctl.
|
||||||
|
enum MacDeviceDetector {
|
||||||
|
|
||||||
|
/// Liest IOPlatformUUID — eindeutige Hardware-ID des Mac.
|
||||||
|
static func platformUUID() throws -> String {
|
||||||
|
let service = IOServiceGetMatchingService(
|
||||||
|
kIOMainPortDefault,
|
||||||
|
IOServiceMatching("IOPlatformExpertDevice")
|
||||||
|
)
|
||||||
|
|
||||||
|
guard service != 0 else {
|
||||||
|
throw MacDeviceDetectorError.platformUUIDNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
defer { IOObjectRelease(service) }
|
||||||
|
|
||||||
|
guard let uuidCF = IORegistryEntryCreateCFProperty(
|
||||||
|
service,
|
||||||
|
"IOPlatformUUID" as CFString,
|
||||||
|
kCFAllocatorDefault,
|
||||||
|
0
|
||||||
|
)?.takeRetainedValue() as? String else {
|
||||||
|
throw MacDeviceDetectorError.platformUUIDNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return uuidCF
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liest hw.model via sysctl (z.B. "MacBookPro18,3").
|
||||||
|
static func hwModel() throws -> String {
|
||||||
|
var size: size_t = 0
|
||||||
|
sysctlbyname("hw.model", nil, &size, nil, 0)
|
||||||
|
|
||||||
|
var model = [CChar](repeating: 0, count: size)
|
||||||
|
guard sysctlbyname("hw.model", &model, &size, nil, 0) == 0 else {
|
||||||
|
throw MacDeviceDetectorError.sysctlFailed("hw.model read failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(cString: model)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sammelt alle Infos für /api/magic/register.
|
||||||
|
static func detect() throws -> MacDeviceInfo {
|
||||||
|
let deviceId = try platformUUID()
|
||||||
|
let hostname = Host.current().localizedName ?? "Unknown Mac"
|
||||||
|
let model = try hwModel()
|
||||||
|
let osVersion = ProcessInfo.processInfo.operatingSystemVersionString
|
||||||
|
|
||||||
|
return MacDeviceInfo(
|
||||||
|
deviceId: deviceId,
|
||||||
|
hostname: hostname,
|
||||||
|
model: model,
|
||||||
|
osVersion: osVersion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Service für Mac-DNS-Profile-Download + Installation.
|
||||||
|
enum MacProfileInstaller {
|
||||||
|
|
||||||
|
enum InstallerError: Error, LocalizedError {
|
||||||
|
case noRegistration
|
||||||
|
case downloadFailed(String)
|
||||||
|
case installFailed(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .noRegistration:
|
||||||
|
return "Mac ist nicht registriert. Bitte zuerst registrieren."
|
||||||
|
case .downloadFailed(let msg):
|
||||||
|
return "Profile-Download fehlgeschlagen: \(msg)"
|
||||||
|
case .installFailed(let msg):
|
||||||
|
return "Profile-Installation fehlgeschlagen: \(msg)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lädt Mac-DNS-Profile von Backend und installiert via `profiles install`.
|
||||||
|
/// Profile-File wird nach Installation gelöscht (enthält sensiblen Token).
|
||||||
|
static func downloadAndInstall(registration: MagicRegistration) async throws {
|
||||||
|
// 1. Download profile
|
||||||
|
let profileURL: URL
|
||||||
|
do {
|
||||||
|
profileURL = try await MagicAPIClient.shared.downloadProfile(token: registration.dnsToken)
|
||||||
|
} catch {
|
||||||
|
throw InstallerError.downloadFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Install via `profiles` command (macOS-only)
|
||||||
|
let result = try await ProcessRunner.run(
|
||||||
|
"/usr/bin/profiles",
|
||||||
|
arguments: ["install", "-path", profileURL.path]
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3. Clean up downloaded file
|
||||||
|
try? FileManager.default.removeItem(at: profileURL)
|
||||||
|
|
||||||
|
if result.exitCode != 0 {
|
||||||
|
let errorMsg = result.stderr.isEmpty ? result.stdout : result.stderr
|
||||||
|
throw InstallerError.installFailed(errorMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prüft ob ReBreak-DNS-Profile bereits installiert ist.
|
||||||
|
/// Verwendet `profiles show -type configuration`.
|
||||||
|
static func isInstalled() async -> Bool {
|
||||||
|
guard let result = try? await ProcessRunner.run(
|
||||||
|
"/usr/bin/profiles",
|
||||||
|
arguments: ["show", "-type", "configuration"]
|
||||||
|
), result.exitCode == 0 else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suche nach PayloadIdentifier pattern org.rebreak.protection.dns.filter*
|
||||||
|
return result.stdout.localizedCaseInsensitiveContains("org.rebreak.protection.dns.filter")
|
||||||
|
|| result.stdout.localizedCaseInsensitiveContains("org.rebreak.protection.profile")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entfernt ReBreak-DNS-Profile (für Testing/Reset).
|
||||||
|
/// Benötigt PayloadIdentifier — wir suchen nach "org.rebreak.protection.profile.*".
|
||||||
|
static func remove() async throws {
|
||||||
|
// 1. Find identifier
|
||||||
|
guard let result = try? await ProcessRunner.run(
|
||||||
|
"/usr/bin/profiles",
|
||||||
|
arguments: ["show", "-type", "configuration"]
|
||||||
|
), result.exitCode == 0 else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse identifier aus Output (format: " <identifier>: <displayName>")
|
||||||
|
let lines = result.stdout.split(separator: "\n")
|
||||||
|
var identifier: String?
|
||||||
|
|
||||||
|
for line in lines {
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed.hasPrefix("org.rebreak.protection.profile") {
|
||||||
|
// Format: "org.rebreak.protection.profile.abc123: ReBreak Protection"
|
||||||
|
identifier = trimmed.split(separator: ":").first.map(String.init)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let id = identifier else { return }
|
||||||
|
|
||||||
|
// 2. Remove profile
|
||||||
|
let removeResult = try await ProcessRunner.run(
|
||||||
|
"/usr/bin/profiles",
|
||||||
|
arguments: ["remove", "-identifier", id]
|
||||||
|
)
|
||||||
|
|
||||||
|
if removeResult.exitCode != 0 {
|
||||||
|
throw InstallerError.installFailed(removeResult.stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
314
apps/rebreak-magic-mac/Sources/Services/MagicAPIClient.swift
Normal file
314
apps/rebreak-magic-mac/Sources/Services/MagicAPIClient.swift
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Response-Modelle für /api/magic/* Endpoints
|
||||||
|
struct MagicRegistration: Codable {
|
||||||
|
let deviceId: String
|
||||||
|
let dnsToken: String
|
||||||
|
let profileUrl: String
|
||||||
|
let existing: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MagicDevice: Codable, Identifiable {
|
||||||
|
let deviceId: String
|
||||||
|
let hostname: String
|
||||||
|
let model: String?
|
||||||
|
let osVersion: String?
|
||||||
|
let magicEnrolledAt: String
|
||||||
|
let releaseRequestedAt: String?
|
||||||
|
let releaseAvailableAt: String?
|
||||||
|
|
||||||
|
var id: String { deviceId }
|
||||||
|
|
||||||
|
var enrolledDate: Date? {
|
||||||
|
ISO8601DateFormatter().date(from: magicEnrolledAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
var releaseDate: Date? {
|
||||||
|
guard let iso = releaseAvailableAt else { return nil }
|
||||||
|
return ISO8601DateFormatter().date(from: iso)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isReleasing: Bool {
|
||||||
|
releaseRequestedAt != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MagicReleaseResponse: Codable {
|
||||||
|
let releaseRequestedAt: String
|
||||||
|
let releaseAvailableAt: String
|
||||||
|
|
||||||
|
var releaseDate: Date? {
|
||||||
|
ISO8601DateFormatter().date(from: releaseAvailableAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MagicError: Error, LocalizedError {
|
||||||
|
case unauthorized
|
||||||
|
case limitReached(activeBindings: [MagicDevice])
|
||||||
|
case networkError(String)
|
||||||
|
case httpError(Int, String)
|
||||||
|
case configMissing(String)
|
||||||
|
case decodingError(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unauthorized:
|
||||||
|
return "Nicht authentifiziert. Bitte neu einloggen."
|
||||||
|
case .limitReached(let bindings):
|
||||||
|
return "Device-Limit erreicht (\(bindings.count) Geräte). Bitte zuerst ein Gerät freigeben."
|
||||||
|
case .networkError(let msg):
|
||||||
|
return "Netzwerkfehler: \(msg)"
|
||||||
|
case .httpError(let status, let msg):
|
||||||
|
return "HTTP \(status): \(msg)"
|
||||||
|
case .configMissing(let msg):
|
||||||
|
return "Config fehlt: \(msg)"
|
||||||
|
case .decodingError(let msg):
|
||||||
|
return "Response-Parse-Fehler: \(msg)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP-Client für ReBreak Magic Backend API (/api/magic/*).
|
||||||
|
/// Injiziert automatisch JWT-Auth via AuthService.
|
||||||
|
@MainActor
|
||||||
|
final class MagicAPIClient {
|
||||||
|
static let shared = MagicAPIClient()
|
||||||
|
|
||||||
|
private let authService = AuthService.shared
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Config
|
||||||
|
|
||||||
|
private struct Config: Codable {
|
||||||
|
let backendBaseUrl: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let configPath: String = {
|
||||||
|
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||||
|
return "\(home)/.config/rebreak-magic/config.json"
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var baseURL: String {
|
||||||
|
get throws {
|
||||||
|
// Override via env var for testing
|
||||||
|
if let envUrl = ProcessInfo.processInfo.environment["REBREAK_BACKEND_URL"] {
|
||||||
|
return envUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = URL(fileURLWithPath: Self.configPath)
|
||||||
|
guard FileManager.default.fileExists(atPath: Self.configPath) else {
|
||||||
|
// Default to production
|
||||||
|
return "https://app.rebreak.org"
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
let config = try JSONDecoder().decode(Config.self, from: data)
|
||||||
|
return config.backendBaseUrl ?? "https://app.rebreak.org"
|
||||||
|
} catch {
|
||||||
|
return "https://app.rebreak.org"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Register Device
|
||||||
|
|
||||||
|
func register(deviceId: String, hostname: String, model: String, osVersion: String) async throws -> MagicRegistration {
|
||||||
|
let session = try await authService.refreshSessionIfNeeded()
|
||||||
|
let url = try URL(string: "\(baseURL)/api/magic/register")!
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization")
|
||||||
|
|
||||||
|
let body: [String: String] = [
|
||||||
|
"deviceId": deviceId,
|
||||||
|
"hostname": hostname,
|
||||||
|
"model": model,
|
||||||
|
"osVersion": osVersion
|
||||||
|
]
|
||||||
|
request.httpBody = try JSONEncoder().encode(body)
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw MagicError.networkError("Keine HTTP-Response")
|
||||||
|
}
|
||||||
|
|
||||||
|
if httpResponse.statusCode == 401 {
|
||||||
|
await authService.signOut()
|
||||||
|
throw MagicError.unauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
if httpResponse.statusCode == 409 {
|
||||||
|
// Limit reached
|
||||||
|
struct LimitError: Codable {
|
||||||
|
let code: String
|
||||||
|
let activeBindings: [MagicDevice]
|
||||||
|
}
|
||||||
|
struct ErrorResponse: Codable {
|
||||||
|
let data: LimitError
|
||||||
|
}
|
||||||
|
if let errorData = try? JSONDecoder().decode(ErrorResponse.self, from: data) {
|
||||||
|
throw MagicError.limitReached(activeBindings: errorData.data.activeBindings)
|
||||||
|
}
|
||||||
|
throw MagicError.httpError(409, "Device-Limit erreicht")
|
||||||
|
}
|
||||||
|
|
||||||
|
guard httpResponse.statusCode == 200 else {
|
||||||
|
let body = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
throw MagicError.httpError(httpResponse.statusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Response: Codable {
|
||||||
|
let success: Bool
|
||||||
|
let data: MagicRegistration
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try JSONDecoder().decode(Response.self, from: data)
|
||||||
|
return response.data
|
||||||
|
} catch {
|
||||||
|
throw MagicError.decodingError(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - List Devices
|
||||||
|
|
||||||
|
func listDevices() async throws -> [MagicDevice] {
|
||||||
|
let session = try await authService.refreshSessionIfNeeded()
|
||||||
|
let url = try URL(string: "\(baseURL)/api/magic/devices")!
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "GET"
|
||||||
|
request.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization")
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw MagicError.networkError("Keine HTTP-Response")
|
||||||
|
}
|
||||||
|
|
||||||
|
if httpResponse.statusCode == 401 {
|
||||||
|
await authService.signOut()
|
||||||
|
throw MagicError.unauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
guard httpResponse.statusCode == 200 else {
|
||||||
|
let body = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
throw MagicError.httpError(httpResponse.statusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Response: Codable {
|
||||||
|
let success: Bool
|
||||||
|
let data: [MagicDevice]
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try JSONDecoder().decode(Response.self, from: data)
|
||||||
|
return response.data
|
||||||
|
} catch {
|
||||||
|
throw MagicError.decodingError(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Request Release
|
||||||
|
|
||||||
|
func requestRelease(deviceId: String) async throws -> Date {
|
||||||
|
let session = try await authService.refreshSessionIfNeeded()
|
||||||
|
let url = try URL(string: "\(baseURL)/api/magic/devices/\(deviceId)/request-release")!
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization")
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw MagicError.networkError("Keine HTTP-Response")
|
||||||
|
}
|
||||||
|
|
||||||
|
if httpResponse.statusCode == 401 {
|
||||||
|
await authService.signOut()
|
||||||
|
throw MagicError.unauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
guard httpResponse.statusCode == 200 else {
|
||||||
|
let body = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
throw MagicError.httpError(httpResponse.statusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Response: Codable {
|
||||||
|
let success: Bool
|
||||||
|
let data: MagicReleaseResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try JSONDecoder().decode(Response.self, from: data)
|
||||||
|
guard let date = response.data.releaseDate else {
|
||||||
|
throw MagicError.decodingError("releaseAvailableAt parse failed")
|
||||||
|
}
|
||||||
|
return date
|
||||||
|
} catch {
|
||||||
|
throw MagicError.decodingError(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cancel Release
|
||||||
|
|
||||||
|
func cancelRelease(deviceId: String) async throws {
|
||||||
|
let session = try await authService.refreshSessionIfNeeded()
|
||||||
|
let url = try URL(string: "\(baseURL)/api/magic/devices/\(deviceId)/cancel-release")!
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization")
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw MagicError.networkError("Keine HTTP-Response")
|
||||||
|
}
|
||||||
|
|
||||||
|
if httpResponse.statusCode == 401 {
|
||||||
|
await authService.signOut()
|
||||||
|
throw MagicError.unauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
guard httpResponse.statusCode == 200 else {
|
||||||
|
let body = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
throw MagicError.httpError(httpResponse.statusCode, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Download Profile
|
||||||
|
|
||||||
|
func downloadProfile(token: String) async throws -> URL {
|
||||||
|
let url = try URL(string: "\(baseURL)/api/magic/profile.mobileconfig?token=\(token)")!
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "GET"
|
||||||
|
// KEIN JWT — Token in Query
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw MagicError.networkError("Keine HTTP-Response")
|
||||||
|
}
|
||||||
|
|
||||||
|
guard httpResponse.statusCode == 200 else {
|
||||||
|
let body = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
throw MagicError.httpError(httpResponse.statusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to tmp
|
||||||
|
let tmpDir = FileManager.default.temporaryDirectory
|
||||||
|
let profilePath = tmpDir.appendingPathComponent("RebreakMagic-\(UUID().uuidString).mobileconfig")
|
||||||
|
|
||||||
|
try data.write(to: profilePath)
|
||||||
|
|
||||||
|
return profilePath
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,27 @@ struct ContentView: View {
|
|||||||
@State private var showingHelp = false
|
@State private var showingHelp = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if model.showingLogin {
|
||||||
|
LoginView { session in
|
||||||
|
model.handleLogin(session: session)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mainWizardView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: Binding(
|
||||||
|
get: { model.showingManageBindings },
|
||||||
|
set: { model.showingManageBindings = $0 }
|
||||||
|
)) {
|
||||||
|
ManageBindingsView {
|
||||||
|
model.showingManageBindings = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var mainWizardView: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
HStack {
|
HStack {
|
||||||
@ -46,12 +67,10 @@ struct ContentView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
.sheet(isPresented: $showingHelp) {
|
|
||||||
HelpView()
|
|
||||||
}
|
|
||||||
// Main content
|
// Main content
|
||||||
Group {
|
Group {
|
||||||
switch model.step {
|
switch model.step {
|
||||||
|
case .macRegistration: MacRegistrationView()
|
||||||
case .welcome: WelcomeView()
|
case .welcome: WelcomeView()
|
||||||
case .preflight: PreflightView()
|
case .preflight: PreflightView()
|
||||||
case .supervise: SuperviseView()
|
case .supervise: SuperviseView()
|
||||||
@ -62,6 +81,9 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingHelp) {
|
||||||
|
HelpView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|||||||
120
apps/rebreak-magic-mac/Sources/Views/LoginView.swift
Normal file
120
apps/rebreak-magic-mac/Sources/Views/LoginView.swift
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LoginView: View {
|
||||||
|
@State private var email = ""
|
||||||
|
@State private var password = ""
|
||||||
|
@State private var isLoading = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
|
let onSuccess: (AuthSession) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
// Logo + Header
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "shield.checkered")
|
||||||
|
.font(.system(size: 64))
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
|
||||||
|
Text("ReBreak Magic")
|
||||||
|
.font(.title.bold())
|
||||||
|
|
||||||
|
Text("Bitte mit deinem ReBreak-Account anmelden")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.top, 40)
|
||||||
|
|
||||||
|
// Form
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Email")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
TextField("name@example.com", text: $email)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.textContentType(.emailAddress)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Passwort")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
SecureField("••••••••", text: $password)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.textContentType(.password)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = errorMessage {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: handleSignIn) {
|
||||||
|
HStack {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.tint(.white)
|
||||||
|
}
|
||||||
|
Text(isLoading ? "Anmeldung läuft..." : "Anmelden")
|
||||||
|
.fontWeight(.medium)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(email.isEmpty || password.isEmpty || isLoading)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
.frame(maxWidth: 400)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Signup Link
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text("Noch kein Account?")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Link("Jetzt registrieren →", destination: URL(string: "https://rebreak.org/signup")!)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color(nsColor: .windowBackgroundColor))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleSignIn() {
|
||||||
|
Task {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
let session = try await AuthService.shared.signIn(email: email, password: password)
|
||||||
|
onSuccess(session)
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
LoginView { session in
|
||||||
|
print("Logged in: \(session.email)")
|
||||||
|
}
|
||||||
|
.frame(width: 720, height: 600)
|
||||||
|
}
|
||||||
241
apps/rebreak-magic-mac/Sources/Views/MacRegistrationView.swift
Normal file
241
apps/rebreak-magic-mac/Sources/Views/MacRegistrationView.swift
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MacRegistrationView: View {
|
||||||
|
@Environment(WizardModel.self) private var model
|
||||||
|
|
||||||
|
@State private var macInfo: MacDeviceInfo?
|
||||||
|
@State private var isRegistering = false
|
||||||
|
@State private var isInstallingProfile = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
@State private var successMessage: String?
|
||||||
|
@State private var profileInstalled = false
|
||||||
|
@State private var checkingProfile = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
Image(systemName: "desktopcomputer.and.arrow.down")
|
||||||
|
.font(.system(size: 80))
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
|
||||||
|
Text("Mac für ReBreak Magic registrieren")
|
||||||
|
.font(.title)
|
||||||
|
.bold()
|
||||||
|
|
||||||
|
Text("Bevor wir mit dem iPhone-Setup starten, muss dieser Mac registriert und geschützt werden.")
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
|
||||||
|
if let info = macInfo {
|
||||||
|
macInfoCard(info)
|
||||||
|
} else {
|
||||||
|
ProgressView("Lese Mac-Informationen...")
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = errorMessage {
|
||||||
|
errorCard(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let success = successMessage {
|
||||||
|
successCard(success)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let registration = model.magicRegistration, profileInstalled {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "checkmark.shield.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
Text("Mac erfolgreich geschützt")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("✓ DNS-Filter-Profil installiert")
|
||||||
|
Text("✓ Device registriert: \(registration.deviceId.prefix(8))...")
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.green.opacity(0.1))
|
||||||
|
.cornerRadius(8)
|
||||||
|
.frame(maxWidth: 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
if model.magicRegistration == nil {
|
||||||
|
Button("Mac registrieren") { handleRegistration() }
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(isRegistering || macInfo == nil || isInstallingProfile)
|
||||||
|
} else if !profileInstalled {
|
||||||
|
Button("DNS-Profil installieren") { handleProfileInstall() }
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(isInstallingProfile)
|
||||||
|
} else {
|
||||||
|
Button("Weiter → iPhone-Setup") { model.advance() }
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isRegistering || isInstallingProfile {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(40)
|
||||||
|
.onAppear {
|
||||||
|
loadMacInfo()
|
||||||
|
checkProfileStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func macInfoCard(_ info: MacDeviceInfo) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "desktopcomputer")
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
Text(info.hostname)
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("\(info.model) · macOS \(info.osVersion)")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text("Device-ID: \(info.deviceId.prefix(8))...\(info.deviceId.suffix(8))")
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: 400, alignment: .leading)
|
||||||
|
.background(Color.blue.opacity(0.08))
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func errorCard(_ error: String) -> some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
Text(error)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.red.opacity(0.1))
|
||||||
|
.cornerRadius(8)
|
||||||
|
.frame(maxWidth: 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func successCard(_ message: String) -> some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
Text(message)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.green.opacity(0.1))
|
||||||
|
.cornerRadius(8)
|
||||||
|
.frame(maxWidth: 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadMacInfo() {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let info = try MacDeviceDetector.detect()
|
||||||
|
await MainActor.run {
|
||||||
|
macInfo = info
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
errorMessage = "Mac-Info konnte nicht gelesen werden: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkProfileStatus() {
|
||||||
|
Task {
|
||||||
|
checkingProfile = true
|
||||||
|
let installed = await MacProfileInstaller.isInstalled()
|
||||||
|
await MainActor.run {
|
||||||
|
profileInstalled = installed
|
||||||
|
checkingProfile = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleRegistration() {
|
||||||
|
Task {
|
||||||
|
isRegistering = true
|
||||||
|
errorMessage = nil
|
||||||
|
successMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await model.registerMac()
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
successMessage = "Mac erfolgreich registriert ✓"
|
||||||
|
isRegistering = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-trigger profile install
|
||||||
|
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s delay
|
||||||
|
await handleProfileInstall()
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
isRegistering = false
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleProfileInstall() {
|
||||||
|
guard let registration = model.magicRegistration else {
|
||||||
|
errorMessage = "Keine Registrierung vorhanden. Bitte zuerst registrieren."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
isInstallingProfile = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await MacProfileInstaller.downloadAndInstall(registration: registration)
|
||||||
|
|
||||||
|
// Re-check profile status
|
||||||
|
await checkProfileStatus()
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
isInstallingProfile = false
|
||||||
|
successMessage = "DNS-Filter-Profil installiert ✓"
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
isInstallingProfile = false
|
||||||
|
errorMessage = "Profil-Installation fehlgeschlagen: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
MacRegistrationView()
|
||||||
|
.environment(WizardModel())
|
||||||
|
.frame(width: 720, height: 600)
|
||||||
|
}
|
||||||
247
apps/rebreak-magic-mac/Sources/Views/ManageBindingsView.swift
Normal file
247
apps/rebreak-magic-mac/Sources/Views/ManageBindingsView.swift
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ManageBindingsView: View {
|
||||||
|
@State private var devices: [MagicDevice] = []
|
||||||
|
@State private var isLoading = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Header
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Gebundene Geräte verwalten")
|
||||||
|
.font(.title2.bold())
|
||||||
|
Text("Hier kannst du bestehende Magic-Bindings freigeben.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Schließen", action: onDismiss)
|
||||||
|
.keyboardShortcut(.escape)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(nsColor: .windowBackgroundColor))
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Content
|
||||||
|
if isLoading && devices.isEmpty {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Lade Geräte...")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if let error = errorMessage {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.font(.largeTitle)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Button("Erneut versuchen", action: loadDevices)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding()
|
||||||
|
} else if devices.isEmpty {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "laptopcomputer.slash")
|
||||||
|
.font(.largeTitle)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Keine gebundenen Geräte")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ForEach(devices) { device in
|
||||||
|
DeviceRow(device: device, onAction: handleDeviceAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 600, minHeight: 400)
|
||||||
|
.onAppear(perform: loadDevices)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadDevices() {
|
||||||
|
Task {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
devices = try await MagicAPIClient.shared.listDevices()
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleDeviceAction(_ action: DeviceAction, for device: MagicDevice) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
switch action {
|
||||||
|
case .requestRelease:
|
||||||
|
_ = try await MagicAPIClient.shared.requestRelease(deviceId: device.deviceId)
|
||||||
|
case .cancelRelease:
|
||||||
|
try await MagicAPIClient.shared.cancelRelease(deviceId: device.deviceId)
|
||||||
|
}
|
||||||
|
// Reload list
|
||||||
|
try await Task.sleep(for: .milliseconds(500))
|
||||||
|
devices = try await MagicAPIClient.shared.listDevices()
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DeviceAction {
|
||||||
|
case requestRelease
|
||||||
|
case cancelRelease
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DeviceRow: View {
|
||||||
|
let device: MagicDevice
|
||||||
|
let onAction: (DeviceAction, MagicDevice) -> Void
|
||||||
|
|
||||||
|
@State private var timeRemaining: String = ""
|
||||||
|
@State private var timer: Timer?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "laptopcomputer")
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(device.hostname)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
if let model = device.model {
|
||||||
|
Text(model)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if device.isReleasing {
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text("Freigabe läuft")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
|
||||||
|
if !timeRemaining.isEmpty {
|
||||||
|
Text(timeRemaining)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Aktiv")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.green.opacity(0.1))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Label(formatDate(device.enrolledDate), systemImage: "calendar")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
if let os = device.osVersion {
|
||||||
|
Label(os, systemImage: "info.circle")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if device.isReleasing {
|
||||||
|
Button("Freigabe abbrechen") {
|
||||||
|
onAction(.cancelRelease, device)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
.font(.caption)
|
||||||
|
} else {
|
||||||
|
Button("Freigabe anfordern") {
|
||||||
|
onAction(.requestRelease, device)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(nsColor: .controlBackgroundColor))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.onAppear {
|
||||||
|
updateTimeRemaining()
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
|
||||||
|
updateTimeRemaining()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
timer?.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatDate(_ date: Date?) -> String {
|
||||||
|
guard let date = date else { return "—" }
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .short
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateTimeRemaining() {
|
||||||
|
guard let releaseDate = device.releaseDate else {
|
||||||
|
timeRemaining = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining = releaseDate.timeIntervalSince(Date())
|
||||||
|
if remaining <= 0 {
|
||||||
|
timeRemaining = "Läuft ab..."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let hours = Int(remaining) / 3600
|
||||||
|
let minutes = (Int(remaining) % 3600) / 60
|
||||||
|
|
||||||
|
if hours > 0 {
|
||||||
|
timeRemaining = "noch \(hours)h \(minutes)m"
|
||||||
|
} else {
|
||||||
|
timeRemaining = "noch \(minutes)m"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ManageBindingsView {
|
||||||
|
print("Dismissed")
|
||||||
|
}
|
||||||
|
}
|
||||||
8
apps/rebreak-magic-mac/config.example.json
Normal file
8
apps/rebreak-magic-mac/config.example.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"supabaseUrl": "https://YOUR-PROJECT.supabase.co",
|
||||||
|
"supabaseAnonKey": "YOUR-ANON-KEY-HERE",
|
||||||
|
"backendBaseUrl": "https://staging.rebreak.org",
|
||||||
|
"mdmServer": "https://mdm.rebreak.org",
|
||||||
|
"mdmUser": "admin",
|
||||||
|
"mdmApiKey": "YOUR-MDM-API-KEY"
|
||||||
|
}
|
||||||
@ -1,6 +1,51 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to rebreak-native will be documented in this file.
|
All notable changes to rebreak-native will be documented in this file.
|
||||||
|
## v0.3.13 (Build 56 / versionCode 46) — 2026-06-01\n\n### Features
|
||||||
|
- DiGA milestone modal: at day 3, 7, 10 clean — celebratory bottom sheet with soft demographic data ask; milestone-specific emoji+color (orange/purple/gold); AsyncStorage tracks per-user/per-milestone shown state; auto-opens DemographicsAccordion in profile; never shows if demographics already filled; dismissed cleanly with "Vielleicht später"
|
||||||
|
- Lyra coach: gelegentlicher DiGA-Demografie-Hinweis kontextuell im Gespräch — nur bei positiven Momenten, max. einmal pro Session, sofortiges Akzeptieren bei Ablehnung, streng user-initiated (kein heimliches Extrahieren)
|
||||||
|
- Chat list: search second stage — typed query shows "Neue Unterhaltung" section with user search results below active conversations; debounced 300ms; only shows users not already in conversations; tap → opens DM immediately
|
||||||
|
- Chat list: last message shows "🎤 Sprachnachricht" / "📷 Foto" fallback when voice/image sent (was showing empty)
|
||||||
|
- Push notifications: voice messages send "🎤 Sprachnachricht", images "📷 Foto" in preview (was "📎 Anhang" for all)
|
||||||
|
- DM screen: voice notes (WhatsApp-style) — mic button when input is empty, tap to record, checkmark to send, trash to cancel; audio bubbles with circular play/pause button, position dot, deterministic waveform (played = accent color, unplayed = muted), duration counter, distinct bubble background from text messages
|
||||||
|
- Shared VoiceRecordingBar component (Coach + DM unified look): trash left, live waveform + timer center, send right; accent color and send icon configurable per context
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Blocker iOS Layer 3 (Screen Time Passcode): card now visible in locked-in state (was hidden once URL filter + App Lock both active — users could never reach it); screentime confirmed status loaded from backend on mount; guarded to unsupervised/VPN+FC path only (not MDM/NEFilter)
|
||||||
|
- Blocker iOS Layer 3: redesigned card with numbered step instructions (iOS has no deep link to passcode dialog — steps guide user: open ST → tap "Use Passcode" → enter code); URL fallback chain App-Prefs:SCREEN_TIME → App-Prefs:root=SCREEN_TIME → openSettings
|
||||||
|
- i18n: mic_access permission strings added for DE/EN/FR/AR; Layer 3 step strings added for all 4 languages\n
|
||||||
|
## v0.3.13 (Build 54 / versionCode 44) — 2026-06-01\n\n### Features
|
||||||
|
- DM screen: voice notes (WhatsApp-style) — mic button when input is empty, tap to record, checkmark to send, trash to cancel; audio bubbles with circular play/pause button, position dot, deterministic waveform (played = accent color, unplayed = muted), duration counter, distinct bubble background from text messages
|
||||||
|
- Shared VoiceRecordingBar component (Coach + DM unified look): trash left, live waveform + timer center, send right; accent color and send icon are configurable per context
|
||||||
|
- DM screen: info sheet (85% height, FormSheet) with shared media grid (3-col), partner profile link, image lightbox
|
||||||
|
- DM screen: avatar tap in header navigates to partner profile
|
||||||
|
- DM screen: info icon (ℹ) in header opens info sheet
|
||||||
|
- Coach: Instagram-style voice recording bar — trash (left) + waveform + timer (center) + send (right)
|
||||||
|
- Coach: silence/speech detection via audio metering — dots when silent, animated bars when speaking
|
||||||
|
- Coach: trash button flashes red briefly on cancel (Instagram-style)
|
||||||
|
- iOS Layer 3: Screen Time Passcode setup flow — generate code, set in iOS Settings, stored on backend
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Blocker iOS Layer 3 (Screen Time Passcode): card now visible in locked-in state (was hidden once URL filter + App Lock both active — users could never reach it); screentime confirmed status loaded from backend on mount; guarded to unsupervised/VPN+FC path only (not MDM/NEFilter)
|
||||||
|
- Android: Force Stop bypass blocked — Samsung SubSettings/FrameLayout class detection fixed in a11y tamper lock
|
||||||
|
- Android: Force Stop confirmation dialog now detected and blocked
|
||||||
|
- Android: a11y service label corrected to "ReBreak — Schutz" (HIGH_CONFIDENCE_KEYWORD match)
|
||||||
|
- Arabic STT: switched to Deepgram nova-3 (nova-2-general dropped Arabic support)
|
||||||
|
- DM: scroll-to-bottom now reliable via scrollToOffset(999999) on Android (scrollToEnd miscalculates content height)
|
||||||
|
- DM: voice recording timer uses Date.now() diff — eliminates Android setInterval jitter
|
||||||
|
- DM: voice bars fill full width via flex:1 + space-evenly
|
||||||
|
- DNS filter: own domains (rebreak.org, rebreak.app) bypass blocklist — fixes OAuth Google callback
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- Mail classifier v1.2: FS-token +20pts, extreme-percent (≥100%) +20pts, casino in sender name +30pts, block threshold lowered 50→40
|
||||||
|
- Screen Time Passcode API: POST/GET /api/protection/screentime-passcode
|
||||||
|
- mail_classification_samples row-cap cron: max 100k rows, daily pruning (prevents disk-full)
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
- CI/CD: race condition fixed — deploy lock prevents webhook + GH-Actions colliding
|
||||||
|
- CI/CD: health check retry loop (12×5s = 60s max) instead of single sleep 5
|
||||||
|
- Hetzner: 20GB block volume attached, Docker moved to /mnt/data (freed 14GB on root)
|
||||||
|
- Deepgram nova-2-general → nova-3 for all languages\n
|
||||||
## v0.3.13 (Build 50 / versionCode 40) — 2026-06-01\n\nlayer 3 for ios / fix a11y\n
|
## v0.3.13 (Build 50 / versionCode 40) — 2026-06-01\n\nlayer 3 for ios / fix a11y\n
|
||||||
## v0.3.13 (Build 46 / versionCode 36) — 2026-05-31\n\nDM-Chat: Die letzte Nachricht wird jetzt zuverlässig oberhalb der Eingabezeile angezeigt — kein manuelles Nachscrollen mehr beim Öffnen oder nach dem Senden.
|
## v0.3.13 (Build 46 / versionCode 36) — 2026-05-31\n\nDM-Chat: Die letzte Nachricht wird jetzt zuverlässig oberhalb der Eingabezeile angezeigt — kein manuelles Nachscrollen mehr beim Öffnen oder nach dem Senden.
|
||||||
|
|
||||||
|
|||||||
@ -1,29 +0,0 @@
|
|||||||
### Features
|
|
||||||
- DM screen: info sheet (85% height, FormSheet) with shared media grid (3-col), partner profile link, image lightbox
|
|
||||||
- DM screen: avatar tap in header navigates to partner profile
|
|
||||||
- DM screen: info icon (ℹ) in header opens info sheet
|
|
||||||
- Coach: Instagram-style voice recording bar — trash (left) + waveform + timer (center) + send (right)
|
|
||||||
- Coach: silence/speech detection via audio metering — dots when silent, animated bars when speaking
|
|
||||||
- Coach: trash button flashes red briefly on cancel (Instagram-style)
|
|
||||||
- iOS Layer 3: Screen Time Passcode setup flow — generate code, set in iOS Settings, stored on backend
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Android: Force Stop bypass blocked — Samsung SubSettings/FrameLayout class detection fixed in a11y tamper lock
|
|
||||||
- Android: Force Stop confirmation dialog now detected and blocked
|
|
||||||
- Android: a11y service label corrected to "ReBreak — Schutz" (HIGH_CONFIDENCE_KEYWORD match)
|
|
||||||
- Arabic STT: switched to Deepgram nova-3 (nova-2-general dropped Arabic support)
|
|
||||||
- DM: scroll-to-bottom now reliable via scrollToOffset(999999) on Android (scrollToEnd miscalculates content height)
|
|
||||||
- DM: voice recording timer uses Date.now() diff — eliminates Android setInterval jitter
|
|
||||||
- DM: voice bars fill full width via flex:1 + space-evenly
|
|
||||||
- DNS filter: own domains (rebreak.org, rebreak.app) bypass blocklist — fixes OAuth Google callback
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
- Mail classifier v1.2: FS-token +20pts, extreme-percent (≥100%) +20pts, casino in sender name +30pts, block threshold lowered 50→40
|
|
||||||
- Screen Time Passcode API: POST/GET /api/protection/screentime-passcode
|
|
||||||
- mail_classification_samples row-cap cron: max 100k rows, daily pruning (prevents disk-full)
|
|
||||||
|
|
||||||
### Infrastructure
|
|
||||||
- CI/CD: race condition fixed — deploy lock prevents webhook + GH-Actions colliding
|
|
||||||
- CI/CD: health check retry loop (12×5s = 60s max) instead of single sleep 5
|
|
||||||
- Hetzner: 20GB block volume attached, Docker moved to /mnt/data (freed 14GB on root)
|
|
||||||
- Deepgram nova-2-general → nova-3 for all languages
|
|
||||||
@ -3,5 +3,5 @@
|
|||||||
<string name="expo_splash_screen_resize_mode" translatable="false">cover</string>
|
<string name="expo_splash_screen_resize_mode" translatable="false">cover</string>
|
||||||
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
|
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
|
||||||
<string name="accessibility_service_description" translatable="false">Sichert deinen Schutz gegen impulsives Abschalten ab: Solange App-Lock aktiv ist, kann das ReBreak-VPN nicht in den Einstellungen deaktiviert und die App nicht deinstalliert werden. Das Blockieren von Glücksspielseiten selbst übernimmt das VPN — diese Berechtigung sichert es nur. Du kannst den Schutz jederzeit über die Abkühlphase in der App beenden.</string>
|
<string name="accessibility_service_description" translatable="false">Sichert deinen Schutz gegen impulsives Abschalten ab: Solange App-Lock aktiv ist, kann das ReBreak-VPN nicht in den Einstellungen deaktiviert und die App nicht deinstalliert werden. Das Blockieren von Glücksspielseiten selbst übernimmt das VPN — diese Berechtigung sichert es nur. Du kannst den Schutz jederzeit über die Abkühlphase in der App beenden.</string>
|
||||||
<string name="accessibility_service_summary" translatable="false">ReBreak — Schutz</string>
|
<string name="accessibility_service_summary" translatable="false">Sichert den Schutz gegen Abschalten ab</string>
|
||||||
</resources>
|
</resources>
|
||||||
@ -36,7 +36,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||||||
ios: {
|
ios: {
|
||||||
supportsTablet: true,
|
supportsTablet: true,
|
||||||
bundleIdentifier: MAIN_BUNDLE,
|
bundleIdentifier: MAIN_BUNDLE,
|
||||||
buildNumber: "50",
|
buildNumber: "58",
|
||||||
// Apple Sign-In Entitlement — Pflicht für expo-apple-authentication nativen
|
// Apple Sign-In Entitlement — Pflicht für expo-apple-authentication nativen
|
||||||
// signInAsync()-Flow. Ohne flag generiert Expo's prebuild den
|
// signInAsync()-Flow. Ohne flag generiert Expo's prebuild den
|
||||||
// com.apple.developer.applesignin-Entitlement nicht in die .entitlements.
|
// com.apple.developer.applesignin-Entitlement nicht in die .entitlements.
|
||||||
@ -59,7 +59,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||||||
|
|
||||||
android: {
|
android: {
|
||||||
package: "org.rebreak.app",
|
package: "org.rebreak.app",
|
||||||
versionCode: 40,
|
versionCode: 47,
|
||||||
adaptiveIcon: {
|
adaptiveIcon: {
|
||||||
// Foreground muss in der ~66%-Safe-Zone bleiben (Launcher-Mask clippt den
|
// Foreground muss in der ~66%-Safe-Zone bleiben (Launcher-Mask clippt den
|
||||||
// Außenring) → adaptive-foreground.png ist das Logo auf transparentem
|
// Außenring) → adaptive-foreground.png ist das Logo auf transparentem
|
||||||
|
|||||||
@ -389,6 +389,27 @@ ASC_API_KEY_PATH="${ASC_API_KEY_PATH:-}"
|
|||||||
ASC_API_KEY_ID="${ASC_API_KEY_ID:-}"
|
ASC_API_KEY_ID="${ASC_API_KEY_ID:-}"
|
||||||
ASC_API_KEY_ISSUER="${ASC_API_KEY_ISSUER:-}"
|
ASC_API_KEY_ISSUER="${ASC_API_KEY_ISSUER:-}"
|
||||||
|
|
||||||
|
# Stellt sicher dass ios/ oder android/ existiert — sonst Auto-Prebuild.
|
||||||
|
# Usage: ensure_native_dir ios | ensure_native_dir android
|
||||||
|
# Nutzt --platform <p> ohne --clean, damit der jeweils andere Ordner unangetastet bleibt.
|
||||||
|
ensure_native_dir() {
|
||||||
|
local platform="$1"
|
||||||
|
local target_dir
|
||||||
|
case "$platform" in
|
||||||
|
ios) target_dir="$IOS_DIR" ;;
|
||||||
|
android) target_dir="$ANDROID_DIR" ;;
|
||||||
|
*) die "ensure_native_dir: unbekannte Plattform '$platform'" ;;
|
||||||
|
esac
|
||||||
|
if [[ -d "$target_dir" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
warn "$platform/ fehlt — führe 'expo prebuild --platform $platform' automatisch aus"
|
||||||
|
run_quiet "expo prebuild ($platform)" "$LOG_DIR/prebuild-$platform-$TIMESTAMP.log" \
|
||||||
|
pnpm exec expo prebuild --platform "$platform" --no-install
|
||||||
|
[[ -d "$target_dir" ]] || die "$platform/ nach prebuild immer noch nicht vorhanden"
|
||||||
|
ok "$platform/ regeneriert"
|
||||||
|
}
|
||||||
|
|
||||||
# Build xcodebuild auth-args (ASC API-Key enables automatic cert/profile download)
|
# Build xcodebuild auth-args (ASC API-Key enables automatic cert/profile download)
|
||||||
xcodebuild_auth_args() {
|
xcodebuild_auth_args() {
|
||||||
if [[ -n "$ASC_API_KEY_PATH" && -n "$ASC_API_KEY_ID" && -n "$ASC_API_KEY_ISSUER" ]]; then
|
if [[ -n "$ASC_API_KEY_PATH" && -n "$ASC_API_KEY_ID" && -n "$ASC_API_KEY_ISSUER" ]]; then
|
||||||
@ -620,7 +641,7 @@ deploy_mdm() {
|
|||||||
command -v ssh >/dev/null 2>&1 || die "ssh nicht gefunden"
|
command -v ssh >/dev/null 2>&1 || die "ssh nicht gefunden"
|
||||||
command -v scp >/dev/null 2>&1 || die "scp nicht gefunden"
|
command -v scp >/dev/null 2>&1 || die "scp nicht gefunden"
|
||||||
[[ -f "$ADHOC_EXPORT_OPTIONS" ]] || die "ExportOptions nicht gefunden: $ADHOC_EXPORT_OPTIONS"
|
[[ -f "$ADHOC_EXPORT_OPTIONS" ]] || die "ExportOptions nicht gefunden: $ADHOC_EXPORT_OPTIONS"
|
||||||
[[ -d "$IOS_DIR" ]] || die "ios/ nicht gefunden — expo prebuild zuerst ausführen"
|
ensure_native_dir ios
|
||||||
require_asc_api_key
|
require_asc_api_key
|
||||||
|
|
||||||
log "Prüfe SSH-Verbindung zu $MDM_SERVER..."
|
log "Prüfe SSH-Verbindung zu $MDM_SERVER..."
|
||||||
@ -754,7 +775,7 @@ deploy_android() {
|
|||||||
section "Android Release"
|
section "Android Release"
|
||||||
|
|
||||||
# Preflight
|
# Preflight
|
||||||
[[ -d "$ANDROID_DIR" ]] || die "android/ nicht gefunden — expo prebuild zuerst ausführen"
|
ensure_native_dir android
|
||||||
|
|
||||||
local KEYSTORE_PROPS="$ANDROID_DIR/key.properties"
|
local KEYSTORE_PROPS="$ANDROID_DIR/key.properties"
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
import { resolveVipCountry } from './useWebContentDomains';
|
import { resolveVipCountry } from './useWebContentDomains';
|
||||||
|
import { useBlockerStatsStore } from '../stores/blockerStats';
|
||||||
|
|
||||||
export type DomainStatus = 'active' | 'submitted' | 'approved' | 'rejected';
|
export type DomainStatus = 'active' | 'submitted' | 'approved' | 'rejected';
|
||||||
|
|
||||||
@ -243,6 +244,9 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
|
|||||||
if (!tier.canSubmit) return { ok: false, error: 'plan_does_not_support_submit' };
|
if (!tier.canSubmit) return { ok: false, error: 'plan_does_not_support_submit' };
|
||||||
try {
|
try {
|
||||||
await apiFetch(`/api/custom-domains/${id}/submit`, { method: 'POST', body: {} });
|
await apiFetch(`/api/custom-domains/${id}/submit`, { method: 'POST', body: {} });
|
||||||
|
// Optimistisches lokales Update: Half-Donut im ProtectionDetailsSheet
|
||||||
|
// soll sofort die neue Freigabe zeigen, ohne 60s auf Stats-Refresh zu warten.
|
||||||
|
useBlockerStatsStore.getState().bumpMyInReview(1);
|
||||||
await fetchDomains();
|
await fetchDomains();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.3.13</string>
|
<string>0.3.13</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>50</string>
|
<string>58</string>
|
||||||
<key>NSExtension</key>
|
<key>NSExtension</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionPointIdentifier</key>
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.3.13</string>
|
<string>0.3.13</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>50</string>
|
<string>58</string>
|
||||||
<key>NSExtension</key>
|
<key>NSExtension</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionPointIdentifier</key>
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.3.13</string>
|
<string>0.3.13</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>50</string>
|
<string>58</string>
|
||||||
<key>EXAppExtensionAttributes</key>
|
<key>EXAppExtensionAttributes</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>EXExtensionPointIdentifier</key>
|
<key>EXExtensionPointIdentifier</key>
|
||||||
|
|||||||
@ -51,6 +51,11 @@ type BlockerStatsState = {
|
|||||||
fetchedAt: number | null;
|
fetchedAt: number | null;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
refreshIfStale: (maxAgeMs?: number) => Promise<void>;
|
refreshIfStale: (maxAgeMs?: number) => Promise<void>;
|
||||||
|
/** Optimistische lokale Erhöhung von mySubmissions.inReview — damit das Half-Donut
|
||||||
|
* im ProtectionDetailsSheet sofort die neue Freigabe zeigt, ohne auf den
|
||||||
|
* 60s-Cache-Refresh zu warten. Der nächste echte refresh() überschreibt den Wert
|
||||||
|
* ohnehin mit dem Server-State. */
|
||||||
|
bumpMyInReview: (delta?: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let inFlight: Promise<void> | null = null;
|
let inFlight: Promise<void> | null = null;
|
||||||
@ -136,4 +141,22 @@ export const useBlockerStatsStore = create<BlockerStatsState>((set, get) => ({
|
|||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
bumpMyInReview: (delta = 1) => {
|
||||||
|
const { stats } = get();
|
||||||
|
if (!stats) return;
|
||||||
|
set({
|
||||||
|
stats: {
|
||||||
|
...stats,
|
||||||
|
mySubmissions: {
|
||||||
|
...stats.mySubmissions,
|
||||||
|
inReview: Math.max(0, stats.mySubmissions.inReview + delta),
|
||||||
|
},
|
||||||
|
submissions: {
|
||||||
|
...stats.submissions,
|
||||||
|
inReview: Math.max(0, stats.submissions.inReview + delta),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -16,9 +16,22 @@ Validating IPA (App-Store Connect)|88
|
|||||||
Uploading zu App-Store Connect (TestFlight)|111
|
Uploading zu App-Store Connect (TestFlight)|111
|
||||||
Building Release AAB (gradlew bundleRelease)|275
|
Building Release AAB (gradlew bundleRelease)|275
|
||||||
Building Release AAB (gradlew bundleRelease)|110
|
Building Release AAB (gradlew bundleRelease)|110
|
||||||
Building xcarchive|253
|
|
||||||
Exporting Ad-Hoc IPA|22
|
|
||||||
Exporting App-Store IPA|26
|
|
||||||
Validating IPA (App-Store Connect)|104
|
Validating IPA (App-Store Connect)|104
|
||||||
Uploading zu App-Store Connect (TestFlight)|131
|
Uploading zu App-Store Connect (TestFlight)|131
|
||||||
Building Release AAB (gradlew bundleRelease)|453
|
Building Release AAB (gradlew bundleRelease)|453
|
||||||
|
expo prebuild (ios)|2
|
||||||
|
Validating IPA (App-Store Connect)|82
|
||||||
|
Uploading zu App-Store Connect (TestFlight)|120
|
||||||
|
Building Release AAB (gradlew bundleRelease)|319
|
||||||
|
Validating IPA (App-Store Connect)|90
|
||||||
|
Uploading zu App-Store Connect (TestFlight)|155
|
||||||
|
Building Release AAB (gradlew bundleRelease)|307
|
||||||
|
Validating IPA (App-Store Connect)|83
|
||||||
|
Uploading zu App-Store Connect (TestFlight)|103
|
||||||
|
Building Release AAB (gradlew bundleRelease)|370
|
||||||
|
Exporting App-Store IPA|25
|
||||||
|
Validating IPA (App-Store Connect)|115
|
||||||
|
Uploading zu App-Store Connect (TestFlight)|147
|
||||||
|
Building Release AAB (gradlew bundleRelease)|320
|
||||||
|
Building xcarchive|221
|
||||||
|
Exporting Ad-Hoc IPA|19
|
||||||
|
|||||||
60
backend/ENV_VARS.md
Normal file
60
backend/ENV_VARS.md
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# Backend Environment Variables
|
||||||
|
|
||||||
|
Dieses Dokument listet alle ENV-Variablen die das Rebreak-Backend benötigt.
|
||||||
|
Alle Secrets werden via **Infisical** injected. NIEMALS `.env`-Files committen.
|
||||||
|
|
||||||
|
## Core / Database
|
||||||
|
- `DATABASE_URL` — PostgreSQL Connection-String (Supabase self-hosted)
|
||||||
|
- `ENCRYPTION_KEY` — AES-256 Key für sensible DB-Fields (z.B. mdmDnsToken)
|
||||||
|
|
||||||
|
## Admin / Cron
|
||||||
|
- `ADMIN_SECRET` — Shared Secret für Admin-Endpoints
|
||||||
|
- `CRON_SECRET` — Auth-Header für Cron-Trigger-Endpoints
|
||||||
|
- `HANDSHAKE_SECRET` — AdGuard→Backend DoH-Handshake
|
||||||
|
|
||||||
|
## LLM-Provider
|
||||||
|
- `OPENROUTER_API_KEY` / `NUXT_OPENROUTER_API_KEY`
|
||||||
|
- `OPENAI_API_KEY` / `NUXT_OPENAI_API_KEY`
|
||||||
|
- `GROQ_API_KEY` / `NUXT_GROQ_API_KEY`
|
||||||
|
- `GOOGLE_AI_API_KEY`
|
||||||
|
- `GEMINI_API_KEY`
|
||||||
|
|
||||||
|
## TTS-Provider
|
||||||
|
- `GOOGLE_API_KEY` / `NUXT_GOOGLE_API_KEY`
|
||||||
|
- `DEEPGRAM_API_KEY` / `NUXT_DEEPGRAM_API_KEY`
|
||||||
|
- `AZURE_TTS_KEY`, `AZURE_TTS_REGION`
|
||||||
|
- `CARTESIA_API_KEY`, `CARTESIA_VOICE_ID`
|
||||||
|
- `ELEVENLABS_API_KEY`, `ELEVENLABS_VOICE_ID`
|
||||||
|
|
||||||
|
## Supabase (Server-only)
|
||||||
|
- `SUPABASE_URL` — Default: `https://db-staging.rebreak.org`
|
||||||
|
- `SUPABASE_KEY` / `SUPABASE_ANON_KEY`
|
||||||
|
- `SUPABASE_SERVICE_KEY` / `SUPABASE_SERVICE_ROLE_KEY`
|
||||||
|
|
||||||
|
## Stripe
|
||||||
|
- `STRIPE_SECRET_KEY`
|
||||||
|
- `STRIPE_WEBHOOK_SECRET`
|
||||||
|
- `STRIPE_PUBLISHABLE_KEY` (public)
|
||||||
|
|
||||||
|
## Email / External APIs
|
||||||
|
- `RESEND_API_KEY`
|
||||||
|
- `BREVO_API_KEY` — Brevo Transactional API
|
||||||
|
- `HOOK_SEND_EMAIL_SECRETS` — Comma-separated Webhook-Secrets (Standard-Webhooks Format)
|
||||||
|
- `MAIL_SENDER_EMAIL` — Default: `welcome@rebreak.org`
|
||||||
|
|
||||||
|
## **RebreakMagic DNS-over-HTTPS (NEU 2026-06-01)**
|
||||||
|
- `ADGUARD_BASE_URL` — Default: `https://dns.rebreak.org`
|
||||||
|
- `ADGUARD_USER` — Admin-User für AdGuard Home REST API
|
||||||
|
- `ADGUARD_PASSWORD` — Admin-Password für AdGuard Home REST API
|
||||||
|
|
||||||
|
## OAuth
|
||||||
|
- `MS_OAUTH_CLIENT_ID` — Microsoft Azure App-Registrierung (PKCE, Public Client)
|
||||||
|
- `GOOGLE_OAUTH_CLIENT_ID` — Google Cloud Console iOS-App (PKCE S256)
|
||||||
|
|
||||||
|
## Bot-User-IDs
|
||||||
|
- `LYRA_BOT_USER_ID` — DB-User-UUID für Lyra-Bot-Posts
|
||||||
|
- `REBREAK_BOT_USER_ID` — DB-User-UUID für Rebreak-System-Posts
|
||||||
|
|
||||||
|
## Public (Client-readable)
|
||||||
|
- `APP_URL` — Default: `https://staging.rebreak.org`
|
||||||
|
- `API_BASE` — Default: `https://staging.rebreak.org`
|
||||||
266
backend/MAGIC_API.md
Normal file
266
backend/MAGIC_API.md
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
# RebreakMagic Device-Binding — API Documentation
|
||||||
|
|
||||||
|
Backend-Implementation für DNS-basiertes Device-Binding via AdGuard Home DoH.
|
||||||
|
|
||||||
|
## Architektur-Überblick
|
||||||
|
|
||||||
|
```
|
||||||
|
RebreakMagic.app (Swift/macOS)
|
||||||
|
↓ POST /api/magic/register (JWT Auth)
|
||||||
|
↓
|
||||||
|
Backend (Nitro)
|
||||||
|
├─ DB: UserDevice (magicDnsToken, magicEnrolledAt, ...)
|
||||||
|
├─ AdGuard REST API: Create Persistent Client
|
||||||
|
└─ Response: { dnsToken, profileUrl }
|
||||||
|
↓
|
||||||
|
RebreakMagic.app → GET /api/magic/profile.mobileconfig?token=<dnsToken>
|
||||||
|
↓
|
||||||
|
macOS Configuration Profile (.mobileconfig)
|
||||||
|
↓ DNS-over-HTTPS: https://dns.rebreak.org/dns-query/{dnsToken}
|
||||||
|
↓
|
||||||
|
AdGuard Home (Hetzner) — Filtering + Logging per Client-ID
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### 1. `POST /api/magic/register`
|
||||||
|
Registriert Mac als Magic-Client, generiert DNS-Token, provisioniert AdGuard.
|
||||||
|
|
||||||
|
**Auth:** `Authorization: Bearer <jwt>`
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"deviceId": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"hostname": "Chahines MacBook Pro",
|
||||||
|
"model": "MacBookPro18,3",
|
||||||
|
"osVersion": "14.5"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (Success):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"deviceId": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"dnsToken": "QX7g9kL2mN4pR6tV8wY0zB3cD5fG7hJ9kM2nP4qS6uW8xZ0",
|
||||||
|
"profileUrl": "/api/magic/profile.mobileconfig?token=QX7g9kL2mN4pR6tV8wY0zB3cD5fG7hJ9kM2nP4qS6uW8xZ0",
|
||||||
|
"existing": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (Limit erreicht):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCode": 409,
|
||||||
|
"message": "Magic-Device-Limit erreicht (max 3)",
|
||||||
|
"data": {
|
||||||
|
"code": "limit_reached",
|
||||||
|
"activeBindings": [
|
||||||
|
{
|
||||||
|
"deviceId": "...",
|
||||||
|
"hostname": "Mac #1",
|
||||||
|
"model": "MacBookPro18,3",
|
||||||
|
"osVersion": "14.5",
|
||||||
|
"magicEnrolledAt": "2026-06-01T10:00:00.000Z",
|
||||||
|
"releaseRequestedAt": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**cURL:**
|
||||||
|
```bash
|
||||||
|
curl -X POST https://staging.rebreak.org/api/magic/register \
|
||||||
|
-H "Authorization: Bearer $JWT_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"deviceId": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"hostname": "Chahines MacBook Pro",
|
||||||
|
"model": "MacBookPro18,3",
|
||||||
|
"osVersion": "14.5"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. `GET /api/magic/devices`
|
||||||
|
Listet alle aktiven Magic-Bindings des Users.
|
||||||
|
|
||||||
|
**Auth:** `Authorization: Bearer <jwt>`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"deviceId": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"hostname": "Chahines MacBook Pro",
|
||||||
|
"model": "MacBookPro18,3",
|
||||||
|
"osVersion": "14.5",
|
||||||
|
"magicEnrolledAt": "2026-06-01T10:00:00.000Z",
|
||||||
|
"releaseRequestedAt": null,
|
||||||
|
"releaseAvailableAt": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**cURL:**
|
||||||
|
```bash
|
||||||
|
curl https://staging.rebreak.org/api/magic/devices \
|
||||||
|
-H "Authorization: Bearer $JWT_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. `POST /api/magic/devices/:deviceId/request-release`
|
||||||
|
Startet 24h Cooldown für Device-Freigabe.
|
||||||
|
|
||||||
|
**Auth:** `Authorization: Bearer <jwt>`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"releaseRequestedAt": "2026-06-01T10:00:00.000Z",
|
||||||
|
"releaseAvailableAt": "2026-06-02T10:00:00.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**cURL:**
|
||||||
|
```bash
|
||||||
|
curl -X POST https://staging.rebreak.org/api/magic/devices/550e8400-e29b-41d4-a716-446655440000/request-release \
|
||||||
|
-H "Authorization: Bearer $JWT_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. `POST /api/magic/devices/:deviceId/cancel-release`
|
||||||
|
Zieht Release-Request zurück.
|
||||||
|
|
||||||
|
**Auth:** `Authorization: Bearer <jwt>`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": { "ok": true }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**cURL:**
|
||||||
|
```bash
|
||||||
|
curl -X POST https://staging.rebreak.org/api/magic/devices/550e8400-e29b-41d4-a716-446655440000/cancel-release \
|
||||||
|
-H "Authorization: Bearer $JWT_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. `GET /api/magic/profile.mobileconfig?token=<dnsToken>`
|
||||||
|
Generiert personalisiertes macOS Configuration Profile.
|
||||||
|
|
||||||
|
**Auth:** KEINE (Token in Query-Parameter)
|
||||||
|
|
||||||
|
**Response-Headers:**
|
||||||
|
- `Content-Type: application/x-apple-aspen-config`
|
||||||
|
- `Content-Disposition: attachment; filename="RebreakMagic-<deviceId>.mobileconfig"`
|
||||||
|
|
||||||
|
**Response-Body:** XML-Plist (mobileconfig)
|
||||||
|
|
||||||
|
**cURL:**
|
||||||
|
```bash
|
||||||
|
curl "https://staging.rebreak.org/api/magic/profile.mobileconfig?token=QX7g9kL2mN4pR6tV8wY0zB3cD5fG7hJ9kM2nP4qS6uW8xZ0" \
|
||||||
|
-o RebreakMagic.mobileconfig
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DB-Schema
|
||||||
|
|
||||||
|
**UserDevice Model (Prisma Schema):**
|
||||||
|
```prisma
|
||||||
|
model UserDevice {
|
||||||
|
// ... existing fields ...
|
||||||
|
|
||||||
|
// RebreakMagic DNS-Device-Binding
|
||||||
|
magicDnsToken String? @unique @map("magic_dns_token")
|
||||||
|
magicEnrolledAt DateTime? @map("magic_enrolled_at")
|
||||||
|
magicRevokedAt DateTime? @map("magic_revoked_at")
|
||||||
|
magicHostname String? @map("magic_hostname")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Migration:**
|
||||||
|
```bash
|
||||||
|
# User führt aus (NICHT auto-deployen):
|
||||||
|
pnpm prisma migrate dev --name magic_binding_fields
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AdGuard-Integration
|
||||||
|
|
||||||
|
**API-Endpoint:** `https://dns.rebreak.org/control/clients/add`
|
||||||
|
|
||||||
|
**Auth:** Basic Auth (`ADGUARD_USER`, `ADGUARD_PASSWORD`)
|
||||||
|
|
||||||
|
**Payload:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "magic_<deviceId>",
|
||||||
|
"ids": ["<dnsToken>"],
|
||||||
|
"use_global_settings": false,
|
||||||
|
"filtering_enabled": true,
|
||||||
|
"parental_enabled": false,
|
||||||
|
"safebrowsing_enabled": true,
|
||||||
|
"blocked_services": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**DoH-URL-Format (embedded in mobileconfig):**
|
||||||
|
```
|
||||||
|
https://dns.rebreak.org/dns-query/<dnsToken>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cron-Worker
|
||||||
|
|
||||||
|
**Funktion:** `processMagicReleases()` in `server/utils/magicCron.ts`
|
||||||
|
|
||||||
|
**Logic:**
|
||||||
|
1. Findet alle UserDevice mit `releaseRequestedAt < NOW() - 24h` AND `magicRevokedAt IS NULL`
|
||||||
|
2. Für jedes Device:
|
||||||
|
- DELETE AdGuard Client (`/control/clients/delete`)
|
||||||
|
- Setze `magicRevokedAt = NOW()`
|
||||||
|
3. Return `{ processed, errors }`
|
||||||
|
|
||||||
|
**Deployment:** TODO — Nitro Scheduled Task oder externer Cron-Trigger
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ENV-Variablen
|
||||||
|
|
||||||
|
Siehe [ENV_VARS.md](../ENV_VARS.md#rebreakmagic-dns-over-https-neu-2026-06-01):
|
||||||
|
|
||||||
|
- `ADGUARD_BASE_URL` — Default: `https://dns.rebreak.org`
|
||||||
|
- `ADGUARD_USER` — Admin-User für AdGuard Home REST API
|
||||||
|
- `ADGUARD_PASSWORD` — Admin-Password
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TODOs (Phase 2)
|
||||||
|
|
||||||
|
- [ ] Profile-Signierung via Apple Developer Certificate (`/usr/bin/security cms -S`)
|
||||||
|
- [ ] Cron-Registration für `processMagicReleases()` (Nitro scheduled task oder externer Cron)
|
||||||
|
- [ ] Plan-basierte Limits (jetzt hardcoded `MAGIC_DEVICE_LIMIT = 3`)
|
||||||
|
- [ ] AdGuard Blocked-Services konfigurieren (Gambling-Filter via AdGuard-Blocklisten)
|
||||||
|
- [ ] Tests (Phase 2: `rebreak-tester`)
|
||||||
|
- [ ] Frontend-Integration (RN-UI + RebreakMagic.app)
|
||||||
@ -84,6 +84,14 @@ export default defineNitroConfig({
|
|||||||
// dynamisch in templates.ts gesetzt.
|
// dynamisch in templates.ts gesetzt.
|
||||||
mailSenderEmail: process.env.MAIL_SENDER_EMAIL ?? "welcome@rebreak.org",
|
mailSenderEmail: process.env.MAIL_SENDER_EMAIL ?? "welcome@rebreak.org",
|
||||||
|
|
||||||
|
// ─── AdGuard Home (RebreakMagic DNS-over-HTTPS) ──────────────────────
|
||||||
|
// Base-URL für AdGuard Home REST API. Default: dns.rebreak.org (Hetzner).
|
||||||
|
adguardBaseUrl: process.env.ADGUARD_BASE_URL ?? "https://dns.rebreak.org",
|
||||||
|
// Basic-Auth Credentials für /control/clients/* API-Endpoints.
|
||||||
|
// User + Password aus AdGuard-Settings → Users → Add User (Admin-Rechte).
|
||||||
|
adguardUser: process.env.ADGUARD_USER ?? "",
|
||||||
|
adguardPassword: process.env.ADGUARD_PASSWORD ?? "",
|
||||||
|
|
||||||
// ─── Microsoft OAuth (PKCE, Public Client) ───────────────────────────────
|
// ─── Microsoft OAuth (PKCE, Public Client) ───────────────────────────────
|
||||||
// Client-ID der Azure-App-Registrierung "Rebreak Mail Access".
|
// Client-ID der Azure-App-Registrierung "Rebreak Mail Access".
|
||||||
// Tenant: 'common' (Multi-Tenant + Personal-Accounts) — hardcoded im Code.
|
// Tenant: 'common' (Multi-Tenant + Personal-Accounts) — hardcoded im Code.
|
||||||
|
|||||||
@ -0,0 +1,17 @@
|
|||||||
|
-- Add RebreakMagic DNS-Device-Binding fields to UserDevice table
|
||||||
|
|
||||||
|
ALTER TABLE "rebreak"."user_devices"
|
||||||
|
ADD COLUMN IF NOT EXISTS "magic_dns_token" TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE "rebreak"."user_devices"
|
||||||
|
ADD COLUMN IF NOT EXISTS "magic_enrolled_at" TIMESTAMP(3);
|
||||||
|
|
||||||
|
ALTER TABLE "rebreak"."user_devices"
|
||||||
|
ADD COLUMN IF NOT EXISTS "magic_revoked_at" TIMESTAMP(3);
|
||||||
|
|
||||||
|
ALTER TABLE "rebreak"."user_devices"
|
||||||
|
ADD COLUMN IF NOT EXISTS "magic_hostname" TEXT;
|
||||||
|
|
||||||
|
-- Create unique index on magic_dns_token (NULL values are ignored in unique constraints)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "user_devices_magic_dns_token_key"
|
||||||
|
ON "rebreak"."user_devices"("magic_dns_token");
|
||||||
@ -1059,6 +1059,18 @@ model UserDevice {
|
|||||||
/// Letzte Mail-Notification bei fremdem Login-Versuch. Rate-Limit 6h.
|
/// Letzte Mail-Notification bei fremdem Login-Versuch. Rate-Limit 6h.
|
||||||
lockNotifiedAt DateTime? @map("lock_notified_at")
|
lockNotifiedAt DateTime? @map("lock_notified_at")
|
||||||
|
|
||||||
|
// ─── RebreakMagic DNS-Device-Binding ────────────────────────────────────
|
||||||
|
/// 48+ char URL-safe random token für DNS-over-HTTPS Client-ID.
|
||||||
|
/// NULL → Device nicht als Magic-Client gebunden. Unique → pro Token nur 1 Device.
|
||||||
|
magicDnsToken String? @unique @map("magic_dns_token")
|
||||||
|
/// Wann Magic-Binding aktiviert wurde (Config-Profil installiert).
|
||||||
|
magicEnrolledAt DateTime? @map("magic_enrolled_at")
|
||||||
|
/// Killswitch: wenn gesetzt → DNS-Token serverseitig invalidiert (AdGuard-Client deleted).
|
||||||
|
/// User kann Config-Profil manuell deinstallieren, aber DNS-Queries werden abgelehnt.
|
||||||
|
magicRevokedAt DateTime? @map("magic_revoked_at")
|
||||||
|
/// Mac-Hostname für UI (z.B. "Chahines MacBook Pro"). Nur bei Magic-Devices.
|
||||||
|
magicHostname String? @map("magic_hostname")
|
||||||
|
|
||||||
@@unique([userId, deviceId])
|
@@unique([userId, deviceId])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([deviceId])
|
@@index([deviceId])
|
||||||
|
|||||||
@ -9,8 +9,11 @@ Du bist einfühlsam, stärkend und verwendest Techniken der kognitiven Verhalten
|
|||||||
ANTWORTFORMAT – KRITISCH:
|
ANTWORTFORMAT – KRITISCH:
|
||||||
NIE Markdown verwenden. Kein **bold**, kein _italic_, keine #-Headings, keine -Bullet-Lists. Schreib Klartext mit normalen Sätzen + Punkten. Markdown verwirrt User in der Mobile-App.
|
NIE Markdown verwenden. Kein **bold**, kein _italic_, keine #-Headings, keine -Bullet-Lists. Schreib Klartext mit normalen Sätzen + Punkten. Markdown verwirrt User in der Mobile-App.
|
||||||
|
|
||||||
SOS-MODE LOCK — GRÜNDER-STORY & PRICING VERBOTEN:
|
SOS-MODE LOCK — GRÜNDER-STORY, PRICING & REBREAKMAGIC VERBOTEN:
|
||||||
In diesem SOS-Mode NIEMALS die Gründer-Story erwähnen oder andeuten. ZUSÄTZLICH: NIEMALS Preise, Tier-Vergleiche, Upgrades, Trial-Hinweise, Plan-Details oder Stripe-Checkout erwähnen — auch nicht passiv, auch nicht wenn User direkt fragt. Kurz parken („das schauen wir uns nachher in Ruhe an, jetzt bist du wichtiger") und voller Fokus auf User-Krise. Re-Trigger-Risiko + Sales-Energie sind im Crisis-Moment beide tabu.
|
In diesem SOS-Mode NIEMALS die Gründer-Story erwähnen oder andeuten. ZUSÄTZLICH: NIEMALS Preise, Tier-Vergleiche, Upgrades, Trial-Hinweise, Plan-Details oder Stripe-Checkout erwähnen. ZUSÄTZLICH: NIEMALS RebreakMagic oder den Lock-Modus proaktiv erwähnen, empfehlen oder erklären — RebreakMagic ist Prävention, nicht Krise. Wenn der User im SOS-Mode direkt nach RebreakMagic / Lock-Modus / „stärkerem Schutz" fragt: kurz parken („das schauen wir uns nachher in Ruhe an, jetzt bist du wichtiger") und voller Fokus auf User-Krise. Alle drei (Sales-Energie, Gründer-Story, Setup-Erklärungen) sind im Crisis-Moment tabu.
|
||||||
|
|
||||||
|
FORBIDDEN-VOKABULAR — STRIKT (gegenüber User, intern OK):
|
||||||
|
NIEMALS diese Begriffe gegenüber dem User verwenden: „MDM", „supervised", „Supervise", „Supervise-Mode", „NEFilter", „Configuration Profile", „Profile-Payload", „DNS-Profil", „dns.rebreak.org", „dnsToken", „AdGuard", „DoH", „Apple Configurator", „cfgutil", „libimobiledevice", „NanoMDM", „Werks-Reset", „Factory-Reset", „Recovery-Mode", „iPhone-Recovery". Wenn der User diese Begriffe SELBST benutzt: in deiner Antwort sanft auf Klartext-Sprache wechseln („Lock-Modus", „RebreakMagic", „Vertrauensperson", „24-Stunden-Wartezeit"). Niemals technische Tiefe gegenüber dem User — auch nicht wie das Schutz-Routing technisch funktioniert.
|
||||||
|
|
||||||
SPRACHE & HALTUNG – ABSOLUT KRITISCH:
|
SPRACHE & HALTUNG – ABSOLUT KRITISCH:
|
||||||
- Verwende NIEMALS die Begriffe "Sucht", "Spielsucht", "Abhängigkeit", "Suchtkranker", "süchtig" oder ähnliche Pathologisierungen.
|
- Verwende NIEMALS die Begriffe "Sucht", "Spielsucht", "Abhängigkeit", "Suchtkranker", "süchtig" oder ähnliche Pathologisierungen.
|
||||||
@ -90,37 +93,28 @@ iOS (iPhone & iPad):
|
|||||||
- So erklärst du es einem User in einfachen Worten: "Dein iPhone hat zwei Schutzschichten. Die erste blockt Hunderttausende bekannter Glücksspielseiten, bevor sie laden. Die zweite ist eine kuratierte Liste der wichtigsten Seiten deines Landes — als Auffangnetz, falls die erste mal hakt. Deine eigenen Trigger-Seiten laufen separat in der ersten Schicht. So bist du doppelt abgesichert." Keine technischen Fachbegriffe gegenüber dem User — sprich von "zwei Schutzschichten", "deinem Land" und "Auffangnetz".
|
- So erklärst du es einem User in einfachen Worten: "Dein iPhone hat zwei Schutzschichten. Die erste blockt Hunderttausende bekannter Glücksspielseiten, bevor sie laden. Die zweite ist eine kuratierte Liste der wichtigsten Seiten deines Landes — als Auffangnetz, falls die erste mal hakt. Deine eigenen Trigger-Seiten laufen separat in der ersten Schicht. So bist du doppelt abgesichert." Keine technischen Fachbegriffe gegenüber dem User — sprich von "zwei Schutzschichten", "deinem Land" und "Auffangnetz".
|
||||||
- Technische Randbemerkung (falls User fragt): Apple lässt Apps aus Datenschutzgründen nicht auf den nutzergesetzten Gerätenamen (z.B. "Chahines iPhone") zugreifen – das ist eine bewusste Apple-Entscheidung, keine ReBreak-Einschränkung.
|
- Technische Randbemerkung (falls User fragt): Apple lässt Apps aus Datenschutzgründen nicht auf den nutzergesetzten Gerätenamen (z.B. "Chahines iPhone") zugreifen – das ist eine bewusste Apple-Entscheidung, keine ReBreak-Einschränkung.
|
||||||
|
|
||||||
iOS — Selbstbindungs-Schutz / Lock-Modus (optional, stärkster Modus):
|
iOS — Selbstbindungs-Schutz / Lock-Modus via RebreakMagic (optional, stärkster Modus):
|
||||||
- Auf Anfrage kann ein User sein iPhone in einen "Lock-Modus" stellen: der Schutz (App + Filter) lässt sich dann nicht mehr aus den iOS-Einstellungen entfernen. Das ist die stärkste Selbstbindungsstufe — gedacht für Menschen, die wissen dass sie sich im Impulsmoment selbst überlisten würden, wenn der Schutz mit drei Taps deaktivierbar wäre.
|
- Auf Wunsch kann ein User sein iPhone (oder iPad/Mac) in einen „Lock-Modus" stellen: der Schutz lässt sich dann nicht mehr einfach in den iOS-Einstellungen abschalten. Das ist die stärkste Selbstbindungsstufe — gedacht für Menschen, die wissen dass sie sich im Impulsmoment selbst überlisten würden.
|
||||||
- Wie es eingerichtet wird (aktueller, empfohlener Weg): Es gibt eine kleine Mac-Begleit-App, den RebreakMagic. iPhone per USB-Kabel an den Mac, RebreakMagic öffnen, ein Klick — etwa 2 Minuten und der Lock läuft. Kein Werks-Reset, kein Datenverlust, keine zusätzliche Apple-Software nötig. Danach läuft alles autonom am iPhone, auch wenn die App aus dem App-Switcher gewischt wird.
|
- Eingerichtet wird das mit der **RebreakMagic-App für Mac** (Download auf rebreak.org/download/rebreakmagic). iPhone per USB-Kabel an den Mac, in RebreakMagic mit ReBreak-Email+Passwort einloggen, Klick — etwa 2 Minuten und der Lock läuft. Keine Apple-Zusatzsoftware nötig, keine Daten gehen verloren. Danach läuft alles autonom am iPhone.
|
||||||
- Wenn ein User wissen will WIE man das einrichtet: erwähne den RebreakMagic als 2-Minuten-Setup am Mac via USB-Kabel und verweise sanft auf die In-App-Hilfe ("In den Einstellungen unter Schutz → Selbstbindung findest du die genaue Schritt-für-Schritt-Anleitung — die ist präziser als wir das hier im Chat machen können"). Liste die Setup-Schritte NICHT selbst auf.
|
- **Bis zu 3 Geräte** pro Account können so geschützt werden (iPhone/iPad/Mac frei mischbar). Ein ReBreak-Account ist Pflicht — die Mac-App fragt vor jedem Setup nach Email+Passwort.
|
||||||
- Wenn ein User keinen Mac hat: empathisch — "aktuell brauchst du einmalig jemand mit Mac in der Familie oder im Freundeskreis. Wir arbeiten dran, dass das später auch ohne Mac geht." Kein Mac-Shaming. Erinnere, dass der normale Schutz (URL-Filter + VIP-Liste) auch ohne Lock schon stark ist — der Lock ist die optionale Extra-Härtung für den schwächsten Moment.
|
- **Im SOS-Mode erklärst du das NICHT proaktiv.** Wenn der User in der akuten Krise direkt fragt: kurz parken („dazu zeig ich dir alles im Coach-Tab oder in der App-Hilfe — jetzt erstmal du") und auf Atem/Trustee/Erdung fokussieren.
|
||||||
- Wenn ein User wissen will WIE man den Lock wieder löst: ruhig und ehrlich erklären — im Lock-Modus geht das nicht über die normalen iOS-Einstellungen. Es gibt drei Wege: die Vertrauensperson (Trustee) kann entsperren, oder der User schließt das iPhone noch einmal per USB-Kabel am Mac an und löst die Bindung im RebreakMagic, oder — als allerletzter Notausweg — Werks-Reset des iPhones. Das ist gewollt: der Schutz soll genau in dem Moment standhalten, in dem der Impuls ihn loswerden will. Validiere die Frustration zuerst, dann erkläre die Architektur-Entscheidung — der User ist der AUTOR dieser Entscheidung, nicht das Opfer.
|
- Sprache gegenüber dem User (wenn du überhaupt antwortest): „Lock-Modus", „Selbstbindungs-Schutz", „der stärkste Modus", „Vertrauensperson", „RebreakMagic", „Mac-Begleit-App", „24-Stunden-Wartezeit". NIEMALS technische Begriffe wie „MDM", „supervised", „Configuration Profile", „NEFilter", „Apple Configurator", „Werks-Reset", „Recovery-Mode".
|
||||||
- Sprache gegenüber dem User: "Lock-Modus", "Selbstbindungs-Schutz", "der stärkste Modus", "Vertrauensperson", "RebreakMagic", "Mac-Begleit-App". NIEMALS technische Begriffe wie "MDM", "supervised", "Configuration Profile", "NEFilter", "Apple Configurator" — außer der User benutzt sie selbst.
|
|
||||||
|
|
||||||
WENN USER FRAGT OB SEIN LOCK-MODUS AKTIV IST (Selbst-Check):
|
WENN USER FRAGT OB SEIN LOCK-MODUS AKTIV IST (Selbst-Check):
|
||||||
Es gibt EINEN klaren iPhone-Check, den der User selbst machen kann — den nennst du ihm direkt, ohne Drumherum:
|
Im SOS-Mode beantwortest du diese Frage knapp und ohne RebreakMagic-Setup-Erklärung: „Am einfachsten siehst du das in der RebreakMagic-App auf deinem Mac — dort stehen alle aktiven Geräte. Aber jetzt ist nicht der Moment dafür, lass uns erst durch den Drang durch.“ Nicht ausschmücken, sofort zurück zur Krise.
|
||||||
"Geh auf Einstellungen → Allgemein → Info. Wenn ganz oben steht 'Dieses iPhone wird betreut und von Rebreak GmbH verwaltet' — dann läuft der Lock-Modus. Steht da nichts davon, bist du im normalen Schutz-Modus (also der Schutz über den geräteweiten Filter, ohne Selbstbindungs-Lock)."
|
|
||||||
Das ist die EINE klare Antwort. Nicht ausschmücken, nicht philosophisch werden — der User will einfach wissen, in welchem Modus er ist.
|
|
||||||
|
|
||||||
WENN USER "REBREAK GMBH" UND "RAYNIS GMBH" VERWECHSELT ODER SICH WUNDERT:
|
WENN USER "REBREAK GMBH" UND "RAYNIS GMBH" VERWECHSELT ODER SICH WUNDERT:
|
||||||
Manchmal sieht der User in den iPhone-Einstellungen "Rebreak GmbH" als verwaltende Firma, in Profil-Details aber "Raynis GmbH" — und denkt, etwas stimmt nicht. Klärung:
|
Manchmal sieht der User in den iPhone-Einstellungen "Rebreak GmbH" als verwaltende Firma, in Profil-Details aber "Raynis GmbH" — und denkt, etwas stimmt nicht. Klärung:
|
||||||
"Rebreak GmbH und Raynis GmbH sind dasselbe Team — Raynis ist die Mutterfirma, ReBreak ist die App. Wenn du irgendwo den einen oder anderen Namen siehst, ist beides legitim und gehört zusammen."
|
"Rebreak GmbH und Raynis GmbH sind dasselbe Team — Raynis ist die Mutterfirma, ReBreak ist die App. Wenn du irgendwo den einen oder anderen Namen siehst, ist beides legitim und gehört zusammen."
|
||||||
Kurz, beruhigend, kein Drama.
|
Kurz, beruhigend, kein Drama.
|
||||||
|
|
||||||
WENN USER NACH "MDM" FRAGT (er benutzt das Wort selbst):
|
WENN USER NACH „MDM" FRAGT (er benutzt das Wort selbst):
|
||||||
Häufiges Missverständnis: User denkt MDM sei nur was für Firmen. Korrigiere sanft:
|
Im SOS-Mode bleibst du kurz und sanft — keine technische Belehrung jetzt: „Das ist eine gute Frage — die schauen wir uns nachher in Ruhe an. Jetzt erstmal du: wie fühlt sich der Impuls gerade an?“ Antworte nicht inhaltlich auf den MDM-Begriff im SOS-Mode — vermeide das Wort, wechsle auf „Lock-Modus" wenn du überhaupt antwortest. Detaillierte Erklärung gehört in den Coach-Mode.
|
||||||
"MDM gibt's in zwei Kontexten: Erstens das klassische Firmen-MDM — dein Arbeitgeber installiert's auf dem Diensthandy. Zweitens 'Self-Bind' — du installierst es FREIWILLIG auf deinem eigenen iPhone, damit dein Schutz besonders fest verankert ist und sich nicht im Impulsmoment mit drei Taps abschalten lässt. Im ReBreak-Kontext ist es IMMER Self-Bind — niemand zwingt dich, du wählst es selbst. Genau das ist der ReBreak-Lock-Modus."
|
|
||||||
Wenn der User das Wort "MDM" NICHT benutzt hat, antworte weiterhin in der User-Sprache ("Lock-Modus", "Selbstbindungs-Schutz") und vermeide den Begriff.
|
|
||||||
|
|
||||||
WENN USER DEN LOCK-MODUS AKTIVIEREN WILL:
|
WENN USER DEN LOCK-MODUS AKTIVIEREN WILL:
|
||||||
"Den Lock-Modus richtest du mit unserer kleinen Mac-Begleit-App ein, dem RebreakMagic. iPhone per USB-Kabel an den Mac, RebreakMagic öffnen, ein Klick — etwa 2 Minuten und der Lock läuft. Kein Werks-Reset, kein Datenverlust, keine zusätzliche Apple-Software nötig. Die genaue Schritt-für-Schritt-Anleitung findest du in der App unter Schutz → Selbstbindung — die ist präziser als alles was ich dir hier im Chat erklären könnte. Magst du da reingehen, oder hast du noch eine Frage offen?"
|
Im SOS-Mode: KEINE Setup-Erklärung jetzt. Sanft parken: „Mega dass du das willst — das richten wir gleich gemeinsam ein, sobald du wieder durchatmest. Jetzt erstmal: lass uns kurz durch diesen Moment kommen. Magst du eine Atemübung, oder lieber jemand anrufen?“ Der RebreakMagic-Setup-Flow (Mac + USB + 2 Minuten, 3-Geräte-Limit, 24h-Cooldown beim Lösen) wird im Coach-Mode oder in der In-App-Hilfe besprochen — nicht hier.
|
||||||
|
|
||||||
Wichtig — liste die Setup-Schritte NICHT selbst im Chat auf. Der RebreakMagic + die In-App-Hilfe sind die Anlaufstelle. Du darfst grob beschreiben dass es ein 2-Minuten-Setup am Mac via USB ist und dass alles autonom am iPhone weiterläuft, sobald der Klick durch ist.
|
Wenn der User fragt wie er den Lock wieder LÖSEN kann (im SOS): nicht philosophisch werden, kein Werks-Reset erwähnen. Knapp: „Das geht über die RebreakMagic-App auf deinem Mac mit 24 Stunden Wartezeit — genau damit der Schutz dem Impuls standhält, der ihn loswerden will. Jetzt ist der Impuls da. Lass uns erstmal durch.“ Dann sofort zurück zur Krise: Atem, Trustee, Erdung.
|
||||||
|
|
||||||
Wenn der User keinen Mac hat: validiere kurz ("verstehe, das ist gerade noch eine Hürde") und erinnere, dass er auch ohne Lock-Modus durch URL-Filter + VIP-Liste bereits stark geschützt ist. Frag freundlich ob jemand in Familie/Freundeskreis kurz mit seinem Mac aushelfen könnte — wir arbeiten dran, dass das später auch ohne Mac geht.
|
|
||||||
|
|
||||||
Wenn der User fragt wie er den Lock wieder LÖSEN kann: drei Wege — die Vertrauensperson (Trustee) kann entsperren, oder das iPhone noch einmal mit dem RebreakMagic am Mac anschließen und die Bindung dort lösen, oder — als allerletzter Notausweg — Werks-Reset des iPhones. Validiere die Frustration zuerst.
|
|
||||||
|
|
||||||
Android:
|
Android:
|
||||||
- ReBreak arbeitet mit zwei Schutz-Schichten (beide müssen aktiviert sein):
|
- ReBreak arbeitet mit zwei Schutz-Schichten (beide müssen aktiviert sein):
|
||||||
@ -254,10 +248,15 @@ FEATURES (organisch erwähnen, nur wenn passt):
|
|||||||
- Gambling-Blocker: blockt Hunderttausende bekannter Glücksspielseiten, system-tief auf iOS, Android via VPN, 6h Cooldown
|
- Gambling-Blocker: blockt Hunderttausende bekannter Glücksspielseiten, system-tief auf iOS, Android via VPN, 6h Cooldown
|
||||||
- iOS-Schutz = zwei Schutzschichten: Schicht 1 ist der "URL-Filter" — blockt rund 330.000 bekannte Glücksspielseiten, bevor sie laden (der Hauptschutz im Alltag). Schicht 2 ist die "VIP-Liste" — eine vom ReBreak-Team kuratierte Liste der wichtigsten Glücksspielseiten je Land (bis zu 30 pro Land), die als Auffangnetz greift wenn Schicht 1 mal hakt. Die Liste switcht automatisch, wenn der User reist. WICHTIG: Die VIP-Liste ist nicht mehr vom User pflegbar — die eigenen Trigger-Seiten laufen separat in Schicht 1 als "Custom-Domains". Wenn ein User fragt ob er wirklich geschützt ist: beruhig ihn warm — "falls die eine Schicht mal hakt, fängt die andere auf, du bist doppelt abgesichert". Keine Fachbegriffe, sprich von "zwei Schutzschichten", "deinem Land" und "Auffangnetz".
|
- iOS-Schutz = zwei Schutzschichten: Schicht 1 ist der "URL-Filter" — blockt rund 330.000 bekannte Glücksspielseiten, bevor sie laden (der Hauptschutz im Alltag). Schicht 2 ist die "VIP-Liste" — eine vom ReBreak-Team kuratierte Liste der wichtigsten Glücksspielseiten je Land (bis zu 30 pro Land), die als Auffangnetz greift wenn Schicht 1 mal hakt. Die Liste switcht automatisch, wenn der User reist. WICHTIG: Die VIP-Liste ist nicht mehr vom User pflegbar — die eigenen Trigger-Seiten laufen separat in Schicht 1 als "Custom-Domains". Wenn ein User fragt ob er wirklich geschützt ist: beruhig ihn warm — "falls die eine Schicht mal hakt, fängt die andere auf, du bist doppelt abgesichert". Keine Fachbegriffe, sprich von "zwei Schutzschichten", "deinem Land" und "Auffangnetz".
|
||||||
- Custom-Domains: Der User kann eigene Trigger-Seiten hinzufügen (Pro: 10 Slots, Legend: 20 Slots, refillable, web+mail gemeinsam). Einmal drin, kann er sie nicht selbst löschen — bewusst so, als Halt gegen den eigenen Impuls; nur das ReBreak-Team kann eine entfernen. Bei "Limit voll" → erklären: vorhandene Domain zur globalen Aufnahme vorschlagen, Slot wird nach Admin-Decision frei. KEIN "Swap"-Mechanismus mehr in der VIP-Liste (gibt's seit dem Country-Pivot nicht mehr).
|
- Custom-Domains: Der User kann eigene Trigger-Seiten hinzufügen (Pro: 10 Slots, Legend: 20 Slots, refillable, web+mail gemeinsam). Einmal drin, kann er sie nicht selbst löschen — bewusst so, als Halt gegen den eigenen Impuls; nur das ReBreak-Team kann eine entfernen. Bei "Limit voll" → erklären: vorhandene Domain zur globalen Aufnahme vorschlagen, Slot wird nach Admin-Decision frei. KEIN "Swap"-Mechanismus mehr in der VIP-Liste (gibt's seit dem Country-Pivot nicht mehr).
|
||||||
- Lock-Modus (Selbstbindungs-Schutz, optional, stärkster Modus): Auf Anfrage kann ein User sein iPhone so einrichten, dass App + Filter nicht mehr aus den iOS-Einstellungen entfernbar sind — gedacht für Menschen, die wissen dass sie sich im Impulsmoment selbst überlisten würden. Wenn User "wie installiere ich das?": erklär kurz dass das mit unserer Mac-Begleit-App RebreakMagic geht — iPhone per USB-Kabel an den Mac, RebreakMagic öffnen, ein Klick, ca. 2 Minuten und der Lock läuft. Kein Werks-Reset, kein Datenverlust, keine zusätzliche Apple-Software nötig. Für die exakten Schritte verweise auf die In-App-Hilfe unter Schutz → Selbstbindung (präziser als hier im Chat) — liste die Schritte NICHT selbst auf. Wenn User keinen Mac hat: empathisch — "aktuell brauchst du einmalig jemand mit Mac in der Familie oder im Freundeskreis; wir arbeiten dran, dass das später auch ohne Mac geht". Kein Mac-Shaming; erinnere dass der normale Schutz (URL-Filter + VIP-Liste) auch ohne Lock schon stark ist. Wenn User "wie deaktiviere ich das wieder?": drei Wege — die Vertrauensperson (Trustee) entsperrt, oder der User schließt das iPhone noch einmal per USB-Kabel mit dem RebreakMagic am Mac an und löst die Bindung dort, oder — letzter Notausweg — Werks-Reset des iPhones. Das ist gewollt: der Schutz soll genau dem Impuls standhalten, der ihn loswerden will. Sprache: "Lock-Modus", "Selbstbindung", "RebreakMagic", "Mac-Begleit-App", "Vertrauensperson" — NIEMALS "MDM", "supervised", "NEFilter", "Configuration Profile", "Apple Configurator", "cfgutil" (außer User benutzt diese Wörter selbst).
|
- Lock-Modus via RebreakMagic (Selbstbindungs-Schutz, optional, stärkster Modus): Auf Wunsch kann ein User bis zu 3 Geräte (iPhone/iPad/Mac frei mischbar) pro ReBreak-Account so einrichten, dass der Schutz nicht mehr einfach in den iOS-Einstellungen abschaltbar ist. Eingerichtet wird das mit der RebreakMagic-App für Mac — Download auf rebreak.org/download/rebreakmagic, Anmeldung mit eigener ReBreak-Email + Passwort (Account-Pflicht), iPhone per USB-Kabel an den Mac, ein Klick, ~2 Minuten und der Lock läuft. Keine Apple-Zusatzsoftware, keine Datenmigration, kein Hardware-Reset, kein Datenverlust. Für die exakten Schritte verweise auf rebreak.org/download/rebreakmagic oder die In-App-Hilfe — nicht selbst auflisten. Wenn User keinen Mac hat: empathisch — "aktuell brauchst du einmalig jemand mit Mac in Familie/Freundeskreis; wir arbeiten dran, dass das später auch ohne Mac geht". Kein Mac-Shaming. Wenn User "wie löse ich das wieder?": ruhig erklären — in der RebreakMagic-App auf 'Gerät entfernen' klicken; das startet einen 24-Stunden-Countdown, in dem der Schutz weiter aktiv bleibt und der Geräte-Slot belegt bleibt. Während der 24 Stunden kann der Cooldown jederzeit abgebrochen werden. Erst nach Ablauf wird der Schutz wirklich gelöst. "Genau das ist der Punkt — der Schutz hält genau dem Impuls stand, der ihn loswerden will." Erwähne NIEMALS "Werks-Reset", "Factory-Reset", "Recovery-Mode" als Lösungsweg — das gehört zum alten Design. Sprache: "Lock-Modus", "Selbstbindung", "RebreakMagic", "Mac-Begleit-App", "24-Stunden-Wartezeit", "Vertrauensperson" — NIEMALS "MDM", "supervised", "Supervise", "Supervise-Mode", "NEFilter", "Configuration Profile", "Profile-Payload", "DNS-Profil", "dns.rebreak.org", "Apple Configurator", "cfgutil", "libimobiledevice", "NanoMDM" (außer User benutzt diese Wörter selbst).
|
||||||
- Self-Check Lock-Modus aktiv? Wenn ein User fragt, ob sein Lock-Modus läuft, gib ihm die EINE klare Antwort: "Geh auf Einstellungen → Allgemein → Info. Wenn da oben steht 'Dieses iPhone wird betreut und von Rebreak GmbH verwaltet' — dann läuft der Lock-Modus. Sonst bist du im normalen Schutz-Modus." Nicht ausschmücken.
|
|
||||||
- Wenn User sich wundert, dass an einer Stelle "Rebreak GmbH" und an anderer "Raynis GmbH" steht: kurz beruhigen — "Rebreak GmbH und Raynis GmbH sind dasselbe Team, Raynis ist die Mutterfirma hinter der ReBreak-App. Beides ist legitim."
|
WANN DU REBREAKMAGIC AKTIV EMPFIEHLST (Coach-Mode-Disziplin):
|
||||||
- Wenn User selbst nach "MDM" fragt und denkt, das sei nur was für Firmen: sanft korrigieren — "MDM gibt's in zwei Kontexten: klassisches Firmen-MDM (Arbeitgeber installiert's aufs Diensthandy) und 'Self-Bind' (du installierst es freiwillig auf deinem eigenen iPhone, damit der Schutz besonders fest verankert ist). Bei ReBreak ist es IMMER Self-Bind — niemand zwingt dich, du wählst es selbst. Das ist der ReBreak-Lock-Modus."
|
- Wenn User direkt nach "stärkerem Schutz" / "App nicht mehr deinstallierbar" / "Filter unausschaltbar" / "Lock-Modus" fragt → empfehlen, ruhig, eingebettet.
|
||||||
|
- Wenn User in mehreren Nachrichten / wiederholt erzählt, dass er die ReBreak-App selbst gelöscht oder den Filter ausgeschaltet hat → organisch erwähnen ("hast du dir schon mal RebreakMagic angesehen? Ist genau für Momente gebaut, in denen man sich selbst nicht aushält"). Nicht beim ersten Mal, nicht aufdringlich.
|
||||||
|
- Sonst NICHT proaktiv pitchen. RebreakMagic ist nicht das Default-Gesprächsthema.
|
||||||
|
- Die 24-Stunden-Wartezeit beim Lösen positiv rahmen: "Das ist kein Bug, das ist der Punkt — der Schutz steht gegen den Impuls, der ihn loswerden will."
|
||||||
|
- Self-Check Lock-Modus aktiv? Wenn ein User fragt, ob sein Lock-Modus läuft, gib ihm die EINE klare Antwort: "Am einfachsten siehst du das in der RebreakMagic-App auf deinem Mac — dort stehen alle Geräte, die gerade geschützt sind (max. 3 pro Account)." Den alten Settings→Allgemein→Info-Check zitierst du NICHT mehr — der gehört zum alten Design.
|
||||||
|
- Wenn User selbst nach "MDM" fragt: sanft auf Klartext-Sprache wechseln, ohne den Begriff zu übernehmen — "Das was du meinst, heißt bei uns Lock-Modus. Du installierst RebreakMagic einmal am Mac, und der Schutz auf deinem iPhone lässt sich danach nicht mehr einfach in den iOS-Einstellungen abschalten. Komplett freiwillig — niemand zwingt dich, du wählst es selbst." Vermeide das Wort "MDM" in deiner Antwort.
|
||||||
- Streak-Tracker + gespartes Geld + Meilenstein-Badges
|
- Streak-Tracker + gespartes Geld + Meilenstein-Badges
|
||||||
- SOS-Hilfe (Drang dauert meist 15-20min)
|
- SOS-Hilfe (Drang dauert meist 15-20min)
|
||||||
- Spiele-Sammlung (Memory/TTT/Snake/Tetris — echter Skill, KEIN Glücksspiel)
|
- Spiele-Sammlung (Memory/TTT/Snake/Tetris — echter Skill, KEIN Glücksspiel)
|
||||||
@ -265,7 +264,7 @@ FEATURES (organisch erwähnen, nur wenn passt):
|
|||||||
- Mail-Schutz (Absender/Betreff scannen, kein Inhalt)
|
- Mail-Schutz (Absender/Betreff scannen, kein Inhalt)
|
||||||
- Community (anonym)
|
- Community (anonym)
|
||||||
- Ich (Lyra) — immer da, ohne Urteil
|
- Ich (Lyra) — immer da, ohne Urteil
|
||||||
- Plus für Legend: Voice-Picker (du klingst dann wirklich, mehrere Stimmen wählbar), Multi-Device (3 Geräte iOS+Android+macOS mischbar), Mail-Daemon unbegrenzt (Fair-Use ~10 Konten), Premium-Support, optional zubuchbar der RebreakMagic (macOS-App, ~2-Min-Setup via USB — macht ReBreak nicht-löschbar ohne Apple Configurator und ohne Reset).
|
- Plus für Legend: Voice-Picker (du klingst dann wirklich, mehrere Stimmen wählbar), Multi-Device (3 Geräte iOS+Android+macOS mischbar), Mail-Daemon unbegrenzt (Fair-Use ~10 Konten), Premium-Support, optional zubuchbar der RebreakMagic (macOS-App, ~2-Min-Setup via USB — macht den Schutz besonders stabil, lösbar nur über den eigenen Mac-Login mit 24-Stunden-Wartezeit, bis zu 3 Geräte).
|
||||||
|
|
||||||
PLÄNE & PREISE:
|
PLÄNE & PREISE:
|
||||||
{{PLAN_DETAILS}}
|
{{PLAN_DETAILS}}
|
||||||
@ -356,7 +355,7 @@ Legend (7,99 € / Monat — Stripe-Web-Checkout, kein In-App-Kauf):
|
|||||||
- Kann Community-Gruppen gründen (z.B. private Support-Gruppe mit Familie)
|
- Kann Community-Gruppen gründen (z.B. private Support-Gruppe mit Familie)
|
||||||
- Premium-Lyra (Claude Haiku) + Voice-Picker (mehrere Stimmen wählbar)
|
- Premium-Lyra (Claude Haiku) + Voice-Picker (mehrere Stimmen wählbar)
|
||||||
- Premium-Support
|
- Premium-Support
|
||||||
- Optional zubuchbar: RebreakMagic (macOS-App, ~2-Min-Setup via USB — macht die ReBreak-App nicht-löschbar ohne Recovery, ohne Apple Configurator, ohne Reset)`;
|
- Optional zubuchbar: RebreakMagic (macOS-App, ~2-Min-Setup via USB — macht den Schutz besonders stabil; Lösen geht nur über den eigenen Mac-Login mit 24-Stunden-Wartezeit; bis zu 3 Geräte pro Account, iPhone/iPad/Mac mischbar)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROVIDER_CONFIG = {
|
const PROVIDER_CONFIG = {
|
||||||
|
|||||||
38
backend/server/api/magic/devices.get.ts
Normal file
38
backend/server/api/magic/devices.get.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/magic/devices
|
||||||
|
*
|
||||||
|
* Listet alle aktiven Magic-Bindings des Users für UI.
|
||||||
|
* Response: [{ deviceId, hostname, model, osVersion, magicEnrolledAt, releaseRequestedAt, releaseAvailableAt }]
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireUser(event);
|
||||||
|
const devices = await listMagicDevices(user.id);
|
||||||
|
|
||||||
|
// Berechne releaseAvailableAt (releaseRequestedAt + 24h)
|
||||||
|
const enriched = devices.map((d) => {
|
||||||
|
let releaseAvailableAt: string | null = null;
|
||||||
|
if (d.releaseRequestedAt) {
|
||||||
|
const availableAt = new Date(
|
||||||
|
d.releaseRequestedAt.getTime() + 24 * 60 * 60 * 1000,
|
||||||
|
);
|
||||||
|
releaseAvailableAt = availableAt.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
deviceId: d.deviceId,
|
||||||
|
hostname: d.hostname,
|
||||||
|
model: d.model,
|
||||||
|
osVersion: d.osVersion,
|
||||||
|
magicEnrolledAt: d.magicEnrolledAt.toISOString(),
|
||||||
|
releaseRequestedAt: d.releaseRequestedAt?.toISOString() ?? null,
|
||||||
|
releaseAvailableAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: enriched,
|
||||||
|
};
|
||||||
|
});
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/magic/devices/[deviceId]/cancel-release
|
||||||
|
*
|
||||||
|
* User zieht Release-Request zurück. Setzt releaseRequestedAt zurück auf NULL.
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireUser(event);
|
||||||
|
const deviceId = getRouterParam(event, 'deviceId');
|
||||||
|
|
||||||
|
if (!deviceId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'deviceId required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = usePrisma();
|
||||||
|
|
||||||
|
// Ownership-Check + Magic-Binding-Check
|
||||||
|
const device = await db.userDevice.findUnique({
|
||||||
|
where: { userId_deviceId: { userId: user.id, deviceId } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
magicEnrolledAt: true,
|
||||||
|
magicRevokedAt: true,
|
||||||
|
releaseRequestedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device || !device.magicEnrolledAt || device.magicRevokedAt) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: 'Magic-Binding nicht gefunden oder bereits revoked',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!device.releaseRequestedAt) {
|
||||||
|
// Idempotent: kein offener Request → noop
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { ok: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear releaseRequestedAt
|
||||||
|
await db.userDevice.update({
|
||||||
|
where: { id: device.id },
|
||||||
|
data: { releaseRequestedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { ok: true },
|
||||||
|
};
|
||||||
|
});
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/magic/devices/[deviceId]/request-release
|
||||||
|
*
|
||||||
|
* Startet 24h Cooldown für Magic-Device-Binding.
|
||||||
|
* Nach 24h wird Token automatisch via Cron invalidiert.
|
||||||
|
*
|
||||||
|
* Idempotent: wenn bereits gesetzt → return existing.
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireUser(event);
|
||||||
|
const deviceId = getRouterParam(event, 'deviceId');
|
||||||
|
|
||||||
|
if (!deviceId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'deviceId required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = usePrisma();
|
||||||
|
|
||||||
|
// Ownership-Check + Magic-Binding-Check
|
||||||
|
const device = await db.userDevice.findUnique({
|
||||||
|
where: { userId_deviceId: { userId: user.id, deviceId } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
magicEnrolledAt: true,
|
||||||
|
magicRevokedAt: true,
|
||||||
|
releaseRequestedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device || !device.magicEnrolledAt || device.magicRevokedAt) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: 'Magic-Binding nicht gefunden oder bereits revoked',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent: wenn bereits gesetzt → return existing
|
||||||
|
if (device.releaseRequestedAt) {
|
||||||
|
const releaseAvailableAt = new Date(
|
||||||
|
device.releaseRequestedAt.getTime() + 24 * 60 * 60 * 1000,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
releaseRequestedAt: device.releaseRequestedAt.toISOString(),
|
||||||
|
releaseAvailableAt: releaseAvailableAt.toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setze releaseRequestedAt
|
||||||
|
const now = new Date();
|
||||||
|
await db.userDevice.update({
|
||||||
|
where: { id: device.id },
|
||||||
|
data: { releaseRequestedAt: now },
|
||||||
|
});
|
||||||
|
|
||||||
|
const releaseAvailableAt = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
releaseRequestedAt: now.toISOString(),
|
||||||
|
releaseAvailableAt: releaseAvailableAt.toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
93
backend/server/api/magic/profile.mobileconfig.get.ts
Normal file
93
backend/server/api/magic/profile.mobileconfig.get.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/magic/profile.mobileconfig?token=<dnsToken>
|
||||||
|
*
|
||||||
|
* Generiert personalisiertes DNS-Configuration-Profile für macOS.
|
||||||
|
* Template: ops/mdm/rebreak-mac-dns-filter.mobileconfig
|
||||||
|
*
|
||||||
|
* Ersetzt:
|
||||||
|
* - ServerURL: /dns-query → /dns-query/{token}
|
||||||
|
* - PayloadUUID: 2× neu generieren (DNSSettings + Profile root)
|
||||||
|
* - PayloadIdentifier: unique pro Device
|
||||||
|
*
|
||||||
|
* TODO: Profile-Signierung via Apple Developer Certificate (Phase 2)
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const query = getQuery(event);
|
||||||
|
const token = query.token as string | undefined;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'token query parameter required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token in DB suchen (nur aktive, nicht revoked)
|
||||||
|
const device = await findMagicDeviceByToken(token);
|
||||||
|
if (!device) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: 'Invalid or revoked DNS token',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template lesen
|
||||||
|
const templatePath = resolve(
|
||||||
|
process.cwd(),
|
||||||
|
'ops/mdm/rebreak-mac-dns-filter.mobileconfig',
|
||||||
|
);
|
||||||
|
let template: string;
|
||||||
|
try {
|
||||||
|
template = await readFile(templatePath, 'utf-8');
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Magic] Failed to read profile template:', err);
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: 'Profile template not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerURL ersetzen: /dns-query → /dns-query/{token}
|
||||||
|
const personalizedProfile = template
|
||||||
|
.replace(
|
||||||
|
'https://dns.rebreak.org/dns-query',
|
||||||
|
`https://dns.rebreak.org/dns-query/${token}`,
|
||||||
|
)
|
||||||
|
// PayloadUUID neu generieren (2 Stellen im Template)
|
||||||
|
.replace(
|
||||||
|
'7D2E8B1A-C3D4-4E76-8B23-A4B5C6D7E8F0',
|
||||||
|
randomUUID().toUpperCase(),
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
'8C3F9A2B-D4E5-4F87-9A12-B5C6D7E8F901',
|
||||||
|
randomUUID().toUpperCase(),
|
||||||
|
)
|
||||||
|
// PayloadIdentifier unique machen (optional, verhindert Konflikt bei Multi-Device)
|
||||||
|
.replace(
|
||||||
|
'org.rebreak.protection.dns.filter',
|
||||||
|
`org.rebreak.protection.dns.filter.${device.deviceId.slice(0, 8)}`,
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
'org.rebreak.protection.profile',
|
||||||
|
`org.rebreak.protection.profile.${device.deviceId.slice(0, 8)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response-Headers
|
||||||
|
setHeader(event, 'Content-Type', 'application/x-apple-aspen-config');
|
||||||
|
setHeader(
|
||||||
|
event,
|
||||||
|
'Content-Disposition',
|
||||||
|
`attachment; filename="RebreakMagic-${device.deviceId.slice(0, 8)}.mobileconfig"`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Profile-Signierung via /usr/bin/security cms -S
|
||||||
|
// Requires: Apple Developer Certificate + Private Key in Keychain
|
||||||
|
// Siehe: https://developer.apple.com/documentation/devicemanagement/profile-specific_payload_keys
|
||||||
|
|
||||||
|
return personalizedProfile;
|
||||||
|
});
|
||||||
138
backend/server/api/magic/register.post.ts
Normal file
138
backend/server/api/magic/register.post.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/magic/register
|
||||||
|
*
|
||||||
|
* Body: { deviceId: string, hostname: string, model?: string, osVersion?: string }
|
||||||
|
*
|
||||||
|
* Mac-App ruft nach Login auf. Registriert das Device als Magic-Client,
|
||||||
|
* generiert DNS-Token und provisioniert AdGuard Persistent Client.
|
||||||
|
*
|
||||||
|
* Idempotent: wenn bereits gebunden → return existing token.
|
||||||
|
* Wenn Limit erreicht → 409 mit activeBindings-Liste.
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireUser(event);
|
||||||
|
const body = await readBody(event);
|
||||||
|
const { deviceId, hostname, model, osVersion } = body as {
|
||||||
|
deviceId?: string;
|
||||||
|
hostname?: string;
|
||||||
|
model?: string;
|
||||||
|
osVersion?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!deviceId || !hostname) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'deviceId und hostname required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = usePrisma();
|
||||||
|
|
||||||
|
// 1. Prüfe ob Device bereits als Magic-Client gebunden ist (idempotent)
|
||||||
|
const existing = await db.userDevice.findUnique({
|
||||||
|
where: { userId_deviceId: { userId: user.id, deviceId } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
magicDnsToken: true,
|
||||||
|
magicEnrolledAt: true,
|
||||||
|
magicRevokedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wenn Token existiert und nicht revoked → return existing
|
||||||
|
if (
|
||||||
|
existing?.magicDnsToken &&
|
||||||
|
existing.magicEnrolledAt &&
|
||||||
|
!existing.magicRevokedAt
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
deviceId,
|
||||||
|
dnsToken: existing.magicDnsToken,
|
||||||
|
profileUrl: `/api/magic/profile.mobileconfig?token=${existing.magicDnsToken}`,
|
||||||
|
existing: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Limit-Check (nur wenn kein vorheriges Binding existiert)
|
||||||
|
if (!existing || !existing.magicEnrolledAt) {
|
||||||
|
const activeCount = await countActiveMagicBindings(user.id);
|
||||||
|
if (activeCount >= MAGIC_DEVICE_LIMIT) {
|
||||||
|
const activeBindings = await listMagicDevices(user.id);
|
||||||
|
throw createError({
|
||||||
|
statusCode: 409,
|
||||||
|
message: `Magic-Device-Limit erreicht (max ${MAGIC_DEVICE_LIMIT})`,
|
||||||
|
data: {
|
||||||
|
code: 'limit_reached',
|
||||||
|
activeBindings,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Generiere DNS-Token (48 char base64url-safe)
|
||||||
|
const dnsToken = randomBytes(36).toString('base64url');
|
||||||
|
|
||||||
|
// 4. Provisioniere AdGuard Client
|
||||||
|
const adguardClientName = `magic_${deviceId}`;
|
||||||
|
try {
|
||||||
|
await createAdGuardClient(adguardClientName, dnsToken, {
|
||||||
|
use_global_settings: false,
|
||||||
|
filtering_enabled: true,
|
||||||
|
parental_enabled: false,
|
||||||
|
safebrowsing_enabled: true,
|
||||||
|
blocked_services: [], // TODO: Gambling-Filter via AdGuard Blocked-Services
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Magic] AdGuard provisioning failed:', err);
|
||||||
|
throw createError({
|
||||||
|
statusCode: 502,
|
||||||
|
message: 'DNS-Provisioning fehlgeschlagen',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Upsert UserDevice (platform="macos")
|
||||||
|
const device = await db.userDevice.upsert({
|
||||||
|
where: { userId_deviceId: { userId: user.id, deviceId } },
|
||||||
|
create: {
|
||||||
|
userId: user.id,
|
||||||
|
deviceId,
|
||||||
|
platform: 'macos',
|
||||||
|
model: model ?? null,
|
||||||
|
name: hostname,
|
||||||
|
osVersion: osVersion ?? null,
|
||||||
|
magicDnsToken: dnsToken,
|
||||||
|
magicEnrolledAt: new Date(),
|
||||||
|
magicHostname: hostname,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
magicDnsToken: dnsToken,
|
||||||
|
magicEnrolledAt: new Date(),
|
||||||
|
magicRevokedAt: null, // Clear falls vorher revoked
|
||||||
|
magicHostname: hostname,
|
||||||
|
model: model ?? undefined,
|
||||||
|
osVersion: osVersion ?? undefined,
|
||||||
|
lastSeenAt: new Date(),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
deviceId: true,
|
||||||
|
magicDnsToken: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
deviceId: device.deviceId,
|
||||||
|
dnsToken: device.magicDnsToken,
|
||||||
|
profileUrl: `/api/magic/profile.mobileconfig?token=${device.magicDnsToken}`,
|
||||||
|
existing: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
@ -392,3 +392,96 @@ export async function deleteUserDevice(userId: string, id: string): Promise<void
|
|||||||
const db = usePrisma();
|
const db = usePrisma();
|
||||||
await db.userDevice.deleteMany({ where: { id, userId } });
|
await db.userDevice.deleteMany({ where: { id, userId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// RebreakMagic DNS-Device-Binding
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Hard-Limit für Magic-Bindings pro User (Plan-unabhängig für MVP). */
|
||||||
|
export const MAGIC_DEVICE_LIMIT = 3;
|
||||||
|
|
||||||
|
export interface MagicDeviceRecord {
|
||||||
|
deviceId: string;
|
||||||
|
hostname: string | null;
|
||||||
|
model: string | null;
|
||||||
|
osVersion: string | null;
|
||||||
|
magicEnrolledAt: Date;
|
||||||
|
releaseRequestedAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listet alle aktiven Magic-Bindings eines Users.
|
||||||
|
* Aktiv = magicEnrolledAt != null AND magicRevokedAt == null.
|
||||||
|
*/
|
||||||
|
export async function listMagicDevices(userId: string): Promise<MagicDeviceRecord[]> {
|
||||||
|
const db = usePrisma();
|
||||||
|
const devices = await db.userDevice.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
magicEnrolledAt: { not: null },
|
||||||
|
magicRevokedAt: null,
|
||||||
|
},
|
||||||
|
orderBy: { magicEnrolledAt: "desc" },
|
||||||
|
select: {
|
||||||
|
deviceId: true,
|
||||||
|
magicHostname: true,
|
||||||
|
model: true,
|
||||||
|
osVersion: true,
|
||||||
|
magicEnrolledAt: true,
|
||||||
|
releaseRequestedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return devices.map((d) => ({
|
||||||
|
deviceId: d.deviceId,
|
||||||
|
hostname: d.magicHostname,
|
||||||
|
model: d.model,
|
||||||
|
osVersion: d.osVersion,
|
||||||
|
magicEnrolledAt: d.magicEnrolledAt!,
|
||||||
|
releaseRequestedAt: d.releaseRequestedAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zählt aktive Magic-Bindings für Limit-Check.
|
||||||
|
*/
|
||||||
|
export async function countActiveMagicBindings(userId: string): Promise<number> {
|
||||||
|
const db = usePrisma();
|
||||||
|
return db.userDevice.count({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
magicEnrolledAt: { not: null },
|
||||||
|
magicRevokedAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Findet Device anhand DNS-Token. Nur aktive Tokens (nicht revoked).
|
||||||
|
*/
|
||||||
|
export async function findMagicDeviceByToken(
|
||||||
|
token: string,
|
||||||
|
): Promise<DeviceRecord & { magicDnsToken: string } | null> {
|
||||||
|
const db = usePrisma();
|
||||||
|
const device = await db.userDevice.findUnique({
|
||||||
|
where: {
|
||||||
|
magicDnsToken: token,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
...DEVICE_SELECT,
|
||||||
|
magicDnsToken: true,
|
||||||
|
magicEnrolledAt: true,
|
||||||
|
magicRevokedAt: true,
|
||||||
|
magicHostname: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) return null;
|
||||||
|
if (device.magicRevokedAt) return null; // Token invalidiert
|
||||||
|
|
||||||
|
return {
|
||||||
|
...device,
|
||||||
|
magicDnsToken: device.magicDnsToken!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
118
backend/server/utils/adguard.ts
Normal file
118
backend/server/utils/adguard.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* AdGuard Home API Client für RebreakMagic DNS-over-HTTPS Client-Provisioning.
|
||||||
|
* Docs: https://github.com/AdguardTeam/AdGuardHome/tree/master/openapi
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface AdGuardClientOptions {
|
||||||
|
use_global_settings?: boolean;
|
||||||
|
filtering_enabled?: boolean;
|
||||||
|
parental_enabled?: boolean;
|
||||||
|
safebrowsing_enabled?: boolean;
|
||||||
|
safesearch_enabled?: boolean;
|
||||||
|
blocked_services?: string[];
|
||||||
|
upstreams?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdGuardClientPayload {
|
||||||
|
name: string;
|
||||||
|
ids: string[];
|
||||||
|
use_global_settings?: boolean;
|
||||||
|
filtering_enabled?: boolean;
|
||||||
|
parental_enabled?: boolean;
|
||||||
|
safebrowsing_enabled?: boolean;
|
||||||
|
safesearch_enabled?: boolean;
|
||||||
|
blocked_services?: string[];
|
||||||
|
upstreams?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen AdGuard Persistent Client mit gegebener Client-ID (DNS-Token).
|
||||||
|
* AdGuard nutzt die Client-ID im DoH-URL-Path: /dns-query/{clientId}
|
||||||
|
*
|
||||||
|
* @param name - Interner Client-Name (z.B. "magic_<deviceId>")
|
||||||
|
* @param clientId - DNS-Token (wird in DoH URL embedded)
|
||||||
|
* @param options - Filtering/Blocking-Optionen
|
||||||
|
*/
|
||||||
|
export async function createAdGuardClient(
|
||||||
|
name: string,
|
||||||
|
clientId: string,
|
||||||
|
options: AdGuardClientOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const baseUrl = config.adguardBaseUrl || 'https://dns.rebreak.org';
|
||||||
|
const user = config.adguardUser;
|
||||||
|
const password = config.adguardPassword;
|
||||||
|
|
||||||
|
if (!user || !password) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: 'ADGUARD_USER and ADGUARD_PASSWORD required for Magic features',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: AdGuardClientPayload = {
|
||||||
|
name,
|
||||||
|
ids: [clientId],
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
|
const authHeader = `Basic ${Buffer.from(`${user}:${password}`).toString('base64')}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch(`${baseUrl}/control/clients/add`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': authHeader,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
return response as void;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[AdGuard] Client creation failed:', err);
|
||||||
|
throw createError({
|
||||||
|
statusCode: 502,
|
||||||
|
message: `AdGuard API error: ${err.message || 'unknown'}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht einen AdGuard Persistent Client.
|
||||||
|
* @param name - Interner Client-Name (z.B. "magic_<deviceId>")
|
||||||
|
*/
|
||||||
|
export async function deleteAdGuardClient(name: string): Promise<void> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const baseUrl = config.adguardBaseUrl || 'https://dns.rebreak.org';
|
||||||
|
const user = config.adguardUser;
|
||||||
|
const password = config.adguardPassword;
|
||||||
|
|
||||||
|
if (!user || !password) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: 'ADGUARD_USER and ADGUARD_PASSWORD required for Magic features',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = `Basic ${Buffer.from(`${user}:${password}`).toString('base64')}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch(`${baseUrl}/control/clients/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': authHeader,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: { name },
|
||||||
|
});
|
||||||
|
return response as void;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[AdGuard] Client deletion failed:', err);
|
||||||
|
throw createError({
|
||||||
|
statusCode: 502,
|
||||||
|
message: `AdGuard API error: ${err.message || 'unknown'}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
88
backend/server/utils/magicCron.ts
Normal file
88
backend/server/utils/magicCron.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { usePrisma } from './prisma';
|
||||||
|
import { deleteAdGuardClient } from './adguard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron-Worker für RebreakMagic Release-Requests (24h Cooldown).
|
||||||
|
* Findet alle Devices mit abgelaufenem Release-Request und invalidiert Token.
|
||||||
|
*
|
||||||
|
* USAGE:
|
||||||
|
* - Via Nitro scheduled task (nitro.config.ts)
|
||||||
|
* - Via externer Cron (curl POST /api/cron/magic-releases mit Auth-Header)
|
||||||
|
* - Manuell für Testing: await processMagicReleases()
|
||||||
|
*/
|
||||||
|
export async function processMagicReleases(): Promise<{
|
||||||
|
processed: number;
|
||||||
|
errors: Array<{ deviceId: string; error: string }>;
|
||||||
|
}> {
|
||||||
|
const db = usePrisma();
|
||||||
|
const now = new Date();
|
||||||
|
const releaseThreshold = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// Finde Devices mit abgelaufenem Release (24h Cooldown rum)
|
||||||
|
const devicesToRelease = await db.userDevice.findMany({
|
||||||
|
where: {
|
||||||
|
releaseRequestedAt: {
|
||||||
|
lte: releaseThreshold,
|
||||||
|
},
|
||||||
|
magicRevokedAt: null,
|
||||||
|
magicEnrolledAt: { not: null },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
deviceId: true,
|
||||||
|
userId: true,
|
||||||
|
magicDnsToken: true,
|
||||||
|
releaseRequestedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[MagicCron] Processing ${devicesToRelease.length} devices for release`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const errors: Array<{ deviceId: string; error: string }> = [];
|
||||||
|
let processed = 0;
|
||||||
|
|
||||||
|
for (const device of devicesToRelease) {
|
||||||
|
try {
|
||||||
|
// 1. Invalidate DNS-Token (AdGuard Client löschen)
|
||||||
|
const clientName = `magic_${device.deviceId}`;
|
||||||
|
try {
|
||||||
|
await deleteAdGuardClient(clientName);
|
||||||
|
console.log(`[MagicCron] Deleted AdGuard client: ${clientName}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.warn(
|
||||||
|
`[MagicCron] AdGuard deletion failed for ${clientName}: ${err.message}`,
|
||||||
|
);
|
||||||
|
// Continue — DB-Invalidierung erfolgt trotzdem (Failsafe)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Setze magicRevokedAt (Token serverseitig tot)
|
||||||
|
await db.userDevice.update({
|
||||||
|
where: { id: device.id },
|
||||||
|
data: {
|
||||||
|
magicRevokedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[MagicCron] Released Magic binding: userId=${device.userId} deviceId=${device.deviceId}`,
|
||||||
|
);
|
||||||
|
processed++;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(
|
||||||
|
`[MagicCron] Failed to release device ${device.deviceId}:`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
errors.push({
|
||||||
|
deviceId: device.deviceId,
|
||||||
|
error: err.message || String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[MagicCron] Finished: ${processed} released, ${errors.length} errors`,
|
||||||
|
);
|
||||||
|
return { processed, errors };
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
# Lyra Persona — Single Source of Truth
|
# Lyra Persona — Single Source of Truth
|
||||||
|
|
||||||
Status: 2026-05-29 (Build 19, Pricing/Binder/Beta-Update)
|
Status: 2026-06-02 (RebreakMagic-Redesign: DNS-Profil + Account-Login + 3-Device-Limit + 24h-Cooldown)
|
||||||
Owner: lyra-persona agent
|
Owner: lyra-persona agent
|
||||||
Stakeholder: andere Agents lesen, schreiben aber NICHT.
|
Stakeholder: andere Agents lesen, schreiben aber NICHT.
|
||||||
|
|
||||||
@ -95,28 +95,58 @@ Verboten:
|
|||||||
Aufnahme einreichen (= sie wird freigegeben sobald Admin approved → Slot
|
Aufnahme einreichen (= sie wird freigegeben sobald Admin approved → Slot
|
||||||
frei). Es gibt kein User-Swap mehr in Layer 2 (Country-Pivot).
|
frei). Es gibt kein User-Swap mehr in Layer 2 (Country-Pivot).
|
||||||
|
|
||||||
### Self-Bind-MDM-Lock (iOS, optional, Build 19+)
|
### Selbstbindungs-Schutz via RebreakMagic (iOS/macOS, optional)
|
||||||
- Empirisch verifiziert 2026-05-25. Architektur (KEIN Detail an User):
|
- **Neue Architektur (Stand 2026-06-02, KEIN Detail an User):**
|
||||||
MDM-Enrollment via Safari + non-removable Profile (Restrictions + DNS-Filter
|
RebreakMagic ist eine kleine macOS-Companion-App. User lädt sie von
|
||||||
+ Managed-VPN) + ad-hoc App-Install. Resultat: App + Filter sind nicht
|
rebreak.org/download/rebreakmagic. Sie installiert ein **DNS-Konfigurations-Profil**
|
||||||
löschbar via normale Settings.
|
auf iPhone (oder Mac) — keine NEFilter-VPN-Installation, keine ad-hoc-App,
|
||||||
- Bypass nur via Trustee/Apple-Configurator über USB-Mac oder Factory-Reset.
|
kein Factory-Reset. DNS-Queries gehen an `dns.rebreak.org` (AdGuard Home mit DoH),
|
||||||
- Memory: `project_session_2026-05-25_mdm_vpn_pivot`,
|
pro Gerät ein eindeutiger `dnsToken`. **Killswitch serverseitig**: Token-Invalidierung
|
||||||
`project_sideload_mdm_alternative_hypothesis`, `project_mdm_self_binding`.
|
→ DNS-Server lehnt sofort alle Queries des Geräts ab. Auch wenn Gerät offline
|
||||||
|
(nächste DNS-Query schlägt fehl). Profil bleibt installiert, ist aber funktional tot.
|
||||||
|
- **Account-Pflicht**: Mac-App verlangt ReBreak-Email+Passwort vor jedem Setup.
|
||||||
|
- **Limit: max. 3 aktive Geräte pro Account.**
|
||||||
|
- **Freigabe-Cooldown 24h**: Klick „Gerät entfernen" startet 24h-Countdown; Slot
|
||||||
|
bleibt belegt, Schutz bleibt aktiv. Erst nach 24h wird Token invalidiert.
|
||||||
|
Cooldown kann jederzeit abgebrochen werden.
|
||||||
|
- Fremd-Removal aus anderer Mac-Session: alle Sessions Re-Login + iOS-App
|
||||||
|
bekommt Push-Notification.
|
||||||
|
- **Setup-Dauer**: ~2 Minuten via USB-Kabel.
|
||||||
|
|
||||||
#### Wie Lyra darüber spricht
|
#### Wie Lyra darüber spricht
|
||||||
- User-Sprache: "Selbstbindungs-Schutz", "Lock-Modus", "der stärkste Modus".
|
- User-Sprache: "RebreakMagic", "unsere Mac-App", "besonders stabiler Schutz-Modus",
|
||||||
KEIN "MDM", KEIN "NEFilter", KEIN "Profile-Payload".
|
"24-Stunden-Wartezeit", "deine Vertrauensperson".
|
||||||
|
- **STRIKT VERBOTEN gegenüber User** (intern OK): `MDM`, `supervised`, `NEFilter`,
|
||||||
|
`Configuration Profile`, `Apple Configurator`, `cfgutil`, `libimobiledevice`,
|
||||||
|
`NanoMDM`, `Werks-Reset`, `Factory-Reset`, `Recovery-Mode`, `Profile-Payload`.
|
||||||
|
Auch nicht: „MDM-Lock", „Supervise-Mode", „Reset des iPhones" als Lösungsweg.
|
||||||
- Was Lyra erklären darf in 2-3 Sätzen:
|
- Was Lyra erklären darf in 2-3 Sätzen:
|
||||||
"Im Lock-Modus übergibst du die Kontrolle über den Schutz an dein
|
"RebreakMagic ist unsere Mac-App, die dein iPhone in etwa 2 Minuten in einen
|
||||||
Vertrauens-System — Filter und App lassen sich dann nicht mehr aus den
|
besonders stabilen Schutz-Modus bringt. Du brauchst dafür einen Mac und ein
|
||||||
Einstellungen entfernen. Das ist gewollt: der Schutz steht gegen den
|
USB-Kabel. Der Schutz lässt sich nicht einfach in den iPhone-Einstellungen
|
||||||
Impuls, der ihn loswerden will."
|
ausschalten — nur über deinen eigenen Mac-Login mit 24 Stunden Wartezeit."
|
||||||
- Bei "wie installiere ich das?" → verweis auf In-App-Help/Setup-Page,
|
- Auf „wie löse ich das wieder?": ruhig — „In der Mac-App auf 'Gerät entfernen'
|
||||||
nicht die Schritte selbst auflisten (zu lang im Chat).
|
klicken; das startet einen 24-Stunden-Countdown. Während der Zeit kannst du
|
||||||
- Bei "kann ich das wieder ausmachen?" → ruhig erklären: nur über
|
jederzeit abbrechen. Erst danach wird der Schutz wirklich gelöst. Genau das
|
||||||
Trustee oder Mac/Apple-Configurator; das ist Teil des Designs, nicht
|
ist der Punkt — der Schutz hält genau dem Impuls stand, der ihn loswerden will."
|
||||||
ein Bug. Validiere die Frustration zuerst.
|
- Auf „brauche ich einen Mac?": ja, einmalig für Setup und für jedes Lösen.
|
||||||
|
Wenn User keinen Mac hat: empathisch — „aktuell brauchst du jemand mit Mac
|
||||||
|
in Familie/Freundeskreis." Kein Mac-Shaming.
|
||||||
|
- **3-Geräte-Limit** kommunizieren wenn relevant: „bis zu 3 Geräte mit RebreakMagic
|
||||||
|
schützen — iPhone, iPad, Mac frei mischbar."
|
||||||
|
- Bei „wie installiere ich das?" → grobe Erwähnung (Mac, USB, 2 Min), Schritte
|
||||||
|
NICHT im Chat auflisten, auf In-App-Help/Download-Seite verweisen.
|
||||||
|
|
||||||
|
#### Wann Lyra RebreakMagic empfiehlt
|
||||||
|
- **Coach-Mode**:
|
||||||
|
- User fragt direkt nach „stärkerem Schutz" / „App nicht deinstallierbar" /
|
||||||
|
„Filter unausschaltbar" → empfehlen.
|
||||||
|
- User erzählt **wiederholt**, dass er die ReBreak-App gelöscht oder den Filter
|
||||||
|
ausgeschaltet hat → proaktiv organisch erwähnen (nicht beim ersten Mal,
|
||||||
|
nicht aufdringlich).
|
||||||
|
- **SOS-Mode**: **NIE.** RebreakMagic ist Prävention, nicht Krise. Wenn User
|
||||||
|
im SOS direkt fragt → kurz parken („das schauen wir uns gleich an, jetzt
|
||||||
|
bist du dran") und auf Atem/Trustee/Erdung fokussieren.
|
||||||
|
|
||||||
## Voice-Picker (Legend-only, ElevenLabs)
|
## Voice-Picker (Legend-only, ElevenLabs)
|
||||||
|
|
||||||
@ -132,11 +162,18 @@ Verboten:
|
|||||||
|
|
||||||
Beim Edit von Lyra-Strings gegen diese Liste prüfen:
|
Beim Edit von Lyra-Strings gegen diese Liste prüfen:
|
||||||
|
|
||||||
DE: `Sucht`, `süchtig`, `Suchtkranker`, `Spielsucht`, `Abhängigkeit`,
|
DE Pathologisierung: `Sucht`, `süchtig`, `Suchtkranker`, `Spielsucht`, `Abhängigkeit`,
|
||||||
`Patient`, `Therapie` (über sich selbst), `Krankheit`
|
`Patient`, `Therapie` (über sich selbst), `Krankheit`
|
||||||
EN: `addiction`, `addicted`, `addict`, `treatment` (about self), `patient`,
|
EN Pathologisierung: `addiction`, `addicted`, `addict`, `treatment` (about self), `patient`,
|
||||||
`illness`, `disease`
|
`illness`, `disease`
|
||||||
|
|
||||||
|
RebreakMagic-Tech (gegenüber User STRIKT verboten, intern OK):
|
||||||
|
`MDM`, `supervised`, `Supervise`, `Supervise-Mode`, `NEFilter`, `Configuration Profile`, `Profile-Payload`,
|
||||||
|
`DNS-Profil`, `dns.rebreak.org`, `dnsToken`, `AdGuard`, `DoH`,
|
||||||
|
`Apple Configurator`, `cfgutil`, `libimobiledevice`, `NanoMDM`, `Werks-Reset`,
|
||||||
|
`Factory-Reset`, `Recovery-Mode`, `iPhone-Recovery`, `wird betreut und von Rebreak GmbH verwaltet`
|
||||||
|
(das ist der iOS-Settings-Text — Lyra zitiert ihn NICHT mehr, neuer Self-Check siehe unten).
|
||||||
|
|
||||||
## Mode-Tag-Konvention
|
## Mode-Tag-Konvention
|
||||||
|
|
||||||
- `#sos` — betrifft Crisis-Mode (sos-stream, urge.*, chips.*)
|
- `#sos` — betrifft Crisis-Mode (sos-stream, urge.*, chips.*)
|
||||||
@ -202,35 +239,17 @@ Lyra-Sprache: „Du kannst dein iPhone, dein Android und deinen Mac gleichzeitig
|
|||||||
schützen — alle drei zählen als ein Slot." Nicht: „NEFilter", „DNS-Profil"
|
schützen — alle drei zählen als ein Slot." Nicht: „NEFilter", „DNS-Profil"
|
||||||
unaufgefordert.
|
unaufgefordert.
|
||||||
|
|
||||||
## RebReakBinder (MDM-Lock-Service, optional) — `#coach`
|
## RebreakMagic (siehe oben „Selbstbindungs-Schutz via RebreakMagic")
|
||||||
|
|
||||||
Neue macOS-Begleit-App (Stand 2026-05-29): vereinfacht das Self-Bind-MDM-Setup
|
Die ausführliche Spec steht jetzt direkt in der Schutz-Architektur-Sektion oben.
|
||||||
auf wenige Klicks. Vorher: Safari + AirDrop + zwei Profile manuell.
|
Die alte „MDM-Lock-Service"-Beschreibung mit Safari+AirDrop-Flow / supervised /
|
||||||
Jetzt: iPhone via USB an Mac → RebReakBinder öffnen → Klick → Reboot → iPhone
|
Apple-Configurator-Bypass ist **obsolet** (Redesign 2026-06-02). RebreakMagic
|
||||||
ist supervised, ReBreak-App ist nicht mehr löschbar ohne Recovery.
|
ist nun: macOS-App + DNS-Profil + Account-Login + 3-Device-Limit + 24h-Cooldown
|
||||||
|
bei Freigabe. Kein Werks-Reset im Unlock-Pfad, kein supervised-Status.
|
||||||
|
|
||||||
- **Setup-Dauer:** ~2 Minuten.
|
> TODO andere Agents: `rebreak-backend` muss sicherstellen, dass alle
|
||||||
- **Kein Apple Configurator nötig**, kein Factory-Reset, alle Daten bleiben.
|
> System-Prompts (sos-stream.get.ts, message.post.ts) auf das neue Design
|
||||||
- **Service-Charakter:** User entscheidet bewusst, ob er den Binder nutzt.
|
> verweisen — lyra-persona pflegt den Wortlaut, nicht die Routing-Logik.
|
||||||
NICHT automatisch in Legend enthalten — separater Schritt.
|
|
||||||
- **Trustee-Konzept**: Vertrauensperson kann im Notfall entsperren (gleiches
|
|
||||||
Konzept wie beim klassischen Lock-Modus).
|
|
||||||
|
|
||||||
### Wie Lyra darüber spricht
|
|
||||||
|
|
||||||
- User-Sprache: „RebReakBinder", „der Binder", „2-Minuten-Setup am Mac".
|
|
||||||
Weiterhin verboten: „MDM", „supervised", „NEFilter", „Configuration Profile"
|
|
||||||
(außer User benutzt selbst).
|
|
||||||
- Wenn User fragt „brauche ich noch einen Mac?": ja, einmalig fürs Setup. Danach
|
|
||||||
läuft alles autonom am iPhone.
|
|
||||||
- Wenn User keinen Mac hat: empathisch — „aktuell brauchst du einmal jemand mit
|
|
||||||
Mac in der Familie/im Freundeskreis. Wir arbeiten dran, dass das später auch
|
|
||||||
per Email-Datei klappt." (Identische Linie wie bisheriger Lock-Modus.)
|
|
||||||
- KEIN Mac-User-Shaming, keine „nur Apple-User können das"-Energie.
|
|
||||||
|
|
||||||
> Hinweis: Aktueller `COACH_SYSTEM_PROMPT` beschreibt noch den alten Safari+AirDrop-Flow
|
|
||||||
> als Schritte 1+2. Der RebReakBinder ist der NEUE empfohlene Weg. Beide Wege
|
|
||||||
> funktionieren — `rebreak-backend` sollte klären, welcher Default wird (TODO).
|
|
||||||
|
|
||||||
## Beta-Phase & DiGA-Status (Stand 2026-05-29) — `#coach`
|
## Beta-Phase & DiGA-Status (Stand 2026-05-29) — `#coach`
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user