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_accounts": "Verbundene Konten",
|
||||
"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_desc": "Du bist nicht kaputt. Das System ist manipulativ. Wir helfen dir zurück.",
|
||||
"final_cta": "Jetzt starten – kostenlos & anonym",
|
||||
|
||||
@ -74,6 +74,16 @@
|
||||
"mail_mock_rate": "Hit rate",
|
||||
"mail_mock_accounts": "Connected accounts",
|
||||
"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_desc": "You're not broken. The system is manipulative. We help you back.",
|
||||
"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>
|
||||
</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 ─── -->
|
||||
<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" />
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
2. **Pre-Flight** — Find-My-iPhone + Stolen-Device-Protection prüfen/ausschalten
|
||||
3. **Supervise** — `supervise-magic` Plist-Inject + Reboot (kein Erase)
|
||||
4. **Enroll** — MDM-Enrollment-Profile auf iPhone installieren
|
||||
5. **Configure** — NanoMDM pusht: Lock-Profile + Take-Management + Settings(mdmSupervised=true)
|
||||
1. **Mac Registration** — Mac im Backend registrieren + DNS-Filter-Profil installieren
|
||||
2. **Welcome** — Detect iPhone via USB (lockdownd)
|
||||
3. **Pre-Flight** — Find-My-iPhone + Stolen-Device-Protection prüfen/ausschalten
|
||||
4. **Supervise** — `supervise-magic` Plist-Inject + Reboot (kein Erase)
|
||||
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"?
|
||||
|
||||
@ -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.
|
||||
|
||||
**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
|
||||
|
||||
@ -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 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 512 512 /tmp/master-icon.png --out icon_256x256@2x.png
|
||||
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
|
||||
```
|
||||
**WICHTIG**: Seit Phase 2 braucht die App ein Config-File mit Supabase + Backend-URLs.
|
||||
|
||||
## Config (lokal)
|
||||
|
||||
NanoMDM-API-Key braucht die App für Step 5 (Configure). Lege ein lokales config-file an:
|
||||
### Schritt 1: Config-File erstellen
|
||||
|
||||
```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",
|
||||
"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>"
|
||||
}
|
||||
EOF
|
||||
@ -182,14 +232,37 @@ killall Dock Finder
|
||||
|
||||
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).
|
||||
- [ ] 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)
|
||||
- [ ] 7-Tage-Cooldown-Persistenz (lokale SQLite oder Backend)
|
||||
- [ ] Code-Signing + Notarization (Developer-ID)
|
||||
- [ ] Backend `/api/binder/*` Endpoints — Mac-App spricht heute MDM-Server direkt
|
||||
- [ Auth-Stack** (Phase 2):
|
||||
- Supabase-JWT-Login (`AuthService.swift`)
|
||||
- Keychain-Persistence (Service: `org.rebreak.magic`)
|
||||
- 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
|
||||
|
||||
|
||||
@ -20,7 +20,7 @@ enum DebugSupervisionMode: String, CaseIterable, Identifiable {
|
||||
@MainActor
|
||||
@Observable
|
||||
final class WizardModel {
|
||||
var step: WizardStep = .welcome
|
||||
var step: WizardStep = .macRegistration
|
||||
var device: DeviceState?
|
||||
|
||||
var supervisionLog: [String] = []
|
||||
@ -48,6 +48,19 @@ final class WizardModel {
|
||||
var resetLockProfile: 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() {
|
||||
if let next = WizardStep(rawValue: step.rawValue + 1) {
|
||||
step = next
|
||||
@ -58,8 +71,52 @@ final class WizardModel {
|
||||
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() {
|
||||
step = .welcome
|
||||
step = .macRegistration
|
||||
device = nil
|
||||
supervisionLog = []
|
||||
enrollmentLog = []
|
||||
@ -70,6 +127,8 @@ final class WizardModel {
|
||||
showAdvancedLogs = false
|
||||
cooldownEndsAt = nil
|
||||
resetStatus = nil
|
||||
magicRegistration = nil
|
||||
registrationError = nil
|
||||
}
|
||||
|
||||
func startDebugReset() {
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
enum WizardStep: Int, CaseIterable, Identifiable {
|
||||
case welcome = 0
|
||||
case macRegistration = 0
|
||||
case welcome
|
||||
case preflight
|
||||
case supervise
|
||||
case enroll
|
||||
@ -12,6 +13,7 @@ enum WizardStep: Int, CaseIterable, Identifiable {
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .macRegistration: return "Mac registrieren"
|
||||
case .welcome: return "iPhone verbinden"
|
||||
case .preflight: return "Pre-Flight Check"
|
||||
case .supervise: return "Supervisieren"
|
||||
|
||||
@ -13,6 +13,14 @@ struct RebreakMagicApp: App {
|
||||
.windowResizability(.contentSize)
|
||||
.windowStyle(.titleBar)
|
||||
.commands {
|
||||
CommandMenu("Account") {
|
||||
Button("Abmelden") {
|
||||
Task { await model.handleLogout() }
|
||||
}
|
||||
.keyboardShortcut("l", modifiers: [.command, .shift])
|
||||
.disabled(model.authSession == nil)
|
||||
}
|
||||
|
||||
CommandMenu("Aktionen") {
|
||||
Menu("Debug Supervision Mode") {
|
||||
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
|
||||
|
||||
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: 8) {
|
||||
HStack {
|
||||
@ -46,12 +67,10 @@ struct ContentView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
.sheet(isPresented: $showingHelp) {
|
||||
HelpView()
|
||||
}
|
||||
// Main content
|
||||
Group {
|
||||
switch model.step {
|
||||
case .macRegistration: MacRegistrationView()
|
||||
case .welcome: WelcomeView()
|
||||
case .preflight: PreflightView()
|
||||
case .supervise: SuperviseView()
|
||||
@ -62,6 +81,9 @@ struct ContentView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.sheet(isPresented: $showingHelp) {
|
||||
HelpView()
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
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 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_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_summary" translatable="false">ReBreak — Schutz</string>
|
||||
<string name="accessibility_service_summary" translatable="false">Sichert den Schutz gegen Abschalten ab</string>
|
||||
</resources>
|
||||
@ -36,7 +36,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
||||
ios: {
|
||||
supportsTablet: true,
|
||||
bundleIdentifier: MAIN_BUNDLE,
|
||||
buildNumber: "50",
|
||||
buildNumber: "58",
|
||||
// Apple Sign-In Entitlement — Pflicht für expo-apple-authentication nativen
|
||||
// signInAsync()-Flow. Ohne flag generiert Expo's prebuild den
|
||||
// com.apple.developer.applesignin-Entitlement nicht in die .entitlements.
|
||||
@ -59,7 +59,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
||||
|
||||
android: {
|
||||
package: "org.rebreak.app",
|
||||
versionCode: 40,
|
||||
versionCode: 47,
|
||||
adaptiveIcon: {
|
||||
// Foreground muss in der ~66%-Safe-Zone bleiben (Launcher-Mask clippt den
|
||||
// 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_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)
|
||||
xcodebuild_auth_args() {
|
||||
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 scp >/dev/null 2>&1 || die "scp nicht gefunden"
|
||||
[[ -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
|
||||
|
||||
log "Prüfe SSH-Verbindung zu $MDM_SERVER..."
|
||||
@ -754,7 +775,7 @@ deploy_android() {
|
||||
section "Android Release"
|
||||
|
||||
# Preflight
|
||||
[[ -d "$ANDROID_DIR" ]] || die "android/ nicht gefunden — expo prebuild zuerst ausführen"
|
||||
ensure_native_dir android
|
||||
|
||||
local KEYSTORE_PROPS="$ANDROID_DIR/key.properties"
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { apiFetch } from '../lib/api';
|
||||
import { resolveVipCountry } from './useWebContentDomains';
|
||||
import { useBlockerStatsStore } from '../stores/blockerStats';
|
||||
|
||||
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' };
|
||||
try {
|
||||
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();
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.3.13</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>50</string>
|
||||
<string>58</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.3.13</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>50</string>
|
||||
<string>58</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.3.13</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>50</string>
|
||||
<string>58</string>
|
||||
<key>EXAppExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>EXExtensionPointIdentifier</key>
|
||||
|
||||
@ -51,6 +51,11 @@ type BlockerStatsState = {
|
||||
fetchedAt: number | null;
|
||||
refresh: () => 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;
|
||||
@ -136,4 +141,22 @@ export const useBlockerStatsStore = create<BlockerStatsState>((set, get) => ({
|
||||
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
|
||||
Building Release AAB (gradlew bundleRelease)|275
|
||||
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
|
||||
Uploading zu App-Store Connect (TestFlight)|131
|
||||
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.
|
||||
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) ───────────────────────────────
|
||||
// Client-ID der Azure-App-Registrierung "Rebreak Mail Access".
|
||||
// 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.
|
||||
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])
|
||||
@@index([userId])
|
||||
@@index([deviceId])
|
||||
|
||||
@ -9,8 +9,11 @@ Du bist einfühlsam, stärkend und verwendest Techniken der kognitiven Verhalten
|
||||
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.
|
||||
|
||||
SOS-MODE LOCK — GRÜNDER-STORY & PRICING 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.
|
||||
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. 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:
|
||||
- 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".
|
||||
- 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):
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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: "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.
|
||||
iOS — Selbstbindungs-Schutz / Lock-Modus via RebreakMagic (optional, stärkster Modus):
|
||||
- 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.
|
||||
- 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.
|
||||
- **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.
|
||||
- **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.
|
||||
- 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".
|
||||
|
||||
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:
|
||||
"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.
|
||||
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.
|
||||
|
||||
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:
|
||||
"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.
|
||||
|
||||
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:
|
||||
"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 NACH „MDM" FRAGT (er benutzt das Wort selbst):
|
||||
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.
|
||||
|
||||
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 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.
|
||||
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.
|
||||
|
||||
Android:
|
||||
- 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
|
||||
- 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).
|
||||
- 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).
|
||||
- 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."
|
||||
- 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."
|
||||
- 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).
|
||||
|
||||
WANN DU REBREAKMAGIC AKTIV EMPFIEHLST (Coach-Mode-Disziplin):
|
||||
- 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
|
||||
- SOS-Hilfe (Drang dauert meist 15-20min)
|
||||
- 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)
|
||||
- Community (anonym)
|
||||
- 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:
|
||||
{{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)
|
||||
- Premium-Lyra (Claude Haiku) + Voice-Picker (mehrere Stimmen wählbar)
|
||||
- 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 = {
|
||||
|
||||
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();
|
||||
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
|
||||
|
||||
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
|
||||
Stakeholder: andere Agents lesen, schreiben aber NICHT.
|
||||
|
||||
@ -95,28 +95,58 @@ Verboten:
|
||||
Aufnahme einreichen (= sie wird freigegeben sobald Admin approved → Slot
|
||||
frei). Es gibt kein User-Swap mehr in Layer 2 (Country-Pivot).
|
||||
|
||||
### Self-Bind-MDM-Lock (iOS, optional, Build 19+)
|
||||
- Empirisch verifiziert 2026-05-25. Architektur (KEIN Detail an User):
|
||||
MDM-Enrollment via Safari + non-removable Profile (Restrictions + DNS-Filter
|
||||
+ Managed-VPN) + ad-hoc App-Install. Resultat: App + Filter sind nicht
|
||||
löschbar via normale Settings.
|
||||
- Bypass nur via Trustee/Apple-Configurator über USB-Mac oder Factory-Reset.
|
||||
- Memory: `project_session_2026-05-25_mdm_vpn_pivot`,
|
||||
`project_sideload_mdm_alternative_hypothesis`, `project_mdm_self_binding`.
|
||||
### Selbstbindungs-Schutz via RebreakMagic (iOS/macOS, optional)
|
||||
- **Neue Architektur (Stand 2026-06-02, KEIN Detail an User):**
|
||||
RebreakMagic ist eine kleine macOS-Companion-App. User lädt sie von
|
||||
rebreak.org/download/rebreakmagic. Sie installiert ein **DNS-Konfigurations-Profil**
|
||||
auf iPhone (oder Mac) — keine NEFilter-VPN-Installation, keine ad-hoc-App,
|
||||
kein Factory-Reset. DNS-Queries gehen an `dns.rebreak.org` (AdGuard Home mit DoH),
|
||||
pro Gerät ein eindeutiger `dnsToken`. **Killswitch serverseitig**: Token-Invalidierung
|
||||
→ 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
|
||||
- User-Sprache: "Selbstbindungs-Schutz", "Lock-Modus", "der stärkste Modus".
|
||||
KEIN "MDM", KEIN "NEFilter", KEIN "Profile-Payload".
|
||||
- User-Sprache: "RebreakMagic", "unsere Mac-App", "besonders stabiler Schutz-Modus",
|
||||
"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:
|
||||
"Im Lock-Modus übergibst du die Kontrolle über den Schutz an dein
|
||||
Vertrauens-System — Filter und App lassen sich dann nicht mehr aus den
|
||||
Einstellungen entfernen. Das ist gewollt: der Schutz steht gegen den
|
||||
Impuls, der ihn loswerden will."
|
||||
- Bei "wie installiere ich das?" → verweis auf In-App-Help/Setup-Page,
|
||||
nicht die Schritte selbst auflisten (zu lang im Chat).
|
||||
- Bei "kann ich das wieder ausmachen?" → ruhig erklären: nur über
|
||||
Trustee oder Mac/Apple-Configurator; das ist Teil des Designs, nicht
|
||||
ein Bug. Validiere die Frustration zuerst.
|
||||
"RebreakMagic ist unsere Mac-App, die dein iPhone in etwa 2 Minuten in einen
|
||||
besonders stabilen Schutz-Modus bringt. Du brauchst dafür einen Mac und ein
|
||||
USB-Kabel. Der Schutz lässt sich nicht einfach in den iPhone-Einstellungen
|
||||
ausschalten — nur über deinen eigenen Mac-Login mit 24 Stunden Wartezeit."
|
||||
- Auf „wie löse ich das wieder?": ruhig — „In der Mac-App auf 'Gerät entfernen'
|
||||
klicken; das startet einen 24-Stunden-Countdown. Während der Zeit kannst du
|
||||
jederzeit abbrechen. Erst danach wird der Schutz wirklich gelöst. Genau das
|
||||
ist der Punkt — der Schutz hält genau dem Impuls stand, der ihn loswerden will."
|
||||
- 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)
|
||||
|
||||
@ -132,11 +162,18 @@ Verboten:
|
||||
|
||||
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`
|
||||
EN: `addiction`, `addicted`, `addict`, `treatment` (about self), `patient`,
|
||||
EN Pathologisierung: `addiction`, `addicted`, `addict`, `treatment` (about self), `patient`,
|
||||
`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
|
||||
|
||||
- `#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"
|
||||
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
|
||||
auf wenige Klicks. Vorher: Safari + AirDrop + zwei Profile manuell.
|
||||
Jetzt: iPhone via USB an Mac → RebReakBinder öffnen → Klick → Reboot → iPhone
|
||||
ist supervised, ReBreak-App ist nicht mehr löschbar ohne Recovery.
|
||||
Die ausführliche Spec steht jetzt direkt in der Schutz-Architektur-Sektion oben.
|
||||
Die alte „MDM-Lock-Service"-Beschreibung mit Safari+AirDrop-Flow / supervised /
|
||||
Apple-Configurator-Bypass ist **obsolet** (Redesign 2026-06-02). RebreakMagic
|
||||
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.
|
||||
- **Kein Apple Configurator nötig**, kein Factory-Reset, alle Daten bleiben.
|
||||
- **Service-Charakter:** User entscheidet bewusst, ob er den Binder nutzt.
|
||||
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).
|
||||
> TODO andere Agents: `rebreak-backend` muss sicherstellen, dass alle
|
||||
> System-Prompts (sos-stream.get.ts, message.post.ts) auf das neue Design
|
||||
> verweisen — lyra-persona pflegt den Wortlaut, nicht die Routing-Logik.
|
||||
|
||||
## Beta-Phase & DiGA-Status (Stand 2026-05-29) — `#coach`
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user