Compare commits
10 Commits
cb6dd0555a
...
709f8cb32e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
709f8cb32e | ||
|
|
e6fad4f51e | ||
|
|
dd84f8ec38 | ||
|
|
0efdf2f8f1 | ||
|
|
e14a36f95a | ||
|
|
bb8e0d3f62 | ||
|
|
ac7bd800bc | ||
|
|
5117c7b37c | ||
|
|
2919ce45b8 | ||
|
|
b9dddc00e7 |
11
.github/workflows/test-runner.yml
vendored
@ -1,11 +0,0 @@
|
|||||||
name: Test Runner
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: [self-hosted, raynis-builder]
|
|
||||||
steps:
|
|
||||||
- name: Hello
|
|
||||||
run: echo "Runner works" && whoami && uname -a
|
|
||||||
11
.github/workflows/test-ubuntu.yml
vendored
@ -1,11 +0,0 @@
|
|||||||
name: Test Ubuntu Runner
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Hello
|
|
||||||
run: echo "GitHub Actions works"
|
|
||||||
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>
|
||||||
@ -58,8 +58,12 @@
|
|||||||
v-if="!isConnected"
|
v-if="!isConnected"
|
||||||
class="mt-3 text-sm text-gray-600 dark:text-gray-300 flex items-start gap-2"
|
class="mt-3 text-sm text-gray-600 dark:text-gray-300 flex items-start gap-2"
|
||||||
>
|
>
|
||||||
<UIcon name="i-heroicons-information-circle" class="w-5 h-5 text-[var(--rebreak-primary)] shrink-0 mt-0.5" />
|
<UIcon
|
||||||
<span>Verbinde dein iPhone mit USB, um den Schutz zu vervollständigen.</span>
|
:name="isSearching ? 'i-heroicons-arrow-path' : 'i-heroicons-information-circle'"
|
||||||
|
:class="isSearching ? 'animate-spin' : ''"
|
||||||
|
class="w-5 h-5 text-[var(--rebreak-primary)] shrink-0 mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>{{ isSearching ? "Suche nach verbundenem iPhone…" : "Verbinde dein iPhone mit USB, um den Schutz zu vervollständigen." }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Backend-MDM always visible; local USB only when connected -->
|
<!-- Backend-MDM always visible; local USB only when connected -->
|
||||||
@ -190,8 +194,8 @@
|
|||||||
:variant="action.variant"
|
:variant="action.variant"
|
||||||
size="sm"
|
size="sm"
|
||||||
:icon="action.icon"
|
:icon="action.icon"
|
||||||
:loading="manualSyncing || autoSyncing"
|
:loading="manualSyncing || autoSyncing || isSearching"
|
||||||
:disabled="autoSyncing"
|
:disabled="autoSyncing || isSearching"
|
||||||
@click="onActionClick"
|
@click="onActionClick"
|
||||||
>
|
>
|
||||||
{{ action.label }}
|
{{ action.label }}
|
||||||
@ -222,6 +226,7 @@ const props = defineProps<{
|
|||||||
device: ComputedDevice;
|
device: ComputedDevice;
|
||||||
iphone: IphoneDeviceState | null;
|
iphone: IphoneDeviceState | null;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
|
isSearching?: boolean;
|
||||||
inGracePeriod?: boolean;
|
inGracePeriod?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@ -232,6 +237,7 @@ 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;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const ENROLLMENT_PROFILE_ID = "org.rebreak.mdm.enrollment";
|
const ENROLLMENT_PROFILE_ID = "org.rebreak.mdm.enrollment";
|
||||||
@ -264,12 +270,10 @@ const backendRows = computed(() => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Supervised",
|
label: "Supervised",
|
||||||
value: data?.enrolled ? (data.supervised ? "Ja" : "Nein") : "—",
|
value: data?.supervised ? "Ja" : "Nein",
|
||||||
valueClass: data?.enrolled
|
valueClass: data?.supervised
|
||||||
? data.supervised
|
|
||||||
? "text-green-600 dark:text-green-400 font-medium"
|
? "text-green-600 dark:text-green-400 font-medium"
|
||||||
: "text-red-600 dark:text-red-400 font-medium"
|
: "text-red-600 dark:text-red-400 font-medium",
|
||||||
: "text-gray-400 dark:text-gray-500",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Organisation",
|
label: "Organisation",
|
||||||
@ -280,16 +284,10 @@ const backendRows = computed(() => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Lock-Profil",
|
label: "Lock-Profil",
|
||||||
value: data?.enrolled
|
value: data?.lockProfileInstalled ? "Installiert" : "Fehlt",
|
||||||
? data.lockProfileInstalled
|
valueClass: data?.lockProfileInstalled
|
||||||
? "Installiert"
|
|
||||||
: "Fehlt"
|
|
||||||
: "—",
|
|
||||||
valueClass: data?.enrolled
|
|
||||||
? data.lockProfileInstalled
|
|
||||||
? "text-green-600 dark:text-green-400 font-medium"
|
? "text-green-600 dark:text-green-400 font-medium"
|
||||||
: "text-red-600 dark:text-red-400 font-medium"
|
: "text-red-600 dark:text-red-400 font-medium",
|
||||||
: "text-gray-400 dark:text-gray-500",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "ReBreak App",
|
label: "ReBreak App",
|
||||||
@ -472,13 +470,21 @@ const action = computed<IosAction>(() => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (props.isSearching) {
|
||||||
|
return {
|
||||||
|
label: "iPhone suchen…",
|
||||||
|
icon: "i-heroicons-arrow-path",
|
||||||
|
color: "neutral",
|
||||||
|
variant: "soft",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!props.isConnected || !props.iphone) {
|
if (!props.isConnected || !props.iphone) {
|
||||||
return {
|
return {
|
||||||
label: "iPhone verbinden, um ReBreak Cloud zu synchronisieren",
|
label: "iPhone verbinden, um ReBreak Cloud zu synchronisieren",
|
||||||
icon: "i-heroicons-link",
|
icon: "i-heroicons-link",
|
||||||
color: "primary",
|
color: "primary",
|
||||||
variant: "solid",
|
variant: "solid",
|
||||||
to: "/detect",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -505,12 +511,13 @@ const action = computed<IosAction>(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!backend?.enrolled || !localEnrollment.value) {
|
if (!backend?.enrolled || !localEnrollment.value) {
|
||||||
|
const isKnownDevice = !!props.device.mdmId;
|
||||||
return {
|
return {
|
||||||
label: "Enrollen",
|
label: isKnownDevice ? "Schutz vervollständigen" : "Enrollen",
|
||||||
icon: "i-heroicons-document-check",
|
icon: isKnownDevice ? "i-heroicons-shield-check" : "i-heroicons-document-check",
|
||||||
color: "primary",
|
color: "primary",
|
||||||
variant: "solid",
|
variant: "solid",
|
||||||
to: "/enroll",
|
to: isKnownDevice ? "/preflight" : "/enroll",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -615,6 +622,11 @@ function onActionClick() {
|
|||||||
|
|
||||||
if (autoSyncing.value) return;
|
if (autoSyncing.value) return;
|
||||||
|
|
||||||
|
if (!props.isConnected || !props.iphone) {
|
||||||
|
emit("connect", props.device);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
manualSyncing.value = true;
|
manualSyncing.value = true;
|
||||||
emit("sync", props.device);
|
emit("sync", props.device);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@ -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"
|
||||||
|
}
|
||||||
@ -36,7 +36,13 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (!device.mdmId) {
|
if (!device.mdmId) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: { enrolled: false },
|
data: {
|
||||||
|
enrolled: false,
|
||||||
|
company: null,
|
||||||
|
supervised: false,
|
||||||
|
lockProfileInstalled: false,
|
||||||
|
lastAppPushAt: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,12 +58,18 @@ export default defineEventHandler(async (event) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// UDID stored but no longer present in NanoMDM → clear stale link.
|
// UDID stored but the device is completely gone from NanoMDM → clear stale link.
|
||||||
if (!status.enrolled) {
|
if (!status.exists) {
|
||||||
await clearUserDeviceMdmId(user.id, deviceId);
|
await clearUserDeviceMdmId(user.id, deviceId);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: { enrolled: false },
|
data: {
|
||||||
|
enrolled: false,
|
||||||
|
company: null,
|
||||||
|
supervised: false,
|
||||||
|
lockProfileInstalled: false,
|
||||||
|
lastAppPushAt: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,8 +84,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
enrolled: true,
|
enrolled: status.enrolled,
|
||||||
company: "ReBreak",
|
company: status.exists ? "ReBreak" : null,
|
||||||
supervised: status.supervised,
|
supervised: status.supervised,
|
||||||
lockProfileInstalled: lockState?.active ?? false,
|
lockProfileInstalled: lockState?.active ?? false,
|
||||||
lastAppPushAt: status.lastAppPushAt?.toISOString() ?? null,
|
lastAppPushAt: status.lastAppPushAt?.toISOString() ?? null,
|
||||||
|
|||||||
@ -116,6 +116,7 @@ export async function getLinkedUserDevices(): Promise<
|
|||||||
|
|
||||||
export interface MdmDeviceStatus {
|
export interface MdmDeviceStatus {
|
||||||
enrolled: boolean;
|
enrolled: boolean;
|
||||||
|
exists: boolean;
|
||||||
company: string | null;
|
company: string | null;
|
||||||
supervised: boolean;
|
supervised: boolean;
|
||||||
tokenUpdateAt: Date | null;
|
tokenUpdateAt: Date | null;
|
||||||
@ -157,10 +158,12 @@ export async function getMdmStatusByUdid(
|
|||||||
token_update_at: Date | null;
|
token_update_at: Date | null;
|
||||||
last_ack: Date | null;
|
last_ack: Date | null;
|
||||||
last_app_push_at: Date | null;
|
last_app_push_at: Date | null;
|
||||||
|
enrolled: boolean;
|
||||||
}>(
|
}>(
|
||||||
`SELECT
|
`SELECT
|
||||||
d.unlock_token,
|
d.unlock_token,
|
||||||
d.token_update_at,
|
d.token_update_at,
|
||||||
|
COALESCE(e.enabled = TRUE, FALSE) AS enrolled,
|
||||||
(SELECT max(updated_at) FROM command_results WHERE id = d.id) AS last_ack,
|
(SELECT max(updated_at) FROM command_results WHERE id = d.id) AS last_ack,
|
||||||
(SELECT max(r.updated_at)
|
(SELECT max(r.updated_at)
|
||||||
FROM command_results r
|
FROM command_results r
|
||||||
@ -169,17 +172,20 @@ export async function getMdmStatusByUdid(
|
|||||||
AND c.request_type = 'InstallApplication'
|
AND c.request_type = 'InstallApplication'
|
||||||
AND r.status = 'Acknowledged') AS last_app_push_at
|
AND r.status = 'Acknowledged') AS last_app_push_at
|
||||||
FROM devices d
|
FROM devices d
|
||||||
|
LEFT JOIN enrollments e ON e.device_id = d.id
|
||||||
WHERE d.id = $1`,
|
WHERE d.id = $1`,
|
||||||
[udid],
|
[udid],
|
||||||
);
|
);
|
||||||
|
|
||||||
const row = result.rows[0];
|
const row = result.rows[0];
|
||||||
const enrolled = !!row;
|
const exists = row !== undefined;
|
||||||
|
const enrolled = row?.enrolled ?? false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enrolled,
|
enrolled,
|
||||||
|
exists,
|
||||||
company: enrolled ? "ReBreak" : null,
|
company: enrolled ? "ReBreak" : null,
|
||||||
supervised: enrolled && row?.unlock_token != null,
|
supervised: exists && row?.unlock_token != null,
|
||||||
tokenUpdateAt: row?.token_update_at ?? null,
|
tokenUpdateAt: row?.token_update_at ?? null,
|
||||||
lastAckAt: row?.last_ack ?? null,
|
lastAckAt: row?.last_ack ?? null,
|
||||||
lastAppPushAt: row?.last_app_push_at ?? null,
|
lastAppPushAt: row?.last_app_push_at ?? null,
|
||||||
|
|||||||
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.
|
||||||
437
docs/superpowers/plans/2026-06-18-self-hosted-github-runner.md
Normal file
@ -0,0 +1,437 @@
|
|||||||
|
# Self-Hosted GitHub Actions Runner 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:** Betreiben eines self-hosted GitHub Actions Runners auf `api.trucko.org` (128.140.47.53) mit dem Label `raynis-builder`, um Backend- und Admin-Builds für das rebreak-monorepo kostenlos auszuführen und weiterhin auf `staging.rebreak.org` zu deployen.
|
||||||
|
|
||||||
|
**Architecture:** Code-Hosting bleibt auf GitHub. GitHub Actions Workflows werden auf `runs-on: [self-hosted, raynis-builder]` umgestellt. Der Runner auf `api.trucko.org` checkt aus, baut das Artifact und kopiert es per SCP auf `staging.rebreak.org`. Dort übernimmt das bestehende `deploy-from-artifact.sh` das Entpacken, Migrations-Check und PM2-Restart.
|
||||||
|
|
||||||
|
**Tech Stack:** GitHub Actions, self-hosted Runner, Hetzner VPS, pnpm, Node.js 24.11.1, SSH/SCP, PM2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File / Pfad | Verantwortung | Aktion |
|
||||||
|
|---|---|---|
|
||||||
|
| `api.trucko.org` (Server) | Self-hosted Runner + Build-Umgebung | Einrichten |
|
||||||
|
| `staging.rebreak.org` (Server) | Production/Staging + Deploy-Script | SSH-Key hinzufügen |
|
||||||
|
| `.github/workflows/deploy-staging.yml` | Backend-Deploy-Workflow | Ändern: `runs-on` + SSH-Secret |
|
||||||
|
| `.github/workflows/deploy-admin-staging.yml` | Admin-Deploy-Workflow | Ändern: `runs-on` + SSH-Secret |
|
||||||
|
| GitHub Repo → Settings → Environments → staging | Secrets/Variables | `STAGING_DEPLOY_KEY` hinzufügen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Server `api.trucko.org` vorbereiten
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Ausführen auf: `api.trucko.org` (per SSH)
|
||||||
|
|
||||||
|
- [ ] **Step 1: SSH auf Server verbinden**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@128.140.47.53
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: System aktualisieren und Basis-Tools installieren**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt-get update && apt-get install -y curl git build-essential
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Node.js 24.11.1 installieren (via nvm)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||||
|
source ~/.bashrc
|
||||||
|
nvm install 24.11.1
|
||||||
|
nvm use 24.11.1
|
||||||
|
node -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output: `v24.11.1`
|
||||||
|
|
||||||
|
- [ ] **Step 4: pnpm 10.23.0 installieren**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack enable
|
||||||
|
corepack prepare pnpm@10.23.0 --activate
|
||||||
|
pnpm -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output: `10.23.0`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Git-Config für den Runner setzen**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config --global user.name "Raynis Builder"
|
||||||
|
git config --global user.email "builder@raynis.dev"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Workspace-Verzeichnis anlegen**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /srv/raynis-builder
|
||||||
|
chown root:root /srv/raynis-builder
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: GitHub Actions Runner installieren und registrieren
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Ausführen auf: `api.trucko.org`
|
||||||
|
- Zu prüfen in GitHub: Settings → Actions → Runners
|
||||||
|
|
||||||
|
- [ ] **Step 1: Runner-Version ermitteln (aktuellste)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUNNER_VERSION=$(curl -sL https://api.github.com/repos/actions/runner/releases/latest | jq -r '.tag_name' | sed 's/^v//')
|
||||||
|
echo "$RUNNER_VERSION"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Runner herunterladen und entpacken**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /srv/raynis-builder
|
||||||
|
curl -o actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz -L https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz
|
||||||
|
tar xzf actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz
|
||||||
|
rm actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Runner konfigurieren (Token aus GitHub holen)**
|
||||||
|
|
||||||
|
In GitHub: Repository `rebreak-monorepo` → Settings → Actions → Runners → New self-hosted runner → Linux → x64.
|
||||||
|
|
||||||
|
Dort angezeigte `config.sh`-Befehle auf dem Server ausführen, z. B.:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /srv/raynis-builder
|
||||||
|
./config.sh --url https://github.com/RaynisDev/rebreak --token <TOKEN_AUS_GITHUB> --name raynis-builder-01 --labels raynis-builder --work _work
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Runner als systemd-Service registrieren**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /srv/raynis-builder
|
||||||
|
./svc.sh install
|
||||||
|
./svc.sh start
|
||||||
|
./svc.sh status
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `active (running)`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Runner in GitHub UI als Online verifizieren**
|
||||||
|
|
||||||
|
In GitHub: Settings → Actions → Runners → Status muss `Idle` oder `Online` zeigen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: SSH-Deploy-Key zwischen Servern einrichten
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Ausführen auf: `api.trucko.org` und `staging.rebreak.org`
|
||||||
|
|
||||||
|
- [ ] **Step 1: SSH-Key auf `api.trucko.org` erzeugen**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh-keygen -t ed25519 -f /root/.ssh/rebreak-deploy -C "raynis-builder@api.trucko.org" -N ""
|
||||||
|
cat /root/.ssh/rebreak-deploy.pub
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Public Key auf `staging.rebreak.org` autorisieren**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@staging.rebreak.org
|
||||||
|
mkdir -p /root/.ssh
|
||||||
|
cat >> /root/.ssh/authorized_keys << 'EOF'
|
||||||
|
<PUBLIC_KEY_VON_OBEN>
|
||||||
|
EOF
|
||||||
|
chmod 600 /root/.ssh/authorized_keys
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verbindung vom Builder zum Staging testen**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -i /root/.ssh/rebreak-deploy root@staging.rebreak.org "whoami"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output: `root`
|
||||||
|
|
||||||
|
- [ ] **Step 4: SSH-Konfiguration für einfacheren Zugriff anlegen**
|
||||||
|
|
||||||
|
Auf `api.trucko.org`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat >> /root/.ssh/config << 'EOF'
|
||||||
|
Host rebreak-staging
|
||||||
|
HostName staging.rebreak.org
|
||||||
|
User root
|
||||||
|
IdentityFile ~/.ssh/rebreak-deploy
|
||||||
|
StrictHostKeyChecking no
|
||||||
|
UserKnownHostsFile /dev/null
|
||||||
|
EOF
|
||||||
|
chmod 600 /root/.ssh/config
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verbindung mit Alias testen**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh rebreak-staging "whoami"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output: `root`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: GitHub Secrets aktualisieren
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- GitHub Repository: Settings → Environments → staging
|
||||||
|
|
||||||
|
- [ ] **Step 1: Neuen Private Key als Secret hinzufügen**
|
||||||
|
|
||||||
|
Auf `api.trucko.org`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat /root/.ssh/rebreak-deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Inhalt kopieren und in GitHub einfügen:
|
||||||
|
|
||||||
|
- Environment: `staging`
|
||||||
|
- Secret-Name: `STAGING_DEPLOY_KEY`
|
||||||
|
- Wert: Inhalt von `/root/.ssh/rebreak-deploy`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Bestehende Secrets überprüfen**
|
||||||
|
|
||||||
|
In GitHub prüfen, dass folgende Secrets/Vars im Environment `staging` vorhanden sind:
|
||||||
|
|
||||||
|
- `HETZNER_SSH_KEY` → kann später entfernt werden, wenn neuer Key funktioniert
|
||||||
|
- `HETZNER_HOST` → `staging.rebreak.org`
|
||||||
|
- `HETZNER_USER` → `root`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Altes Secret nicht löschen (Fallback)**
|
||||||
|
|
||||||
|
`HETZNER_SSH_KEY` erst nach erfolgreichem Test-Deploy entfernen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Workflows auf self-hosted Runner umstellen
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.github/workflows/deploy-staging.yml`
|
||||||
|
- Modify: `.github/workflows/deploy-admin-staging.yml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: `deploy-staging.yml` anpassen**
|
||||||
|
|
||||||
|
Ändere in beiden Jobs `runs-on: ubuntu-latest` zu:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
runs-on: [self-hosted, raynis-builder]
|
||||||
|
```
|
||||||
|
|
||||||
|
Ändere den SSH-Setup-Step, sodass er `STAGING_DEPLOY_KEY` verwendet:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Setup SSH
|
||||||
|
env:
|
||||||
|
SSH_PRIVATE_KEY: ${{ secrets.STAGING_DEPLOY_KEY }}
|
||||||
|
SSH_HOST: ${{ vars.HETZNER_HOST }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$SSH_PRIVATE_KEY" ] || [ -z "$SSH_HOST" ]; then
|
||||||
|
echo "FATAL: STAGING_DEPLOY_KEY oder HETZNER_HOST nicht gesetzt"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan -H "$SSH_HOST" >> ~/.ssh/known_hosts
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: `deploy-admin-staging.yml` analog anpassen**
|
||||||
|
|
||||||
|
Gleiche Änderungen wie bei `deploy-staging.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
runs-on: [self-hosted, raynis-builder]
|
||||||
|
```
|
||||||
|
|
||||||
|
und SSH-Setup-Step auf `STAGING_DEPLOY_KEY` umstellen.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Node-Setup überprüfen**
|
||||||
|
|
||||||
|
Da der Runner Node.js 24.11.1 und pnpm 10.23.0 bereits hat, kann der Schritt `actions/setup-node` theoretisch entfallen. Für Robustheit aber beibehalten:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24.11.1
|
||||||
|
cache: pnpm
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hinweis:** `cache: pnpm` funktioniert auf self-hosted Runnern nur, wenn der Runner persistent ist. Da der Server nicht bei jedem Job neu aufgesetzt wird, ist das gegeben.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Beide Dateien lokal validieren**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/chahinebrini/mono/rebreak-monorepo
|
||||||
|
git diff .github/workflows/deploy-staging.yml .github/workflows/deploy-admin-staging.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Änderungen commiten**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .github/workflows/deploy-staging.yml .github/workflows/deploy-admin-staging.yml
|
||||||
|
git commit -m "ci: use self-hosted runner raynis-builder on api.trucko.org"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Test-Deploy durchführen
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- GitHub Actions UI
|
||||||
|
- Server-Logs auf `api.trucko.org` und `staging.rebreak.org`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Workflow manuell triggern**
|
||||||
|
|
||||||
|
In GitHub: Actions → Deploy Staging → Run workflow → Branch: main → Run.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build-Logs auf Runner verfolgen**
|
||||||
|
|
||||||
|
Auf `api.trucko.org`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tail -f /srv/raynis-builder/_diag/Worker_*.log
|
||||||
|
tail -f /srv/raynis-builder/_diag/SelfUpdate-*.log
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Deploy-Logs auf Staging verfolgen**
|
||||||
|
|
||||||
|
Auf `staging.rebreak.org`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 logs rebreak-staging --lines 50
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Health-Check manuell ausführen**
|
||||||
|
|
||||||
|
Lokal oder auf `api.trucko.org`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for i in $(seq 1 12); do
|
||||||
|
STATUS=$(curl -sS -o /dev/null -w '%{http_code}' https://staging.rebreak.org/api/auth/me 2>/dev/null || echo "000")
|
||||||
|
echo "Attempt $i: $STATUS"
|
||||||
|
[ "$STATUS" = "401" ] || [ "$STATUS" = "200" ] && echo "PASSED" && break
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `PASSED`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Admin-Deploy testen**
|
||||||
|
|
||||||
|
In GitHub: Actions → Deploy Admin Staging → Run workflow.
|
||||||
|
|
||||||
|
Health-Check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -o /dev/null -w '%{http_code}' https://admin.staging.rebreak.org/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Nicht `000`, `502` oder `503`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Alten Webhook-Deploy abschalten (optional, nach stabilem Betrieb)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Ausführen auf: `staging.rebreak.org`
|
||||||
|
- GitHub Repo: Settings → Webhooks
|
||||||
|
|
||||||
|
- [ ] **Step 1: Mindestens 3–5 erfolgreiche Deploys über neuen Runner abwarten**
|
||||||
|
|
||||||
|
- [ ] **Step 2: GitHub-Webhook deaktivieren**
|
||||||
|
|
||||||
|
In GitHub: Settings → Webhooks → `https://staging.rebreak.org/webhook` → Active: aus.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Webhook-Service auf Server stoppen**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@staging.rebreak.org
|
||||||
|
pm2 stop rebreak-webhook
|
||||||
|
pm2 save
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Legacy-Dateien archivieren (nicht löschen)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /srv/rebreak
|
||||||
|
mkdir -p scripts/legacy
|
||||||
|
mv scripts/deploy.sh scripts/legacy/deploy.sh
|
||||||
|
mv scripts/deploy-webhook scripts/legacy/deploy-webhook
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: `ecosystem.config.js` bereinigen**
|
||||||
|
|
||||||
|
Entferne den Block für `rebreak-webhook` aus `ecosystem.config.js` und deploye die Änderung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Cleanup und Monitoring
|
||||||
|
|
||||||
|
- [ ] **Step 1: Altes `HETZNER_SSH_KEY` Secret aus GitHub entfernen**
|
||||||
|
|
||||||
|
Nur nachdem `STAGING_DEPLOY_KEY` erfolgreich getestet wurde.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Runner-Verfügbarkeit überwachen**
|
||||||
|
|
||||||
|
In GitHub: Settings → Actions → Runners → Status regelmäßig prüfen.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Log-Rotation auf Runner einrichten**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
logrotate --version || apt-get install -y logrotate
|
||||||
|
cat > /etc/logrotate.d/github-runner << 'EOF'
|
||||||
|
/srv/raynis-builder/_diag/*.log {
|
||||||
|
daily
|
||||||
|
rotate 7
|
||||||
|
compress
|
||||||
|
missingok
|
||||||
|
notifempty
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: pnpm-Store auf Runner bereinigen (monatlich)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm store prune
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
### Spec coverage
|
||||||
|
|
||||||
|
- Self-hosted Runner auf `api.trucko.org`: Task 1 + 2
|
||||||
|
- Label `raynis-builder`: Task 2
|
||||||
|
- Code-Hosting bleibt auf GitHub: implizit durch GitHub Actions
|
||||||
|
- Workflows auf `runs-on: [self-hosted, raynis-builder]`: Task 5
|
||||||
|
- Deploy zu `staging.rebreak.org`: Task 3 + 4 + 6
|
||||||
|
- Sicherheit (kein PR-Trigger): Task 5 (Workflow-Trigger bleibt `push: branches: [main]`)
|
||||||
|
- Webhook abschalten: Task 7
|
||||||
|
|
||||||
|
### Placeholder scan
|
||||||
|
|
||||||
|
Keine TBD/TODO. Alle Befehle sind konkret.
|
||||||
|
|
||||||
|
### Konsistenz
|
||||||
|
|
||||||
|
- Label durchgehend `raynis-builder`
|
||||||
|
- Secret-Name durchgehend `STAGING_DEPLOY_KEY`
|
||||||
|
- Node-Version durchgehend `24.11.1`
|
||||||
|
- pnpm-Version durchgehend `10.23.0`
|
||||||
|
- Server-IP durchgehend `128.140.47.53`
|
||||||
@ -0,0 +1,242 @@
|
|||||||
|
# Self-Hosted GitHub Actions Runner für rebreak-monorepo
|
||||||
|
|
||||||
|
**Datum:** 2026-06-18
|
||||||
|
**Status:** Genehmigt — bereit für Implementierungsplanung
|
||||||
|
**Autor:** Kimi Code (Brainstorming-Session)
|
||||||
|
**Ziel:** GitHub-Actions-Buildkosten eliminieren, indem Backend- und Admin-Builds auf den bereits vorhandenen Hetzner-Server `api.trucko.org` (128.140.47.53) verlagert werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Zusammenfassung
|
||||||
|
|
||||||
|
Derzeit laufen Backend- und Admin-Builds auf kostenpflichtigen GitHub-gehosteten Runnern (`ubuntu-latest`). Bei einem privaten Repository führt das bei hoher Push-Frequenz zu Kosten von über 200 €/Monat.
|
||||||
|
|
||||||
|
Dieses Design schlägt vor, einen **self-hosted GitHub Actions Runner** auf dem bereits vorhandenen, aktuell ungenutzten Hetzner-Server `api.trucko.org` (128.140.47.53, 8 GB RAM) zu betreiben. Der Code-Hosting-Aspekt bleibt vollständig auf GitHub; nur die Build- und Deploy-Ausführung wird auf die eigene Infrastruktur verlagert.
|
||||||
|
|
||||||
|
Das Runner-Label `raynis-builder` ist bewusst generisch gewählt, damit später weitere Raynis-Apps (Mutterfirma von ReBreak) denselben Runner nutzen können.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Kontext & Ausgangslage
|
||||||
|
|
||||||
|
### Aktueller Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
local dev → push to main
|
||||||
|
↓
|
||||||
|
GitHub Actions (ubuntu-latest)
|
||||||
|
↓
|
||||||
|
Build Backend / Admin
|
||||||
|
↓
|
||||||
|
Upload Artifact
|
||||||
|
↓
|
||||||
|
Deploy-Job (ubuntu-latest)
|
||||||
|
↓
|
||||||
|
SCP + SSH zu staging.rebreak.org
|
||||||
|
↓
|
||||||
|
deploy-from-artifact.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bestehende Komponenten (werden wiederverwendet)
|
||||||
|
|
||||||
|
- `.github/workflows/deploy-staging.yml`
|
||||||
|
- `.github/workflows/deploy-admin-staging.yml`
|
||||||
|
- `scripts/deploy-from-artifact.sh`
|
||||||
|
- `scripts/deploy-admin-from-artifact.sh`
|
||||||
|
- `ecosystem.config.js` auf `staging.rebreak.org`
|
||||||
|
|
||||||
|
### Probleme mit dem aktuellen Setup
|
||||||
|
|
||||||
|
- GitHub Actions Minuten kosten bei privatem Repo über 200 €/Monat.
|
||||||
|
- Keine Notwendigkeit für die ursprüngliche Entscheidung, Builds auf GitHub laufen zu lassen (Server-OOM auf CX23) — `api.trucko.org` hat 8 GB RAM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Ziele
|
||||||
|
|
||||||
|
1. **Kosten eliminieren:** Keine kostenpflichtigen GitHub Actions Minuten mehr für Backend-/Admin-Builds.
|
||||||
|
2. **Minimaler Änderungsaufwand:** Bestehende Workflows und Deploy-Scripts bleiben weitgehend erhalten.
|
||||||
|
3. **Sicherheit:** Self-hosted Runner wird nicht für Pull Requests von Forks genutzt.
|
||||||
|
4. **Zuverlässigkeit:** Concurrency-Group, Health-Check und atomic deploy bleiben erhalten.
|
||||||
|
|
||||||
|
## 4. Nicht-Ziele
|
||||||
|
|
||||||
|
- Kein Wechsel des Code-Hostings (GitHub bleibt).
|
||||||
|
- Kein Ersatz für Windows- oder iOS-Builds (diese bleiben bei GitHub Actions oder werden lokal gebaut).
|
||||||
|
- Keine Einführung zusätzlicher CI-Tools wie Gitea, GitLab oder Woodpecker.
|
||||||
|
- Keine Änderung an der Production-Infrastruktur auf `staging.rebreak.org` über das Nötige hinaus.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
GitHub Push (main)
|
||||||
|
↓
|
||||||
|
GitHub Actions Workflow
|
||||||
|
↓
|
||||||
|
Self-hosted Runner auf api.trucko.org
|
||||||
|
↓
|
||||||
|
Checkout → pnpm install → pnpm build → tar artifact
|
||||||
|
↓
|
||||||
|
SCP artifact zu staging.rebreak.org
|
||||||
|
↓
|
||||||
|
SSH: bash /srv/rebreak/scripts/deploy-from-artifact.sh
|
||||||
|
↓
|
||||||
|
Atomic swap .output-staging + pm2 restart + health-check
|
||||||
|
```
|
||||||
|
|
||||||
|
### Komponenten
|
||||||
|
|
||||||
|
| Komponente | Ort | Verantwortung |
|
||||||
|
|---|---|---|
|
||||||
|
| GitHub Repository | GitHub Cloud | Code-Hosting, Workflow-Definitionen |
|
||||||
|
| GitHub Actions Runner | `api.trucko.org` | Workflow-Ausführung, Build, Deploy-Trigger |
|
||||||
|
| Build-Umgebung | `api.trucko.org` | `pnpm install`, `pnpm build`, Artifact-Erstellung |
|
||||||
|
| Production-Server | `staging.rebreak.org` | Artifact entpacken, Migrationen, PM2-Restart |
|
||||||
|
| Deploy-Script | `staging.rebreak.org` | `scripts/deploy-from-artifact.sh` / `deploy-admin-from-artifact.sh` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Datenfluss (detailliert)
|
||||||
|
|
||||||
|
### 6.1 Backend-Deploy
|
||||||
|
|
||||||
|
1. Push auf `main` triggert `.github/workflows/deploy-staging.yml`.
|
||||||
|
2. Workflow-Job `build` läuft auf `runs-on: self-hosted` (Runner auf `api.trucko.org`).
|
||||||
|
3. Runner checkt Repo mit `actions/checkout@v4` aus.
|
||||||
|
4. `pnpm install --frozen-lockfile` wird ausgeführt.
|
||||||
|
5. `cd backend && pnpm build` erzeugt `backend/.output`.
|
||||||
|
6. Artifact wird gepackt: `tar czf backend-output.tar.gz -C backend/.output .`.
|
||||||
|
7. Artifact wird per SCP auf `staging.rebreak.org:/srv/rebreak/backend/.output-incoming.tar.gz` kopiert.
|
||||||
|
8. `imap-idle/` wird per SCP synchronisiert.
|
||||||
|
9. Runner führt per SSH `bash /srv/rebreak/scripts/deploy-from-artifact.sh` aus.
|
||||||
|
10. Server entpackt Artifact atomisch, führt ggf. Prisma-Migrationen aus und restartet `rebreak-staging`.
|
||||||
|
11. Health-Check gegen `https://staging.rebreak.org/api/auth/me` prüft Erreichbarkeit.
|
||||||
|
|
||||||
|
### 6.2 Admin-Deploy
|
||||||
|
|
||||||
|
- Analog zu Backend über `.github/workflows/deploy-admin-staging.yml`.
|
||||||
|
- Ziel: `staging.rebreak.org:/srv/rebreak/apps/admin/.output-incoming.tar.gz`.
|
||||||
|
- Script: `scripts/deploy-admin-from-artifact.sh`.
|
||||||
|
- Health-Check: `https://admin.staging.rebreak.org/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Sicherheit
|
||||||
|
|
||||||
|
### Runner-Isolation
|
||||||
|
|
||||||
|
- Der Runner wird als **Repository-Level Runner** im `rebreak-monorepo` registriert, damit nur dieses Repo ihn nutzen kann.
|
||||||
|
- Der Runner bekommt ein dediziertes Label: `raynis-builder`.
|
||||||
|
- Der Workflow fordert das Label explizit an: `runs-on: [self-hosted, raynis-builder]`.
|
||||||
|
- Der Runner reagiert **nicht** auf Pull-Request-Events — nur auf `push` zu `main` und `workflow_dispatch`.
|
||||||
|
|
||||||
|
### SSH-Zugriff
|
||||||
|
|
||||||
|
- Ein neuer SSH-Deploy-Key wird auf `api.trucko.org` erzeugt.
|
||||||
|
- Der Public Key wird auf `staging.rebreak.org` in `/root/.ssh/authorized_keys` eingetragen.
|
||||||
|
- Der Private Key wird als GitHub Secret (z. B. `STAGING_DEPLOY_KEY`) im Environment `staging` hinterlegt.
|
||||||
|
|
||||||
|
### Secrets
|
||||||
|
|
||||||
|
- Keine Secrets im Runner-Image.
|
||||||
|
- Alle sensiblen Daten kommen weiterhin aus GitHub Environments oder Infisical (auf `staging.rebreak.org`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Fehlerbehandlung
|
||||||
|
|
||||||
|
### Build-Fehler
|
||||||
|
|
||||||
|
- Wenn `pnpm build` fehlschlägt, bricht der Workflow ab.
|
||||||
|
- Es wird nichts auf `staging.rebreak.org` kopiert.
|
||||||
|
|
||||||
|
### Deploy-Fehler
|
||||||
|
|
||||||
|
- `deploy-from-artifact.sh` bricht bei fehlgeschlagenen Prisma-Migrationen ab.
|
||||||
|
- Der alte `.output-staging`-Ordner bleibt durch atomisches `mv` erhalten.
|
||||||
|
- Health-Check muss bestehen, sonst schlägt der Workflow fehl.
|
||||||
|
|
||||||
|
### Parallelität
|
||||||
|
|
||||||
|
- Concurrency-Group bleibt erhalten:
|
||||||
|
```yaml
|
||||||
|
concurrency:
|
||||||
|
group: deploy-staging
|
||||||
|
cancel-in-progress: false
|
||||||
|
```
|
||||||
|
- Parallele Deploys queueen sich, statt sich gegenseitig abzubrechen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Tests in CI
|
||||||
|
|
||||||
|
Aktuell laufen keine automatisierten Tests in den Deploy-Workflows. Mit dem eigenen Runner entstehen hierfür keine zusätzlichen Kosten.
|
||||||
|
|
||||||
|
**Empfohlener optionaler nächster Schritt:**
|
||||||
|
- Vitest-Unit-Tests vor dem Build-Schritt ausführen.
|
||||||
|
- Reihenfolge: `install → lint → test → build → deploy`.
|
||||||
|
|
||||||
|
Dies ist **nicht Teil dieses Designs** und wird in einem separaten Plan behandelt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Migrationsschritte
|
||||||
|
|
||||||
|
1. **Runner auf `api.trucko.org` installieren**
|
||||||
|
- Node.js 24.11.1, pnpm 10.23.0, git einrichten.
|
||||||
|
- GitHub Actions Runner herunterladen und konfigurieren.
|
||||||
|
- Als Service registrieren (`./svc.sh install && ./svc.sh start`).
|
||||||
|
- Label `raynis-builder` zuweisen.
|
||||||
|
|
||||||
|
2. **SSH-Verbindung einrichten**
|
||||||
|
- Auf `api.trucko.org`: `ssh-keygen -t ed25519 -f ~/.ssh/rebreak-deploy`.
|
||||||
|
- Public Key auf `staging.rebreak.org` in `/root/.ssh/authorized_keys` eintragen.
|
||||||
|
- Private Key als GitHub Secret `STAGING_DEPLOY_KEY` im Environment `staging` hinterlegen.
|
||||||
|
|
||||||
|
3. **Workflows anpassen**
|
||||||
|
- `runs-on: ubuntu-latest` → `runs-on: [self-hosted, raynis-builder]`.
|
||||||
|
- SSH-Setup-Step an neues Secret `STAGING_DEPLOY_KEY` anpassen.
|
||||||
|
- Node-Setup beibehalten (Version 24.11.1).
|
||||||
|
|
||||||
|
4. **Test-Deploy durchführen**
|
||||||
|
- `workflow_dispatch` auf `main` auslösen.
|
||||||
|
- Logs auf `api.trucko.org` und `staging.rebreak.org` prüfen.
|
||||||
|
- Health-Check bestätigen.
|
||||||
|
|
||||||
|
5. **Alte Pfade abschalten**
|
||||||
|
- Nach erfolgreichen Test-Deploys können die alten Webhook-basierten Deploys deaktiviert werden.
|
||||||
|
- `pm2 stop rebreak-webhook && pm2 save` auf `staging.rebreak.org` (nur auf User-Approval).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Risiken & Mitigationen
|
||||||
|
|
||||||
|
| Risiko | Wahrscheinlichkeit | Auswirkung | Mitigation |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Runner-Prozess stürzt ab | Niedrig | Hoch | Runner als systemd-Service laufen lassen; GitHub zeigt Runner offline an. |
|
||||||
|
| Build auf `api.trucko.org` zu langsam | Mittel | Mittel | 8 GB RAM + 8 GB Swap einrichten; SSD statt HDD prüfen. |
|
||||||
|
| SSH-Verbindung zwischen Servern failt | Niedrig | Hoch | SSH-Key testen; `ssh-keyscan` im Workflow nutzen. |
|
||||||
|
| Runner wird versehentlich für PRs genutzt | Niedrig | Hoch | Trigger auf `push: branches: [main]` beschränken; kein `pull_request`. |
|
||||||
|
| Windows-/Native-Builds bleiben kostenpflichtig | Sicher | Niedrig | In separatem Schritt evaluieren (nicht Teil dieses Designs). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Offene Punkte
|
||||||
|
|
||||||
|
1. Exakte Spezifikation von `api.trucko.org` bestätigen (CPU, SSD, OS).
|
||||||
|
2. Soll der Runner unter einem dedizierten User laufen oder als `root`?
|
||||||
|
3. Sollen bestehende Webhook-Deploys sofort abgeschaltet oder parallel als Fallback laufen?
|
||||||
|
4. Sollen Vitest-Tests vor dem Build integriert werden (separater Plan)?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Erwartetes Ergebnis
|
||||||
|
|
||||||
|
Nach der Umstellung:
|
||||||
|
|
||||||
|
- Backend- und Admin-Builds laufen auf `api.trucko.org`.
|
||||||
|
- GitHub-Actions-Minutenkosten für diese Workflows fallen nahezu auf null.
|
||||||
|
- Die Deploy-Mechanik auf `staging.rebreak.org` bleibt unverändert.
|
||||||
|
- Windows-/Native-Builds bleiben bei GitHub Actions oder werden separat betrachtet.
|
||||||
491106
graphify-out/graph.json
511
pnpm-lock.yaml
generated
@ -36,7 +36,7 @@ importers:
|
|||||||
version: 1.15.0(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))
|
version: 1.15.0(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))
|
||||||
'@nuxt/ui':
|
'@nuxt/ui':
|
||||||
specifier: ^4.5.1
|
specifier: ^4.5.1
|
||||||
version: 4.7.1(@internationalized/date@3.12.1)(@internationalized/number@3.6.6)(@tiptap/extensions@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.2.4)(typescript@5.9.3)(valibot@1.4.1(typescript@5.9.3))(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue-router@4.6.4(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3))(yjs@13.6.30)(zod@3.25.76)
|
version: 4.7.1(@internationalized/date@3.12.1)(@internationalized/number@3.6.6)(@tiptap/extensions@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.3)(qrcode@1.5.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.2.4)(typescript@5.9.3)(valibot@1.4.1(typescript@5.9.3))(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue-router@4.6.4(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3))(yjs@13.6.30)(zod@3.25.76)
|
||||||
'@nuxtjs/supabase':
|
'@nuxtjs/supabase':
|
||||||
specifier: ^2.0.4
|
specifier: ^2.0.4
|
||||||
version: 2.0.6
|
version: 2.0.6
|
||||||
@ -45,7 +45,7 @@ importers:
|
|||||||
version: 14.3.0(vue@3.5.34(typescript@5.9.3))
|
version: 14.3.0(vue@3.5.34(typescript@5.9.3))
|
||||||
'@vueuse/nuxt':
|
'@vueuse/nuxt':
|
||||||
specifier: ^14.2.1
|
specifier: ^14.2.1
|
||||||
version: 14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))
|
version: 14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))
|
||||||
nuxt:
|
nuxt:
|
||||||
specifier: 4.1.3
|
specifier: 4.1.3
|
||||||
version: 4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4)
|
version: 4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4)
|
||||||
@ -76,7 +76,7 @@ importers:
|
|||||||
version: 1.2.3
|
version: 1.2.3
|
||||||
'@nuxt/fonts':
|
'@nuxt/fonts':
|
||||||
specifier: ^0.11.4
|
specifier: ^0.11.4
|
||||||
version: 0.11.4(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))
|
version: 0.11.4(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.3)
|
||||||
'@nuxt/icon':
|
'@nuxt/icon':
|
||||||
specifier: ^1.10.0
|
specifier: ^1.10.0
|
||||||
version: 1.15.0(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))
|
version: 1.15.0(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))
|
||||||
@ -85,7 +85,7 @@ importers:
|
|||||||
version: 1.11.0(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.3)
|
version: 1.11.0(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.3)
|
||||||
'@nuxt/ui':
|
'@nuxt/ui':
|
||||||
specifier: ^4.5.1
|
specifier: ^4.5.1
|
||||||
version: 4.7.1(@internationalized/date@3.12.1)(@internationalized/number@3.6.6)(@tiptap/extensions@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.2.4)(typescript@5.9.3)(valibot@1.4.1(typescript@5.9.3))(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue-router@4.6.4(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3))(yjs@13.6.30)(zod@3.25.76)
|
version: 4.7.1(@internationalized/date@3.12.1)(@internationalized/number@3.6.6)(@tiptap/extensions@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.3)(qrcode@1.5.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.2.4)(typescript@5.9.3)(valibot@1.4.1(typescript@5.9.3))(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue-router@4.6.4(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3))(yjs@13.6.30)(zod@3.25.76)
|
||||||
'@nuxtjs/i18n':
|
'@nuxtjs/i18n':
|
||||||
specifier: ^9.5.6
|
specifier: ^9.5.6
|
||||||
version: 9.5.6(@vue/compiler-dom@3.5.35)(eslint@10.3.0(jiti@2.7.0))(magicast@0.5.3)(rollup@4.60.3)(vue@3.5.34(typescript@5.9.3))
|
version: 9.5.6(@vue/compiler-dom@3.5.35)(eslint@10.3.0(jiti@2.7.0))(magicast@0.5.3)(rollup@4.60.3)(vue@3.5.34(typescript@5.9.3))
|
||||||
@ -94,7 +94,7 @@ importers:
|
|||||||
version: 3.0.3(magicast@0.5.3)(vue@3.5.34(typescript@5.9.3))
|
version: 3.0.3(magicast@0.5.3)(vue@3.5.34(typescript@5.9.3))
|
||||||
'@vueuse/nuxt':
|
'@vueuse/nuxt':
|
||||||
specifier: ^14.2.1
|
specifier: ^14.2.1
|
||||||
version: 14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))
|
version: 14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))
|
||||||
chart.js:
|
chart.js:
|
||||||
specifier: ^4.5.1
|
specifier: ^4.5.1
|
||||||
version: 4.5.1
|
version: 4.5.1
|
||||||
@ -124,6 +124,52 @@ importers:
|
|||||||
specifier: ^5.9.3
|
specifier: ^5.9.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
|
apps/rebreak-magic:
|
||||||
|
dependencies:
|
||||||
|
'@nuxt/icon':
|
||||||
|
specifier: ^1.10.0
|
||||||
|
version: 1.15.0(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.35(typescript@5.9.3))
|
||||||
|
'@nuxt/ui':
|
||||||
|
specifier: ^4.5.1
|
||||||
|
version: 4.7.1(@internationalized/date@3.12.1)(@internationalized/number@3.6.6)(@tiptap/extensions@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.3)(qrcode@1.5.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.2.4)(typescript@5.9.3)(valibot@1.4.1(typescript@5.9.3))(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue-router@4.6.4(vue@3.5.35(typescript@5.9.3)))(vue@3.5.35(typescript@5.9.3))(yjs@13.6.30)(zod@3.25.76)
|
||||||
|
'@vueuse/core':
|
||||||
|
specifier: ^14.2.1
|
||||||
|
version: 14.3.0(vue@3.5.35(typescript@5.9.3))
|
||||||
|
'@vueuse/nuxt':
|
||||||
|
specifier: ^14.2.1
|
||||||
|
version: 14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4))(vue@3.5.35(typescript@5.9.3))
|
||||||
|
nuxt:
|
||||||
|
specifier: 4.1.3
|
||||||
|
version: 4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4)
|
||||||
|
qrcode:
|
||||||
|
specifier: ^1.5.4
|
||||||
|
version: 1.5.4
|
||||||
|
tailwindcss:
|
||||||
|
specifier: ^4.1.18
|
||||||
|
version: 4.2.4
|
||||||
|
vue:
|
||||||
|
specifier: ^3.5.22
|
||||||
|
version: 3.5.35(typescript@5.9.3)
|
||||||
|
vue-router:
|
||||||
|
specifier: ^4.5.1
|
||||||
|
version: 4.6.4(vue@3.5.35(typescript@5.9.3))
|
||||||
|
devDependencies:
|
||||||
|
'@iconify-json/heroicons':
|
||||||
|
specifier: ^1.2.3
|
||||||
|
version: 1.2.3
|
||||||
|
'@tauri-apps/api':
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.11.0
|
||||||
|
'@tauri-apps/cli':
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.11.2
|
||||||
|
'@types/qrcode':
|
||||||
|
specifier: ^1.5.5
|
||||||
|
version: 1.5.6
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.9.3
|
||||||
|
version: 5.9.3
|
||||||
|
|
||||||
apps/rebreak-magic-win:
|
apps/rebreak-magic-win:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@fontsource/nunito':
|
'@fontsource/nunito':
|
||||||
@ -4054,6 +4100,9 @@ packages:
|
|||||||
'@types/prop-types@15.7.15':
|
'@types/prop-types@15.7.15':
|
||||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||||
|
|
||||||
|
'@types/qrcode@1.5.6':
|
||||||
|
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
|
||||||
|
|
||||||
'@types/react-dom@18.3.7':
|
'@types/react-dom@18.3.7':
|
||||||
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
|
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -4959,6 +5008,9 @@ packages:
|
|||||||
client-only@0.0.1:
|
client-only@0.0.1:
|
||||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||||
|
|
||||||
|
cliui@6.0.0:
|
||||||
|
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||||
|
|
||||||
cliui@8.0.1:
|
cliui@8.0.1:
|
||||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@ -5246,6 +5298,10 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
decamelize@1.2.0:
|
||||||
|
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
decode-uri-component@0.2.2:
|
decode-uri-component@0.2.2:
|
||||||
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
|
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
|
||||||
engines: {node: '>=0.10'}
|
engines: {node: '>=0.10'}
|
||||||
@ -5359,6 +5415,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==}
|
resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==}
|
||||||
engines: {node: '>=0.3.1'}
|
engines: {node: '>=0.3.1'}
|
||||||
|
|
||||||
|
dijkstrajs@1.0.3:
|
||||||
|
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||||
|
|
||||||
dlv@1.1.3:
|
dlv@1.1.3:
|
||||||
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
||||||
|
|
||||||
@ -7705,6 +7764,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==}
|
resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==}
|
||||||
engines: {node: '>=4.0.0'}
|
engines: {node: '>=4.0.0'}
|
||||||
|
|
||||||
|
pngjs@5.0.0:
|
||||||
|
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
popmotion@11.0.5:
|
popmotion@11.0.5:
|
||||||
resolution: {integrity: sha512-la8gPM1WYeFznb/JqF4GiTkRRPZsfaj2+kCxqQgr2MJylMmIKUwBfWW8Wa5fml/8gmtlD5yI01MP1QCZPWmppA==}
|
resolution: {integrity: sha512-la8gPM1WYeFznb/JqF4GiTkRRPZsfaj2+kCxqQgr2MJylMmIKUwBfWW8Wa5fml/8gmtlD5yI01MP1QCZPWmppA==}
|
||||||
|
|
||||||
@ -8091,6 +8154,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==}
|
resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
qrcode@1.5.4:
|
||||||
|
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
qs@6.15.1:
|
qs@6.15.1:
|
||||||
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
|
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
@ -8433,6 +8501,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
require-main-filename@2.0.0:
|
||||||
|
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||||
|
|
||||||
requireg@0.2.2:
|
requireg@0.2.2:
|
||||||
resolution: {integrity: sha512-nYzyjnFcPNGR3lx9lwPPPnuQxv6JWEZd2Ci0u9opN7N5zUEPIhY/GbL3vMGOr2UXwEg9WwSyV9X9Y/kLFgPsOg==}
|
resolution: {integrity: sha512-nYzyjnFcPNGR3lx9lwPPPnuQxv6JWEZd2Ci0u9opN7N5zUEPIhY/GbL3vMGOr2UXwEg9WwSyV9X9Y/kLFgPsOg==}
|
||||||
engines: {node: '>= 4.0.0'}
|
engines: {node: '>= 4.0.0'}
|
||||||
@ -8631,6 +8702,9 @@ packages:
|
|||||||
server-only@0.0.1:
|
server-only@0.0.1:
|
||||||
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
|
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
|
||||||
|
|
||||||
|
set-blocking@2.0.0:
|
||||||
|
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||||
|
|
||||||
set-function-length@1.2.2:
|
set-function-length@1.2.2:
|
||||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@ -9797,6 +9871,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-f+Gy33Oa5Z14XY9679Zze+7VFhbsQfBFXodnU2x589l4kxGM9L5Y8zETTmcMR5pWOPQyRv4Z0lNax6xCO0NSlA==}
|
resolution: {integrity: sha512-f+Gy33Oa5Z14XY9679Zze+7VFhbsQfBFXodnU2x589l4kxGM9L5Y8zETTmcMR5pWOPQyRv4Z0lNax6xCO0NSlA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
which-module@2.0.1:
|
||||||
|
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||||
|
|
||||||
which-typed-array@1.1.20:
|
which-typed-array@1.1.20:
|
||||||
resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==}
|
resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@ -9828,6 +9905,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
wrap-ansi@6.2.0:
|
||||||
|
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@ -9933,6 +10014,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
yjs: ^13.0.0
|
yjs: ^13.0.0
|
||||||
|
|
||||||
|
y18n@4.0.3:
|
||||||
|
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||||
|
|
||||||
y18n@5.0.8:
|
y18n@5.0.8:
|
||||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@ -9953,6 +10037,10 @@ packages:
|
|||||||
engines: {node: '>= 14.6'}
|
engines: {node: '>= 14.6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
yargs-parser@18.1.3:
|
||||||
|
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
yargs-parser@21.1.1:
|
yargs-parser@21.1.1:
|
||||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@ -9961,6 +10049,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
|
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
|
||||||
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
|
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
|
||||||
|
|
||||||
|
yargs@15.4.1:
|
||||||
|
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
yargs@17.7.2:
|
yargs@17.7.2:
|
||||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@ -11414,6 +11506,15 @@ snapshots:
|
|||||||
- '@vue/composition-api'
|
- '@vue/composition-api'
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
|
'@floating-ui/vue@1.1.11(vue@3.5.35(typescript@5.9.3))':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/dom': 1.7.6
|
||||||
|
'@floating-ui/utils': 0.2.11
|
||||||
|
vue-demi: 0.14.10(vue@3.5.35(typescript@5.9.3))
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@vue/composition-api'
|
||||||
|
- vue
|
||||||
|
|
||||||
'@fontsource/nunito@5.2.7': {}
|
'@fontsource/nunito@5.2.7': {}
|
||||||
|
|
||||||
'@hono/node-server@1.19.11(hono@4.12.17)':
|
'@hono/node-server@1.19.11(hono@4.12.17)':
|
||||||
@ -11474,6 +11575,11 @@ snapshots:
|
|||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
vue: 3.5.34(typescript@5.9.3)
|
vue: 3.5.34(typescript@5.9.3)
|
||||||
|
|
||||||
|
'@iconify/vue@5.0.1(vue@3.5.35(typescript@5.9.3))':
|
||||||
|
dependencies:
|
||||||
|
'@iconify/types': 2.0.0
|
||||||
|
vue: 3.5.35(typescript@5.9.3)
|
||||||
|
|
||||||
'@ide/backoff@1.0.0': {}
|
'@ide/backoff@1.0.0': {}
|
||||||
|
|
||||||
'@internationalized/date@3.12.1':
|
'@internationalized/date@3.12.1':
|
||||||
@ -11918,7 +12024,7 @@ snapshots:
|
|||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
'@nuxt/fonts@0.11.4(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))':
|
'@nuxt/fonts@0.11.4(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nuxt/devtools-kit': 2.7.0(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))
|
'@nuxt/devtools-kit': 2.7.0(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))
|
||||||
'@nuxt/kit': 3.21.4(magicast@0.5.3)
|
'@nuxt/kit': 3.21.4(magicast@0.5.3)
|
||||||
@ -12026,6 +12132,28 @@ snapshots:
|
|||||||
- vite
|
- vite
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
|
'@nuxt/icon@1.15.0(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.35(typescript@5.9.3))':
|
||||||
|
dependencies:
|
||||||
|
'@iconify/collections': 1.0.680
|
||||||
|
'@iconify/types': 2.0.0
|
||||||
|
'@iconify/utils': 2.3.0
|
||||||
|
'@iconify/vue': 5.0.1(vue@3.5.35(typescript@5.9.3))
|
||||||
|
'@nuxt/devtools-kit': 2.7.0(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))
|
||||||
|
'@nuxt/kit': 3.21.4(magicast@0.5.3)
|
||||||
|
consola: 3.4.2
|
||||||
|
local-pkg: 1.1.2
|
||||||
|
mlly: 1.8.2
|
||||||
|
ohash: 2.0.11
|
||||||
|
pathe: 2.0.3
|
||||||
|
picomatch: 4.0.4
|
||||||
|
std-env: 3.10.0
|
||||||
|
tinyglobby: 0.2.16
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- magicast
|
||||||
|
- supports-color
|
||||||
|
- vite
|
||||||
|
- vue
|
||||||
|
|
||||||
'@nuxt/icon@2.2.2(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))':
|
'@nuxt/icon@2.2.2(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/collections': 1.0.680
|
'@iconify/collections': 1.0.680
|
||||||
@ -12047,6 +12175,27 @@ snapshots:
|
|||||||
- vite
|
- vite
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
|
'@nuxt/icon@2.2.2(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.35(typescript@5.9.3))':
|
||||||
|
dependencies:
|
||||||
|
'@iconify/collections': 1.0.680
|
||||||
|
'@iconify/types': 2.0.0
|
||||||
|
'@iconify/utils': 3.1.3
|
||||||
|
'@iconify/vue': 5.0.1(vue@3.5.35(typescript@5.9.3))
|
||||||
|
'@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))
|
||||||
|
'@nuxt/kit': 4.4.4(magicast@0.5.3)
|
||||||
|
consola: 3.4.2
|
||||||
|
local-pkg: 1.1.2
|
||||||
|
mlly: 1.8.2
|
||||||
|
ohash: 2.0.11
|
||||||
|
pathe: 2.0.3
|
||||||
|
picomatch: 4.0.4
|
||||||
|
std-env: 4.1.0
|
||||||
|
tinyglobby: 0.2.16
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- magicast
|
||||||
|
- vite
|
||||||
|
- vue
|
||||||
|
|
||||||
'@nuxt/image@1.11.0(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.3)':
|
'@nuxt/image@1.11.0(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nuxt/kit': 3.21.4(magicast@0.5.3)
|
'@nuxt/kit': 3.21.4(magicast@0.5.3)
|
||||||
@ -12242,7 +12391,7 @@ snapshots:
|
|||||||
rc9: 3.0.1
|
rc9: 3.0.1
|
||||||
std-env: 4.1.0
|
std-env: 4.1.0
|
||||||
|
|
||||||
'@nuxt/ui@4.7.1(@internationalized/date@3.12.1)(@internationalized/number@3.6.6)(@tiptap/extensions@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.2.4)(typescript@5.9.3)(valibot@1.4.1(typescript@5.9.3))(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue-router@4.6.4(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3))(yjs@13.6.30)(zod@3.25.76)':
|
'@nuxt/ui@4.7.1(@internationalized/date@3.12.1)(@internationalized/number@3.6.6)(@tiptap/extensions@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.3)(qrcode@1.5.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.2.4)(typescript@5.9.3)(valibot@1.4.1(typescript@5.9.3))(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue-router@4.6.4(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3))(yjs@13.6.30)(zod@3.25.76)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/dom': 1.7.6
|
'@floating-ui/dom': 1.7.6
|
||||||
'@iconify/vue': 5.0.1(vue@3.5.34(typescript@5.9.3))
|
'@iconify/vue': 5.0.1(vue@3.5.34(typescript@5.9.3))
|
||||||
@ -12275,7 +12424,7 @@ snapshots:
|
|||||||
'@tiptap/vue-3': 3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(vue@3.5.34(typescript@5.9.3))
|
'@tiptap/vue-3': 3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(vue@3.5.34(typescript@5.9.3))
|
||||||
'@unhead/vue': 2.1.13(vue@3.5.34(typescript@5.9.3))
|
'@unhead/vue': 2.1.13(vue@3.5.34(typescript@5.9.3))
|
||||||
'@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3))
|
'@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3))
|
||||||
'@vueuse/integrations': 14.3.0(fuse.js@7.3.0)(vue@3.5.34(typescript@5.9.3))
|
'@vueuse/integrations': 14.3.0(fuse.js@7.3.0)(qrcode@1.5.4)(vue@3.5.34(typescript@5.9.3))
|
||||||
'@vueuse/shared': 14.3.0(vue@3.5.34(typescript@5.9.3))
|
'@vueuse/shared': 14.3.0(vue@3.5.34(typescript@5.9.3))
|
||||||
colortranslator: 5.0.0
|
colortranslator: 5.0.0
|
||||||
consola: 3.4.2
|
consola: 3.4.2
|
||||||
@ -12356,6 +12505,120 @@ snapshots:
|
|||||||
- vue
|
- vue
|
||||||
- yjs
|
- yjs
|
||||||
|
|
||||||
|
'@nuxt/ui@4.7.1(@internationalized/date@3.12.1)(@internationalized/number@3.6.6)(@tiptap/extensions@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.3)(qrcode@1.5.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.2.4)(typescript@5.9.3)(valibot@1.4.1(typescript@5.9.3))(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue-router@4.6.4(vue@3.5.35(typescript@5.9.3)))(vue@3.5.35(typescript@5.9.3))(yjs@13.6.30)(zod@3.25.76)':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/dom': 1.7.6
|
||||||
|
'@iconify/vue': 5.0.1(vue@3.5.35(typescript@5.9.3))
|
||||||
|
'@nuxt/fonts': 0.14.0(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))
|
||||||
|
'@nuxt/icon': 2.2.2(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.35(typescript@5.9.3))
|
||||||
|
'@nuxt/kit': 4.4.4(magicast@0.5.3)
|
||||||
|
'@nuxt/schema': 4.4.4
|
||||||
|
'@nuxtjs/color-mode': 3.5.2(magicast@0.5.3)
|
||||||
|
'@standard-schema/spec': 1.1.0
|
||||||
|
'@tailwindcss/postcss': 4.2.4
|
||||||
|
'@tailwindcss/vite': 4.2.4(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))
|
||||||
|
'@tanstack/vue-table': 8.21.3(vue@3.5.35(typescript@5.9.3))
|
||||||
|
'@tanstack/vue-virtual': 3.13.24(vue@3.5.35(typescript@5.9.3))
|
||||||
|
'@tiptap/core': 3.23.1(@tiptap/pm@3.23.1)
|
||||||
|
'@tiptap/extension-bubble-menu': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)
|
||||||
|
'@tiptap/extension-code': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))
|
||||||
|
'@tiptap/extension-collaboration': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30)
|
||||||
|
'@tiptap/extension-drag-handle': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/extension-collaboration@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30))(@tiptap/extension-node-range@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))
|
||||||
|
'@tiptap/extension-drag-handle-vue-3': 3.23.1(@tiptap/extension-drag-handle@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/extension-collaboration@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30))(@tiptap/extension-node-range@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30)))(@tiptap/pm@3.23.1)(@tiptap/vue-3@3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(vue@3.5.35(typescript@5.9.3)))(vue@3.5.35(typescript@5.9.3))
|
||||||
|
'@tiptap/extension-floating-menu': 3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)
|
||||||
|
'@tiptap/extension-horizontal-rule': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)
|
||||||
|
'@tiptap/extension-image': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))
|
||||||
|
'@tiptap/extension-mention': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/suggestion@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))
|
||||||
|
'@tiptap/extension-node-range': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)
|
||||||
|
'@tiptap/extension-placeholder': 3.23.1(@tiptap/extensions@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))
|
||||||
|
'@tiptap/markdown': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)
|
||||||
|
'@tiptap/pm': 3.23.1
|
||||||
|
'@tiptap/starter-kit': 3.23.1
|
||||||
|
'@tiptap/suggestion': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)
|
||||||
|
'@tiptap/vue-3': 3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(vue@3.5.35(typescript@5.9.3))
|
||||||
|
'@unhead/vue': 2.1.13(vue@3.5.35(typescript@5.9.3))
|
||||||
|
'@vueuse/core': 14.3.0(vue@3.5.35(typescript@5.9.3))
|
||||||
|
'@vueuse/integrations': 14.3.0(fuse.js@7.3.0)(qrcode@1.5.4)(vue@3.5.35(typescript@5.9.3))
|
||||||
|
'@vueuse/shared': 14.3.0(vue@3.5.35(typescript@5.9.3))
|
||||||
|
colortranslator: 5.0.0
|
||||||
|
consola: 3.4.2
|
||||||
|
defu: 6.1.7
|
||||||
|
embla-carousel-auto-height: 8.6.0(embla-carousel@8.6.0)
|
||||||
|
embla-carousel-auto-scroll: 8.6.0(embla-carousel@8.6.0)
|
||||||
|
embla-carousel-autoplay: 8.6.0(embla-carousel@8.6.0)
|
||||||
|
embla-carousel-class-names: 8.6.0(embla-carousel@8.6.0)
|
||||||
|
embla-carousel-fade: 8.6.0(embla-carousel@8.6.0)
|
||||||
|
embla-carousel-vue: 8.6.0(vue@3.5.35(typescript@5.9.3))
|
||||||
|
embla-carousel-wheel-gestures: 8.1.0(embla-carousel@8.6.0)
|
||||||
|
fuse.js: 7.3.0
|
||||||
|
hookable: 6.1.1
|
||||||
|
knitwork: 1.3.0
|
||||||
|
magic-string: 0.30.21
|
||||||
|
mlly: 1.8.2
|
||||||
|
motion-v: 2.2.1(@vueuse/core@14.3.0(vue@3.5.35(typescript@5.9.3)))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vue@3.5.35(typescript@5.9.3))
|
||||||
|
ohash: 2.0.11
|
||||||
|
pathe: 2.0.3
|
||||||
|
reka-ui: 2.9.6(vue@3.5.35(typescript@5.9.3))
|
||||||
|
scule: 1.3.0
|
||||||
|
tailwind-merge: 3.5.0
|
||||||
|
tailwind-variants: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.4)
|
||||||
|
tailwindcss: 4.2.4
|
||||||
|
tinyglobby: 0.2.16
|
||||||
|
typescript: 5.9.3
|
||||||
|
ufo: 1.6.4
|
||||||
|
unplugin: 3.0.0
|
||||||
|
unplugin-auto-import: 21.0.0(@nuxt/kit@4.4.4(magicast@0.5.3))(@vueuse/core@14.3.0(vue@3.5.35(typescript@5.9.3)))
|
||||||
|
unplugin-vue-components: 32.0.0(@nuxt/kit@4.4.4(magicast@0.5.3))(vue@3.5.35(typescript@5.9.3))
|
||||||
|
vaul-vue: 0.4.1(reka-ui@2.9.6(vue@3.5.35(typescript@5.9.3)))(vue@3.5.35(typescript@5.9.3))
|
||||||
|
vue-component-type-helpers: 3.2.8
|
||||||
|
optionalDependencies:
|
||||||
|
'@internationalized/date': 3.12.1
|
||||||
|
'@internationalized/number': 3.6.6
|
||||||
|
valibot: 1.4.1(typescript@5.9.3)
|
||||||
|
vue-router: 4.6.4(vue@3.5.35(typescript@5.9.3))
|
||||||
|
zod: 3.25.76
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@azure/app-configuration'
|
||||||
|
- '@azure/cosmos'
|
||||||
|
- '@azure/data-tables'
|
||||||
|
- '@azure/identity'
|
||||||
|
- '@azure/keyvault-secrets'
|
||||||
|
- '@azure/storage-blob'
|
||||||
|
- '@capacitor/preferences'
|
||||||
|
- '@deno/kv'
|
||||||
|
- '@emotion/is-prop-valid'
|
||||||
|
- '@netlify/blobs'
|
||||||
|
- '@planetscale/database'
|
||||||
|
- '@tiptap/extensions'
|
||||||
|
- '@tiptap/y-tiptap'
|
||||||
|
- '@upstash/redis'
|
||||||
|
- '@vercel/blob'
|
||||||
|
- '@vercel/functions'
|
||||||
|
- '@vercel/kv'
|
||||||
|
- '@vue/composition-api'
|
||||||
|
- async-validator
|
||||||
|
- aws4fetch
|
||||||
|
- axios
|
||||||
|
- change-case
|
||||||
|
- db0
|
||||||
|
- drauu
|
||||||
|
- embla-carousel
|
||||||
|
- focus-trap
|
||||||
|
- idb-keyval
|
||||||
|
- ioredis
|
||||||
|
- jwt-decode
|
||||||
|
- magicast
|
||||||
|
- nprogress
|
||||||
|
- qrcode
|
||||||
|
- react
|
||||||
|
- react-dom
|
||||||
|
- sortablejs
|
||||||
|
- universal-cookie
|
||||||
|
- uploadthing
|
||||||
|
- vite
|
||||||
|
- vue
|
||||||
|
- yjs
|
||||||
|
|
||||||
'@nuxt/vite-builder@4.1.3(@types/node@22.19.17)(eslint@10.3.0(jiti@2.7.0))(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))(yaml@2.8.4)':
|
'@nuxt/vite-builder@4.1.3(@types/node@22.19.17)(eslint@10.3.0(jiti@2.7.0))(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))(yaml@2.8.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nuxt/kit': 4.1.3(magicast@0.5.3)
|
'@nuxt/kit': 4.1.3(magicast@0.5.3)
|
||||||
@ -13679,11 +13942,21 @@ snapshots:
|
|||||||
'@tanstack/table-core': 8.21.3
|
'@tanstack/table-core': 8.21.3
|
||||||
vue: 3.5.34(typescript@5.9.3)
|
vue: 3.5.34(typescript@5.9.3)
|
||||||
|
|
||||||
|
'@tanstack/vue-table@8.21.3(vue@3.5.35(typescript@5.9.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/table-core': 8.21.3
|
||||||
|
vue: 3.5.35(typescript@5.9.3)
|
||||||
|
|
||||||
'@tanstack/vue-virtual@3.13.24(vue@3.5.34(typescript@5.9.3))':
|
'@tanstack/vue-virtual@3.13.24(vue@3.5.34(typescript@5.9.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/virtual-core': 3.14.0
|
'@tanstack/virtual-core': 3.14.0
|
||||||
vue: 3.5.34(typescript@5.9.3)
|
vue: 3.5.34(typescript@5.9.3)
|
||||||
|
|
||||||
|
'@tanstack/vue-virtual@3.13.24(vue@3.5.35(typescript@5.9.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/virtual-core': 3.14.0
|
||||||
|
vue: 3.5.35(typescript@5.9.3)
|
||||||
|
|
||||||
'@tauri-apps/api@2.11.0': {}
|
'@tauri-apps/api@2.11.0': {}
|
||||||
|
|
||||||
'@tauri-apps/cli-darwin-arm64@2.11.2':
|
'@tauri-apps/cli-darwin-arm64@2.11.2':
|
||||||
@ -13782,6 +14055,13 @@ snapshots:
|
|||||||
'@tiptap/vue-3': 3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(vue@3.5.34(typescript@5.9.3))
|
'@tiptap/vue-3': 3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(vue@3.5.34(typescript@5.9.3))
|
||||||
vue: 3.5.34(typescript@5.9.3)
|
vue: 3.5.34(typescript@5.9.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-drag-handle-vue-3@3.23.1(@tiptap/extension-drag-handle@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/extension-collaboration@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30))(@tiptap/extension-node-range@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30)))(@tiptap/pm@3.23.1)(@tiptap/vue-3@3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(vue@3.5.35(typescript@5.9.3)))(vue@3.5.35(typescript@5.9.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/extension-drag-handle': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/extension-collaboration@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30))(@tiptap/extension-node-range@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))
|
||||||
|
'@tiptap/pm': 3.23.1
|
||||||
|
'@tiptap/vue-3': 3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(vue@3.5.35(typescript@5.9.3))
|
||||||
|
vue: 3.5.35(typescript@5.9.3)
|
||||||
|
|
||||||
'@tiptap/extension-drag-handle@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/extension-collaboration@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30))(@tiptap/extension-node-range@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))':
|
'@tiptap/extension-drag-handle@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/extension-collaboration@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30))(@tiptap/extension-node-range@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/dom': 1.7.6
|
'@floating-ui/dom': 1.7.6
|
||||||
@ -13948,6 +14228,16 @@ snapshots:
|
|||||||
'@tiptap/extension-bubble-menu': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)
|
'@tiptap/extension-bubble-menu': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)
|
||||||
'@tiptap/extension-floating-menu': 3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)
|
'@tiptap/extension-floating-menu': 3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)
|
||||||
|
|
||||||
|
'@tiptap/vue-3@3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(vue@3.5.35(typescript@5.9.3))':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/dom': 1.7.6
|
||||||
|
'@tiptap/core': 3.23.1(@tiptap/pm@3.23.1)
|
||||||
|
'@tiptap/pm': 3.23.1
|
||||||
|
vue: 3.5.35(typescript@5.9.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@tiptap/extension-bubble-menu': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)
|
||||||
|
'@tiptap/extension-floating-menu': 3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)
|
||||||
|
|
||||||
'@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30)':
|
'@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30)':
|
||||||
dependencies:
|
dependencies:
|
||||||
lib0: 0.2.117
|
lib0: 0.2.117
|
||||||
@ -14031,6 +14321,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/prop-types@15.7.15': {}
|
'@types/prop-types@15.7.15': {}
|
||||||
|
|
||||||
|
'@types/qrcode@1.5.6':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 22.19.17
|
||||||
|
|
||||||
'@types/react-dom@18.3.7(@types/react@18.3.31)':
|
'@types/react-dom@18.3.7(@types/react@18.3.31)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 18.3.31
|
'@types/react': 18.3.31
|
||||||
@ -14115,6 +14409,12 @@ snapshots:
|
|||||||
unhead: 2.1.13
|
unhead: 2.1.13
|
||||||
vue: 3.5.34(typescript@5.9.3)
|
vue: 3.5.34(typescript@5.9.3)
|
||||||
|
|
||||||
|
'@unhead/vue@2.1.13(vue@3.5.35(typescript@5.9.3))':
|
||||||
|
dependencies:
|
||||||
|
hookable: 6.1.1
|
||||||
|
unhead: 2.1.13
|
||||||
|
vue: 3.5.35(typescript@5.9.3)
|
||||||
|
|
||||||
'@urql/core@5.2.0':
|
'@urql/core@5.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@0no-co/graphql.web': 1.2.0
|
'@0no-co/graphql.web': 1.2.0
|
||||||
@ -14359,7 +14659,7 @@ snapshots:
|
|||||||
|
|
||||||
'@vue-macros/common@1.16.1(vue@3.5.34(typescript@5.9.3))':
|
'@vue-macros/common@1.16.1(vue@3.5.34(typescript@5.9.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/compiler-sfc': 3.5.34
|
'@vue/compiler-sfc': 3.5.35
|
||||||
ast-kit: 1.4.3
|
ast-kit: 1.4.3
|
||||||
local-pkg: 1.1.2
|
local-pkg: 1.1.2
|
||||||
magic-string-ast: 0.7.1
|
magic-string-ast: 0.7.1
|
||||||
@ -14370,7 +14670,7 @@ snapshots:
|
|||||||
|
|
||||||
'@vue-macros/common@3.0.0-beta.16(vue@3.5.34(typescript@5.9.3))':
|
'@vue-macros/common@3.0.0-beta.16(vue@3.5.34(typescript@5.9.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/compiler-sfc': 3.5.34
|
'@vue/compiler-sfc': 3.5.35
|
||||||
ast-kit: 2.2.0
|
ast-kit: 2.2.0
|
||||||
local-pkg: 1.1.2
|
local-pkg: 1.1.2
|
||||||
magic-string-ast: 1.0.3
|
magic-string-ast: 1.0.3
|
||||||
@ -14390,7 +14690,7 @@ snapshots:
|
|||||||
'@babel/types': 7.29.0
|
'@babel/types': 7.29.0
|
||||||
'@vue/babel-helper-vue-transform-on': 2.0.1
|
'@vue/babel-helper-vue-transform-on': 2.0.1
|
||||||
'@vue/babel-plugin-resolve-type': 2.0.1(@babel/core@7.29.0)
|
'@vue/babel-plugin-resolve-type': 2.0.1(@babel/core@7.29.0)
|
||||||
'@vue/shared': 3.5.34
|
'@vue/shared': 3.5.35
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@ -14403,7 +14703,7 @@ snapshots:
|
|||||||
'@babel/helper-module-imports': 7.28.6
|
'@babel/helper-module-imports': 7.28.6
|
||||||
'@babel/helper-plugin-utils': 7.28.6
|
'@babel/helper-plugin-utils': 7.28.6
|
||||||
'@babel/parser': 7.29.3
|
'@babel/parser': 7.29.3
|
||||||
'@vue/compiler-sfc': 3.5.34
|
'@vue/compiler-sfc': 3.5.35
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@ -14513,8 +14813,8 @@ snapshots:
|
|||||||
'@vue/language-core@3.2.8':
|
'@vue/language-core@3.2.8':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@volar/language-core': 2.4.28
|
'@volar/language-core': 2.4.28
|
||||||
'@vue/compiler-dom': 3.5.34
|
'@vue/compiler-dom': 3.5.35
|
||||||
'@vue/shared': 3.5.34
|
'@vue/shared': 3.5.35
|
||||||
alien-signals: 3.1.2
|
alien-signals: 3.1.2
|
||||||
muggle-string: 0.4.1
|
muggle-string: 0.4.1
|
||||||
path-browserify: 1.0.1
|
path-browserify: 1.0.1
|
||||||
@ -14578,6 +14878,16 @@ snapshots:
|
|||||||
- '@vue/composition-api'
|
- '@vue/composition-api'
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
|
'@vueuse/core@10.11.1(vue@3.5.35(typescript@5.9.3))':
|
||||||
|
dependencies:
|
||||||
|
'@types/web-bluetooth': 0.0.20
|
||||||
|
'@vueuse/metadata': 10.11.1
|
||||||
|
'@vueuse/shared': 10.11.1(vue@3.5.35(typescript@5.9.3))
|
||||||
|
vue-demi: 0.14.10(vue@3.5.35(typescript@5.9.3))
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@vue/composition-api'
|
||||||
|
- vue
|
||||||
|
|
||||||
'@vueuse/core@13.9.0(vue@3.5.34(typescript@5.9.3))':
|
'@vueuse/core@13.9.0(vue@3.5.34(typescript@5.9.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/web-bluetooth': 0.0.21
|
'@types/web-bluetooth': 0.0.21
|
||||||
@ -14592,13 +14902,30 @@ snapshots:
|
|||||||
'@vueuse/shared': 14.3.0(vue@3.5.34(typescript@5.9.3))
|
'@vueuse/shared': 14.3.0(vue@3.5.34(typescript@5.9.3))
|
||||||
vue: 3.5.34(typescript@5.9.3)
|
vue: 3.5.34(typescript@5.9.3)
|
||||||
|
|
||||||
'@vueuse/integrations@14.3.0(fuse.js@7.3.0)(vue@3.5.34(typescript@5.9.3))':
|
'@vueuse/core@14.3.0(vue@3.5.35(typescript@5.9.3))':
|
||||||
|
dependencies:
|
||||||
|
'@types/web-bluetooth': 0.0.21
|
||||||
|
'@vueuse/metadata': 14.3.0
|
||||||
|
'@vueuse/shared': 14.3.0(vue@3.5.35(typescript@5.9.3))
|
||||||
|
vue: 3.5.35(typescript@5.9.3)
|
||||||
|
|
||||||
|
'@vueuse/integrations@14.3.0(fuse.js@7.3.0)(qrcode@1.5.4)(vue@3.5.34(typescript@5.9.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3))
|
'@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3))
|
||||||
'@vueuse/shared': 14.3.0(vue@3.5.34(typescript@5.9.3))
|
'@vueuse/shared': 14.3.0(vue@3.5.34(typescript@5.9.3))
|
||||||
vue: 3.5.34(typescript@5.9.3)
|
vue: 3.5.34(typescript@5.9.3)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fuse.js: 7.3.0
|
fuse.js: 7.3.0
|
||||||
|
qrcode: 1.5.4
|
||||||
|
|
||||||
|
'@vueuse/integrations@14.3.0(fuse.js@7.3.0)(qrcode@1.5.4)(vue@3.5.35(typescript@5.9.3))':
|
||||||
|
dependencies:
|
||||||
|
'@vueuse/core': 14.3.0(vue@3.5.35(typescript@5.9.3))
|
||||||
|
'@vueuse/shared': 14.3.0(vue@3.5.35(typescript@5.9.3))
|
||||||
|
vue: 3.5.35(typescript@5.9.3)
|
||||||
|
optionalDependencies:
|
||||||
|
fuse.js: 7.3.0
|
||||||
|
qrcode: 1.5.4
|
||||||
|
|
||||||
'@vueuse/metadata@10.11.1': {}
|
'@vueuse/metadata@10.11.1': {}
|
||||||
|
|
||||||
@ -14620,7 +14947,7 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- magicast
|
- magicast
|
||||||
|
|
||||||
'@vueuse/nuxt@14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))':
|
'@vueuse/nuxt@14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nuxt/kit': 4.4.4(magicast@0.5.3)
|
'@nuxt/kit': 4.4.4(magicast@0.5.3)
|
||||||
'@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3))
|
'@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3))
|
||||||
@ -14631,6 +14958,17 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- magicast
|
- magicast
|
||||||
|
|
||||||
|
'@vueuse/nuxt@14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4))(vue@3.5.35(typescript@5.9.3))':
|
||||||
|
dependencies:
|
||||||
|
'@nuxt/kit': 4.4.4(magicast@0.5.3)
|
||||||
|
'@vueuse/core': 14.3.0(vue@3.5.35(typescript@5.9.3))
|
||||||
|
'@vueuse/metadata': 14.3.0
|
||||||
|
local-pkg: 1.1.2
|
||||||
|
nuxt: 4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4)
|
||||||
|
vue: 3.5.35(typescript@5.9.3)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- magicast
|
||||||
|
|
||||||
'@vueuse/shared@10.11.1(vue@3.5.34(typescript@5.9.3))':
|
'@vueuse/shared@10.11.1(vue@3.5.34(typescript@5.9.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
vue-demi: 0.14.10(vue@3.5.34(typescript@5.9.3))
|
vue-demi: 0.14.10(vue@3.5.34(typescript@5.9.3))
|
||||||
@ -14638,6 +14976,13 @@ snapshots:
|
|||||||
- '@vue/composition-api'
|
- '@vue/composition-api'
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
|
'@vueuse/shared@10.11.1(vue@3.5.35(typescript@5.9.3))':
|
||||||
|
dependencies:
|
||||||
|
vue-demi: 0.14.10(vue@3.5.35(typescript@5.9.3))
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@vue/composition-api'
|
||||||
|
- vue
|
||||||
|
|
||||||
'@vueuse/shared@13.9.0(vue@3.5.34(typescript@5.9.3))':
|
'@vueuse/shared@13.9.0(vue@3.5.34(typescript@5.9.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.34(typescript@5.9.3)
|
vue: 3.5.34(typescript@5.9.3)
|
||||||
@ -14646,6 +14991,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.34(typescript@5.9.3)
|
vue: 3.5.34(typescript@5.9.3)
|
||||||
|
|
||||||
|
'@vueuse/shared@14.3.0(vue@3.5.35(typescript@5.9.3))':
|
||||||
|
dependencies:
|
||||||
|
vue: 3.5.35(typescript@5.9.3)
|
||||||
|
|
||||||
'@xmldom/xmldom@0.8.13': {}
|
'@xmldom/xmldom@0.8.13': {}
|
||||||
|
|
||||||
'@xmldom/xmldom@0.9.10': {}
|
'@xmldom/xmldom@0.9.10': {}
|
||||||
@ -15273,6 +15622,12 @@ snapshots:
|
|||||||
|
|
||||||
client-only@0.0.1: {}
|
client-only@0.0.1: {}
|
||||||
|
|
||||||
|
cliui@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
string-width: 4.2.3
|
||||||
|
strip-ansi: 6.0.1
|
||||||
|
wrap-ansi: 6.2.0
|
||||||
|
|
||||||
cliui@8.0.1:
|
cliui@8.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
string-width: 4.2.3
|
string-width: 4.2.3
|
||||||
@ -15545,6 +15900,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
decamelize@1.2.0: {}
|
||||||
|
|
||||||
decode-uri-component@0.2.2: {}
|
decode-uri-component@0.2.2: {}
|
||||||
|
|
||||||
decompress-response@6.0.0:
|
decompress-response@6.0.0:
|
||||||
@ -15634,6 +15991,8 @@ snapshots:
|
|||||||
|
|
||||||
diff@9.0.0: {}
|
diff@9.0.0: {}
|
||||||
|
|
||||||
|
dijkstrajs@1.0.3: {}
|
||||||
|
|
||||||
dlv@1.1.3: {}
|
dlv@1.1.3: {}
|
||||||
|
|
||||||
dom-serializer@2.0.0:
|
dom-serializer@2.0.0:
|
||||||
@ -15719,6 +16078,12 @@ snapshots:
|
|||||||
embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0)
|
embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0)
|
||||||
vue: 3.5.34(typescript@5.9.3)
|
vue: 3.5.34(typescript@5.9.3)
|
||||||
|
|
||||||
|
embla-carousel-vue@8.6.0(vue@3.5.35(typescript@5.9.3)):
|
||||||
|
dependencies:
|
||||||
|
embla-carousel: 8.6.0
|
||||||
|
embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0)
|
||||||
|
vue: 3.5.35(typescript@5.9.3)
|
||||||
|
|
||||||
embla-carousel-wheel-gestures@8.1.0(embla-carousel@8.6.0):
|
embla-carousel-wheel-gestures@8.1.0(embla-carousel@8.6.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
embla-carousel: 8.6.0
|
embla-carousel: 8.6.0
|
||||||
@ -17801,6 +18166,19 @@ snapshots:
|
|||||||
- react
|
- react
|
||||||
- react-dom
|
- react-dom
|
||||||
|
|
||||||
|
motion-v@2.2.1(@vueuse/core@14.3.0(vue@3.5.35(typescript@5.9.3)))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vue@3.5.35(typescript@5.9.3)):
|
||||||
|
dependencies:
|
||||||
|
'@vueuse/core': 14.3.0(vue@3.5.35(typescript@5.9.3))
|
||||||
|
framer-motion: 12.38.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
|
hey-listen: 1.0.8
|
||||||
|
motion-dom: 12.38.0
|
||||||
|
motion-utils: 12.36.0
|
||||||
|
vue: 3.5.35(typescript@5.9.3)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@emotion/is-prop-valid'
|
||||||
|
- react
|
||||||
|
- react-dom
|
||||||
|
|
||||||
mri@1.2.0: {}
|
mri@1.2.0: {}
|
||||||
|
|
||||||
mrmime@2.0.1: {}
|
mrmime@2.0.1: {}
|
||||||
@ -18675,6 +19053,8 @@ snapshots:
|
|||||||
|
|
||||||
pngjs@3.4.0: {}
|
pngjs@3.4.0: {}
|
||||||
|
|
||||||
|
pngjs@5.0.0: {}
|
||||||
|
|
||||||
popmotion@11.0.5:
|
popmotion@11.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
framesync: 6.1.2
|
framesync: 6.1.2
|
||||||
@ -19073,6 +19453,12 @@ snapshots:
|
|||||||
|
|
||||||
qrcode-terminal@0.11.0: {}
|
qrcode-terminal@0.11.0: {}
|
||||||
|
|
||||||
|
qrcode@1.5.4:
|
||||||
|
dependencies:
|
||||||
|
dijkstrajs: 1.0.3
|
||||||
|
pngjs: 5.0.0
|
||||||
|
yargs: 15.4.1
|
||||||
|
|
||||||
qs@6.15.1:
|
qs@6.15.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
side-channel: 1.1.0
|
side-channel: 1.1.0
|
||||||
@ -19468,12 +19854,30 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@vue/composition-api'
|
- '@vue/composition-api'
|
||||||
|
|
||||||
|
reka-ui@2.9.6(vue@3.5.35(typescript@5.9.3)):
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/dom': 1.7.6
|
||||||
|
'@floating-ui/vue': 1.1.11(vue@3.5.35(typescript@5.9.3))
|
||||||
|
'@internationalized/date': 3.12.1
|
||||||
|
'@internationalized/number': 3.6.6
|
||||||
|
'@tanstack/vue-virtual': 3.13.24(vue@3.5.35(typescript@5.9.3))
|
||||||
|
'@vueuse/core': 14.3.0(vue@3.5.35(typescript@5.9.3))
|
||||||
|
'@vueuse/shared': 14.3.0(vue@3.5.35(typescript@5.9.3))
|
||||||
|
aria-hidden: 1.2.6
|
||||||
|
defu: 6.1.7
|
||||||
|
ohash: 2.0.11
|
||||||
|
vue: 3.5.35(typescript@5.9.3)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@vue/composition-api'
|
||||||
|
|
||||||
remeda@2.33.4: {}
|
remeda@2.33.4: {}
|
||||||
|
|
||||||
require-directory@2.1.1: {}
|
require-directory@2.1.1: {}
|
||||||
|
|
||||||
require-from-string@2.0.2: {}
|
require-from-string@2.0.2: {}
|
||||||
|
|
||||||
|
require-main-filename@2.0.0: {}
|
||||||
|
|
||||||
requireg@0.2.2:
|
requireg@0.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
nested-error-stacks: 2.0.1
|
nested-error-stacks: 2.0.1
|
||||||
@ -19693,6 +20097,8 @@ snapshots:
|
|||||||
|
|
||||||
server-only@0.0.1: {}
|
server-only@0.0.1: {}
|
||||||
|
|
||||||
|
set-blocking@2.0.0: {}
|
||||||
|
|
||||||
set-function-length@1.2.2:
|
set-function-length@1.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
define-data-property: 1.1.4
|
define-data-property: 1.1.4
|
||||||
@ -20379,6 +20785,18 @@ snapshots:
|
|||||||
'@nuxt/kit': 4.4.4(magicast@0.5.3)
|
'@nuxt/kit': 4.4.4(magicast@0.5.3)
|
||||||
'@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3))
|
'@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3))
|
||||||
|
|
||||||
|
unplugin-auto-import@21.0.0(@nuxt/kit@4.4.4(magicast@0.5.3))(@vueuse/core@14.3.0(vue@3.5.35(typescript@5.9.3))):
|
||||||
|
dependencies:
|
||||||
|
local-pkg: 1.1.2
|
||||||
|
magic-string: 0.30.21
|
||||||
|
picomatch: 4.0.4
|
||||||
|
unimport: 5.7.0
|
||||||
|
unplugin: 2.3.11
|
||||||
|
unplugin-utils: 0.3.1
|
||||||
|
optionalDependencies:
|
||||||
|
'@nuxt/kit': 4.4.4(magicast@0.5.3)
|
||||||
|
'@vueuse/core': 14.3.0(vue@3.5.35(typescript@5.9.3))
|
||||||
|
|
||||||
unplugin-utils@0.2.5:
|
unplugin-utils@0.2.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
@ -20404,6 +20822,21 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@nuxt/kit': 4.4.4(magicast@0.5.3)
|
'@nuxt/kit': 4.4.4(magicast@0.5.3)
|
||||||
|
|
||||||
|
unplugin-vue-components@32.0.0(@nuxt/kit@4.4.4(magicast@0.5.3))(vue@3.5.35(typescript@5.9.3)):
|
||||||
|
dependencies:
|
||||||
|
chokidar: 5.0.0
|
||||||
|
local-pkg: 1.1.2
|
||||||
|
magic-string: 0.30.21
|
||||||
|
mlly: 1.8.2
|
||||||
|
obug: 2.1.1
|
||||||
|
picomatch: 4.0.4
|
||||||
|
tinyglobby: 0.2.16
|
||||||
|
unplugin: 3.0.0
|
||||||
|
unplugin-utils: 0.3.1
|
||||||
|
vue: 3.5.35(typescript@5.9.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@nuxt/kit': 4.4.4(magicast@0.5.3)
|
||||||
|
|
||||||
unplugin-vue-router@0.12.0(vue-router@4.6.4(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3)):
|
unplugin-vue-router@0.12.0(vue-router@4.6.4(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.29.0
|
'@babel/types': 7.29.0
|
||||||
@ -20578,6 +21011,14 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@vue/composition-api'
|
- '@vue/composition-api'
|
||||||
|
|
||||||
|
vaul-vue@0.4.1(reka-ui@2.9.6(vue@3.5.35(typescript@5.9.3)))(vue@3.5.35(typescript@5.9.3)):
|
||||||
|
dependencies:
|
||||||
|
'@vueuse/core': 10.11.1(vue@3.5.35(typescript@5.9.3))
|
||||||
|
reka-ui: 2.9.6(vue@3.5.35(typescript@5.9.3))
|
||||||
|
vue: 3.5.35(typescript@5.9.3)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@vue/composition-api'
|
||||||
|
|
||||||
vaul@1.1.2(@types/react-dom@18.3.7(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
vaul@1.1.2(@types/react-dom@18.3.7(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
@ -20815,6 +21256,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.34(typescript@5.9.3)
|
vue: 3.5.34(typescript@5.9.3)
|
||||||
|
|
||||||
|
vue-demi@0.14.10(vue@3.5.35(typescript@5.9.3)):
|
||||||
|
dependencies:
|
||||||
|
vue: 3.5.35(typescript@5.9.3)
|
||||||
|
|
||||||
vue-devtools-stub@0.1.0: {}
|
vue-devtools-stub@0.1.0: {}
|
||||||
|
|
||||||
vue-i18n@10.0.8(vue@3.5.34(typescript@5.9.3)):
|
vue-i18n@10.0.8(vue@3.5.34(typescript@5.9.3)):
|
||||||
@ -20829,6 +21274,11 @@ snapshots:
|
|||||||
'@vue/devtools-api': 6.6.4
|
'@vue/devtools-api': 6.6.4
|
||||||
vue: 3.5.34(typescript@5.9.3)
|
vue: 3.5.34(typescript@5.9.3)
|
||||||
|
|
||||||
|
vue-router@4.6.4(vue@3.5.35(typescript@5.9.3)):
|
||||||
|
dependencies:
|
||||||
|
'@vue/devtools-api': 6.6.4
|
||||||
|
vue: 3.5.35(typescript@5.9.3)
|
||||||
|
|
||||||
vue-virtual-scroller@3.0.4(vue@3.5.35(typescript@5.9.3)):
|
vue-virtual-scroller@3.0.4(vue@3.5.35(typescript@5.9.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.35(typescript@5.9.3)
|
vue: 3.5.35(typescript@5.9.3)
|
||||||
@ -20888,6 +21338,8 @@ snapshots:
|
|||||||
|
|
||||||
wheel-gestures@2.2.48: {}
|
wheel-gestures@2.2.48: {}
|
||||||
|
|
||||||
|
which-module@2.0.1: {}
|
||||||
|
|
||||||
which-typed-array@1.1.20:
|
which-typed-array@1.1.20:
|
||||||
dependencies:
|
dependencies:
|
||||||
available-typed-arrays: 1.0.7
|
available-typed-arrays: 1.0.7
|
||||||
@ -20919,6 +21371,12 @@ snapshots:
|
|||||||
|
|
||||||
word-wrap@1.2.5: {}
|
word-wrap@1.2.5: {}
|
||||||
|
|
||||||
|
wrap-ansi@6.2.0:
|
||||||
|
dependencies:
|
||||||
|
ansi-styles: 4.3.0
|
||||||
|
string-width: 4.2.3
|
||||||
|
strip-ansi: 6.0.1
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
@ -20990,6 +21448,8 @@ snapshots:
|
|||||||
lib0: 0.2.117
|
lib0: 0.2.117
|
||||||
yjs: 13.6.30
|
yjs: 13.6.30
|
||||||
|
|
||||||
|
y18n@4.0.3: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
@ -21003,10 +21463,29 @@ snapshots:
|
|||||||
|
|
||||||
yaml@2.8.4: {}
|
yaml@2.8.4: {}
|
||||||
|
|
||||||
|
yargs-parser@18.1.3:
|
||||||
|
dependencies:
|
||||||
|
camelcase: 5.3.1
|
||||||
|
decamelize: 1.2.0
|
||||||
|
|
||||||
yargs-parser@21.1.1: {}
|
yargs-parser@21.1.1: {}
|
||||||
|
|
||||||
yargs-parser@22.0.0: {}
|
yargs-parser@22.0.0: {}
|
||||||
|
|
||||||
|
yargs@15.4.1:
|
||||||
|
dependencies:
|
||||||
|
cliui: 6.0.0
|
||||||
|
decamelize: 1.2.0
|
||||||
|
find-up: 4.1.0
|
||||||
|
get-caller-file: 2.0.5
|
||||||
|
require-directory: 2.1.1
|
||||||
|
require-main-filename: 2.0.0
|
||||||
|
set-blocking: 2.0.0
|
||||||
|
string-width: 4.2.3
|
||||||
|
which-module: 2.0.1
|
||||||
|
y18n: 4.0.3
|
||||||
|
yargs-parser: 18.1.3
|
||||||
|
|
||||||
yargs@17.7.2:
|
yargs@17.7.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
cliui: 8.0.1
|
cliui: 8.0.1
|
||||||
|
|||||||