feat(magic): sync current ReBreak Magic app state

Include recent Magic app work: Tauri native shell, iOS device detection
via supervise-magic sidecar, MDM client, local HTTP server, new pages
(detect, enroll, supervise, sideload, pair, preflight, configure, done),
and updated device section/status UI.
This commit is contained in:
chahinebrini 2026-06-18 05:23:26 +02:00
parent b9dddc00e7
commit 2919ce45b8
55 changed files with 9405 additions and 2 deletions

View File

@ -64,3 +64,25 @@ Wenn das Ziel reine Token-Ersparnis ist: Updates code-only halten.
**graphify-eigene memory:** `graphify-out/memory/` hält gespeicherte Query-Antworten
(z. B. den Lyra-Traceability-Trace) und fließt beim `--update` zurück in den Graphen.
## Agent-Verhaltensregel: Keine eigenmächtigen Code-Änderungen
> ⚠️ HARTREGEL — vom User explizit verlangt
- **Nie** Code schreiben, ändern oder erstellen, nur weil etwas „offensichtlich" erscheint.
- **Keine Implementierung** ohne ausdrückliches „Go" des Users.
- **Ausnahme:** Der User gibt einen konkreten, knappen und verständlichen Implementierungstask.
- Vorher sind Fragen, Planen und Recherche erlaubt und gewollt — aber erst nach einem klaren „mach das" wird Code produziert.
## Session-Kontext-Limit: Stop & Prompt
> ⚠️ HARTREGEL — vom User explizit verlangt
Wenn Anzeichen dafür bestehen, dass der Session-Kontext voll läuft oder alte Details verloren gehen (z. B. ich vergesse wiederholt Werte wie `hardwareId`, UDIDs oder bereits besprochene Fakten), dann:
1. **Sofort stoppen** — keine weiteren Code-Änderungen, keine langen Analysen.
2. **Kurzen Status-Block notieren** — was wurde besprochen, was steht noch aus, welche Dateien betroffen sind.
3. **Dem User einen einfachen Copy-Paste-Prompt geben**, mit dem er die nächste Session nahtlos fortsetzen kann.
4. **Den User bitten, diese Session zu beenden** und die neue mit dem Prompt zu starten.
Das verhindert, dass bereits erledigte Arbeit oder wichtige Kontextdetails erneut aufgebaut werden müssen.

38
apps/rebreak-magic/.gitignore vendored Normal file
View File

@ -0,0 +1,38 @@
# Dependencies
node_modules
# Nuxt
.nuxt
.output
.dist
# Tauri
src-tauri/target
src-tauri/gen
src-tauri/debug
src-tauri/release
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor
.idea
.vscode
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Build artifacts
*.dmg
*.exe
*.msi
dist
*.bak

136
apps/rebreak-magic/PLAN.md Normal file
View File

@ -0,0 +1,136 @@
# ReBreak Magic — Unified Desktop App (Nuxt + Tauri)
## Ziel
Eine einheitliche Cross-Platform-Desktop-App für Mac und Windows, die:
- Mac + iPhone in den ReBreak-Schutz einbindet
- Windows-PC in den ReBreak-Schutz einbindet
- iOS-Supervision ohne Erase über `supervise-magic` durchführt
- Sideload-Protect-Profil über lokalen HTTP-Server + QR-Code installiert
- Später ABM/ADE-Silent-Enrollment vorbereitet
## Tech-Stack
- **Frontend:** Nuxt 4.1.3 + @nuxt/ui 4.5.1 + Tailwind CSS 4 + Vue 3
- **Desktop-Runtime:** Tauri 2.x (Rust Core + WebView)
- **System-Zugriff:** Rust-Tauri-Commands + Sidecars
- **iOS-Supervision:** `supervise-magic` Go-Binary als Sidecar
- **Profile-Transfer:** Lokaler Rust-HTTP-Server + QR-Code
## Vergleich: Aktuelle Mac vs. Windows
| Bereich | Mac (SwiftUI) | Windows (Tauri/React) | Unified (Nuxt+Tauri) |
|---|---|---|---|
| **UI-Framework** | SwiftUI | React + Vite | **Nuxt + NuxtUI** |
| **Desktop-Runtime** | Native App | Tauri 2 | **Tauri 2** |
| **System-Core** | Swift Services | Rust | **Rust** |
| **Login** | Supabase JWT | 6-stelliger Pairing-Code | **Nur Pairing-Code** |
| **iPhone Detection** | `libimobiledevice` via Shell | — | **`libimobiledevice` Sidecar** |
| **iOS Supervision** | `supervise-magic` Go-Binary | — | **`supervise-magic` Sidecar** |
| **Sideload-Profil** | AirDrop | — | **Lokaler HTTP-Server + QR-Code** |
| **MDM-Enrollment** | NanoMDM HTTP-API | — | **NanoMDM HTTP-API** |
| **Mac-PC-Schutz** | `.mobileconfig` DNS-Profil | — | **`.mobileconfig` + `profiles` command** |
| **Windows-PC-Schutz** | — | DoH via PowerShell | **DoH + Tamper-Service** |
| **Tamper-Protection** | MDM/NanoMDM | SYSTEM-Service | **Plattformabhängig** |
| **Token-Speicher** | macOS Keychain | Windows Credential Manager | **Rust `keyring` crate** |
| **Installer** | DMG + Notarization | NSIS | **DMG (Mac) + NSIS/MSI (Win)** |
## Gemeinsamer Wizard-Flow
1. **Welcome** — Plattform erkennen, Willkommen
2. **Pairing** — 6-stelliger Pairing-Code aus der ReBreak-App
3. **Device Detection** — iPhone per USB erkennen (Mac) / PC-Info sammeln (Win)
4. **Pre-Flight** — Find-My-iPhone prüfen, Voraussetzungen checken
5. **Supervise**`supervise-magic` ausführen, iPhone rebootet
6. **Sideload Profile** — Lokaler Server starten, QR-Code anzeigen, User installiert Profil
7. **MDM Enrollment** — QR-Code/Download für NanoMDM-Enrollment-Profil
8. **Configure** — NanoMDM Commands pushen (Take-Management + mdmSupervised=true)
9. **Protection Active** — Schutzstatus anzeigen, Release-Cooldown verwalten
## Plattformspezifische Rust-Module
```
src-tauri/src/
├── main.rs # Entry + Tauri-Setup
├── lib.rs # Öffentliche Commands
├── platform/
│ ├── mod.rs # Trait + Dispatcher
│ ├── macos.rs # Mac-spezifisch (DNS-Profil, Keychain, USB)
│ └── windows.rs # Windows-spezifisch (DoH, Service, Credential Manager)
├── sidecar/
│ └── supervise_magic.rs # Go-Binary Management
├── server/
│ └── local_http.rs # Lokaler HTTP-Server für Profile
├── config.rs # App-Konfiguration
├── backend/
│ └── api.rs # /api/magic/* Client
└── error.rs # Gemeinsame Fehler-Typen
```
## Frontend-Struktur (Nuxt)
```
app/
├── app.vue # Tauri-Root + Layout
├── pages/
│ ├── index.vue # Welcome / Wizard-Start
│ ├── pair.vue # Pairing-Code (UPinInput)
│ ├── detect.vue # Geräte-Erkennung
│ ├── supervise.vue # Supervision-Step
│ ├── sideload.vue # Lokaler Server + QR-Code
│ ├── enroll.vue # MDM-Enrollment
│ ├── protect.vue # Schutz aktivieren
│ └── status.vue # Status + Release
├── components/
│ ├── WizardStep.vue
│ ├── QrDisplay.vue
│ ├── DeviceCard.vue
│ └── ProtectionStatus.vue
├── composables/
│ ├── useTauri.ts
│ ├── useMagicApi.ts
│ └── useLocalServer.ts
└── assets/
└── css/main.css
```
## Wichtige Entscheidungen
1. **Nuxt statt React:** Einheitlicher Stack mit Admin/Marketing, besseres Ökosystem-Sharing.
2. **Tauri statt Electron:** Kleinere Bundle-Größe, Rust-Performance, bessere System-Integration.
3. **Lokaler HTTP-Server statt AirDrop:** Plattformunabhängiger Profil-Transfer.
4. **Sidecar für Go-Binary:** `supervise-magic` muss nicht nach Rust portiert werden.
5. **ABM vorbereiten:** Architektur soll später Silent-Enrollment unterstützen, aber aktuell manuell.
## Risiken / Offene Punkte
- `supervise-magic` Windows-Build noch nicht verifiziert
- Verhalten von `PayloadRemovalDisallowed` bei Webserver-Download noch nicht getestet
- ABM-Beantragung dauert Wochen
- macOS Code-Signing + Notarization erforderlich für Production
- Windows Code-Signing (EV empfohlen) für Production
## Aktueller Stand
- ✅ Nuxt 4 + Tauri 2 Skelett unter `apps/rebreak-magic`
- ✅ Wizard-Pages mit NuxtUI: Welcome, Pair, Detect, Supervise, Sideload, Enroll, Status
- ✅ Rust-Module: Config, Backend-API, Platform-Abstraction, lokaler HTTP-Server, Sidecar-Integration
- ✅ `supervise-magic` Go-Binary als Tauri-Sidecar eingebunden
- ✅ Lokaler HTTP-Server für Sideload-Profil + QR-Code-Generierung
- ✅ `cargo check` erfolgreich
- ✅ `pnpm build` erfolgreich (Nuxt mit `nitro.preset: "static"` erzeugt `index.html`)
- ✅ `.app` Bundle mit `rebreak-magic` + `supervise-magic` Sidecar wird erzeugt
- ✅ Komplette Backend-Logik: Pairing-Code einlösen, Gerät registrieren, Status/Device-Liste abrufen, Release anfragen/abbrechen
- ✅ Token sicher im System-Keyring gespeichert
- ✅ Profil-Download vom Backend + lokaler QR-Code-Server für Sideload
- ✅ Release-Cooldown in Status-Seite angezeigt
- ⚠️ `.app` Bundle nur (kein DMG, um Bundling-Probleme zu vermeiden)
## Nächste Schritte
1. Plattformspezifische Schutzmechanismen implementieren (Mac DNS-Profil, Windows DoH + Service)
2. Echte Backend-API-Calls für Pairing / Status implementieren
3. Profil-Generierung in Rust ergänzen (statt hartcodiertem `/tmp/...` Pfad)
4. Windows-Build der `supervise-magic` Sidecar verifizieren
5. Code-Signing + Notarization für Production vorbereiten
6. CI-Pipeline für Mac + Windows Builds

View File

@ -0,0 +1,6 @@
<template>
<UApp>
<NuxtPage />
<DevLogDrawer />
</UApp>
</template>

View File

@ -0,0 +1,25 @@
@import "tailwindcss";
@import "@nuxt/ui";
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700;800&display=swap');
:root {
--rebreak-primary: #2e7fd4;
--rebreak-primary-light: #4a9af0;
--rebreak-primary-dark: #1e5fa3;
--font-family: 'Nunito', system-ui, -apple-system, sans-serif;
}
html,
body,
#__nuxt {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
font-family: var(--font-family);
}
/* Nunito for all UI components */
* {
font-family: var(--font-family);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@ -0,0 +1,115 @@
<template>
<div>
<!-- Toggle button (small, bottom-right) -->
<UButton icon="i-heroicons-bug-ant" color="neutral" variant="ghost" size="xs"
class="fixed bottom-3 right-3 z-50 opacity-60 hover:opacity-100" @click="open = true">
Logs
</UButton>
<UDrawer v-model:open="open" direction="bottom" :handle="true" :dismissible="true">
<template #body>
<div class="flex flex-col h-full">
<div class="flex items-center justify-end gap-2 pb-3 border-b border-gray-200">
<UButton size="xs" color="neutral" variant="ghost" icon="i-heroicons-clipboard-document" @click="copyLogs">
Kopieren
</UButton>
<UButton size="xs" color="error" variant="ghost" icon="i-heroicons-trash" @click="clear">
Löschen
</UButton>
</div>
<div class="flex-1 overflow-auto pt-3 space-y-2 bg-gray-950 -mx-4 px-4">
<div v-for="entry in logs" :key="entry.id" class="text-xs font-mono p-2 rounded border"
:class="entryClass(entry.level)">
<div class="flex items-center gap-2 opacity-70">
<span>{{ formatTime(entry.timestamp) }}</span>
<UBadge :color="badgeColor(entry.level)" size="xs" variant="solid">
{{ entry.level.toUpperCase() }}
</UBadge>
</div>
<div class="mt-1 whitespace-pre-wrap break-all">{{ entry.message }}</div>
<details v-if="entry.details" class="mt-2 group">
<summary class="cursor-pointer hover:underline flex items-center gap-2">
<span>Details</span>
<UButton size="2xs" color="neutral" variant="ghost" icon="i-heroicons-clipboard-document"
class="opacity-0 group-hover:opacity-100 transition-opacity"
@click.stop="copyDetails(entry.details)">
Kopieren
</UButton>
</summary>
<div class="mt-1 p-2 bg-black/30 rounded whitespace-pre-wrap break-all">{{ entry.details }}</div>
</details>
</div>
<div v-if="logs.length === 0" class="text-gray-500 text-center py-8">
Noch keine Logs vorhanden.
</div>
</div>
</div>
</template>
</UDrawer>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { useLogger, useLoggerState, type LogEntry } from "~/composables/useLogger";
const open = ref(false);
const logs = useLoggerState();
const { clear, exportLogs } = useLogger();
function entryClass(level: LogEntry["level"]) {
switch (level) {
case "error": return "bg-red-950/50 border-red-800 text-red-100";
case "warn": return "bg-yellow-950/50 border-yellow-800 text-yellow-100";
case "info": return "bg-blue-950/50 border-blue-800 text-blue-100";
default: return "bg-gray-900 border-gray-800 text-gray-200";
}
}
function badgeColor(level: LogEntry["level"]) {
switch (level) {
case "error": return "red";
case "warn": return "yellow";
case "info": return "blue";
default: return "neutral";
}
}
function formatTime(date: Date) {
return date.toLocaleTimeString("de-DE", { hour12: false }) + "." + String(date.getMilliseconds()).padStart(3, "0");
}
async function copyLogs() {
try {
await navigator.clipboard.writeText(exportLogs());
} catch {
// ignore
}
}
async function copyDetails(details: string) {
try {
await navigator.clipboard.writeText(details);
} catch {
// ignore
}
}
// Keyboard shortcut: Cmd/Ctrl + Shift + L
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "l") {
e.preventDefault();
open.value = !open.value;
}
}
onMounted(() => {
window.addEventListener("keydown", onKeyDown);
});
onUnmounted(() => {
window.removeEventListener("keydown", onKeyDown);
});
</script>

View File

@ -32,10 +32,12 @@
:device="device"
:iphone="iphone"
:is-connected="device.deviceId === connectedDeviceId"
:is-searching="device.deviceId === searchingForDeviceId"
:in-grace-period="inGracePeriod"
@sync="emit('sync', $event)"
@open="emit('open', $event)"
@remove="emit('remove', $event)"
@connect="emit('connect', $event)"
/>
</div>
</section>
@ -52,12 +54,14 @@ const props = defineProps<{
loading: boolean;
hasRefreshed: boolean;
inGracePeriod?: boolean;
searchingForDeviceId?: string | null;
}>();
const emit = defineEmits<{
(e: "sync", device: ComputedDevice): void;
(e: "open", device: ComputedDevice): void;
(e: "remove", device: ComputedDevice): void;
(e: "connect", device: ComputedDevice): void;
}>();
function matchesIphone(device: ComputedDevice, iphone: IphoneDeviceState): boolean {

View File

@ -0,0 +1,43 @@
<template>
<button
class="w-full text-left"
@click="toggle"
>
<div
class="flex items-start gap-3 p-4 rounded-xl transition-colors"
:class="checked || auto ? 'bg-green-50/50 ring-1 ring-green-100' : 'bg-gray-50 hover:bg-gray-100'"
>
<UIcon
:name="checked || auto ? 'i-heroicons-check-circle-solid' : 'i-heroicons-circle'"
class="w-6 h-6 shrink-0"
:class="checked || auto ? 'text-green-600' : 'text-gray-400'"
/>
<div>
<div class="font-bold text-gray-900">{{ title }}</div>
<div class="text-sm text-gray-500 mt-0.5">{{ detail }}</div>
<div v-if="auto && !checked" class="text-xs text-green-600 font-semibold mt-1">
Automatisch erkannt
</div>
</div>
</div>
</button>
</template>
<script setup lang="ts">
const props = defineProps<{
modelValue: boolean;
title: string;
detail: string;
auto?: boolean;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: boolean): void;
}>();
const checked = computed(() => props.modelValue || props.auto);
function toggle() {
emit("update:modelValue", !props.modelValue);
}
</script>

View File

@ -0,0 +1,16 @@
<template>
<div
class="flex items-center gap-1.5 px-2 py-1.5 rounded-lg"
:class="ok ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-500'"
>
<UIcon :name="ok ? 'i-heroicons-check-circle' : 'i-heroicons-x-circle'" class="w-3.5 h-3.5" />
<span>{{ label }}</span>
</div>
</template>
<script setup lang="ts">
defineProps<{
ok: boolean;
label: string;
}>();
</script>

View File

@ -0,0 +1,46 @@
<template>
<button
class="w-full text-left p-4 rounded-xl transition-colors flex items-center justify-between"
:class="buttonClass"
:disabled="loading"
@click="$emit('click')"
>
<div class="flex items-center gap-3">
<UIcon
:name="icon"
class="w-6 h-6"
/>
<div>
<div class="font-bold">{{ title }}</div>
<div v-if="error" class="text-xs text-red-600 mt-0.5">{{ error }}</div>
<div v-else-if="done" class="text-xs text-green-700 mt-0.5">Abgeschlossen</div>
</div>
</div>
<UIcon v-if="loading" name="i-heroicons-arrow-path" class="w-5 h-5 animate-spin" />
</button>
</template>
<script setup lang="ts">
const props = defineProps<{
title: string;
done: boolean;
loading: boolean;
error?: string | null;
}>();
defineEmits<{
(e: "click"): void;
}>();
const icon = computed(() => {
if (props.error) return "i-heroicons-x-circle";
if (props.done) return "i-heroicons-check-circle-solid";
return "i-heroicons-circle";
});
const buttonClass = computed(() => {
if (props.error) return "bg-red-50 text-red-700 ring-1 ring-red-100";
if (props.done) return "bg-green-50 text-green-700 ring-1 ring-green-100";
return "bg-white ring-1 ring-gray-200 hover:ring-[var(--rebreak-primary)]/30";
});
</script>

View File

@ -0,0 +1,97 @@
export interface LogEntry {
id: string;
timestamp: Date;
level: "debug" | "info" | "warn" | "error";
message: string;
details?: string;
}
let nextId = 1;
export function useLoggerState() {
return useState<LogEntry[]>("dev-logs", () => []);
}
export function useLogger() {
const logs = useLoggerState();
function log(level: LogEntry["level"], message: string, details?: string) {
const entry: LogEntry = {
id: String(nextId++),
timestamp: new Date(),
level,
message,
details,
};
logs.value.unshift(entry);
// Keep max 200 entries
if (logs.value.length > 200) {
logs.value = logs.value.slice(0, 200);
}
// Also mirror to console
const consoleMsg = details ? `${message} | ${details}` : message;
if (level === "error") console.error(consoleMsg);
else if (level === "warn") console.warn(consoleMsg);
else if (level === "debug") console.debug(consoleMsg);
else console.log(consoleMsg);
}
function debug(message: string, details?: string) {
log("debug", message, details);
}
function info(message: string, details?: string) {
log("info", message, details);
}
function warn(message: string, details?: string) {
log("warn", message, details);
}
function error(message: string, details?: string) {
log("error", message, details);
}
function clear() {
logs.value = [];
}
function exportLogs(): string {
return logs.value
.slice()
.reverse()
.map(
(entry) =>
`[${entry.timestamp.toISOString()}] [${entry.level.toUpperCase()}] ${entry.message}${
entry.details ? `\n Details: ${entry.details}` : ""
}`,
)
.join("\n---\n");
}
return {
logs,
debug,
info,
warn,
error,
clear,
exportLogs,
};
}
export function formatError(err: unknown): { message: string; details?: string } {
if (err instanceof Error) {
return { message: err.message, details: err.stack };
}
if (typeof err === "string") {
return { message: err };
}
try {
return { message: JSON.stringify(err) };
} catch {
return { message: "Unknown error" };
}
}

View File

@ -290,6 +290,22 @@ export function useTauri() {
await invokeLogged("link_mdm_device", { deviceId, mdmId });
}
async function reportDeviceProtectionState(
deviceId: string,
platform: string,
protectionType: string,
active: boolean,
reason?: string,
): Promise<void> {
await invokeLogged("report_device_protection_state", {
deviceId,
platform,
protectionType,
active,
reason,
});
}
return {
getPlatform,
redeemPairingCode,
@ -328,5 +344,6 @@ export function useTauri() {
getMdmStatus,
getMdmStatusByUdid,
linkMdmDevice,
reportDeviceProtectionState,
};
}

View File

@ -0,0 +1,199 @@
<template>
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6">
<div class="max-w-md w-full space-y-6">
<div class="text-center">
<h1 class="text-2xl font-bold text-gray-900">Schutz aktivieren</h1>
<p class="text-gray-600 mt-2">
Wir richten die ReBreak-App ein und installieren das Lock-Profil. Scanne die QR-Codes mit deinem iPhone.
</p>
</div>
<UCard>
<div class="space-y-4">
<div class="space-y-3">
<StepButton
title="1. ReBreak-App einrichten"
:done="appDone"
:loading="appLoading"
:error="appError"
@click="setupApp"
/>
<StepButton
title="2. Lock-Profil installieren"
:done="lockDone"
:loading="lockLoading"
:error="lockError"
@click="setupLockProfile"
/>
</div>
<div v-if="qrDataUrl" class="flex justify-center">
<img :src="qrDataUrl" alt="QR Code" class="w-48 h-48 rounded-xl shadow-md">
</div>
<div v-if="lastCommand" class="text-xs bg-gray-100 p-3 rounded break-all">
<p class="font-semibold">Letzter Command:</p>
<p>{{ lastCommand }}</p>
<p class="font-mono mt-1">{{ lastResponse }}</p>
</div>
<div v-if="logs.length > 0" class="text-xs bg-gray-100 p-3 rounded overflow-auto max-h-40">
<pre class="whitespace-pre-wrap">{{ logs.join('\n') }}</pre>
</div>
</div>
</UCard>
<div class="flex justify-between">
<UButton to="/enroll" variant="ghost" color="neutral">
Zurück
</UButton>
<UButton
to="/done"
variant="solid"
color="primary"
:disabled="!allDone"
>
Weiter
</UButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import QRCode from "qrcode";
import {
useTauri,
type LocalServerInfo,
type MdmCommandResult,
} from "~/composables/useTauri";
import { useIphoneDevice } from "~/composables/useMagicState";
import StepButton from "~/components/StepButton.vue";
const {
mdmPing,
mdmInstallApp,
mdmSetSupervisedMode,
mdmTakeManagement,
mdmInstallLockProfile,
startLocalProfileServer,
stopLocalProfileServer,
getInstalledProfiles,
} = useTauri();
const iphone = useIphoneDevice();
const appLoading = ref(false);
const appDone = ref(false);
const appError = ref<string | null>(null);
const lockLoading = ref(false);
const lockDone = ref(false);
const lockError = ref<string | null>(null);
const logs = ref<string[]>([]);
const lastCommand = ref<string>("");
const lastResponse = ref<string>("");
const qrDataUrl = ref<string>("");
const LOCK_PROFILE_PATH = "/Users/chahinebrini/mono/rebreak-monorepo/ops/mdm/profiles/rebreak-content-filter-sideload.mobileconfig";
const allDone = computed(() => appDone.value && lockDone.value);
async function setupApp() {
appLoading.value = true;
appError.value = null;
logs.value = [];
qrDataUrl.value = "";
try {
if (!iphone.value?.udid) {
throw new Error("Kein iPhone erkannt.");
}
logs.value.push("→ Ping NanoMDM …");
const version = await mdmPing();
logs.value.push(`✓ NanoMDM ${version.trim()}`);
const udid = iphone.value.udid;
logs.value.push("→ InstallApplication …");
const r1 = await mdmInstallApp(udid);
logCommand("InstallApplication", r1);
logs.value.push("→ Settings mdmSupervised=true …");
const r2 = await mdmSetSupervisedMode(udid);
logCommand("Settings", r2);
if (iphone.value.installedAppBundleIDs?.includes("org.rebreak.app")) {
logs.value.push("→ Take Management …");
const r3 = await mdmTakeManagement(udid);
logCommand("TakeManagement", r3);
}
appDone.value = true;
logs.value.push("✓ App-Setup abgeschlossen.");
} catch (e: any) {
appError.value = e?.message ?? "App-Setup fehlgeschlagen";
logs.value.push(`${appError.value}`);
} finally {
appLoading.value = false;
}
}
async function setupLockProfile() {
lockLoading.value = true;
lockError.value = null;
qrDataUrl.value = "";
try {
if (!iphone.value?.udid) {
throw new Error("Kein iPhone erkannt.");
}
// Try MDM push first
logs.value.push("→ Versuche Lock-Profil per MDM …");
const r = await mdmInstallLockProfile(iphone.value.udid, LOCK_PROFILE_PATH);
logCommand("InstallProfile (Lock)", r);
// Also start local QR server as fallback / confirmation
logs.value.push("→ Starte lokalen Server für Lock-Profil …");
const serverInfo: LocalServerInfo = await startLocalProfileServer(LOCK_PROFILE_PATH);
logs.value.push(`✓ QR: ${serverInfo.url}`);
qrDataUrl.value = await QRCode.toDataURL(serverInfo.qr_payload, {
width: 192,
margin: 2,
});
await refreshProfileList();
lockDone.value = true;
logs.value.push("✓ Lock-Profil-Installation initiiert.");
} catch (e: any) {
lockError.value = e?.message ?? "Lock-Profil fehlgeschlagen";
logs.value.push(`${lockError.value}`);
} finally {
lockLoading.value = false;
}
}
async function refreshProfileList() {
try {
const ids = await getInstalledProfiles();
if (iphone.value) {
iphone.value.installedProfileIDs = ids;
}
} catch (e: any) {
logs.value.push(`Profil-Liste nicht lesbar: ${e?.message ?? e}`);
}
}
function logCommand(name: string, result: MdmCommandResult) {
lastCommand.value = name;
lastResponse.value = `${result.command_uuid}: ${result.response_body.substring(0, 200)}`;
logs.value.push(`${name}: ${result.command_uuid}`);
}
onUnmounted(async () => {
await stopLocalProfileServer();
});
</script>

View File

@ -0,0 +1,108 @@
<template>
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6">
<div class="max-w-md w-full space-y-6">
<div class="text-center">
<h1 class="text-2xl font-bold text-gray-900">Schutz aktivieren</h1>
<p class="text-gray-600 mt-2">
Aktiviere den ReBreak-DNS-Schutz auf diesem Computer.
</p>
</div>
<UCard>
<div class="space-y-4">
<div class="text-sm text-gray-600">
<p v-if="platform === 'MacOS'">
Das Schutz-Profil wird in den Systemeinstellungen geöffnet. Bestätige die Installation mit deinem Admin-Passwort.
</p>
<p v-else-if="platform === 'Windows'">
Der DoH-Schutz wird auf System-Ebene konfiguriert. Administratorrechte erforderlich.
</p>
<p v-else>
Plattform nicht erkannt.
</p>
</div>
<UButton
color="primary"
block
size="lg"
:loading="activating"
:disabled="!canActivate"
@click="activate"
>
Schutz aktivieren
</UButton>
<p v-if="result" class="text-sm text-center" :class="result.success ? 'text-green-600' : 'text-red-600'">
{{ result.success ? '✅ ' + result.message : '❌ ' + result.message }}
</p>
</div>
</UCard>
<div class="flex justify-between">
<UButton to="/status" variant="ghost" color="neutral">
Zurück
</UButton>
<UButton to="/status" variant="solid" color="primary">
Weiter
</UButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useTauri, type PlatformInfo } from "~/composables/useTauri";
import { useMagicSession } from "~/composables/useMagicState";
const { getPlatform, activateProtection, downloadProfile, setDesktopProtectionStatus } = useTauri();
const session = useMagicSession();
const platform = ref<string>("");
const activating = ref(false);
const canActivate = ref(false);
const result = ref<{ success: boolean; message: string } | null>(null);
onMounted(async () => {
const info: PlatformInfo = await getPlatform();
platform.value = info.platform;
canActivate.value = info.platform === "MacOS" || info.platform === "Windows";
});
async function activate() {
if (!session.value?.profileUrl) {
result.value = { success: false, message: "Kein Profil verfügbar. Bitte zuerst koppeln." };
return;
}
activating.value = true;
result.value = null;
try {
let path: string;
if (platform.value === "MacOS") {
path = await downloadProfile(session.value.profileUrl);
} else {
path = session.value.dnsToken;
}
await activateProtection(path);
try {
await setDesktopProtectionStatus(true, platform.value);
} catch (e: any) {
console.warn("Could not persist desktop protection status:", e);
}
result.value = {
success: true,
message: platform.value === "MacOS"
? "Systemeinstellungen geöffnet. Bitte Profil manuell installieren."
: "DoH-Schutz aktiviert.",
};
} catch (e: any) {
result.value = { success: false, message: e?.message ?? "Aktivierung fehlgeschlagen" };
} finally {
activating.value = false;
}
}
</script>

View File

@ -0,0 +1,175 @@
<template>
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6">
<div class="max-w-md w-full space-y-6">
<div class="text-center">
<h1 class="text-2xl font-bold text-gray-900">Gerät erkennen</h1>
<p class="text-gray-600 mt-2">
Verbinde dein iPhone per USB und bestätige Diesem Computer vertrauen".
</p>
</div>
<UCard>
<div v-if="info" class="space-y-2 text-sm">
<p><strong>Computer:</strong> {{ info.platform }}</p>
<p><strong>Version:</strong> {{ info.version }}</p>
<p>
<strong>iOS-Supervision:</strong>
{{ info.supports_ios_supervision ? "Unterstützt" : "Nicht unterstützt" }}
</p>
</div>
<div v-else class="text-gray-500 text-sm">
Lade Plattform-Informationen...
</div>
</UCard>
<UCard>
<div class="space-y-4">
<UButton
size="lg"
color="primary"
block
:loading="detecting"
@click="detectIphone"
>
iPhone suchen
</UButton>
<div v-if="iphone" class="space-y-3">
<div class="text-sm space-y-1">
<p><strong>Gerät:</strong> {{ iphone.name }}</p>
<p><strong>Modell:</strong> {{ displayModel(iphone.productType) }}</p>
<p><strong>UDID:</strong> {{ iphone.udid }}</p>
<p><strong>iOS:</strong> {{ iphone.productVersion }}</p>
</div>
<div class="grid grid-cols-2 gap-2 text-xs">
<div
class="flex items-center gap-2 p-2 rounded-lg"
:class="iphone.isSupervised ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-500'"
>
<UIcon :name="iphone.isSupervised ? 'i-heroicons-check-circle' : 'i-heroicons-x-circle'" class="w-4 h-4" />
<span>{{ iphone.isSupervised ? 'Supervised' : 'Nicht supervised' }}</span>
</div>
<div
v-if="iphone.organizationName"
class="flex items-center gap-2 p-2 rounded-lg bg-blue-50 text-blue-700"
>
<UIcon name="i-heroicons-building-office" class="w-4 h-4" />
<span>{{ iphone.organizationName }}</span>
</div>
<div
class="flex items-center gap-2 p-2 rounded-lg"
:class="hasEnrollmentProfile ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-500'"
>
<UIcon :name="hasEnrollmentProfile ? 'i-heroicons-check-circle' : 'i-heroicons-x-circle'" class="w-4 h-4" />
<span>Enrollment</span>
</div>
<div
class="flex items-center gap-2 p-2 rounded-lg"
:class="hasLockProfile ? 'bg-green-50 text-green-700' : 'bg-amber-50 text-amber-700'"
>
<UIcon :name="hasLockProfile ? 'i-heroicons-check-circle' : 'i-heroicons-exclamation-triangle'" class="w-4 h-4" />
<span>Lock-Profil</span>
</div>
<div
class="flex items-center gap-2 p-2 rounded-lg"
:class="hasReBreakApp ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-500'"
>
<UIcon :name="hasReBreakApp ? 'i-heroicons-check-circle' : 'i-heroicons-x-circle'" class="w-4 h-4" />
<span>ReBreak-App</span>
</div>
</div>
</div>
<div v-if="rawOutput" class="text-xs bg-gray-100 p-3 rounded overflow-auto max-h-48">
<p class="font-semibold text-gray-700 mb-1">Roh-Output:</p>
<pre class="whitespace-pre-wrap break-all">{{ rawOutput }}</pre>
</div>
<p v-if="error" class="text-sm text-red-600">
{{ error }}
</p>
</div>
</UCard>
<div class="flex justify-between">
<UButton to="/status" variant="ghost" color="neutral">
Zurück
</UButton>
<UButton
to="/preflight"
variant="solid"
color="primary"
:disabled="!iphone"
>
Weiter
</UButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { useTauri, type PlatformInfo, type IphoneDeviceState } from "~/composables/useTauri";
import { useIphoneDevice } from "~/composables/useMagicState";
const { getPlatform, detectIphoneState } = useTauri();
const iphone = useIphoneDevice();
const info = ref<PlatformInfo | null>(null);
const detecting = ref(false);
const error = ref<string | null>(null);
const rawOutput = ref<string | null>(null);
const ENROLLMENT_PROFILE_ID = "org.rebreak.mdm.enrollment";
const LOCK_PROFILE_ID = "org.rebreak.protection.contentfilter.sideload";
const hasEnrollmentProfile = computed(() =>
iphone.value?.installedProfileIDs?.includes(ENROLLMENT_PROFILE_ID) ?? false,
);
const hasLockProfile = computed(() =>
iphone.value?.installedProfileIDs?.includes(LOCK_PROFILE_ID) ?? false,
);
const hasReBreakApp = computed(() =>
iphone.value?.installedAppBundleIDs?.includes("org.rebreak.app") ?? false,
);
onMounted(async () => {
info.value = await getPlatform();
});
async function detectIphone() {
detecting.value = true;
error.value = null;
rawOutput.value = null;
try {
const state = await detectIphoneState();
iphone.value = state;
rawOutput.value = state ? JSON.stringify(state, null, 2) : "Kein iPhone erkannt";
if (!state) {
error.value = "Kein iPhone verbunden. Bitte per USB anschließen und \"Diesem Computer vertrauen\" bestätigen.";
}
} catch (e: any) {
error.value = e?.message ?? "Fehler bei der Geräteerkennung";
rawOutput.value = e?.stack || String(e);
} finally {
detecting.value = false;
}
}
function displayModel(productType: string) {
const map: Record<string, string> = {
"iPhone18,4": "iPhone Air",
"iPhone17,1": "iPhone 16 Pro",
"iPhone17,2": "iPhone 16 Pro Max",
"iPhone17,3": "iPhone 16",
"iPhone17,4": "iPhone 16 Plus",
"iPhone16,1": "iPhone 15 Pro",
"iPhone16,2": "iPhone 15 Pro Max",
"iPhone15,4": "iPhone 15",
"iPhone15,5": "iPhone 15 Plus",
};
return map[productType] || productType;
}
</script>

View File

@ -0,0 +1,37 @@
<template>
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6">
<div class="max-w-md w-full space-y-6 text-center">
<div class="w-20 h-20 mx-auto rounded-full bg-green-100 flex items-center justify-center">
<UIcon name="i-heroicons-check-badge" class="w-10 h-10 text-green-600" />
</div>
<div>
<h1 class="text-2xl font-bold text-gray-900">Schutz aktiv!</h1>
<p class="text-gray-600 mt-2">
Dein iPhone ist jetzt mit ReBreak verbunden. Der Schutz wird im Hintergrund aufrecht erhalten.
</p>
</div>
<UCard>
<div class="space-y-3 text-left text-sm">
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-shield-check" class="w-5 h-5 text-green-600" />
<span>Supervision aktiv</span>
</div>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-device-phone-mobile" class="w-5 h-5 text-green-600" />
<span>ReBreak-App verwaltet</span>
</div>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-lock-closed" class="w-5 h-5 text-green-600" />
<span>Lock-Profil installiert</span>
</div>
</div>
</UCard>
<UButton to="/status" variant="solid" color="primary" size="lg" block>
Zum Dashboard
</UButton>
</div>
</div>
</template>

View File

@ -0,0 +1,203 @@
<template>
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6">
<div class="max-w-md w-full space-y-6">
<div class="text-center">
<h1 class="text-2xl font-bold text-gray-900">MDM-Enrollment</h1>
<p class="text-gray-600 mt-2">
Scanne den QR-Code mit deinem iPhone und installiere das Verbindungs-Profil.
</p>
</div>
<UCard>
<div class="space-y-4">
<div v-if="!profilePath" class="text-center">
<UButton
color="primary"
block
size="lg"
:loading="downloading"
@click="downloadEnrollmentProfile"
>
Enrollment-Profil laden
</UButton>
</div>
<div v-else class="space-y-4">
<div class="flex justify-center">
<img :src="qrDataUrl" alt="QR Code" class="w-48 h-48 rounded-xl shadow-md">
</div>
<div class="text-sm text-center text-gray-600">
<p>Scanne den Code mit der Kamera-App.</p>
<p class="text-xs mt-1 break-all">{{ serverUrl }}</p>
</div>
<UButton
color="primary"
variant="soft"
block
:loading="checking"
@click="checkEnrollment"
>
Installation prüfen
</UButton>
</div>
<div v-if="status" class="text-sm p-3 rounded-lg" :class="status.ok ? 'bg-green-50 text-green-700' : 'bg-amber-50 text-amber-700'">
<p><strong>Push-Status:</strong> {{ status.ok ? '✓ Gerät erreichbar' : '✗ Nicht erreichbar' }}</p>
<p v-if="status.result" class="text-xs break-all mt-1">{{ status.result }}</p>
</div>
<div v-if="iphone" class="grid grid-cols-2 gap-2 text-xs">
<div
class="flex items-center gap-2 p-2 rounded-lg"
:class="hasEnrollmentProfile ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-500'"
>
<UIcon :name="hasEnrollmentProfile ? 'i-heroicons-check-circle' : 'i-heroicons-x-circle'" class="w-4 h-4" />
<span>Enrollment-Profil</span>
</div>
</div>
<p v-if="error" class="text-sm text-red-600">
{{ error }}
</p>
<div v-if="logs.length > 0" class="text-xs bg-gray-100 p-3 rounded overflow-auto max-h-32">
<pre class="whitespace-pre-wrap">{{ logs.join('\n') }}</pre>
</div>
</div>
</UCard>
<div class="flex justify-between">
<UButton to="/supervise" variant="ghost" color="neutral">
Zurück
</UButton>
<UButton
to="/configure"
variant="solid"
color="primary"
:disabled="!canContinue"
>
Weiter
</UButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import QRCode from "qrcode";
import {
useTauri,
type LocalServerInfo,
} from "~/composables/useTauri";
import { useIphoneDevice } from "~/composables/useMagicState";
const {
downloadAndPatchEnrollmentProfile,
startLocalProfileServer,
stopLocalProfileServer,
getInstalledProfiles,
mdmPush,
} = useTauri();
const iphone = useIphoneDevice();
const profilePath = ref<string | null>(null);
const serverInfo = ref<LocalServerInfo | null>(null);
const qrDataUrl = ref<string>("");
const downloading = ref(false);
const checking = ref(false);
const error = ref<string | null>(null);
const logs = ref<string[]>([]);
const status = ref<{ ok: boolean; result?: string } | null>(null);
const ENROLLMENT_PROFILE_ID = "org.rebreak.mdm.enrollment";
const hasEnrollmentProfile = computed(() =>
iphone.value?.installedProfileIDs?.includes(ENROLLMENT_PROFILE_ID) ?? false,
);
const serverUrl = computed(() => serverInfo.value?.url ?? "");
const canContinue = computed(() =>
hasEnrollmentProfile.value || status.value?.ok === true,
);
onMounted(async () => {
if (iphone.value?.udid) {
await refreshProfileList();
}
});
onUnmounted(async () => {
await stopLocalProfileServer();
});
async function refreshProfileList() {
try {
const ids = await getInstalledProfiles();
if (iphone.value) {
iphone.value.installedProfileIDs = ids;
}
} catch (e: any) {
logs.value.push(`Profil-Liste nicht lesbar: ${e?.message ?? e}`);
}
}
async function downloadEnrollmentProfile() {
downloading.value = true;
error.value = null;
logs.value = [];
try {
if (!iphone.value?.udid) {
throw new Error("Kein iPhone erkannt. Bitte zurück zu Schritt 1.");
}
const url = `https://mdm.rebreak.org/enrollment/rebreak-enrollment.mobileconfig`;
logs.value.push(`→ Lade ${url}`);
const path = await downloadAndPatchEnrollmentProfile(url, iphone.value.udid);
profilePath.value = path;
logs.value.push(`✓ Profil gespeichert: ${path}`);
serverInfo.value = await startLocalProfileServer(path);
logs.value.push(`✓ Lokaler Server: ${serverInfo.value.url}`);
qrDataUrl.value = await QRCode.toDataURL(serverInfo.value.qr_payload, {
width: 192,
margin: 2,
});
} catch (e: any) {
error.value = e?.message ?? "Download fehlgeschlagen";
logs.value.push(`${error.value}`);
} finally {
downloading.value = false;
}
}
async function checkEnrollment() {
checking.value = true;
error.value = null;
status.value = null;
try {
await refreshProfileList();
if (!iphone.value?.udid) {
throw new Error("Kein iPhone erkannt.");
}
const push = await mdmPush(iphone.value.udid);
status.value = { ok: true, result: push.push_result };
logs.value.push(`✓ Push erreichbar: ${push.push_result}`);
} catch (e: any) {
status.value = { ok: false, result: e?.message ?? String(e) };
error.value = e?.message ?? "Prüfung fehlgeschlagen";
logs.value.push(`${error.value}`);
} finally {
checking.value = false;
}
}
</script>

View File

@ -0,0 +1,30 @@
<template>
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6">
<div class="max-w-md w-full text-center space-y-6">
<div class="w-20 h-20 mx-auto bg-[var(--rebreak-primary)] rounded-2xl flex items-center justify-center">
<UIcon name="i-heroicons-shield-check" class="w-10 h-10 text-white" />
</div>
<h1 class="text-3xl font-bold text-gray-900">
ReBreak Magic
</h1>
<p class="text-gray-600">
Öffne die ReBreak-App auf deinem iPhone, erzeuge einen Pairing-Code und gib ihn hier ein.
</p>
<UButton
to="/pair"
size="lg"
color="primary"
variant="solid"
block
>
Pairing-Code eingeben
</UButton>
<p class="text-xs text-gray-400">
ReBreak Magic für macOS & Windows
</p>
</div>
</div>
</template>

View File

@ -0,0 +1,131 @@
<template>
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6">
<div class="max-w-md w-full space-y-6">
<div class="text-center">
<h1 class="text-2xl font-bold text-gray-900">Pairing-Code</h1>
<p class="text-gray-600 mt-2">
Erstelle in der ReBreak-App einen 6-stelligen Pairing-Code.
</p>
</div>
<UCard>
<div class="space-y-6">
<div class="flex justify-center">
<UPinInput
v-model="code"
:length="6"
type="numeric"
otp
autofocus
placeholder="0"
class="font-mono text-lg"
:disabled="loading"
@complete="redeem"
/>
</div>
<UButton
color="primary"
block
:loading="loading"
:disabled="codeString.length < 6 || loading"
@click="redeem"
>
{{ loading ? "Wird geladen…" : "Koppeln" }}
</UButton>
<div v-if="loading && loadingMessage" class="flex items-center justify-center gap-2 text-sm text-gray-600">
<UIcon name="i-heroicons-arrow-path" class="w-4 h-4 animate-spin text-[var(--rebreak-primary)]" />
<span>{{ loadingMessage }}</span>
</div>
<p v-else-if="error" class="text-sm text-center text-red-600">
{{ error }}
</p>
</div>
</UCard>
<UButton to="/" variant="ghost" color="neutral" block :disabled="loading">
Zurück
</UButton>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { useTauri } from "~/composables/useTauri";
import { useMagicSession, useMagicDevices } from "~/composables/useMagicState";
const {
redeemPairingCode,
getPlatform,
getHardwareId,
registerDevice,
fetchMe,
getMagicDevices,
getMagicStatus,
} = useTauri();
const session = useMagicSession();
const devices = useMagicDevices();
// UPinInput can return either a string or an array of characters
const code = ref<string | string[]>("");
const loading = ref(false);
const error = ref<string | null>(null);
const loadingMessage = ref<string | null>(null);
const codeString = computed(() => {
if (Array.isArray(code.value)) {
return code.value.join("");
}
return code.value;
});
async function redeem() {
const value = codeString.value;
if (value.length < 6) return;
loading.value = true;
loadingMessage.value = "Pairing-Code wird eingelöst…";
error.value = null;
try {
await redeemPairingCode(value);
loadingMessage.value = "Registriere dieses Gerät…";
const [platformInfo, hardwareId] = await Promise.all([
getPlatform(),
getHardwareId(),
]);
const registered = await registerDevice(platformInfo.platform, platformInfo.version);
session.value = {
deviceId: registered.deviceId,
hardwareId,
dnsToken: registered.dnsToken,
profileUrl: registered.profileUrl,
};
loadingMessage.value = "Lade Profil und Geräte…";
const [_, deviceList] = await Promise.all([
fetchMe(),
getMagicDevices(),
]);
devices.value = deviceList;
loadingMessage.value = "Prüfe Schutzstatus…";
await getMagicStatus(registered.dnsToken);
await navigateTo("/status");
} catch (e: any) {
error.value = e?.message ?? "Koppeln fehlgeschlagen";
loadingMessage.value = null;
} finally {
loading.value = false;
}
}
</script>

View File

@ -0,0 +1,79 @@
<template>
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6">
<div class="max-w-md w-full space-y-6">
<div class="text-center">
<h1 class="text-2xl font-bold text-gray-900">Pre-Flight Check</h1>
<p class="text-gray-600 mt-2">
Bevor wir dein iPhone supervisieren, müssen ein paar Apple-Sicherheitschecks erledigt sein.
</p>
</div>
<UCard>
<div class="space-y-3">
<PreflightItem
v-model="checks.fmi"
title="Find My iPhone deaktiviert"
detail="Settings → [Apple-ID] → Wo ist? → Mein iPhone suchen → AUS. Ohne das blockiert Apple das Supervisieren."
:auto="iphone?.findMyEnabled === false"
/>
<PreflightItem
v-model="checks.sdp"
title="Stolen Device Protection ausgeschaltet"
detail="Settings → Face ID & Code → Schutz für gestohlene Geräte → AUS. SDP zwingt FMI an."
/>
<PreflightItem
v-model="checks.appleId"
title="Apple-ID-Passwort griffbereit"
detail="Apple fragt evtl. dein Apple-ID-PW während des FMI-Toggles ab."
/>
<PreflightItem
v-model="checks.appInstalled"
title="ReBreak-App ist auf dem iPhone installiert"
detail="Über TestFlight. Erst danach kann der Wizard die App in den Managed-State versetzen."
:auto="hasReBreakApp"
/>
</div>
</UCard>
<div class="flex justify-between">
<UButton to="/detect" variant="ghost" color="neutral">
Zurück
</UButton>
<UButton
to="/supervise"
variant="solid"
color="primary"
:disabled="!allChecked"
>
Supervisieren starten
</UButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { useIphoneDevice } from "~/composables/useMagicState";
import PreflightItem from "~/components/PreflightItem.vue";
const iphone = useIphoneDevice();
const checks = ref({
fmi: false,
sdp: false,
appleId: false,
appInstalled: false,
});
const hasReBreakApp = computed(() =>
iphone.value?.installedAppBundleIDs?.includes("org.rebreak.app") ?? false,
);
const allChecked = computed(() =>
(checks.value.fmi || iphone.value?.findMyEnabled === false) &&
checks.value.sdp &&
checks.value.appleId &&
(checks.value.appInstalled || hasReBreakApp.value),
);
</script>

View File

@ -0,0 +1,132 @@
<template>
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6">
<div class="max-w-md w-full space-y-6">
<div class="text-center">
<div class="w-16 h-16 mx-auto bg-[var(--rebreak-primary)] rounded-xl flex items-center justify-center mb-4">
<UIcon name="i-heroicons-qr-code" class="w-8 h-8 text-white" />
</div>
<h1 class="text-2xl font-bold text-gray-900">
Schutz-Profil installieren
</h1>
<p class="text-gray-600 mt-2">
Scanne den QR-Code mit deinem iPhone und installiere das Schutz-Profil.
</p>
</div>
<UCard class="text-center">
<div class="space-y-4">
<UButton
v-if="!serverInfo"
size="lg"
color="primary"
block
:loading="starting"
@click="startServer"
>
QR-Code generieren
</UButton>
<div v-else class="space-y-4">
<div class="bg-white p-4 rounded-lg inline-block">
<img :src="qrCodeDataUrl" alt="QR Code" class="w-48 h-48">
</div>
<p class="text-sm text-gray-500 break-all">
{{ serverInfo.url }}
</p>
<UButton
size="sm"
color="neutral"
variant="ghost"
@click="stopServer"
>
Server stoppen
</UButton>
</div>
<p v-if="error" class="text-sm text-red-600">
{{ error }}
</p>
</div>
</UCard>
<div class="bg-blue-50 text-blue-800 text-sm p-4 rounded-lg">
<p class="font-semibold mb-1">So geht's:</p>
<ol class="list-decimal list-inside space-y-1">
<li>iPhone-Kamera öffnen und QR-Code scannen</li>
<li>Link in Safari öffnen</li>
<li>Einstellungen" → „Profil installieren" tippen</li>
<li>Geräte-Passcode eingeben und bestätigen</li>
</ol>
</div>
<div class="flex justify-between">
<UButton to="/supervise" variant="ghost" color="neutral">
Zurück
</UButton>
<UButton to="/enroll" variant="solid" color="primary" :disabled="!serverInfo">
Weiter
</UButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onUnmounted } from "vue";
import QRCode from "qrcode";
import { useTauri, type LocalServerInfo } from "~/composables/useTauri";
import { useMagicSession } from "~/composables/useMagicState";
const { startLocalProfileServer, stopLocalProfileServer, downloadProfile } = useTauri();
const session = useMagicSession();
const starting = ref(false);
const serverInfo = ref<LocalServerInfo | null>(null);
const qrCodeDataUrl = ref("");
const error = ref<string | null>(null);
watch(
serverInfo,
async (info) => {
if (info) {
qrCodeDataUrl.value = await QRCode.toDataURL(info.qr_payload, {
width: 192,
margin: 2,
});
} else {
qrCodeDataUrl.value = "";
}
},
{ immediate: true },
);
onUnmounted(async () => {
if (serverInfo.value) {
await stopLocalProfileServer();
}
});
async function startServer() {
starting.value = true;
error.value = null;
try {
if (!session.value?.profileUrl) {
error.value = "Kein Profil verfügbar. Bitte zuerst das iPhone koppeln.";
return;
}
const profilePath = await downloadProfile(session.value.profileUrl);
serverInfo.value = await startLocalProfileServer(profilePath);
} catch (e: any) {
error.value = e?.message ?? "QR-Code konnte nicht erzeugt werden";
} finally {
starting.value = false;
}
}
async function stopServer() {
await stopLocalProfileServer();
serverInfo.value = null;
}
</script>

View File

@ -74,9 +74,11 @@
:loading="loading"
:has-refreshed="hasRefreshed"
:in-grace-period="subscriptionInGracePeriod"
:searching-for-device-id="searchingForDeviceId"
@sync="onIosSync"
@open="openDevice"
@remove="onIosRemove"
@connect="startIphoneSearch"
/>
<!-- Other devices list -->
@ -134,8 +136,8 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useTauri, type UserProfile } from "~/composables/useTauri";
import { ref, computed, onMounted, onUnmounted } from "vue";
import { useTauri, type UserProfile, type IphoneDeviceState } from "~/composables/useTauri";
import { useMagicSession, useMagicDevices, useIphoneDevice } from "~/composables/useMagicState";
import { useProtectionStatus } from "~/composables/useProtectionStatus";
import { useDeviceStatus, type ComputedDevice } from "~/composables/useDeviceStatus";
@ -169,6 +171,8 @@ const error = ref<string | null>(null);
const sheetOpen = ref(false);
const selectedDevice = ref<ComputedDevice | null>(null);
const platformInfo = ref<{ platform: string } | null>(null);
const searchingForDeviceId = ref<string | null>(null);
let searchInterval: ReturnType<typeof setInterval> | null = null;
// TODO: populate from backend once subscription/grace-period endpoint exists.
const subscriptionInGracePeriod = ref(false);
@ -200,6 +204,43 @@ onMounted(async () => {
await initCurrentDevice();
});
onUnmounted(() => {
stopIphoneSearch();
});
function matchesIphone(device: ComputedDevice, iphone: IphoneDeviceState): boolean {
if (device.mdmId && device.mdmId === iphone.udid) return true;
const modelMatch = (device.model ?? "").toLowerCase() === iphone.productType.toLowerCase();
const nameMatch = (device.name ?? "").toLowerCase() === iphone.name.toLowerCase();
return modelMatch || nameMatch;
}
async function startIphoneSearch(device: ComputedDevice) {
if (searchInterval) return;
searchingForDeviceId.value = device.deviceId;
error.value = null;
const check = async () => {
await protection.refreshIphone();
if (iphone.value && matchesIphone(device, iphone.value)) {
stopIphoneSearch();
}
};
await check();
if (!searchingForDeviceId.value) return;
searchInterval = setInterval(check, 1100);
}
function stopIphoneSearch() {
if (searchInterval) {
clearInterval(searchInterval);
searchInterval = null;
}
searchingForDeviceId.value = null;
}
async function initCurrentDevice() {
try {
const hardwareId = await getHardwareId();

View File

@ -0,0 +1,137 @@
<template>
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6">
<div class="max-w-md w-full space-y-6">
<div class="text-center">
<div class="w-16 h-16 mx-auto bg-[var(--rebreak-primary)] rounded-xl flex items-center justify-center mb-4">
<UIcon name="i-heroicons-lock-closed" class="w-8 h-8 text-white" />
</div>
<h1 class="text-2xl font-bold text-gray-900">
iPhone supervisieren
</h1>
<p class="text-gray-600 mt-2">
Wir schreiben die Supervision-Metadaten auf dein iPhone und starten es neu. Apps, Daten und Logins bleiben erhalten.
</p>
</div>
<UCard>
<div class="space-y-4">
<div
v-if="status"
class="text-sm space-y-1 p-3 rounded-lg"
:class="status.isSupervised ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-600'"
>
<p><strong>Status:</strong> {{ status.isSupervised ? 'Bereits supervised' : 'Nicht supervised' }}</p>
<p v-if="status.organizationName"><strong>Organisation:</strong> {{ status.organizationName }}</p>
<p v-if="status.findMyEnabled !== undefined"><strong>Find My:</strong> {{ status.findMyEnabled ? 'An' : 'Aus' }}</p>
</div>
<UButton
size="lg"
color="primary"
block
:loading="supervising"
:disabled="skipBecauseOwned"
@click="runSupervise"
>
{{ skipBecauseOwned ? 'Bereits von ReBreak supervised' : 'Supervision starten' }}
</UButton>
<div v-if="result" class="text-sm">
<p v-if="result.success" class="text-green-600">
Supervision abgeschlossen. Das iPhone startet neu.
</p>
<div v-else class="text-red-600 space-y-1">
<p> Supervision fehlgeschlagen</p>
<pre class="text-xs bg-gray-100 p-2 rounded overflow-auto">{{ result.stderr || result.stdout }}</pre>
</div>
</div>
<div v-if="logs.length > 0" class="text-xs bg-gray-100 p-3 rounded overflow-auto max-h-48">
<p class="font-semibold text-gray-700 mb-1">Logs:</p>
<pre class="whitespace-pre-wrap break-all">{{ logs.join('\n') }}</pre>
</div>
</div>
</UCard>
<div class="flex justify-between">
<UButton to="/preflight" variant="ghost" color="neutral">
Zurück
</UButton>
<UButton
to="/enroll"
variant="solid"
color="primary"
:disabled="!canContinue"
>
Weiter
</UButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { useTauri, type SuperviseResult, type SuperviseStatus } from "~/composables/useTauri";
import { useIphoneDevice } from "~/composables/useMagicState";
const { getSuperviseStatus, runSuperviseMagic } = useTauri();
const iphone = useIphoneDevice();
const status = ref<SuperviseStatus | null>(null);
const supervising = ref(false);
const result = ref<SuperviseResult | null>(null);
const logs = ref<string[]>([]);
const skipBecauseOwned = computed(() =>
status.value?.isSupervised && status.value?.organizationName?.toLowerCase() === "rebreak",
);
const canContinue = computed(() =>
skipBecauseOwned.value || result.value?.success === true,
);
onMounted(async () => {
try {
status.value = await getSuperviseStatus();
if (skipBecauseOwned.value) {
logs.value.push("✓ Bereits von ReBreak supervised — überspringe.");
if (iphone.value) {
iphone.value.isSupervised = true;
iphone.value.organizationName = "ReBreak";
}
}
} catch (e: any) {
logs.value.push(`✗ Status konnte nicht gelesen werden: ${e?.message ?? e}`);
}
});
async function runSupervise() {
supervising.value = true;
result.value = null;
logs.value = [];
try {
const force = !status.value?.isSupervised;
const args = ["-org", "ReBreak", "-yes"];
if (force) args.push("-force");
logs.value.push(`→ supervise-magic supervise ${args.join(" ")}`);
result.value = await runSuperviseMagic("supervise", args);
if (result.value.success) {
logs.value.push("✓ Supervision abgeschlossen.");
if (iphone.value) {
iphone.value.isSupervised = true;
iphone.value.organizationName = "ReBreak";
}
} else {
logs.value.push(`✗ Fehler: ${result.value.stderr || result.value.stdout}`);
}
} catch (e: any) {
result.value = { success: false, stdout: "", stderr: e?.message ?? String(e) };
logs.value.push(`✗ Exception: ${e?.message ?? e}`);
} finally {
supervising.value = false;
}
}
</script>

View File

@ -0,0 +1,30 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: "2026-06-14",
devtools: { enabled: false },
ssr: false,
srcDir: "app/",
nitro: {
preset: "static",
},
modules: ["@nuxt/ui", "@vueuse/nuxt"],
css: ["~/assets/css/main.css"],
colorMode: {
preference: "light",
fallback: "light",
},
ui: {
theme: {
colors: {
primary: "blue",
},
},
},
vite: {
// Tauri nutzt sein eigenes Dev-Server-Schema; CORS-Policy anpassen
server: {
strictPort: true,
port: 1420,
},
},
});

View File

@ -0,0 +1,34 @@
{
"name": "@rebreak/magic",
"type": "module",
"private": true,
"version": "0.1.0",
"scripts": {
"dev": "nuxt dev --port 1420",
"build": "nuxt build",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build"
},
"dependencies": {
"@nuxt/ui": "^4.5.1",
"@nuxt/icon": "^1.10.0",
"@vueuse/core": "^14.2.1",
"@vueuse/nuxt": "^14.2.1",
"nuxt": "4.1.3",
"qrcode": "^1.5.4",
"tailwindcss": "^4.1.18",
"vue": "^3.5.22",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@iconify-json/heroicons": "^1.2.3",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/cli": "^2.0.0",
"@types/qrcode": "^1.5.5",
"typescript": "^5.9.3"
}
}

5680
apps/rebreak-magic/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,45 @@
[package]
name = "rebreak-magic"
version = "0.1.0"
description = "ReBreak Magic — Unified Desktop Protection (macOS + Windows)"
authors = ["Rebreak"]
edition = "2021"
[lib]
name = "rebreak_magic_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
tauri-plugin-fs = "2"
tauri-plugin-http = "2"
tauri-plugin-os = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["serde"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] }
anyhow = "1"
uuid = { version = "1", features = ["v4"] }
base64 = "0.22"
dirs = "5"
tiny_http = "0.12"
local-ip-address = "0.6"
# Pin brotli family to avoid alloc-no-stdlib version conflict
brotli-decompressor = "=5.0.1"
alloc-stdlib = "=0.2.2"
# Plattform-spezifischer Credential-Speicher
[target.'cfg(windows)'.dependencies]
keyring = { version = "3", features = ["windows-native"] }
[target.'cfg(not(windows))'.dependencies]
keyring = { version = "3", features = ["apple-native"] }
[profile.release]
strip = true
lto = true

View File

@ -0,0 +1,45 @@
# Tauri Sidecar Binaries
This directory holds native binaries that are bundled with the ReBreak Magic app.
## `supervise-magic`
Go binary that puts an iPhone/iPad into supervised mode without erasing data.
### Build
```bash
cd ../../ops/mdm/supervise-magic
# macOS (Apple Silicon)
make build-arm64
# macOS (Intel)
make build-amd64
# Windows (x86_64)
make build-windows-amd64
```
### Naming convention for Tauri
Tauri expects sidecar binaries in this directory with the following naming pattern:
```
supervise-magic-<target-triple>[.exe]
```
Common target triples:
- `aarch64-apple-darwin` (Apple Silicon)
- `x86_64-apple-darwin` (Intel Mac)
- `x86_64-pc-windows-msvc` (Windows)
After building, copy or symlink the binary into this directory with the correct target-triple name.
Example:
```bash
cp ops/mdm/supervise-magic/bin/supervise-magic-darwin-arm64 \
apps/rebreak-magic/src-tauri/binaries/supervise-magic-aarch64-apple-darwin
```
Tauri will bundle the matching binary at build time.

View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@ -0,0 +1,21 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capabilities for ReBreak Magic",
"windows": ["main"],
"permissions": [
"core:default",
"shell:default",
"shell:allow-execute",
"shell:allow-spawn",
"fs:default",
"fs:allow-read-file",
"fs:allow-write-file",
"fs:allow-read-dir",
"fs:allow-app-read",
"fs:allow-app-write",
"http:default",
"http:allow-fetch",
"os:default"
]
}

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Allow JIT compilation for the web runtime -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<!-- Allow unsigned executable memory (needed for Tauri + sidecars) -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- Disable library validation so the supervise-magic sidecar can load its own libs -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- USB device access for iPhone/iPad detection -->
<key>com.apple.security.device.usb</key>
<true/>
<!-- Apple Events automation (helper tools may need this) -->
<key>com.apple.security.automation.apple-events</key>
<true/>
<!-- Files: user-selected and application-support for session/config storage -->
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.bookmarks.app-scope</key>
<true/>
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

View File

@ -125,6 +125,18 @@ pub struct MdmLinkRequest {
pub mdm_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReportProtectionStateRequest {
#[serde(rename = "deviceId")]
pub device_id: String,
pub platform: String,
#[serde(rename = "protectionType")]
pub protection_type: String,
pub active: bool,
pub reason: Option<String>,
pub source: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiEnvelope<T> {
pub success: bool,
@ -437,6 +449,38 @@ impl MagicApiClient {
.map(|_| ())
}
pub async fn report_device_protection_state(
&self,
token: &str,
device_id: &str,
platform: &str,
protection_type: &str,
active: bool,
reason: Option<&str>,
) -> AppResult<()> {
let url = format!("{}/api/devices/protection-state", self.base_url);
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", token))
.json(&ReportProtectionStateRequest {
device_id: device_id.to_string(),
platform: platform.to_string(),
protection_type: protection_type.to_string(),
active,
reason: reason.map(|s| s.to_string()),
source: Some("magic".to_string()),
})
.send()
.await
.map_err(|e| AppError::new(format!("Network error: {}", e)))?;
Self::handle_response::<ApiEnvelope<serde_json::Value>>(response)
.await
.map(|_| ())
}
async fn handle_response<T: serde::de::DeserializeOwned>(
response: reqwest::Response,
) -> AppResult<T> {

View File

@ -0,0 +1 @@
pub mod api;

View File

@ -0,0 +1,43 @@
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Serialize, Deserialize)]
pub struct AppError {
pub message: String,
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for AppError {}
impl AppError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl From<anyhow::Error> for AppError {
fn from(value: anyhow::Error) -> Self {
Self::new(value.to_string())
}
}
impl From<std::io::Error> for AppError {
fn from(value: std::io::Error) -> Self {
Self::new(value.to_string())
}
}
impl From<serde_json::Error> for AppError {
fn from(value: serde_json::Error) -> Self {
Self::new(value.to_string())
}
}
pub type AppResult<T> = Result<T, AppError>;

View File

@ -0,0 +1,410 @@
use crate::config::AppConfig;
use crate::error::{AppError, AppResult};
use crate::sidecar::supervise_magic::run_supervise_magic_raw;
use serde::{Deserialize, Serialize};
use std::process::Command;
const CFGUTIL_CANDIDATES: &[&str] = &[
"/Applications/Apple Configurator.app/Contents/MacOS/cfgutil",
"/Applications/Apple Configurator 2.app/Contents/MacOS/cfgutil",
];
fn first_executable(candidates: &[&str]) -> Option<String> {
candidates
.iter()
.find(|path| std::path::Path::new(path).is_file())
.map(|path| path.to_string())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IphoneDeviceState {
pub udid: String,
pub name: String,
pub product_type: String,
pub product_version: String,
pub is_supervised: bool,
pub organization_name: Option<String>,
pub find_my_enabled: Option<bool>,
#[serde(rename = "installedProfileIDs")]
pub installed_profile_ids: Vec<String>,
#[serde(rename = "installedAppBundleIDs")]
pub installed_app_bundle_ids: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SuperviseStatus {
pub is_supervised: bool,
pub organization_name: Option<String>,
pub find_my_enabled: Option<bool>,
}
fn normalize_org_name(value: &str) -> String {
value
.trim()
.trim_matches(|c| c == '"' || c == '\'')
.to_string()
}
/// Parse ` Key: Value` (check Format).
fn parse_colon(stdout: &str, key: &str) -> Option<String> {
for raw in stdout.lines() {
let trimmed = raw.trim();
if let Some(rest) = trimmed.strip_prefix(&format!("{}:", key)) {
return Some(rest.trim().to_string());
}
}
None
}
/// Parse ` Key = Value` (cloud-config Format).
fn parse_equals(stdout: &str, key: &str) -> Option<String> {
for raw in stdout.lines() {
let trimmed = raw.trim();
if let Some((k, v)) = trimmed.split_once('=') {
if k.trim() == key {
return Some(v.trim().to_string());
}
}
}
None
}
async fn run_supervise_magic_cmd(
app: tauri::AppHandle,
action: &str,
args: &[&str],
) -> AppResult<String> {
let result = run_supervise_magic_raw(app, action, args).await?;
if !result.success {
return Err(AppError::new(format!(
"supervise-magic {} failed: {}",
action,
result.stderr
)));
}
Ok(result.stdout)
}
fn run_cfgutil(binary: &str, args: &[&str]) -> AppResult<(String, String, bool)> {
let output = Command::new(binary)
.args(args)
.output()
.map_err(|e| AppError::new(format!("Failed to run cfgutil ({}): {}", binary, e)))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Ok((stdout, stderr, output.status.success()))
}
pub async fn read_supervise_status(app: tauri::AppHandle) -> AppResult<SuperviseStatus> {
let mut status = SuperviseStatus {
is_supervised: false,
organization_name: None,
find_my_enabled: None,
};
// 1) cloud-config liest IsSupervised + OrganizationName direkt aus MCInstall.
if let Ok(stdout) = run_supervise_magic_cmd(app.clone(), "cloud-config", &[]).await {
if let Some(v) = parse_equals(&stdout, "IsSupervised") {
status.is_supervised = v.to_lowercase() == "true";
}
if let Some(v) = parse_equals(&stdout, "OrganizationName") {
status.organization_name = Some(normalize_org_name(&v));
}
}
// 2) check gibt zusätzlich FindMyEnabled. Wenn check fehlschlägt, ist kein
// Gerät verbunden — dann liefern wir den Default-Status zurück.
let check_stdout = match run_supervise_magic_cmd(app.clone(), "check", &[]).await {
Ok(stdout) => stdout,
Err(_) => return Ok(status),
};
if let Some(v) = parse_colon(&check_stdout, "FindMyEnabled") {
status.find_my_enabled = Some(v.to_lowercase() == "true");
}
if !status.is_supervised {
if let Some(v) = parse_colon(&check_stdout, "IsSupervised") {
status.is_supervised = v.to_lowercase() == "true";
}
}
if status.organization_name.is_none() {
if let Some(v) = parse_colon(&check_stdout, "OrganizationName")
.or_else(|| parse_colon(&check_stdout, "SupervisionOrganizationName"))
{
status.organization_name = Some(normalize_org_name(&v));
}
}
Ok(status)
}
pub fn installed_profile_ids() -> AppResult<Vec<String>> {
#[cfg(target_os = "macos")]
{
let cfgutil = first_executable(CFGUTIL_CANDIDATES)
.ok_or_else(|| AppError::new("cfgutil nicht gefunden — bitte Apple Configurator installieren.".to_string()))?;
let args = ["--foreach", "get", "configurationProfiles"];
eprintln!("[ios_device] cfgutil profiles: binary={} args={:?}", cfgutil, args);
let (stdout, stderr, success) = run_cfgutil(&cfgutil, &args)?;
eprintln!(
"[ios_device] cfgutil profiles: success={} stderr_len={} stdout_len={} stderr={:?}",
success, stderr.len(), stdout.len(), stderr
);
eprintln!("[ios_device] cfgutil profiles stdout:\n{}", stdout);
// cfgutil mit --foreach liefert auf manchen Setups Exit-Code 1, obwohl die Ausgabe gültig ist.
// Wir behandeln es als Fehler nur, wenn stderr einen Fehler enthält.
if !success && !stderr.trim().is_empty() {
return Err(AppError::new(format!("cfgutil failed: {}", stderr)));
}
let list = parse_cfgutil_list(&stdout);
eprintln!("[ios_device] cfgutil profiles parsed: {:?}", list);
return Ok(list);
}
#[cfg(not(target_os = "macos"))]
{
// cfgutil is only available on macOS. On Windows we cannot enumerate profiles locally.
Ok(Vec::new())
}
}
pub fn installed_app_bundle_ids() -> AppResult<Vec<String>> {
#[cfg(target_os = "macos")]
{
let cfgutil = first_executable(CFGUTIL_CANDIDATES)
.ok_or_else(|| AppError::new("cfgutil nicht gefunden — bitte Apple Configurator installieren.".to_string()))?;
let args = ["--foreach", "get", "installedApps"];
eprintln!("[ios_device] cfgutil apps: binary={} args={:?}", cfgutil, args);
let (stdout, stderr, success) = run_cfgutil(&cfgutil, &args)?;
eprintln!(
"[ios_device] cfgutil apps: success={} stderr_len={} stdout_len={} stderr={:?}",
success, stderr.len(), stdout.len(), stderr
);
eprintln!("[ios_device] cfgutil apps stdout:\n{}", stdout);
if !success && !stderr.trim().is_empty() {
return Err(AppError::new(format!("cfgutil failed: {}", stderr)));
}
let list = parse_cfgutil_list(&stdout);
eprintln!("[ios_device] cfgutil apps parsed: {:?}", list);
return Ok(list);
}
#[cfg(not(target_os = "macos"))]
{
Ok(Vec::new())
}
}
fn parse_cfgutil_list(stdout: &str) -> Vec<String> {
stdout
.lines()
.filter_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
// First token = identifier (split by tab or space)
trimmed
.split(|c: char| c == '\t' || c == ' ')
.next()
.map(|s| s.to_string())
})
.collect()
}
fn install_profile_via_cfgutil(path: &str) -> AppResult<()> {
#[cfg(not(target_os = "macos"))]
{
return Err(AppError::new("Lokale Profil-Installation via cfgutil ist nur auf macOS verfügbar. Bitte das Profil per QR-Code installieren.".to_string()));
}
#[cfg(target_os = "macos")]
let cfgutil = first_executable(CFGUTIL_CANDIDATES)
.ok_or_else(|| AppError::new("cfgutil nicht gefunden — bitte Apple Configurator installieren.".to_string()))?;
#[cfg(target_os = "macos")]
let (_, stderr, success) = run_cfgutil(&cfgutil, &["--foreach", "install-profile", path])?;
#[cfg(target_os = "macos")]
if !success {
let err = stderr.trim();
if err.to_lowercase().contains("device is locked") {
return Err(AppError::new("iPhone ist gesperrt. Bitte entsperren und erneut versuchen.".to_string()));
}
if err.to_lowercase().contains("user interaction")
|| err.to_lowercase().contains("benutzerinteraktion")
|| err.contains("MCInstallationErrorDomain Code: 4009")
{
return Err(AppError::new("iOS verlangt eine Bestätigung direkt am iPhone.".to_string()));
}
if err.contains("DMCInstallationErrorDomain") && err.contains("Code: 4020") {
return Err(AppError::new("Lokale Profil-Installation ist durch iOS-Policy blockiert (DMC 4020).".to_string()));
}
return Err(AppError::new(format!("Profil-Installation fehlgeschlagen: {}", err)));
}
Ok(())
}
pub async fn detect_device_state(app: tauri::AppHandle) -> AppResult<Option<IphoneDeviceState>> {
let check_stdout = match run_supervise_magic_cmd(app.clone(), "check", &[]).await {
Ok(stdout) => stdout,
Err(_) => return Ok(None), // Kein Gerät verbunden
};
let udid = match parse_colon(&check_stdout, "UDID") {
Some(v) => v,
None => return Ok(None),
};
let name = parse_colon(&check_stdout, "Name").unwrap_or_else(|| "iPhone".to_string());
let product_type = parse_colon(&check_stdout, "Type").unwrap_or_default();
let product_version = parse_colon(&check_stdout, "iOS")
.or_else(|| parse_colon(&check_stdout, "ProductVersion"))
.unwrap_or_default();
let status = read_supervise_status(app.clone()).await?;
let installed_profile_ids = tokio::task::spawn_blocking(installed_profile_ids)
.await
.map_err(|e| AppError::new(format!("profile detection task failed: {}", e)))?
.unwrap_or_else(|e| {
eprintln!("[ios_device] installed_profile_ids failed: {}", e);
Vec::new()
});
let installed_app_bundle_ids = tokio::task::spawn_blocking(installed_app_bundle_ids)
.await
.map_err(|e| AppError::new(format!("app detection task failed: {}", e)))?
.unwrap_or_else(|e| {
eprintln!("[ios_device] installed_app_bundle_ids failed: {}", e);
Vec::new()
});
eprintln!(
"[ios_device] detect result: udid={} profiles={:?} apps={:?}",
udid, installed_profile_ids, installed_app_bundle_ids
);
Ok(Some(IphoneDeviceState {
udid,
name,
product_type,
product_version,
is_supervised: status.is_supervised,
organization_name: status.organization_name,
find_my_enabled: status.find_my_enabled,
installed_profile_ids,
installed_app_bundle_ids,
}))
}
// MARK: - Tauri Commands
#[tauri::command]
pub async fn detect_iphone_state(app: tauri::AppHandle) -> AppResult<Option<IphoneDeviceState>> {
detect_device_state(app).await
}
#[tauri::command]
pub async fn get_supervise_status(app: tauri::AppHandle) -> AppResult<SuperviseStatus> {
read_supervise_status(app).await
}
#[tauri::command]
pub fn get_installed_profiles() -> AppResult<Vec<String>> {
installed_profile_ids()
}
#[tauri::command]
pub fn get_installed_apps() -> AppResult<Vec<String>> {
installed_app_bundle_ids()
}
#[tauri::command]
pub fn install_profile(path: String) -> AppResult<()> {
install_profile_via_cfgutil(&path)
}
#[tauri::command]
pub async fn download_and_patch_enrollment_profile(url: String, udid: String) -> AppResult<String> {
let response = reqwest::get(&url)
.await
.map_err(|e| AppError::new(format!("Failed to download enrollment profile: {}", e)))?;
if !response.status().is_success() {
return Err(AppError::new(format!(
"MDM server returned status {}",
response.status()
)));
}
let mut text = response
.text()
.await
.map_err(|e| AppError::new(format!("Failed to read enrollment profile: {}", e)))?;
text = text.replace("%SerialNumber%", &udid);
text = text.replace("%UDID%", &udid);
let config_dir = AppConfig::config_dir()?;
std::fs::create_dir_all(&config_dir)?;
let profile_path = config_dir.join("rebreak-enrollment.mobileconfig");
std::fs::write(&profile_path, text)?;
Ok(profile_path.to_string_lossy().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_equals_handles_multiple_spaces() {
let input = "OrganizationName = ReBreak\nIsSupervised = true";
assert_eq!(
parse_equals(input, "OrganizationName"),
Some("ReBreak".to_string())
);
assert_eq!(
parse_equals(input, "IsSupervised"),
Some("true".to_string())
);
}
#[test]
fn parse_cfgutil_list_extracts_bundle_ids() {
let input = "com.example.app\tExample App v1\norg.rebreak.app\tReBreak v2\n\n";
let list = parse_cfgutil_list(input);
assert_eq!(list, vec!["com.example.app", "org.rebreak.app"]);
}
#[test]
fn parse_cfgutil_list_handles_empty_profile_output() {
// cfgutil --foreach get configurationProfiles kann leere stdout + Exit 1 liefern.
assert!(parse_cfgutil_list("").is_empty());
assert!(parse_cfgutil_list(" \n\n").is_empty());
}
#[test]
fn iphone_state_serializes_id_casing() {
let state = IphoneDeviceState {
udid: "u".into(),
name: "n".into(),
product_type: "iPhone".into(),
product_version: "26".into(),
is_supervised: true,
organization_name: None,
find_my_enabled: None,
installed_profile_ids: vec!["org.rebreak.mdm.enrollment".into()],
installed_app_bundle_ids: vec!["org.rebreak.app".into()],
};
let json = serde_json::to_value(&state).unwrap();
assert!(json.get("installedProfileIDs").is_some(), "expected installedProfileIDs");
assert!(json.get("installedAppBundleIDs").is_some(), "expected installedAppBundleIDs");
assert!(json.get("installedProfileIds").is_none(), "unexpected installedProfileIds");
}
#[test]
#[cfg(target_os = "macos")]
#[ignore = "requires connected iOS device"]
fn cfgutil_live_outputs() {
eprintln!("installed_profile_ids = {:?}", installed_profile_ids());
eprintln!("installed_app_bundle_ids = {:?}", installed_app_bundle_ids());
}
}

View File

@ -54,6 +54,7 @@ pub fn run() {
get_mdm_status,
get_mdm_status_by_udid,
link_mdm_device,
report_device_protection_state,
get_desktop_protection_status,
set_desktop_protection_status,
get_hostname,
@ -236,6 +237,29 @@ async fn link_mdm_device(device_id: String, mdm_id: String) -> AppResult<()> {
client.link_mdm_device(&session.access_token, &device_id, &mdm_id).await
}
#[tauri::command]
async fn report_device_protection_state(
device_id: String,
platform: String,
protection_type: String,
active: bool,
reason: Option<String>,
) -> AppResult<()> {
let session = require_session()?;
let config = AppConfig::load();
let client = MagicApiClient::new(&config);
client
.report_device_protection_state(
&session.access_token,
&device_id,
&platform,
&protection_type,
active,
reason.as_deref(),
)
.await
}
#[tauri::command]
async fn get_mdm_status_by_udid(udid: String) -> AppResult<MdmStatusByUdidData> {
let session = require_session()?;

View File

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
rebreak_magic_lib::run();
}

View File

@ -0,0 +1,142 @@
use crate::config::AppConfig;
use crate::error::{AppError, AppResult};
use serde::{Deserialize, Serialize};
const HTTP_TIMEOUT_SECONDS: u64 = 30;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MdmPushStatus {
pub udid: String,
pub push_result: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MdmEnqueueResult {
pub command_uuid: String,
pub response_body: String,
}
pub struct MdmClient {
client: reqwest::Client,
server: String,
auth_header: String,
}
impl MdmClient {
pub fn new() -> AppResult<Self> {
let cfg = AppConfig::load_binder_config()?;
let creds = format!("{}:{}", cfg.mdm_user, cfg.mdm_api_key);
use base64::{engine::general_purpose::STANDARD, Engine};
let auth_header = format!("Basic {}", STANDARD.encode(creds));
Ok(Self {
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECONDS))
.build()
.map_err(|e| AppError::new(format!("reqwest client build: {}", e)))?,
server: cfg.mdm_server,
auth_header,
})
}
fn url(&self, path: &str) -> AppResult<String> {
let base = self.server.trim_end_matches('/');
Ok(format!("{}{}", base, path))
}
fn request(&self, method: reqwest::Method, path: &str) -> AppResult<reqwest::RequestBuilder> {
let url = self.url(path)?;
Ok(self
.client
.request(method, &url)
.header("Authorization", &self.auth_header))
}
pub async fn ping(&self) -> AppResult<String> {
let resp = self
.request(reqwest::Method::GET, "/version")?
.send()
.await
.map_err(|e| AppError::new(format!("MDM ping failed: {}", e)))?;
let status = resp.status();
let body = resp
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
if !status.is_success() {
return Err(AppError::new(format!("MDM ping HTTP {}: {}", status, body)));
}
Ok(body)
}
pub async fn push(&self, udid: &str) -> AppResult<MdmPushStatus> {
let resp = self
.request(reqwest::Method::GET, &format!("/v1/push/{}", udid))?
.send()
.await
.map_err(|e| AppError::new(format!("MDM push failed: {}", e)))?;
let status = resp.status();
let body = resp
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
if !status.is_success() {
return Err(AppError::new(format!("MDM push HTTP {}: {}", status, body)));
}
// Parse NanoMDM response: { "status": { "<udid>": { "push_result": "..." } } }
let parsed: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| AppError::new(format!("MDM push parse error: {} — body: {}", e, body)))?;
let push_result = parsed
.get("status")
.and_then(|s| s.get(udid))
.and_then(|d| d.get("push_result"))
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::new(format!("MDM push response unerwartet: {}", body)))?
.to_string();
Ok(MdmPushStatus {
udid: udid.to_string(),
push_result,
})
}
pub async fn enqueue(&self, udid: &str, command: serde_json::Value) -> AppResult<MdmEnqueueResult> {
let command_uuid = uuid::Uuid::new_v4().to_string();
let envelope = serde_json::json!({
"CommandUUID": command_uuid,
"Command": command,
});
// NanoMDM expects plist, but also accepts JSON in newer versions.
// For maximum compatibility we send JSON here; if it fails, the caller
// will see the error.
let resp = self
.request(reqwest::Method::PUT, &format!("/v1/enqueue/{}?push=1", udid))?
.header("Content-Type", "application/json")
.json(&envelope)
.send()
.await
.map_err(|e| AppError::new(format!("MDM enqueue failed: {}", e)))?;
let status = resp.status();
let body = resp
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
if !status.is_success() {
return Err(AppError::new(format!("MDM enqueue HTTP {}: {}", status, body)));
}
Ok(MdmEnqueueResult {
command_uuid,
response_body: body,
})
}
}

View File

@ -0,0 +1,89 @@
pub mod client;
use crate::error::AppResult;
use client::MdmClient;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MdmCommandResult {
pub command_uuid: String,
pub response_body: String,
}
#[tauri::command]
pub async fn mdm_ping() -> AppResult<String> {
MdmClient::new()?.ping().await
}
#[tauri::command]
pub async fn mdm_push(udid: String) -> AppResult<client::MdmPushStatus> {
MdmClient::new()?.push(&udid).await
}
#[tauri::command]
pub async fn mdm_install_app(udid: String) -> AppResult<MdmCommandResult> {
let command = serde_json::json!({
"RequestType": "InstallApplication",
"ManifestURL": "https://mdm.rebreak.org/install/manifest.plist",
"ManagementFlags": 0,
});
let r = MdmClient::new()?.enqueue(&udid, command).await?;
Ok(MdmCommandResult {
command_uuid: r.command_uuid,
response_body: r.response_body,
})
}
#[tauri::command]
pub async fn mdm_set_supervised_mode(udid: String) -> AppResult<MdmCommandResult> {
let command = serde_json::json!({
"RequestType": "Settings",
"Settings": [
{
"Item": "ApplicationConfiguration",
"Identifier": "org.rebreak.app",
"Configuration": {
"mdmSupervised": true,
},
},
],
});
let r = MdmClient::new()?.enqueue(&udid, command).await?;
Ok(MdmCommandResult {
command_uuid: r.command_uuid,
response_body: r.response_body,
})
}
#[tauri::command]
pub async fn mdm_take_management(udid: String) -> AppResult<MdmCommandResult> {
let command = serde_json::json!({
"RequestType": "InstallApplication",
"Identifier": "org.rebreak.app",
"ChangeManagementState": "Managed",
"ManagementFlags": 0,
});
let r = MdmClient::new()?.enqueue(&udid, command).await?;
Ok(MdmCommandResult {
command_uuid: r.command_uuid,
response_body: r.response_body,
})
}
#[tauri::command]
pub async fn mdm_install_lock_profile(udid: String, profile_path: String) -> AppResult<MdmCommandResult> {
let bytes = std::fs::read(&profile_path)?;
let payload_b64 = {
use base64::{engine::general_purpose::STANDARD, Engine};
STANDARD.encode(&bytes)
};
let command = serde_json::json!({
"RequestType": "InstallProfile",
"Payload": payload_b64,
});
let r = MdmClient::new()?.enqueue(&udid, command).await?;
Ok(MdmCommandResult {
command_uuid: r.command_uuid,
response_body: r.response_body,
})
}

View File

@ -0,0 +1,87 @@
use crate::error::{AppError, AppResult};
use serde::{Deserialize, Serialize};
use std::fs;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use tiny_http::{Response, Server};
static SERVER_RUNNING: AtomicBool = AtomicBool::new(false);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalServerInfo {
pub url: String,
pub qr_payload: String,
}
#[tauri::command]
pub fn start_local_profile_server(profile_path: String) -> AppResult<LocalServerInfo> {
if SERVER_RUNNING.load(Ordering::SeqCst) {
return Err(AppError::new("Local server is already running"));
}
let path = PathBuf::from(profile_path);
if !path.exists() {
return Err(AppError::new(format!(
"Profile not found at {}",
path.display()
)));
}
// Try to bind to a port; fall back if 8123 is taken
let port = find_free_port(8123)?;
let addr: SocketAddr = format!("0.0.0.0:{}", port).parse().map_err(|e| {
AppError::new(format!("Failed to parse socket address: {}", e))
})?;
let server = Server::http(addr).map_err(|e| {
AppError::new(format!("Failed to start local HTTP server: {}", e))
})?;
let local_ip = local_ip_address::local_ip().ok();
let host = local_ip
.map(|ip| ip.to_string())
.unwrap_or_else(|| "localhost".to_string());
let url = format!("http://{}:{}/profile.mobileconfig", host, port);
let qr_payload = url.clone();
let profile_bytes = fs::read(&path)?;
SERVER_RUNNING.store(true, Ordering::SeqCst);
thread::spawn(move || {
for request in server.incoming_requests() {
let response = Response::from_data(profile_bytes.clone())
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"application/x-apple-aspen-config"[..],
)
.unwrap(),
);
let _ = request.respond(response);
}
SERVER_RUNNING.store(false, Ordering::SeqCst);
});
Ok(LocalServerInfo { url, qr_payload })
}
#[tauri::command]
pub fn stop_local_profile_server() -> AppResult<()> {
// tiny_http does not support graceful shutdown out of the box.
// In a real implementation, store the server handle and close it.
SERVER_RUNNING.store(false, Ordering::SeqCst);
Ok(())
}
fn find_free_port(start: u16) -> AppResult<u16> {
for port in start..start + 100 {
match std::net::TcpListener::bind(format!("0.0.0.0:{}", port)) {
Ok(_) => return Ok(port),
Err(_) => continue,
}
}
Err(AppError::new("No free port found in range 8123-8222"))
}

View File

@ -0,0 +1 @@
pub mod local_http;

View File

@ -0,0 +1 @@
pub mod supervise_magic;

View File

@ -0,0 +1,63 @@
use crate::error::{AppError, AppResult};
use serde::{Deserialize, Serialize};
use std::process::Command;
use tauri_plugin_shell::ShellExt;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SuperviseResult {
pub success: bool,
pub stdout: String,
pub stderr: String,
}
pub async fn run_supervise_magic_raw(
app: tauri::AppHandle,
action: &str,
args: &[&str],
) -> AppResult<SuperviseResult> {
let sidecar = app
.shell()
.sidecar("supervise-magic")
.map_err(|e| AppError::new(format!("Failed to locate supervise-magic sidecar: {}", e)))?;
let mut cmd = sidecar.arg(action);
for a in args {
cmd = cmd.arg(a);
}
let output = cmd
.output()
.await
.map_err(|e| AppError::new(format!("Failed to run supervise-magic: {}", e)))?;
Ok(SuperviseResult {
success: output.status.success(),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
})
}
#[tauri::command]
pub async fn run_supervise_magic(
app: tauri::AppHandle,
action: String,
args: Option<Vec<String>>,
) -> AppResult<SuperviseResult> {
let args: Vec<&str> = args.as_ref().map(|v| v.iter().map(|s| s.as_str()).collect()).unwrap_or_default();
run_supervise_magic_raw(app, &action, &args).await
}
// Synchronous fallback for direct shell execution
#[allow(dead_code)]
pub fn run_supervise_magic_sync(binary_path: &str, action: &str) -> AppResult<SuperviseResult> {
let output = Command::new(binary_path)
.arg(action)
.output()
.map_err(|e| AppError::new(format!("Failed to run supervise-magic: {}", e)))?;
Ok(SuperviseResult {
success: output.status.success(),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
})
}

View File

@ -0,0 +1,46 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ReBreak Magic",
"version": "0.1.0",
"identifier": "org.rebreak.magic",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm build",
"frontendDist": "../.output/public"
},
"app": {
"windows": [
{
"title": "ReBreak Magic",
"width": 900,
"height": 700,
"minWidth": 800,
"minHeight": 600,
"center": true,
"resizable": true
}
],
"security": {
"csp": null,
"capabilities": ["default"]
}
},
"bundle": {
"active": true,
"targets": ["app", "nsis"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.ico",
"icons/icon.png"
],
"externalBin": ["binaries/supervise-magic"],
"macOS": {
"entitlements": "./entitlements.plist",
"frameworks": [],
"minimumSystemVersion": "10.13"
}
}
}

View File

@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}

View File

@ -0,0 +1,212 @@
# iOS-Schutz / NEFilter Architektur-Übersicht
> Stand: 2026-06-17
> Zusammengefasst aus `apps/rebreak-native`, `backend/server` und `apps/rebreak-magic`.
## 1. Ziel
Auf iOS gibt es zwei Schutz-Layer:
1. **NEFilter (Content Filter)** aktiviert durch ein `webcontent-filter`-Profil (Sideload/MDM). Das ist der bevorzugte Weg für supervised/managed Geräte.
2. **Packet-Tunnel DNS-Sinkhole** Fallback für unsupervised Geräte, sichtbar als VPN.
Dieses Dokument beschreibt, wie NEFilter aufgebaut ist und wie das Backend erkennt, ob es aktiv ist **ohne** einen neuen Endpoint anlegen zu müssen.
---
## 2. Native iOS-Module (Expo)
Hauptdateien unter `apps/rebreak-native/modules/rebreak-protection/ios/`:
| Datei | Aufgabe |
|-------|---------|
| `RebreakProtectionModule.swift` | React-Native-Brücke, zentrale API (`isNeFilterActive`, `activateUrlFilter`, `getDeviceState`) |
| `RebreakContentFilter/FilterDataProvider.swift` | `NEFilterDataProvider`, prüft URLs gegen `blocklist.bin` |
| `RebreakPacketTunnelExtension/PacketTunnelProvider.swift` | VPN-DNS-Sinkhole Fallback |
| `RebreakPacketTunnelExtension/DnsFilter.swift` / `HashList.swift` / `DomainHasher.swift` | Domain-Blocking-Logik für den VPN-Pfad |
| `RebreakURLFilterExtension/` | NEURLFilter (iOS 26), aktuell nicht im Standard-Flow aktiv |
Konfiguration/Targets:
- `apps/rebreak-native/plugins/with-rebreak-protection-ios.js`
- `apps/rebreak-native/app.config.ts` (`appExtensions` Block für EAS)
---
## 3. Wie NEFilter erkannt wird
Die App liest den System-Status direkt aus:
```swift
// apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift ~782
AsyncFunction("isNeFilterActive") { () async -> [String: Any] in
var enabled = false
do {
let manager = NEFilterManager.shared()
try await manager.loadFromPreferences()
enabled = manager.isEnabled
} catch let e as NSError { ... }
return ["enabled": enabled]
}
```
Wichtig:
- Build 19+ aktiviert die ContentFilter-Extension **nicht mehr selbst**.
- Das `webcontent-filter`-Profil installiert die Extension autonom.
- Die App fragt nur noch ab, ob sie aktiv ist.
### Supervised vs. Unsupervised
```swift
// RebreakProtectionModule.swift ~172
if supervised {
return await Self.activateContentFilter() // NEFilter
}
// ... PacketTunnel (VPN) Fallback
```
- `supervised = true``NEFilterDataProvider` (Content Filter)
- `supervised = false``NEPacketTunnelProvider` (VPN/DNS-Sinkhole)
---
## 4. Backend: Wie das NEFilter-Tracking funktioniert
### 4.1 App reportet Status
Die iOS-App postet den nativ gemessenen NEFilter-Status an einen **bereits existierenden** Endpoint:
```ts
// apps/rebreak-native/hooks/useProtectionState.ts ~152
if (Platform.OS === "ios") {
protection.isNeFilterActive().then((res) => {
apiFetch('/api/users/me/mdm-status', {
method: 'POST',
body: { mdmManaged: res.enabled },
}).catch(() => {});
});
}
```
### 4.2 Backend-Endpoint
`backend/server/api/users/me/mdm-status.post.ts`:
```ts
const { mdmManaged } = Body.parse(await readBody(event));
const result = await setMdmManaged(user.id, mdmManaged);
```
Dieser Endpoint ist **nicht neu** er existiert schon und wird von der iOS-App genutzt.
### 4.3 Datenbank
`backend/prisma/schema.prisma` `Profile`:
```prisma
mdmManaged Boolean @default(false) @map("mdm_managed")
mdmDetectedAt DateTime? @map("mdm_detected_at")
```
`setMdmManaged` in `backend/server/db/profile.ts`:
- Setzt `mdmManaged` auf true/false.
- Schreibt `mdmDetectedAt` nur beim ersten Mal true (Audit-Trail).
### 4.4 Auslesen
`backend/server/api/protection/state.get.ts`:
```ts
const mdmManaged = profile?.mdmManaged ?? false;
return {
data: {
protectionShouldBeActive,
cooldown: { ... },
plan,
mdmManaged,
},
};
```
Jeder Consumer kann also über `GET /api/protection/state` prüfen, ob der aktuelle User MDM/NEFilter-geschützt ist.
---
## 5. Der DNS-Token-Handshake (separater Weg)
Es gibt noch `ProtectedDevice` + `lastDnsQueryAt`, aber das ist ein **anderer Mechanismus**:
- Genutzt für: Desktop-DNS-Schutz, Android-VPN, iOS Packet-Tunnel-Fallback.
- Funktioniert über: `dnsToken` im DoH-Profil.
- Endpoint: `POST /api/devices/protected/handshake` (server-to-server, aufgerufen vom DoH-Server).
- DB: `backend/server/db/protectedDevices.ts`.
Warum nicht für NEFilter?
- Der `NEFilterDataProvider` lädt `blocklist.bin` direkt aus der App Group.
- Er führt keine DNS-Queries gegen einen per-Device-DoH-Endpoint aus.
- Darum kann er nicht über den DNS-Handshake tracked werden.
**Fazit:** Für den NEFilter/Sideload-Profil-Pfad ist `profile.mdmManaged` die einzige sinnvolle Backend-Quelle.
---
## 6. Was das für ReBreak Magic bedeutet
Aktuell setzt `backend/server/api/magic/devices/[deviceId]/mdm.get.ts` `lockProfileInstalled` aus `deviceProtectionState(nefilter)`. Das funktioniert nur, wenn ein Client aktiv reportet.
Die iOS-App macht das bereits über `POST /api/users/me/mdm-status`. Für Magic/Mac gilt:
- Wenn ein iPhone per USB erkannt wird, kann Magic prüfen, ob das Lock-Profil installiert ist (`installedProfileIDs` enthält `org.rebreak.protection.contentfilter.sideload`).
- Um das Backend zu aktualisieren, **muss nicht zwingend ein neuer Endpoint** angelegt werden.
- Mögliche Optionen:
1. Magic ruft ebenfalls `POST /api/users/me/mdm-status` mit `mdmManaged: true/false` auf (User-Scope, nicht Device-Scope).
2. Magic ruft den existierenden `POST /api/devices/protection-state` mit `protectionType: "nefilter"` auf (Device-Scope).
3. Backend `mdm.get.ts` könnte zusätzlich `profile.mdmManaged` berücksichtigen.
Option 2 ist bereits vorhanden und am geräte-spezifischsten.
---
## 7. Wichtige Dateien im Überblick
### Native iOS
- `apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift`
- `apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/FilterDataProvider.swift`
- `apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/PacketTunnelProvider.swift`
- `apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/DnsFilter.swift`
- `apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/HashList.swift`
- `apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/DomainHasher.swift`
- `apps/rebreak-native/plugins/with-rebreak-protection-ios.js`
- `apps/rebreak-native/app.config.ts`
### App-Logik
- `apps/rebreak-native/app/(app)/blocker.tsx`
- `apps/rebreak-native/hooks/useProtectionState.ts`
- `apps/rebreak-native/lib/protection.ts`
### Backend
- `backend/server/api/users/me/mdm-status.post.ts`
- `backend/server/api/protection/state.get.ts`
- `backend/server/db/profile.ts`
- `backend/server/api/devices/protection-state.post.ts`
- `backend/server/db/device-protection.ts`
- `backend/server/api/devices/protected/handshake.post.ts`
- `backend/server/db/protectedDevices.ts`
- `backend/server/api/magic/devices/[deviceId]/mdm.get.ts`
---
## 8. Zusammenfassung
- NEFilter wird vom iOS-System über ein `webcontent-filter`-Profil aktiviert.
- Die App erkennt ihn via `NEFilterManager.shared().loadFromPreferences()`.
- Der Status fließt über den **bereits existierenden** Endpoint `POST /api/users/me/mdm-status` in `profile.mdmManaged`.
- `GET /api/protection/state` liefert diesen Status zurück.
- Kein neuer Backend-Endpoint nötig nur der richtige Client-Aufruf bzw. die Verknüpfung in Magic/Mac.
- `ProtectedDevice`/`lastDnsQueryAt` ist für den DNS/Packet-Tunnel-Pfad, nicht für NEFilter.

View File

@ -0,0 +1,510 @@
# MDM Healthcheck Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ein Nitro-Cron-Healthcheck prüft alle 5 Minuten alle über `UserDevice.mdmId` mit NanoMDM verknüpften iOS-Geräte und persistiert Enrollment-, Supervision-Status sowie letzten Check-In auf `UserDevice`.
**Architecture:** Ein neues Nitro-Plugin orchestriert den Lauf. `backend/server/db/mdm.ts` bekommt Bulk-Lesefunktionen für NanoMDM und ein Update für `UserDevice`. Die benötigten Spalten werden per Prisma-Migration auf `UserDevice` ergänzt. Keine neue Tabelle.
**Tech Stack:** Nuxt/Nitro, Prisma, PostgreSQL (NanoMDM), TypeScript, Vitest.
---
## Files
- `backend/prisma/schema.prisma``UserDevice` um `mdmEnrolled`, `mdmSupervised`, `mdmLastSeenAt` erweitern
- `backend/prisma/migrations/20250618_add_mdm_health_columns/migration.sql` — idempotente Migration
- `backend/server/db/mdm.ts` — Bulk-Abfrage von NanoMDM, Update der UserDevice-Health-Spalten
- `backend/server/plugins/mdm-health-cron.ts` — 5-Minuten-Cron
- `backend/tests/devices/mdm-health.test.ts` — Unit-Tests für DB-Layer und Cron-Logik
---
### Task 1: Extend UserDevice Prisma schema
**Files:**
- Modify: `backend/prisma/schema.prisma:1111-1114`
- [ ] **Step 1: Add three new fields after `mdmId`**
```prisma
// ─── NanoMDM iOS Enrollment ─────────────────────────────────────────────
/// Apple-Geräte-UDID wie von NanoMDM verwendet (z.B. 00008101-000544261E87001E).
/// NULL → Gerät ist nicht mit einem MDM-UDID verknüpft.
mdmId String? @map("mdm_id")
/// Gespiegelter Enrollment-Status aus NanoMDM enrollments.enabled.
mdmEnrolled Boolean? @map("mdm_enrolled")
/// Gespiegelter Supervision-Status aus NanoMDM devices.unlock_token IS NOT NULL.
mdmSupervised Boolean? @map("mdm_supervised")
/// Letzter NanoMDM Check-In (enrollments.last_seen_at).
mdmLastSeenAt DateTime? @map("mdm_last_seen_at") @db.Timestamptz(6)
```
- [ ] **Step 2: Validate schema**
Run: `cd backend && pnpm prisma validate`
Expected: `Prisma schema validation - (get config )`
- [ ] **Step 3: Commit**
```bash
git add backend/prisma/schema.prisma
git commit -m "feat(mdm): add mdmEnrolled, mdmSupervised, mdmLastSeenAt to UserDevice schema"
```
---
### Task 2: Create migration for MDM health columns
**Files:**
- Create: `backend/prisma/migrations/20250618_add_mdm_health_columns/migration.sql`
- [ ] **Step 1: Write idempotent migration**
```sql
-- Spiegel-Spalten für NanoMDM Enrollment/Supervision-Status auf UserDevice.
-- mdm_id wurde manuell hinzugefügt; IF NOT EXISTS macht die Migration idempotent.
ALTER TABLE "rebreak"."user_devices"
ADD COLUMN IF NOT EXISTS "mdm_id" TEXT,
ADD COLUMN IF NOT EXISTS "mdm_enrolled" BOOLEAN,
ADD COLUMN IF NOT EXISTS "mdm_supervised" BOOLEAN,
ADD COLUMN IF NOT EXISTS "mdm_last_seen_at" TIMESTAMPTZ(6);
CREATE INDEX IF NOT EXISTS "user_devices_mdm_id_idx"
ON "rebreak"."user_devices"("mdm_id");
```
- [ ] **Step 2: Apply migration locally**
Run: `cd backend && pnpm prisma migrate dev --name add_mdm_health_columns`
Expected: Migration applies successfully; Prisma Client is regenerated.
- [ ] **Step 3: Commit**
```bash
git add backend/prisma/migrations/20250618_add_mdm_health_columns
git commit -m "feat(mdm): add migration for UserDevice MDM health columns"
```
---
### Task 3: Extend mdm.ts DB layer
**Files:**
- Modify: `backend/server/db/mdm.ts`
- [ ] **Step 1: Add types and select constant after `MdmDeviceStatus`**
```typescript
export interface MdmEnrollmentStatus {
enrolled: boolean;
supervised: boolean;
lastSeenAt: Date | null;
}
export interface UserDeviceMdmHealthRecord {
id: string;
userId: string;
deviceId: string;
platform: string;
mdmId: string;
mdmEnrolled: boolean | null;
mdmSupervised: boolean | null;
mdmLastSeenAt: Date | null;
}
const USER_DEVICE_MDM_HEALTH_SELECT = {
id: true,
userId: true,
deviceId: true,
platform: true,
mdmId: true,
mdmEnrolled: true,
mdmSupervised: true,
mdmLastSeenAt: true,
} as const;
```
- [ ] **Step 2: Add `getLinkedUserDevices`**
```typescript
/**
* Load all iOS devices that have a NanoMDM UDID link.
*/
export async function getLinkedUserDevices(): Promise<UserDeviceMdmHealthRecord[]> {
const db = usePrisma();
return db.userDevice.findMany({
where: { platform: "ios", mdmId: { not: null } },
select: USER_DEVICE_MDM_HEALTH_SELECT,
}) as Promise<UserDeviceMdmHealthRecord[]>;
}
```
- [ ] **Step 3: Add `getMdmEnrollmentStatusesByUdids`**
```typescript
/**
* Bulk-query NanoMDM for enrollment/supervision/last-seen status.
* Returns a map keyed by UDID. Missing devices are omitted.
*/
export async function getMdmEnrollmentStatusesByUdids(
udids: string[],
): Promise<Map<string, MdmEnrollmentStatus>> {
if (udids.length === 0) {
return new Map();
}
const pool = useMdmPool();
const result = await pool.query<{
udid: string;
enrolled: boolean;
supervised: boolean;
last_seen_at: Date | null;
}>(
`SELECT
d.id AS udid,
(e.enabled = TRUE) AS enrolled,
(d.unlock_token IS NOT NULL) AS supervised,
e.last_seen_at
FROM devices d
LEFT JOIN enrollments e ON e.device_id = d.id
WHERE d.id = ANY($1::text[])`,
[udids],
);
const map = new Map<string, MdmEnrollmentStatus>();
for (const row of result.rows) {
map.set(row.udid, {
enrolled: row.enrolled,
supervised: row.supervised,
lastSeenAt: row.last_seen_at,
});
}
return map;
}
```
- [ ] **Step 4: Add `updateUserDeviceMdmHealth`**
```typescript
/**
* Persist mirrored MDM health status on a UserDevice row.
*/
export async function updateUserDeviceMdmHealth(
id: string,
status: MdmEnrollmentStatus,
): Promise<void> {
const db = usePrisma();
await db.userDevice.update({
where: { id },
data: {
mdmEnrolled: status.enrolled,
mdmSupervised: status.supervised,
mdmLastSeenAt: status.lastSeenAt,
},
});
}
```
- [ ] **Step 5: Run typecheck**
Run: `cd backend && pnpm typecheck`
Expected: No type errors.
- [ ] **Step 6: Commit**
```bash
git add backend/server/db/mdm.ts
git commit -m "feat(mdm): add bulk MDM health status read/write helpers"
```
---
### Task 4: Create mdm-health-cron.ts plugin
**Files:**
- Create: `backend/server/plugins/mdm-health-cron.ts`
- [ ] **Step 1: Write the cron plugin**
```typescript
/**
* MDM Healthcheck Cron
*
* Läuft alle 5 Minuten. Prüft für alle mit NanoMDM verknüpften iOS-Geräte
* den aktuellen Enrollment-/Supervision-Status und spiegelt ihn auf UserDevice.
*/
import { consola } from "consola";
import {
getLinkedUserDevices,
getMdmEnrollmentStatusesByUdids,
updateUserDeviceMdmHealth,
type MdmEnrollmentStatus,
} from "../db/mdm";
const FIVE_MINUTES = 5 * 60 * 1000;
const INITIAL_DELAY_MS = 30 * 1000;
export default defineNitroPlugin((nitro) => {
if (import.meta.dev) {
consola.info("[mdm-health-cron] Skipping cron in dev mode");
return;
}
consola.info("[mdm-health-cron] Starting (5min interval)");
const initialTimer = setTimeout(() => {
runMdmHealthCheck().catch(() => {});
}, INITIAL_DELAY_MS);
const interval = setInterval(() => {
runMdmHealthCheck().catch(() => {});
}, FIVE_MINUTES);
nitro.hooks.hook("close", () => {
clearTimeout(initialTimer);
clearInterval(interval);
});
});
async function runMdmHealthCheck() {
const start = Date.now();
try {
const devices = await getLinkedUserDevices();
if (devices.length === 0) {
consola.info("[mdm-health-cron] No linked iOS devices");
return;
}
const statuses = await getMdmEnrollmentStatusesByUdids(
devices.map((d) => d.mdmId),
);
let updated = 0;
let unchanged = 0;
for (const device of devices) {
const status = statuses.get(device.mdmId) ?? {
enrolled: false,
supervised: false,
lastSeenAt: null,
};
const changed =
device.mdmEnrolled !== status.enrolled ||
device.mdmSupervised !== status.supervised ||
!sameNullableDate(device.mdmLastSeenAt, status.lastSeenAt);
if (changed) {
await updateUserDeviceMdmHealth(device.id, status);
updated++;
} else {
unchanged++;
}
}
consola.success(
`[mdm-health-cron] Checked ${devices.length} devices in ${Date.now() - start}ms (${updated} updated, ${unchanged} unchanged)`,
);
} catch (err: any) {
consola.error("[mdm-health-cron] run failed:", err?.message ?? err);
}
}
function sameNullableDate(a: Date | null, b: Date | null): boolean {
if (a === null && b === null) return true;
if (a === null || b === null) return false;
return a.getTime() === b.getTime();
}
```
- [ ] **Step 2: Typecheck**
Run: `cd backend && pnpm typecheck`
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
git add backend/server/plugins/mdm-health-cron.ts
git commit -m "feat(mdm): add 5-minute MDM healthcheck cron"
```
---
### Task 5: Write tests for MDM healthcheck
**Files:**
- Create: `backend/tests/devices/mdm-health.test.ts`
- [ ] **Step 1: Write tests for DB helpers**
```typescript
/**
* Tests für MDM-Healthcheck DB-Layer.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import {
getLinkedUserDevices,
getMdmEnrollmentStatusesByUdids,
updateUserDeviceMdmHealth,
} from "../../server/db/mdm";
const mockPrisma = {
userDevice: {
findMany: vi.fn(),
update: vi.fn(),
},
};
vi.mock("../../server/utils/prisma", () => ({
usePrisma: () => mockPrisma,
}));
const mockPool = {
query: vi.fn(),
};
vi.mock("pg", () => ({
default: { Pool: vi.fn(() => mockPool) },
Pool: vi.fn(() => mockPool),
}));
vi.mock("../../server/utils/runtime-config", () => ({
useRuntimeConfig: () => ({ mdmDatabaseUrl: "postgres://fake" }),
}));
describe("getLinkedUserDevices", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns only iOS devices with a non-null mdmId", async () => {
mockPrisma.userDevice.findMany.mockResolvedValue([
{ id: "d1", userId: "u1", deviceId: "cap1", platform: "ios", mdmId: "udid-1", mdmEnrolled: true, mdmSupervised: true, mdmLastSeenAt: null },
]);
const result = await getLinkedUserDevices();
expect(result).toHaveLength(1);
expect(result[0].mdmId).toBe("udid-1");
expect(mockPrisma.userDevice.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { platform: "ios", mdmId: { not: null } },
}),
);
});
});
describe("getMdmEnrollmentStatusesByUdids", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns an empty map for empty input", async () => {
const result = await getMdmEnrollmentStatusesByUdids([]);
expect(result.size).toBe(0);
expect(mockPool.query).not.toHaveBeenCalled();
});
it("maps NanoMDM rows to status objects", async () => {
const lastSeen = new Date("2026-06-15T11:25:00Z");
mockPool.query.mockResolvedValue({
rows: [
{ udid: "udid-1", enrolled: true, supervised: true, last_seen_at: lastSeen },
{ udid: "udid-2", enrolled: false, supervised: true, last_seen_at: null },
],
});
const result = await getMdmEnrollmentStatusesByUdids(["udid-1", "udid-2"]);
expect(result.get("udid-1")).toEqual({ enrolled: true, supervised: true, lastSeenAt: lastSeen });
expect(result.get("udid-2")).toEqual({ enrolled: false, supervised: true, lastSeenAt: null });
});
});
describe("updateUserDeviceMdmHealth", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("updates the mirrored status columns", async () => {
mockPrisma.userDevice.update.mockResolvedValue({ id: "d1" });
await updateUserDeviceMdmHealth("d1", {
enrolled: true,
supervised: false,
lastSeenAt: new Date("2026-06-15T11:25:00Z"),
});
expect(mockPrisma.userDevice.update).toHaveBeenCalledWith({
where: { id: "d1" },
data: {
mdmEnrolled: true,
mdmSupervised: false,
mdmLastSeenAt: new Date("2026-06-15T11:25:00Z"),
},
});
});
});
```
- [ ] **Step 2: Run tests**
Run: `cd backend && pnpm test backend/tests/devices/mdm-health.test.ts`
Expected: All tests pass.
- [ ] **Step 3: Commit**
```bash
git add backend/tests/devices/mdm-health.test.ts
git commit -m "test(mdm): add MDM healthcheck DB-layer tests"
```
---
### Task 6: Apply migration and verify
- [ ] **Step 1: Apply migration to the target database**
Run: `cd backend && pnpm prisma migrate deploy`
Expected: Migration `20250618_add_mdm_health_columns` applies successfully.
- [ ] **Step 2: Regenerate Prisma Client**
Run: `cd backend && pnpm prisma generate`
Expected: Client generated.
- [ ] **Step 3: Run full test suite (at least device tests)**
Run: `cd backend && pnpm test backend/tests/devices/`
Expected: All tests pass.
- [ ] **Step 4: Commit**
```bash
git add backend/prisma/schema.prisma backend/prisma/migrations backend/server/db/mdm.ts backend/server/plugins/mdm-health-cron.ts backend/tests/devices/mdm-health.test.ts
git commit -m "feat(mdm): implement MDM healthcheck cron with mirrored status columns"
```
---
## Self-Review
**Spec coverage:**
- Healthcheck runs every 5 minutes → Task 4.
- Supervised devices checked → Task 3 query filters via `unlock_token IS NOT NULL`.
- Enrollment status changes persisted → Task 3 `updateUserDeviceMdmHealth`.
- No new table → only `UserDevice` columns added → Tasks 1-2.
**Placeholder scan:**
- No TBD/TODO/fill-in-details.
- All code blocks contain concrete implementation.
**Type consistency:**
- `MdmEnrollmentStatus` used consistently across Tasks 3, 4, 5.
- Column names `mdmEnrolled`, `mdmSupervised`, `mdmLastSeenAt` match in schema, migration, DB layer, tests.