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
|
**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.
|
(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"
|
:device="device"
|
||||||
:iphone="iphone"
|
:iphone="iphone"
|
||||||
:is-connected="device.deviceId === connectedDeviceId"
|
:is-connected="device.deviceId === connectedDeviceId"
|
||||||
|
:is-searching="device.deviceId === searchingForDeviceId"
|
||||||
:in-grace-period="inGracePeriod"
|
:in-grace-period="inGracePeriod"
|
||||||
@sync="emit('sync', $event)"
|
@sync="emit('sync', $event)"
|
||||||
@open="emit('open', $event)"
|
@open="emit('open', $event)"
|
||||||
@remove="emit('remove', $event)"
|
@remove="emit('remove', $event)"
|
||||||
|
@connect="emit('connect', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -52,12 +54,14 @@ const props = defineProps<{
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
hasRefreshed: boolean;
|
hasRefreshed: boolean;
|
||||||
inGracePeriod?: boolean;
|
inGracePeriod?: boolean;
|
||||||
|
searchingForDeviceId?: string | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "sync", device: ComputedDevice): void;
|
(e: "sync", device: ComputedDevice): void;
|
||||||
(e: "open", device: ComputedDevice): void;
|
(e: "open", device: ComputedDevice): void;
|
||||||
(e: "remove", device: ComputedDevice): void;
|
(e: "remove", device: ComputedDevice): void;
|
||||||
|
(e: "connect", device: ComputedDevice): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
function matchesIphone(device: ComputedDevice, iphone: IphoneDeviceState): boolean {
|
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 });
|
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 {
|
return {
|
||||||
getPlatform,
|
getPlatform,
|
||||||
redeemPairingCode,
|
redeemPairingCode,
|
||||||
@ -328,5 +344,6 @@ export function useTauri() {
|
|||||||
getMdmStatus,
|
getMdmStatus,
|
||||||
getMdmStatusByUdid,
|
getMdmStatusByUdid,
|
||||||
linkMdmDevice,
|
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"
|
:loading="loading"
|
||||||
:has-refreshed="hasRefreshed"
|
:has-refreshed="hasRefreshed"
|
||||||
:in-grace-period="subscriptionInGracePeriod"
|
:in-grace-period="subscriptionInGracePeriod"
|
||||||
|
:searching-for-device-id="searchingForDeviceId"
|
||||||
@sync="onIosSync"
|
@sync="onIosSync"
|
||||||
@open="openDevice"
|
@open="openDevice"
|
||||||
@remove="onIosRemove"
|
@remove="onIosRemove"
|
||||||
|
@connect="startIphoneSearch"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Other devices list -->
|
<!-- Other devices list -->
|
||||||
@ -134,8 +136,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||||
import { useTauri, type UserProfile } from "~/composables/useTauri";
|
import { useTauri, type UserProfile, type IphoneDeviceState } from "~/composables/useTauri";
|
||||||
import { useMagicSession, useMagicDevices, useIphoneDevice } from "~/composables/useMagicState";
|
import { useMagicSession, useMagicDevices, useIphoneDevice } from "~/composables/useMagicState";
|
||||||
import { useProtectionStatus } from "~/composables/useProtectionStatus";
|
import { useProtectionStatus } from "~/composables/useProtectionStatus";
|
||||||
import { useDeviceStatus, type ComputedDevice } from "~/composables/useDeviceStatus";
|
import { useDeviceStatus, type ComputedDevice } from "~/composables/useDeviceStatus";
|
||||||
@ -169,6 +171,8 @@ const error = ref<string | null>(null);
|
|||||||
const sheetOpen = ref(false);
|
const sheetOpen = ref(false);
|
||||||
const selectedDevice = ref<ComputedDevice | null>(null);
|
const selectedDevice = ref<ComputedDevice | null>(null);
|
||||||
const platformInfo = ref<{ platform: string } | 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.
|
// TODO: populate from backend once subscription/grace-period endpoint exists.
|
||||||
const subscriptionInGracePeriod = ref(false);
|
const subscriptionInGracePeriod = ref(false);
|
||||||
@ -200,6 +204,43 @@ onMounted(async () => {
|
|||||||
await initCurrentDevice();
|
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() {
|
async function initCurrentDevice() {
|
||||||
try {
|
try {
|
||||||
const hardwareId = await getHardwareId();
|
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,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ApiEnvelope<T> {
|
pub struct ApiEnvelope<T> {
|
||||||
pub success: bool,
|
pub success: bool,
|
||||||
@ -437,6 +449,38 @@ impl MagicApiClient {
|
|||||||
.map(|_| ())
|
.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>(
|
async fn handle_response<T: serde::de::DeserializeOwned>(
|
||||||
response: reqwest::Response,
|
response: reqwest::Response,
|
||||||
) -> AppResult<T> {
|
) -> 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,
|
||||||
get_mdm_status_by_udid,
|
get_mdm_status_by_udid,
|
||||||
link_mdm_device,
|
link_mdm_device,
|
||||||
|
report_device_protection_state,
|
||||||
get_desktop_protection_status,
|
get_desktop_protection_status,
|
||||||
set_desktop_protection_status,
|
set_desktop_protection_status,
|
||||||
get_hostname,
|
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
|
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]
|
#[tauri::command]
|
||||||
async fn get_mdm_status_by_udid(udid: String) -> AppResult<MdmStatusByUdidData> {
|
async fn get_mdm_status_by_udid(udid: String) -> AppResult<MdmStatusByUdidData> {
|
||||||
let session = require_session()?;
|
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.
|
||||||