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.
22
CLAUDE.md
@ -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
@ -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
@ -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
|
||||
6
apps/rebreak-magic/app/app.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<UApp>
|
||||
<NuxtPage />
|
||||
<DevLogDrawer />
|
||||
</UApp>
|
||||
</template>
|
||||
25
apps/rebreak-magic/app/assets/css/main.css
Normal 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);
|
||||
}
|
||||
BIN
apps/rebreak-magic/app/assets/rebreak-icon.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
115
apps/rebreak-magic/app/components/DevLogDrawer.vue
Normal 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>
|
||||
@ -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 {
|
||||
|
||||
43
apps/rebreak-magic/app/components/PreflightItem.vue
Normal 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>
|
||||
16
apps/rebreak-magic/app/components/StatusBadge.vue
Normal 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>
|
||||
46
apps/rebreak-magic/app/components/StepButton.vue
Normal 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>
|
||||
97
apps/rebreak-magic/app/composables/useLogger.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
199
apps/rebreak-magic/app/pages/configure.vue
Normal 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>
|
||||
108
apps/rebreak-magic/app/pages/desktop-enroll.vue
Normal 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>
|
||||
175
apps/rebreak-magic/app/pages/detect.vue
Normal 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>
|
||||
37
apps/rebreak-magic/app/pages/done.vue
Normal 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>
|
||||
203
apps/rebreak-magic/app/pages/enroll.vue
Normal 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>
|
||||
30
apps/rebreak-magic/app/pages/index.vue
Normal 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>
|
||||
131
apps/rebreak-magic/app/pages/pair.vue
Normal 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>
|
||||
79
apps/rebreak-magic/app/pages/preflight.vue
Normal 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>
|
||||
132
apps/rebreak-magic/app/pages/sideload.vue
Normal 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>
|
||||
@ -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();
|
||||
|
||||
137
apps/rebreak-magic/app/pages/supervise.vue
Normal 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>
|
||||
30
apps/rebreak-magic/nuxt.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
34
apps/rebreak-magic/package.json
Normal 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
45
apps/rebreak-magic/src-tauri/Cargo.toml
Normal 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
|
||||
45
apps/rebreak-magic/src-tauri/binaries/README.md
Normal 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.
|
||||
BIN
apps/rebreak-magic/src-tauri/binaries/supervise-magic-aarch64-apple-darwin
Executable file
3
apps/rebreak-magic/src-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
21
apps/rebreak-magic/src-tauri/capabilities/default.json
Normal 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"
|
||||
]
|
||||
}
|
||||
26
apps/rebreak-magic/src-tauri/entitlements.plist
Normal 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>
|
||||
BIN
apps/rebreak-magic/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
apps/rebreak-magic/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
apps/rebreak-magic/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
apps/rebreak-magic/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
apps/rebreak-magic/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
apps/rebreak-magic/src-tauri/icons/source.png
Normal file
|
After Width: | Height: | Size: 218 KiB |
@ -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> {
|
||||
|
||||
1
apps/rebreak-magic/src-tauri/src/backend/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod api;
|
||||
43
apps/rebreak-magic/src-tauri/src/error.rs
Normal 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>;
|
||||
410
apps/rebreak-magic/src-tauri/src/ios_device.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@ -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()?;
|
||||
|
||||
6
apps/rebreak-magic/src-tauri/src/main.rs
Normal 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();
|
||||
}
|
||||
142
apps/rebreak-magic/src-tauri/src/mdm/client.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
89
apps/rebreak-magic/src-tauri/src/mdm/mod.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
87
apps/rebreak-magic/src-tauri/src/server/local_http.rs
Normal 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"))
|
||||
}
|
||||
1
apps/rebreak-magic/src-tauri/src/server/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod local_http;
|
||||
1
apps/rebreak-magic/src-tauri/src/sidecar/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod supervise_magic;
|
||||
63
apps/rebreak-magic/src-tauri/src/sidecar/supervise_magic.rs
Normal 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(),
|
||||
})
|
||||
}
|
||||
46
apps/rebreak-magic/src-tauri/tauri.conf.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
3
apps/rebreak-magic/tsconfig.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
212
docs/internal/IOS_NEFILTER_ARCHITECTURE.md
Normal 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.
|
||||
510
docs/superpowers/plans/2026-06-18-mdm-health-check.md
Normal 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.
|
||||