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
|
||||
(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"
|
||||
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" />
|
||||
<span>Verbinde dein iPhone mit USB, um den Schutz zu vervollständigen.</span>
|
||||
<UIcon
|
||||
: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>
|
||||
|
||||
<!-- Backend-MDM always visible; local USB only when connected -->
|
||||
@ -190,8 +194,8 @@
|
||||
:variant="action.variant"
|
||||
size="sm"
|
||||
:icon="action.icon"
|
||||
:loading="manualSyncing || autoSyncing"
|
||||
:disabled="autoSyncing"
|
||||
:loading="manualSyncing || autoSyncing || isSearching"
|
||||
:disabled="autoSyncing || isSearching"
|
||||
@click="onActionClick"
|
||||
>
|
||||
{{ action.label }}
|
||||
@ -222,6 +226,7 @@ const props = defineProps<{
|
||||
device: ComputedDevice;
|
||||
iphone: IphoneDeviceState | null;
|
||||
isConnected: boolean;
|
||||
isSearching?: boolean;
|
||||
inGracePeriod?: boolean;
|
||||
}>();
|
||||
|
||||
@ -232,6 +237,7 @@ const emit = defineEmits<{
|
||||
(e: "sync", device: ComputedDevice): void;
|
||||
(e: "open", device: ComputedDevice): void;
|
||||
(e: "remove", device: ComputedDevice): void;
|
||||
(e: "connect", device: ComputedDevice): void;
|
||||
}>();
|
||||
|
||||
const ENROLLMENT_PROFILE_ID = "org.rebreak.mdm.enrollment";
|
||||
@ -264,12 +270,10 @@ const backendRows = computed(() => {
|
||||
},
|
||||
{
|
||||
label: "Supervised",
|
||||
value: data?.enrolled ? (data.supervised ? "Ja" : "Nein") : "—",
|
||||
valueClass: data?.enrolled
|
||||
? data.supervised
|
||||
? "text-green-600 dark:text-green-400 font-medium"
|
||||
: "text-red-600 dark:text-red-400 font-medium"
|
||||
: "text-gray-400 dark:text-gray-500",
|
||||
value: data?.supervised ? "Ja" : "Nein",
|
||||
valueClass: data?.supervised
|
||||
? "text-green-600 dark:text-green-400 font-medium"
|
||||
: "text-red-600 dark:text-red-400 font-medium",
|
||||
},
|
||||
{
|
||||
label: "Organisation",
|
||||
@ -280,16 +284,10 @@ const backendRows = computed(() => {
|
||||
},
|
||||
{
|
||||
label: "Lock-Profil",
|
||||
value: data?.enrolled
|
||||
? data.lockProfileInstalled
|
||||
? "Installiert"
|
||||
: "Fehlt"
|
||||
: "—",
|
||||
valueClass: data?.enrolled
|
||||
? data.lockProfileInstalled
|
||||
? "text-green-600 dark:text-green-400 font-medium"
|
||||
: "text-red-600 dark:text-red-400 font-medium"
|
||||
: "text-gray-400 dark:text-gray-500",
|
||||
value: data?.lockProfileInstalled ? "Installiert" : "Fehlt",
|
||||
valueClass: data?.lockProfileInstalled
|
||||
? "text-green-600 dark:text-green-400 font-medium"
|
||||
: "text-red-600 dark:text-red-400 font-medium",
|
||||
},
|
||||
{
|
||||
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) {
|
||||
return {
|
||||
label: "iPhone verbinden, um ReBreak Cloud zu synchronisieren",
|
||||
icon: "i-heroicons-link",
|
||||
color: "primary",
|
||||
variant: "solid",
|
||||
to: "/detect",
|
||||
};
|
||||
}
|
||||
|
||||
@ -505,12 +511,13 @@ const action = computed<IosAction>(() => {
|
||||
}
|
||||
|
||||
if (!backend?.enrolled || !localEnrollment.value) {
|
||||
const isKnownDevice = !!props.device.mdmId;
|
||||
return {
|
||||
label: "Enrollen",
|
||||
icon: "i-heroicons-document-check",
|
||||
label: isKnownDevice ? "Schutz vervollständigen" : "Enrollen",
|
||||
icon: isKnownDevice ? "i-heroicons-shield-check" : "i-heroicons-document-check",
|
||||
color: "primary",
|
||||
variant: "solid",
|
||||
to: "/enroll",
|
||||
to: isKnownDevice ? "/preflight" : "/enroll",
|
||||
};
|
||||
}
|
||||
|
||||
@ -615,6 +622,11 @@ function onActionClick() {
|
||||
|
||||
if (autoSyncing.value) return;
|
||||
|
||||
if (!props.isConnected || !props.iphone) {
|
||||
emit("connect", props.device);
|
||||
return;
|
||||
}
|
||||
|
||||
manualSyncing.value = true;
|
||||
emit("sync", props.device);
|
||||
setTimeout(() => {
|
||||
|
||||
@ -32,10 +32,12 @@
|
||||
:device="device"
|
||||
:iphone="iphone"
|
||||
:is-connected="device.deviceId === connectedDeviceId"
|
||||
:is-searching="device.deviceId === searchingForDeviceId"
|
||||
:in-grace-period="inGracePeriod"
|
||||
@sync="emit('sync', $event)"
|
||||
@open="emit('open', $event)"
|
||||
@remove="emit('remove', $event)"
|
||||
@connect="emit('connect', $event)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@ -52,12 +54,14 @@ const props = defineProps<{
|
||||
loading: boolean;
|
||||
hasRefreshed: boolean;
|
||||
inGracePeriod?: boolean;
|
||||
searchingForDeviceId?: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "sync", device: ComputedDevice): void;
|
||||
(e: "open", device: ComputedDevice): void;
|
||||
(e: "remove", device: ComputedDevice): void;
|
||||
(e: "connect", device: ComputedDevice): void;
|
||||
}>();
|
||||
|
||||
function matchesIphone(device: ComputedDevice, iphone: IphoneDeviceState): boolean {
|
||||
|
||||
43
apps/rebreak-magic/app/components/PreflightItem.vue
Normal file
@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<button
|
||||
class="w-full text-left"
|
||||
@click="toggle"
|
||||
>
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-xl transition-colors"
|
||||
:class="checked || auto ? 'bg-green-50/50 ring-1 ring-green-100' : 'bg-gray-50 hover:bg-gray-100'"
|
||||
>
|
||||
<UIcon
|
||||
:name="checked || auto ? 'i-heroicons-check-circle-solid' : 'i-heroicons-circle'"
|
||||
class="w-6 h-6 shrink-0"
|
||||
:class="checked || auto ? 'text-green-600' : 'text-gray-400'"
|
||||
/>
|
||||
<div>
|
||||
<div class="font-bold text-gray-900">{{ title }}</div>
|
||||
<div class="text-sm text-gray-500 mt-0.5">{{ detail }}</div>
|
||||
<div v-if="auto && !checked" class="text-xs text-green-600 font-semibold mt-1">
|
||||
Automatisch erkannt ✓
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
title: string;
|
||||
detail: string;
|
||||
auto?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: boolean): void;
|
||||
}>();
|
||||
|
||||
const checked = computed(() => props.modelValue || props.auto);
|
||||
|
||||
function toggle() {
|
||||
emit("update:modelValue", !props.modelValue);
|
||||
}
|
||||
</script>
|
||||
16
apps/rebreak-magic/app/components/StatusBadge.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-1.5 px-2 py-1.5 rounded-lg"
|
||||
:class="ok ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-500'"
|
||||
>
|
||||
<UIcon :name="ok ? 'i-heroicons-check-circle' : 'i-heroicons-x-circle'" class="w-3.5 h-3.5" />
|
||||
<span>{{ label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
ok: boolean;
|
||||
label: string;
|
||||
}>();
|
||||
</script>
|
||||
46
apps/rebreak-magic/app/components/StepButton.vue
Normal file
@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<button
|
||||
class="w-full text-left p-4 rounded-xl transition-colors flex items-center justify-between"
|
||||
:class="buttonClass"
|
||||
:disabled="loading"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon
|
||||
:name="icon"
|
||||
class="w-6 h-6"
|
||||
/>
|
||||
<div>
|
||||
<div class="font-bold">{{ title }}</div>
|
||||
<div v-if="error" class="text-xs text-red-600 mt-0.5">{{ error }}</div>
|
||||
<div v-else-if="done" class="text-xs text-green-700 mt-0.5">Abgeschlossen</div>
|
||||
</div>
|
||||
</div>
|
||||
<UIcon v-if="loading" name="i-heroicons-arrow-path" class="w-5 h-5 animate-spin" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
done: boolean;
|
||||
loading: boolean;
|
||||
error?: string | null;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(e: "click"): void;
|
||||
}>();
|
||||
|
||||
const icon = computed(() => {
|
||||
if (props.error) return "i-heroicons-x-circle";
|
||||
if (props.done) return "i-heroicons-check-circle-solid";
|
||||
return "i-heroicons-circle";
|
||||
});
|
||||
|
||||
const buttonClass = computed(() => {
|
||||
if (props.error) return "bg-red-50 text-red-700 ring-1 ring-red-100";
|
||||
if (props.done) return "bg-green-50 text-green-700 ring-1 ring-green-100";
|
||||
return "bg-white ring-1 ring-gray-200 hover:ring-[var(--rebreak-primary)]/30";
|
||||
});
|
||||
</script>
|
||||
97
apps/rebreak-magic/app/composables/useLogger.ts
Normal file
@ -0,0 +1,97 @@
|
||||
export interface LogEntry {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
level: "debug" | "info" | "warn" | "error";
|
||||
message: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
let nextId = 1;
|
||||
|
||||
export function useLoggerState() {
|
||||
return useState<LogEntry[]>("dev-logs", () => []);
|
||||
}
|
||||
|
||||
export function useLogger() {
|
||||
const logs = useLoggerState();
|
||||
|
||||
function log(level: LogEntry["level"], message: string, details?: string) {
|
||||
const entry: LogEntry = {
|
||||
id: String(nextId++),
|
||||
timestamp: new Date(),
|
||||
level,
|
||||
message,
|
||||
details,
|
||||
};
|
||||
logs.value.unshift(entry);
|
||||
|
||||
// Keep max 200 entries
|
||||
if (logs.value.length > 200) {
|
||||
logs.value = logs.value.slice(0, 200);
|
||||
}
|
||||
|
||||
// Also mirror to console
|
||||
const consoleMsg = details ? `${message} | ${details}` : message;
|
||||
if (level === "error") console.error(consoleMsg);
|
||||
else if (level === "warn") console.warn(consoleMsg);
|
||||
else if (level === "debug") console.debug(consoleMsg);
|
||||
else console.log(consoleMsg);
|
||||
}
|
||||
|
||||
function debug(message: string, details?: string) {
|
||||
log("debug", message, details);
|
||||
}
|
||||
|
||||
function info(message: string, details?: string) {
|
||||
log("info", message, details);
|
||||
}
|
||||
|
||||
function warn(message: string, details?: string) {
|
||||
log("warn", message, details);
|
||||
}
|
||||
|
||||
function error(message: string, details?: string) {
|
||||
log("error", message, details);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
logs.value = [];
|
||||
}
|
||||
|
||||
function exportLogs(): string {
|
||||
return logs.value
|
||||
.slice()
|
||||
.reverse()
|
||||
.map(
|
||||
(entry) =>
|
||||
`[${entry.timestamp.toISOString()}] [${entry.level.toUpperCase()}] ${entry.message}${
|
||||
entry.details ? `\n Details: ${entry.details}` : ""
|
||||
}`,
|
||||
)
|
||||
.join("\n---\n");
|
||||
}
|
||||
|
||||
return {
|
||||
logs,
|
||||
debug,
|
||||
info,
|
||||
warn,
|
||||
error,
|
||||
clear,
|
||||
exportLogs,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatError(err: unknown): { message: string; details?: string } {
|
||||
if (err instanceof Error) {
|
||||
return { message: err.message, details: err.stack };
|
||||
}
|
||||
if (typeof err === "string") {
|
||||
return { message: err };
|
||||
}
|
||||
try {
|
||||
return { message: JSON.stringify(err) };
|
||||
} catch {
|
||||
return { message: "Unknown error" };
|
||||
}
|
||||
}
|
||||
@ -290,6 +290,22 @@ export function useTauri() {
|
||||
await invokeLogged("link_mdm_device", { deviceId, mdmId });
|
||||
}
|
||||
|
||||
async function reportDeviceProtectionState(
|
||||
deviceId: string,
|
||||
platform: string,
|
||||
protectionType: string,
|
||||
active: boolean,
|
||||
reason?: string,
|
||||
): Promise<void> {
|
||||
await invokeLogged("report_device_protection_state", {
|
||||
deviceId,
|
||||
platform,
|
||||
protectionType,
|
||||
active,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
getPlatform,
|
||||
redeemPairingCode,
|
||||
@ -328,5 +344,6 @@ export function useTauri() {
|
||||
getMdmStatus,
|
||||
getMdmStatusByUdid,
|
||||
linkMdmDevice,
|
||||
reportDeviceProtectionState,
|
||||
};
|
||||
}
|
||||
|
||||
199
apps/rebreak-magic/app/pages/configure.vue
Normal file
@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6">
|
||||
<div class="max-w-md w-full space-y-6">
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Schutz aktivieren</h1>
|
||||
<p class="text-gray-600 mt-2">
|
||||
Wir richten die ReBreak-App ein und installieren das Lock-Profil. Scanne die QR-Codes mit deinem iPhone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UCard>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<StepButton
|
||||
title="1. ReBreak-App einrichten"
|
||||
:done="appDone"
|
||||
:loading="appLoading"
|
||||
:error="appError"
|
||||
@click="setupApp"
|
||||
/>
|
||||
<StepButton
|
||||
title="2. Lock-Profil installieren"
|
||||
:done="lockDone"
|
||||
:loading="lockLoading"
|
||||
:error="lockError"
|
||||
@click="setupLockProfile"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="qrDataUrl" class="flex justify-center">
|
||||
<img :src="qrDataUrl" alt="QR Code" class="w-48 h-48 rounded-xl shadow-md">
|
||||
</div>
|
||||
|
||||
<div v-if="lastCommand" class="text-xs bg-gray-100 p-3 rounded break-all">
|
||||
<p class="font-semibold">Letzter Command:</p>
|
||||
<p>{{ lastCommand }}</p>
|
||||
<p class="font-mono mt-1">{{ lastResponse }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="logs.length > 0" class="text-xs bg-gray-100 p-3 rounded overflow-auto max-h-40">
|
||||
<pre class="whitespace-pre-wrap">{{ logs.join('\n') }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<UButton to="/enroll" variant="ghost" color="neutral">
|
||||
Zurück
|
||||
</UButton>
|
||||
<UButton
|
||||
to="/done"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
:disabled="!allDone"
|
||||
>
|
||||
Weiter
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import QRCode from "qrcode";
|
||||
import {
|
||||
useTauri,
|
||||
type LocalServerInfo,
|
||||
type MdmCommandResult,
|
||||
} from "~/composables/useTauri";
|
||||
import { useIphoneDevice } from "~/composables/useMagicState";
|
||||
import StepButton from "~/components/StepButton.vue";
|
||||
|
||||
const {
|
||||
mdmPing,
|
||||
mdmInstallApp,
|
||||
mdmSetSupervisedMode,
|
||||
mdmTakeManagement,
|
||||
mdmInstallLockProfile,
|
||||
startLocalProfileServer,
|
||||
stopLocalProfileServer,
|
||||
getInstalledProfiles,
|
||||
} = useTauri();
|
||||
|
||||
const iphone = useIphoneDevice();
|
||||
|
||||
const appLoading = ref(false);
|
||||
const appDone = ref(false);
|
||||
const appError = ref<string | null>(null);
|
||||
const lockLoading = ref(false);
|
||||
const lockDone = ref(false);
|
||||
const lockError = ref<string | null>(null);
|
||||
const logs = ref<string[]>([]);
|
||||
const lastCommand = ref<string>("");
|
||||
const lastResponse = ref<string>("");
|
||||
const qrDataUrl = ref<string>("");
|
||||
|
||||
const LOCK_PROFILE_PATH = "/Users/chahinebrini/mono/rebreak-monorepo/ops/mdm/profiles/rebreak-content-filter-sideload.mobileconfig";
|
||||
|
||||
const allDone = computed(() => appDone.value && lockDone.value);
|
||||
|
||||
async function setupApp() {
|
||||
appLoading.value = true;
|
||||
appError.value = null;
|
||||
logs.value = [];
|
||||
qrDataUrl.value = "";
|
||||
|
||||
try {
|
||||
if (!iphone.value?.udid) {
|
||||
throw new Error("Kein iPhone erkannt.");
|
||||
}
|
||||
|
||||
logs.value.push("→ Ping NanoMDM …");
|
||||
const version = await mdmPing();
|
||||
logs.value.push(`✓ NanoMDM ${version.trim()}`);
|
||||
|
||||
const udid = iphone.value.udid;
|
||||
|
||||
logs.value.push("→ InstallApplication …");
|
||||
const r1 = await mdmInstallApp(udid);
|
||||
logCommand("InstallApplication", r1);
|
||||
|
||||
logs.value.push("→ Settings mdmSupervised=true …");
|
||||
const r2 = await mdmSetSupervisedMode(udid);
|
||||
logCommand("Settings", r2);
|
||||
|
||||
if (iphone.value.installedAppBundleIDs?.includes("org.rebreak.app")) {
|
||||
logs.value.push("→ Take Management …");
|
||||
const r3 = await mdmTakeManagement(udid);
|
||||
logCommand("TakeManagement", r3);
|
||||
}
|
||||
|
||||
appDone.value = true;
|
||||
logs.value.push("✓ App-Setup abgeschlossen.");
|
||||
} catch (e: any) {
|
||||
appError.value = e?.message ?? "App-Setup fehlgeschlagen";
|
||||
logs.value.push(`✗ ${appError.value}`);
|
||||
} finally {
|
||||
appLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setupLockProfile() {
|
||||
lockLoading.value = true;
|
||||
lockError.value = null;
|
||||
qrDataUrl.value = "";
|
||||
|
||||
try {
|
||||
if (!iphone.value?.udid) {
|
||||
throw new Error("Kein iPhone erkannt.");
|
||||
}
|
||||
|
||||
// Try MDM push first
|
||||
logs.value.push("→ Versuche Lock-Profil per MDM …");
|
||||
const r = await mdmInstallLockProfile(iphone.value.udid, LOCK_PROFILE_PATH);
|
||||
logCommand("InstallProfile (Lock)", r);
|
||||
|
||||
// Also start local QR server as fallback / confirmation
|
||||
logs.value.push("→ Starte lokalen Server für Lock-Profil …");
|
||||
const serverInfo: LocalServerInfo = await startLocalProfileServer(LOCK_PROFILE_PATH);
|
||||
logs.value.push(`✓ QR: ${serverInfo.url}`);
|
||||
|
||||
qrDataUrl.value = await QRCode.toDataURL(serverInfo.qr_payload, {
|
||||
width: 192,
|
||||
margin: 2,
|
||||
});
|
||||
|
||||
await refreshProfileList();
|
||||
lockDone.value = true;
|
||||
logs.value.push("✓ Lock-Profil-Installation initiiert.");
|
||||
} catch (e: any) {
|
||||
lockError.value = e?.message ?? "Lock-Profil fehlgeschlagen";
|
||||
logs.value.push(`✗ ${lockError.value}`);
|
||||
} finally {
|
||||
lockLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshProfileList() {
|
||||
try {
|
||||
const ids = await getInstalledProfiles();
|
||||
if (iphone.value) {
|
||||
iphone.value.installedProfileIDs = ids;
|
||||
}
|
||||
} catch (e: any) {
|
||||
logs.value.push(`Profil-Liste nicht lesbar: ${e?.message ?? e}`);
|
||||
}
|
||||
}
|
||||
|
||||
function logCommand(name: string, result: MdmCommandResult) {
|
||||
lastCommand.value = name;
|
||||
lastResponse.value = `${result.command_uuid}: ${result.response_body.substring(0, 200)}`;
|
||||
logs.value.push(`✓ ${name}: ${result.command_uuid}`);
|
||||
}
|
||||
|
||||
onUnmounted(async () => {
|
||||
await stopLocalProfileServer();
|
||||
});
|
||||
</script>
|
||||
108
apps/rebreak-magic/app/pages/desktop-enroll.vue
Normal file
@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6">
|
||||
<div class="max-w-md w-full space-y-6">
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Schutz aktivieren</h1>
|
||||
<p class="text-gray-600 mt-2">
|
||||
Aktiviere den ReBreak-DNS-Schutz auf diesem Computer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UCard>
|
||||
<div class="space-y-4">
|
||||
<div class="text-sm text-gray-600">
|
||||
<p v-if="platform === 'MacOS'">
|
||||
Das Schutz-Profil wird in den Systemeinstellungen geöffnet. Bestätige die Installation mit deinem Admin-Passwort.
|
||||
</p>
|
||||
<p v-else-if="platform === 'Windows'">
|
||||
Der DoH-Schutz wird auf System-Ebene konfiguriert. Administratorrechte erforderlich.
|
||||
</p>
|
||||
<p v-else>
|
||||
Plattform nicht erkannt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
color="primary"
|
||||
block
|
||||
size="lg"
|
||||
:loading="activating"
|
||||
:disabled="!canActivate"
|
||||
@click="activate"
|
||||
>
|
||||
Schutz aktivieren
|
||||
</UButton>
|
||||
|
||||
<p v-if="result" class="text-sm text-center" :class="result.success ? 'text-green-600' : 'text-red-600'">
|
||||
{{ result.success ? '✅ ' + result.message : '❌ ' + result.message }}
|
||||
</p>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<UButton to="/status" variant="ghost" color="neutral">
|
||||
Zurück
|
||||
</UButton>
|
||||
<UButton to="/status" variant="solid" color="primary">
|
||||
Weiter
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useTauri, type PlatformInfo } from "~/composables/useTauri";
|
||||
import { useMagicSession } from "~/composables/useMagicState";
|
||||
|
||||
const { getPlatform, activateProtection, downloadProfile, setDesktopProtectionStatus } = useTauri();
|
||||
const session = useMagicSession();
|
||||
|
||||
const platform = ref<string>("");
|
||||
const activating = ref(false);
|
||||
const canActivate = ref(false);
|
||||
const result = ref<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
const info: PlatformInfo = await getPlatform();
|
||||
platform.value = info.platform;
|
||||
canActivate.value = info.platform === "MacOS" || info.platform === "Windows";
|
||||
});
|
||||
|
||||
async function activate() {
|
||||
if (!session.value?.profileUrl) {
|
||||
result.value = { success: false, message: "Kein Profil verfügbar. Bitte zuerst koppeln." };
|
||||
return;
|
||||
}
|
||||
|
||||
activating.value = true;
|
||||
result.value = null;
|
||||
|
||||
try {
|
||||
let path: string;
|
||||
if (platform.value === "MacOS") {
|
||||
path = await downloadProfile(session.value.profileUrl);
|
||||
} else {
|
||||
path = session.value.dnsToken;
|
||||
}
|
||||
|
||||
await activateProtection(path);
|
||||
try {
|
||||
await setDesktopProtectionStatus(true, platform.value);
|
||||
} catch (e: any) {
|
||||
console.warn("Could not persist desktop protection status:", e);
|
||||
}
|
||||
result.value = {
|
||||
success: true,
|
||||
message: platform.value === "MacOS"
|
||||
? "Systemeinstellungen geöffnet. Bitte Profil manuell installieren."
|
||||
: "DoH-Schutz aktiviert.",
|
||||
};
|
||||
} catch (e: any) {
|
||||
result.value = { success: false, message: e?.message ?? "Aktivierung fehlgeschlagen" };
|
||||
} finally {
|
||||
activating.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
175
apps/rebreak-magic/app/pages/detect.vue
Normal file
@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6">
|
||||
<div class="max-w-md w-full space-y-6">
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Gerät erkennen</h1>
|
||||
<p class="text-gray-600 mt-2">
|
||||
Verbinde dein iPhone per USB und bestätige „Diesem Computer vertrauen".
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UCard>
|
||||
<div v-if="info" class="space-y-2 text-sm">
|
||||
<p><strong>Computer:</strong> {{ info.platform }}</p>
|
||||
<p><strong>Version:</strong> {{ info.version }}</p>
|
||||
<p>
|
||||
<strong>iOS-Supervision:</strong>
|
||||
{{ info.supports_ios_supervision ? "Unterstützt" : "Nicht unterstützt" }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="text-gray-500 text-sm">
|
||||
Lade Plattform-Informationen...
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<div class="space-y-4">
|
||||
<UButton
|
||||
size="lg"
|
||||
color="primary"
|
||||
block
|
||||
:loading="detecting"
|
||||
@click="detectIphone"
|
||||
>
|
||||
iPhone suchen
|
||||
</UButton>
|
||||
|
||||
<div v-if="iphone" class="space-y-3">
|
||||
<div class="text-sm space-y-1">
|
||||
<p><strong>Gerät:</strong> {{ iphone.name }}</p>
|
||||
<p><strong>Modell:</strong> {{ displayModel(iphone.productType) }}</p>
|
||||
<p><strong>UDID:</strong> {{ iphone.udid }}</p>
|
||||
<p><strong>iOS:</strong> {{ iphone.productVersion }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
<div
|
||||
class="flex items-center gap-2 p-2 rounded-lg"
|
||||
:class="iphone.isSupervised ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-500'"
|
||||
>
|
||||
<UIcon :name="iphone.isSupervised ? 'i-heroicons-check-circle' : 'i-heroicons-x-circle'" class="w-4 h-4" />
|
||||
<span>{{ iphone.isSupervised ? 'Supervised' : 'Nicht supervised' }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="iphone.organizationName"
|
||||
class="flex items-center gap-2 p-2 rounded-lg bg-blue-50 text-blue-700"
|
||||
>
|
||||
<UIcon name="i-heroicons-building-office" class="w-4 h-4" />
|
||||
<span>{{ iphone.organizationName }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 p-2 rounded-lg"
|
||||
:class="hasEnrollmentProfile ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-500'"
|
||||
>
|
||||
<UIcon :name="hasEnrollmentProfile ? 'i-heroicons-check-circle' : 'i-heroicons-x-circle'" class="w-4 h-4" />
|
||||
<span>Enrollment</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 p-2 rounded-lg"
|
||||
:class="hasLockProfile ? 'bg-green-50 text-green-700' : 'bg-amber-50 text-amber-700'"
|
||||
>
|
||||
<UIcon :name="hasLockProfile ? 'i-heroicons-check-circle' : 'i-heroicons-exclamation-triangle'" class="w-4 h-4" />
|
||||
<span>Lock-Profil</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 p-2 rounded-lg"
|
||||
:class="hasReBreakApp ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-500'"
|
||||
>
|
||||
<UIcon :name="hasReBreakApp ? 'i-heroicons-check-circle' : 'i-heroicons-x-circle'" class="w-4 h-4" />
|
||||
<span>ReBreak-App</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="rawOutput" class="text-xs bg-gray-100 p-3 rounded overflow-auto max-h-48">
|
||||
<p class="font-semibold text-gray-700 mb-1">Roh-Output:</p>
|
||||
<pre class="whitespace-pre-wrap break-all">{{ rawOutput }}</pre>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600">
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<UButton to="/status" variant="ghost" color="neutral">
|
||||
Zurück
|
||||
</UButton>
|
||||
<UButton
|
||||
to="/preflight"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
:disabled="!iphone"
|
||||
>
|
||||
Weiter
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import { useTauri, type PlatformInfo, type IphoneDeviceState } from "~/composables/useTauri";
|
||||
import { useIphoneDevice } from "~/composables/useMagicState";
|
||||
|
||||
const { getPlatform, detectIphoneState } = useTauri();
|
||||
const iphone = useIphoneDevice();
|
||||
const info = ref<PlatformInfo | null>(null);
|
||||
const detecting = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const rawOutput = ref<string | null>(null);
|
||||
|
||||
const ENROLLMENT_PROFILE_ID = "org.rebreak.mdm.enrollment";
|
||||
const LOCK_PROFILE_ID = "org.rebreak.protection.contentfilter.sideload";
|
||||
|
||||
const hasEnrollmentProfile = computed(() =>
|
||||
iphone.value?.installedProfileIDs?.includes(ENROLLMENT_PROFILE_ID) ?? false,
|
||||
);
|
||||
const hasLockProfile = computed(() =>
|
||||
iphone.value?.installedProfileIDs?.includes(LOCK_PROFILE_ID) ?? false,
|
||||
);
|
||||
const hasReBreakApp = computed(() =>
|
||||
iphone.value?.installedAppBundleIDs?.includes("org.rebreak.app") ?? false,
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
info.value = await getPlatform();
|
||||
});
|
||||
|
||||
async function detectIphone() {
|
||||
detecting.value = true;
|
||||
error.value = null;
|
||||
rawOutput.value = null;
|
||||
|
||||
try {
|
||||
const state = await detectIphoneState();
|
||||
iphone.value = state;
|
||||
rawOutput.value = state ? JSON.stringify(state, null, 2) : "Kein iPhone erkannt";
|
||||
if (!state) {
|
||||
error.value = "Kein iPhone verbunden. Bitte per USB anschließen und \"Diesem Computer vertrauen\" bestätigen.";
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e?.message ?? "Fehler bei der Geräteerkennung";
|
||||
rawOutput.value = e?.stack || String(e);
|
||||
} finally {
|
||||
detecting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function displayModel(productType: string) {
|
||||
const map: Record<string, string> = {
|
||||
"iPhone18,4": "iPhone Air",
|
||||
"iPhone17,1": "iPhone 16 Pro",
|
||||
"iPhone17,2": "iPhone 16 Pro Max",
|
||||
"iPhone17,3": "iPhone 16",
|
||||
"iPhone17,4": "iPhone 16 Plus",
|
||||
"iPhone16,1": "iPhone 15 Pro",
|
||||
"iPhone16,2": "iPhone 15 Pro Max",
|
||||
"iPhone15,4": "iPhone 15",
|
||||
"iPhone15,5": "iPhone 15 Plus",
|
||||
};
|
||||
return map[productType] || productType;
|
||||
}
|
||||
</script>
|
||||
37
apps/rebreak-magic/app/pages/done.vue
Normal file
@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6">
|
||||
<div class="max-w-md w-full space-y-6 text-center">
|
||||
<div class="w-20 h-20 mx-auto rounded-full bg-green-100 flex items-center justify-center">
|
||||
<UIcon name="i-heroicons-check-badge" class="w-10 h-10 text-green-600" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Schutz aktiv!</h1>
|
||||
<p class="text-gray-600 mt-2">
|
||||
Dein iPhone ist jetzt mit ReBreak verbunden. Der Schutz wird im Hintergrund aufrecht erhalten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UCard>
|
||||
<div class="space-y-3 text-left text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-shield-check" class="w-5 h-5 text-green-600" />
|
||||
<span>Supervision aktiv</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-device-phone-mobile" class="w-5 h-5 text-green-600" />
|
||||
<span>ReBreak-App verwaltet</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-lock-closed" class="w-5 h-5 text-green-600" />
|
||||
<span>Lock-Profil installiert</span>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UButton to="/status" variant="solid" color="primary" size="lg" block>
|
||||
Zum Dashboard
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
203
apps/rebreak-magic/app/pages/enroll.vue
Normal file
@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6">
|
||||
<div class="max-w-md w-full space-y-6">
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold text-gray-900">MDM-Enrollment</h1>
|
||||
<p class="text-gray-600 mt-2">
|
||||
Scanne den QR-Code mit deinem iPhone und installiere das Verbindungs-Profil.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UCard>
|
||||
<div class="space-y-4">
|
||||
<div v-if="!profilePath" class="text-center">
|
||||
<UButton
|
||||
color="primary"
|
||||
block
|
||||
size="lg"
|
||||
:loading="downloading"
|
||||
@click="downloadEnrollmentProfile"
|
||||
>
|
||||
Enrollment-Profil laden
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div class="flex justify-center">
|
||||
<img :src="qrDataUrl" alt="QR Code" class="w-48 h-48 rounded-xl shadow-md">
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-center text-gray-600">
|
||||
<p>Scanne den Code mit der Kamera-App.</p>
|
||||
<p class="text-xs mt-1 break-all">{{ serverUrl }}</p>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
color="primary"
|
||||
variant="soft"
|
||||
block
|
||||
:loading="checking"
|
||||
@click="checkEnrollment"
|
||||
>
|
||||
Installation prüfen
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div v-if="status" class="text-sm p-3 rounded-lg" :class="status.ok ? 'bg-green-50 text-green-700' : 'bg-amber-50 text-amber-700'">
|
||||
<p><strong>Push-Status:</strong> {{ status.ok ? '✓ Gerät erreichbar' : '✗ Nicht erreichbar' }}</p>
|
||||
<p v-if="status.result" class="text-xs break-all mt-1">{{ status.result }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="iphone" class="grid grid-cols-2 gap-2 text-xs">
|
||||
<div
|
||||
class="flex items-center gap-2 p-2 rounded-lg"
|
||||
:class="hasEnrollmentProfile ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-500'"
|
||||
>
|
||||
<UIcon :name="hasEnrollmentProfile ? 'i-heroicons-check-circle' : 'i-heroicons-x-circle'" class="w-4 h-4" />
|
||||
<span>Enrollment-Profil</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600">
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<div v-if="logs.length > 0" class="text-xs bg-gray-100 p-3 rounded overflow-auto max-h-32">
|
||||
<pre class="whitespace-pre-wrap">{{ logs.join('\n') }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<UButton to="/supervise" variant="ghost" color="neutral">
|
||||
Zurück
|
||||
</UButton>
|
||||
<UButton
|
||||
to="/configure"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
:disabled="!canContinue"
|
||||
>
|
||||
Weiter
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import QRCode from "qrcode";
|
||||
import {
|
||||
useTauri,
|
||||
type LocalServerInfo,
|
||||
} from "~/composables/useTauri";
|
||||
import { useIphoneDevice } from "~/composables/useMagicState";
|
||||
|
||||
const {
|
||||
downloadAndPatchEnrollmentProfile,
|
||||
startLocalProfileServer,
|
||||
stopLocalProfileServer,
|
||||
getInstalledProfiles,
|
||||
mdmPush,
|
||||
} = useTauri();
|
||||
|
||||
const iphone = useIphoneDevice();
|
||||
const profilePath = ref<string | null>(null);
|
||||
const serverInfo = ref<LocalServerInfo | null>(null);
|
||||
const qrDataUrl = ref<string>("");
|
||||
const downloading = ref(false);
|
||||
const checking = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const logs = ref<string[]>([]);
|
||||
const status = ref<{ ok: boolean; result?: string } | null>(null);
|
||||
|
||||
const ENROLLMENT_PROFILE_ID = "org.rebreak.mdm.enrollment";
|
||||
|
||||
const hasEnrollmentProfile = computed(() =>
|
||||
iphone.value?.installedProfileIDs?.includes(ENROLLMENT_PROFILE_ID) ?? false,
|
||||
);
|
||||
|
||||
const serverUrl = computed(() => serverInfo.value?.url ?? "");
|
||||
|
||||
const canContinue = computed(() =>
|
||||
hasEnrollmentProfile.value || status.value?.ok === true,
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
if (iphone.value?.udid) {
|
||||
await refreshProfileList();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(async () => {
|
||||
await stopLocalProfileServer();
|
||||
});
|
||||
|
||||
async function refreshProfileList() {
|
||||
try {
|
||||
const ids = await getInstalledProfiles();
|
||||
if (iphone.value) {
|
||||
iphone.value.installedProfileIDs = ids;
|
||||
}
|
||||
} catch (e: any) {
|
||||
logs.value.push(`Profil-Liste nicht lesbar: ${e?.message ?? e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadEnrollmentProfile() {
|
||||
downloading.value = true;
|
||||
error.value = null;
|
||||
logs.value = [];
|
||||
|
||||
try {
|
||||
if (!iphone.value?.udid) {
|
||||
throw new Error("Kein iPhone erkannt. Bitte zurück zu Schritt 1.");
|
||||
}
|
||||
|
||||
const url = `https://mdm.rebreak.org/enrollment/rebreak-enrollment.mobileconfig`;
|
||||
logs.value.push(`→ Lade ${url}`);
|
||||
|
||||
const path = await downloadAndPatchEnrollmentProfile(url, iphone.value.udid);
|
||||
profilePath.value = path;
|
||||
logs.value.push(`✓ Profil gespeichert: ${path}`);
|
||||
|
||||
serverInfo.value = await startLocalProfileServer(path);
|
||||
logs.value.push(`✓ Lokaler Server: ${serverInfo.value.url}`);
|
||||
|
||||
qrDataUrl.value = await QRCode.toDataURL(serverInfo.value.qr_payload, {
|
||||
width: 192,
|
||||
margin: 2,
|
||||
});
|
||||
} catch (e: any) {
|
||||
error.value = e?.message ?? "Download fehlgeschlagen";
|
||||
logs.value.push(`✗ ${error.value}`);
|
||||
} finally {
|
||||
downloading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkEnrollment() {
|
||||
checking.value = true;
|
||||
error.value = null;
|
||||
status.value = null;
|
||||
|
||||
try {
|
||||
await refreshProfileList();
|
||||
|
||||
if (!iphone.value?.udid) {
|
||||
throw new Error("Kein iPhone erkannt.");
|
||||
}
|
||||
|
||||
const push = await mdmPush(iphone.value.udid);
|
||||
status.value = { ok: true, result: push.push_result };
|
||||
logs.value.push(`✓ Push erreichbar: ${push.push_result}`);
|
||||
} catch (e: any) {
|
||||
status.value = { ok: false, result: e?.message ?? String(e) };
|
||||
error.value = e?.message ?? "Prüfung fehlgeschlagen";
|
||||
logs.value.push(`✗ ${error.value}`);
|
||||
} finally {
|
||||
checking.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
30
apps/rebreak-magic/app/pages/index.vue
Normal file
@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6">
|
||||
<div class="max-w-md w-full text-center space-y-6">
|
||||
<div class="w-20 h-20 mx-auto bg-[var(--rebreak-primary)] rounded-2xl flex items-center justify-center">
|
||||
<UIcon name="i-heroicons-shield-check" class="w-10 h-10 text-white" />
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-bold text-gray-900">
|
||||
ReBreak Magic
|
||||
</h1>
|
||||
<p class="text-gray-600">
|
||||
Öffne die ReBreak-App auf deinem iPhone, erzeuge einen Pairing-Code und gib ihn hier ein.
|
||||
</p>
|
||||
|
||||
<UButton
|
||||
to="/pair"
|
||||
size="lg"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
block
|
||||
>
|
||||
Pairing-Code eingeben
|
||||
</UButton>
|
||||
|
||||
<p class="text-xs text-gray-400">
|
||||
ReBreak Magic für macOS & Windows
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
131
apps/rebreak-magic/app/pages/pair.vue
Normal file
@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6">
|
||||
<div class="max-w-md w-full space-y-6">
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Pairing-Code</h1>
|
||||
<p class="text-gray-600 mt-2">
|
||||
Erstelle in der ReBreak-App einen 6-stelligen Pairing-Code.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UCard>
|
||||
<div class="space-y-6">
|
||||
<div class="flex justify-center">
|
||||
<UPinInput
|
||||
v-model="code"
|
||||
:length="6"
|
||||
type="numeric"
|
||||
otp
|
||||
autofocus
|
||||
placeholder="0"
|
||||
class="font-mono text-lg"
|
||||
:disabled="loading"
|
||||
@complete="redeem"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
color="primary"
|
||||
block
|
||||
:loading="loading"
|
||||
:disabled="codeString.length < 6 || loading"
|
||||
@click="redeem"
|
||||
>
|
||||
{{ loading ? "Wird geladen…" : "Koppeln" }}
|
||||
</UButton>
|
||||
|
||||
<div v-if="loading && loadingMessage" class="flex items-center justify-center gap-2 text-sm text-gray-600">
|
||||
<UIcon name="i-heroicons-arrow-path" class="w-4 h-4 animate-spin text-[var(--rebreak-primary)]" />
|
||||
<span>{{ loadingMessage }}</span>
|
||||
</div>
|
||||
|
||||
<p v-else-if="error" class="text-sm text-center text-red-600">
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UButton to="/" variant="ghost" color="neutral" block :disabled="loading">
|
||||
Zurück
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { useTauri } from "~/composables/useTauri";
|
||||
import { useMagicSession, useMagicDevices } from "~/composables/useMagicState";
|
||||
|
||||
const {
|
||||
redeemPairingCode,
|
||||
getPlatform,
|
||||
getHardwareId,
|
||||
registerDevice,
|
||||
fetchMe,
|
||||
getMagicDevices,
|
||||
getMagicStatus,
|
||||
} = useTauri();
|
||||
|
||||
const session = useMagicSession();
|
||||
const devices = useMagicDevices();
|
||||
|
||||
// UPinInput can return either a string or an array of characters
|
||||
const code = ref<string | string[]>("");
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const loadingMessage = ref<string | null>(null);
|
||||
|
||||
const codeString = computed(() => {
|
||||
if (Array.isArray(code.value)) {
|
||||
return code.value.join("");
|
||||
}
|
||||
return code.value;
|
||||
});
|
||||
|
||||
async function redeem() {
|
||||
const value = codeString.value;
|
||||
if (value.length < 6) return;
|
||||
|
||||
loading.value = true;
|
||||
loadingMessage.value = "Pairing-Code wird eingelöst…";
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
await redeemPairingCode(value);
|
||||
|
||||
loadingMessage.value = "Registriere dieses Gerät…";
|
||||
const [platformInfo, hardwareId] = await Promise.all([
|
||||
getPlatform(),
|
||||
getHardwareId(),
|
||||
]);
|
||||
|
||||
const registered = await registerDevice(platformInfo.platform, platformInfo.version);
|
||||
|
||||
session.value = {
|
||||
deviceId: registered.deviceId,
|
||||
hardwareId,
|
||||
dnsToken: registered.dnsToken,
|
||||
profileUrl: registered.profileUrl,
|
||||
};
|
||||
|
||||
loadingMessage.value = "Lade Profil und Geräte…";
|
||||
const [_, deviceList] = await Promise.all([
|
||||
fetchMe(),
|
||||
getMagicDevices(),
|
||||
]);
|
||||
|
||||
devices.value = deviceList;
|
||||
|
||||
loadingMessage.value = "Prüfe Schutzstatus…";
|
||||
await getMagicStatus(registered.dnsToken);
|
||||
|
||||
await navigateTo("/status");
|
||||
} catch (e: any) {
|
||||
error.value = e?.message ?? "Koppeln fehlgeschlagen";
|
||||
loadingMessage.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
79
apps/rebreak-magic/app/pages/preflight.vue
Normal file
@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6">
|
||||
<div class="max-w-md w-full space-y-6">
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Pre-Flight Check</h1>
|
||||
<p class="text-gray-600 mt-2">
|
||||
Bevor wir dein iPhone supervisieren, müssen ein paar Apple-Sicherheitschecks erledigt sein.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UCard>
|
||||
<div class="space-y-3">
|
||||
<PreflightItem
|
||||
v-model="checks.fmi"
|
||||
title="Find My iPhone deaktiviert"
|
||||
detail="Settings → [Apple-ID] → Wo ist? → Mein iPhone suchen → AUS. Ohne das blockiert Apple das Supervisieren."
|
||||
:auto="iphone?.findMyEnabled === false"
|
||||
/>
|
||||
<PreflightItem
|
||||
v-model="checks.sdp"
|
||||
title="Stolen Device Protection ausgeschaltet"
|
||||
detail="Settings → Face ID & Code → Schutz für gestohlene Geräte → AUS. SDP zwingt FMI an."
|
||||
/>
|
||||
<PreflightItem
|
||||
v-model="checks.appleId"
|
||||
title="Apple-ID-Passwort griffbereit"
|
||||
detail="Apple fragt evtl. dein Apple-ID-PW während des FMI-Toggles ab."
|
||||
/>
|
||||
<PreflightItem
|
||||
v-model="checks.appInstalled"
|
||||
title="ReBreak-App ist auf dem iPhone installiert"
|
||||
detail="Über TestFlight. Erst danach kann der Wizard die App in den Managed-State versetzen."
|
||||
:auto="hasReBreakApp"
|
||||
/>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<UButton to="/detect" variant="ghost" color="neutral">
|
||||
Zurück
|
||||
</UButton>
|
||||
<UButton
|
||||
to="/supervise"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
:disabled="!allChecked"
|
||||
>
|
||||
Supervisieren starten →
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { useIphoneDevice } from "~/composables/useMagicState";
|
||||
import PreflightItem from "~/components/PreflightItem.vue";
|
||||
|
||||
const iphone = useIphoneDevice();
|
||||
|
||||
const checks = ref({
|
||||
fmi: false,
|
||||
sdp: false,
|
||||
appleId: false,
|
||||
appInstalled: false,
|
||||
});
|
||||
|
||||
const hasReBreakApp = computed(() =>
|
||||
iphone.value?.installedAppBundleIDs?.includes("org.rebreak.app") ?? false,
|
||||
);
|
||||
|
||||
const allChecked = computed(() =>
|
||||
(checks.value.fmi || iphone.value?.findMyEnabled === false) &&
|
||||
checks.value.sdp &&
|
||||
checks.value.appleId &&
|
||||
(checks.value.appInstalled || hasReBreakApp.value),
|
||||
);
|
||||
</script>
|
||||
132
apps/rebreak-magic/app/pages/sideload.vue
Normal file
@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6">
|
||||
<div class="max-w-md w-full space-y-6">
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 mx-auto bg-[var(--rebreak-primary)] rounded-xl flex items-center justify-center mb-4">
|
||||
<UIcon name="i-heroicons-qr-code" class="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">
|
||||
Schutz-Profil installieren
|
||||
</h1>
|
||||
<p class="text-gray-600 mt-2">
|
||||
Scanne den QR-Code mit deinem iPhone und installiere das Schutz-Profil.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UCard class="text-center">
|
||||
<div class="space-y-4">
|
||||
<UButton
|
||||
v-if="!serverInfo"
|
||||
size="lg"
|
||||
color="primary"
|
||||
block
|
||||
:loading="starting"
|
||||
@click="startServer"
|
||||
>
|
||||
QR-Code generieren
|
||||
</UButton>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div class="bg-white p-4 rounded-lg inline-block">
|
||||
<img :src="qrCodeDataUrl" alt="QR Code" class="w-48 h-48">
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 break-all">
|
||||
{{ serverInfo.url }}
|
||||
</p>
|
||||
<UButton
|
||||
size="sm"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
@click="stopServer"
|
||||
>
|
||||
Server stoppen
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600">
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<div class="bg-blue-50 text-blue-800 text-sm p-4 rounded-lg">
|
||||
<p class="font-semibold mb-1">So geht's:</p>
|
||||
<ol class="list-decimal list-inside space-y-1">
|
||||
<li>iPhone-Kamera öffnen und QR-Code scannen</li>
|
||||
<li>Link in Safari öffnen</li>
|
||||
<li>„Einstellungen" → „Profil installieren" tippen</li>
|
||||
<li>Geräte-Passcode eingeben und bestätigen</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<UButton to="/supervise" variant="ghost" color="neutral">
|
||||
Zurück
|
||||
</UButton>
|
||||
<UButton to="/enroll" variant="solid" color="primary" :disabled="!serverInfo">
|
||||
Weiter
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onUnmounted } from "vue";
|
||||
import QRCode from "qrcode";
|
||||
import { useTauri, type LocalServerInfo } from "~/composables/useTauri";
|
||||
import { useMagicSession } from "~/composables/useMagicState";
|
||||
|
||||
const { startLocalProfileServer, stopLocalProfileServer, downloadProfile } = useTauri();
|
||||
const session = useMagicSession();
|
||||
|
||||
const starting = ref(false);
|
||||
const serverInfo = ref<LocalServerInfo | null>(null);
|
||||
const qrCodeDataUrl = ref("");
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
watch(
|
||||
serverInfo,
|
||||
async (info) => {
|
||||
if (info) {
|
||||
qrCodeDataUrl.value = await QRCode.toDataURL(info.qr_payload, {
|
||||
width: 192,
|
||||
margin: 2,
|
||||
});
|
||||
} else {
|
||||
qrCodeDataUrl.value = "";
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onUnmounted(async () => {
|
||||
if (serverInfo.value) {
|
||||
await stopLocalProfileServer();
|
||||
}
|
||||
});
|
||||
|
||||
async function startServer() {
|
||||
starting.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
if (!session.value?.profileUrl) {
|
||||
error.value = "Kein Profil verfügbar. Bitte zuerst das iPhone koppeln.";
|
||||
return;
|
||||
}
|
||||
|
||||
const profilePath = await downloadProfile(session.value.profileUrl);
|
||||
serverInfo.value = await startLocalProfileServer(profilePath);
|
||||
} catch (e: any) {
|
||||
error.value = e?.message ?? "QR-Code konnte nicht erzeugt werden";
|
||||
} finally {
|
||||
starting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopServer() {
|
||||
await stopLocalProfileServer();
|
||||
serverInfo.value = null;
|
||||
}
|
||||
</script>
|
||||
@ -74,9 +74,11 @@
|
||||
:loading="loading"
|
||||
:has-refreshed="hasRefreshed"
|
||||
:in-grace-period="subscriptionInGracePeriod"
|
||||
:searching-for-device-id="searchingForDeviceId"
|
||||
@sync="onIosSync"
|
||||
@open="openDevice"
|
||||
@remove="onIosRemove"
|
||||
@connect="startIphoneSearch"
|
||||
/>
|
||||
|
||||
<!-- Other devices list -->
|
||||
@ -134,8 +136,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useTauri, type UserProfile } from "~/composables/useTauri";
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { useTauri, type UserProfile, type IphoneDeviceState } from "~/composables/useTauri";
|
||||
import { useMagicSession, useMagicDevices, useIphoneDevice } from "~/composables/useMagicState";
|
||||
import { useProtectionStatus } from "~/composables/useProtectionStatus";
|
||||
import { useDeviceStatus, type ComputedDevice } from "~/composables/useDeviceStatus";
|
||||
@ -169,6 +171,8 @@ const error = ref<string | null>(null);
|
||||
const sheetOpen = ref(false);
|
||||
const selectedDevice = ref<ComputedDevice | null>(null);
|
||||
const platformInfo = ref<{ platform: string } | null>(null);
|
||||
const searchingForDeviceId = ref<string | null>(null);
|
||||
let searchInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// TODO: populate from backend once subscription/grace-period endpoint exists.
|
||||
const subscriptionInGracePeriod = ref(false);
|
||||
@ -200,6 +204,43 @@ onMounted(async () => {
|
||||
await initCurrentDevice();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopIphoneSearch();
|
||||
});
|
||||
|
||||
function matchesIphone(device: ComputedDevice, iphone: IphoneDeviceState): boolean {
|
||||
if (device.mdmId && device.mdmId === iphone.udid) return true;
|
||||
const modelMatch = (device.model ?? "").toLowerCase() === iphone.productType.toLowerCase();
|
||||
const nameMatch = (device.name ?? "").toLowerCase() === iphone.name.toLowerCase();
|
||||
return modelMatch || nameMatch;
|
||||
}
|
||||
|
||||
async function startIphoneSearch(device: ComputedDevice) {
|
||||
if (searchInterval) return;
|
||||
searchingForDeviceId.value = device.deviceId;
|
||||
error.value = null;
|
||||
|
||||
const check = async () => {
|
||||
await protection.refreshIphone();
|
||||
if (iphone.value && matchesIphone(device, iphone.value)) {
|
||||
stopIphoneSearch();
|
||||
}
|
||||
};
|
||||
|
||||
await check();
|
||||
if (!searchingForDeviceId.value) return;
|
||||
|
||||
searchInterval = setInterval(check, 1100);
|
||||
}
|
||||
|
||||
function stopIphoneSearch() {
|
||||
if (searchInterval) {
|
||||
clearInterval(searchInterval);
|
||||
searchInterval = null;
|
||||
}
|
||||
searchingForDeviceId.value = null;
|
||||
}
|
||||
|
||||
async function initCurrentDevice() {
|
||||
try {
|
||||
const hardwareId = await getHardwareId();
|
||||
|
||||
137
apps/rebreak-magic/app/pages/supervise.vue
Normal file
@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6">
|
||||
<div class="max-w-md w-full space-y-6">
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 mx-auto bg-[var(--rebreak-primary)] rounded-xl flex items-center justify-center mb-4">
|
||||
<UIcon name="i-heroicons-lock-closed" class="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">
|
||||
iPhone supervisieren
|
||||
</h1>
|
||||
<p class="text-gray-600 mt-2">
|
||||
Wir schreiben die Supervision-Metadaten auf dein iPhone und starten es neu. Apps, Daten und Logins bleiben erhalten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UCard>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-if="status"
|
||||
class="text-sm space-y-1 p-3 rounded-lg"
|
||||
:class="status.isSupervised ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-600'"
|
||||
>
|
||||
<p><strong>Status:</strong> {{ status.isSupervised ? 'Bereits supervised' : 'Nicht supervised' }}</p>
|
||||
<p v-if="status.organizationName"><strong>Organisation:</strong> {{ status.organizationName }}</p>
|
||||
<p v-if="status.findMyEnabled !== undefined"><strong>Find My:</strong> {{ status.findMyEnabled ? 'An' : 'Aus' }}</p>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
size="lg"
|
||||
color="primary"
|
||||
block
|
||||
:loading="supervising"
|
||||
:disabled="skipBecauseOwned"
|
||||
@click="runSupervise"
|
||||
>
|
||||
{{ skipBecauseOwned ? 'Bereits von ReBreak supervised' : 'Supervision starten' }}
|
||||
</UButton>
|
||||
|
||||
<div v-if="result" class="text-sm">
|
||||
<p v-if="result.success" class="text-green-600">
|
||||
✅ Supervision abgeschlossen. Das iPhone startet neu.
|
||||
</p>
|
||||
<div v-else class="text-red-600 space-y-1">
|
||||
<p>❌ Supervision fehlgeschlagen</p>
|
||||
<pre class="text-xs bg-gray-100 p-2 rounded overflow-auto">{{ result.stderr || result.stdout }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="logs.length > 0" class="text-xs bg-gray-100 p-3 rounded overflow-auto max-h-48">
|
||||
<p class="font-semibold text-gray-700 mb-1">Logs:</p>
|
||||
<pre class="whitespace-pre-wrap break-all">{{ logs.join('\n') }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<UButton to="/preflight" variant="ghost" color="neutral">
|
||||
Zurück
|
||||
</UButton>
|
||||
<UButton
|
||||
to="/enroll"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
:disabled="!canContinue"
|
||||
>
|
||||
Weiter
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import { useTauri, type SuperviseResult, type SuperviseStatus } from "~/composables/useTauri";
|
||||
import { useIphoneDevice } from "~/composables/useMagicState";
|
||||
|
||||
const { getSuperviseStatus, runSuperviseMagic } = useTauri();
|
||||
const iphone = useIphoneDevice();
|
||||
const status = ref<SuperviseStatus | null>(null);
|
||||
const supervising = ref(false);
|
||||
const result = ref<SuperviseResult | null>(null);
|
||||
const logs = ref<string[]>([]);
|
||||
|
||||
const skipBecauseOwned = computed(() =>
|
||||
status.value?.isSupervised && status.value?.organizationName?.toLowerCase() === "rebreak",
|
||||
);
|
||||
|
||||
const canContinue = computed(() =>
|
||||
skipBecauseOwned.value || result.value?.success === true,
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
status.value = await getSuperviseStatus();
|
||||
if (skipBecauseOwned.value) {
|
||||
logs.value.push("✓ Bereits von ReBreak supervised — überspringe.");
|
||||
if (iphone.value) {
|
||||
iphone.value.isSupervised = true;
|
||||
iphone.value.organizationName = "ReBreak";
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
logs.value.push(`✗ Status konnte nicht gelesen werden: ${e?.message ?? e}`);
|
||||
}
|
||||
});
|
||||
|
||||
async function runSupervise() {
|
||||
supervising.value = true;
|
||||
result.value = null;
|
||||
logs.value = [];
|
||||
|
||||
try {
|
||||
const force = !status.value?.isSupervised;
|
||||
const args = ["-org", "ReBreak", "-yes"];
|
||||
if (force) args.push("-force");
|
||||
|
||||
logs.value.push(`→ supervise-magic supervise ${args.join(" ")}`);
|
||||
result.value = await runSuperviseMagic("supervise", args);
|
||||
|
||||
if (result.value.success) {
|
||||
logs.value.push("✓ Supervision abgeschlossen.");
|
||||
if (iphone.value) {
|
||||
iphone.value.isSupervised = true;
|
||||
iphone.value.organizationName = "ReBreak";
|
||||
}
|
||||
} else {
|
||||
logs.value.push(`✗ Fehler: ${result.value.stderr || result.value.stdout}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
result.value = { success: false, stdout: "", stderr: e?.message ?? String(e) };
|
||||
logs.value.push(`✗ Exception: ${e?.message ?? e}`);
|
||||
} finally {
|
||||
supervising.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
30
apps/rebreak-magic/nuxt.config.ts
Normal file
@ -0,0 +1,30 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: "2026-06-14",
|
||||
devtools: { enabled: false },
|
||||
ssr: false,
|
||||
srcDir: "app/",
|
||||
nitro: {
|
||||
preset: "static",
|
||||
},
|
||||
modules: ["@nuxt/ui", "@vueuse/nuxt"],
|
||||
css: ["~/assets/css/main.css"],
|
||||
colorMode: {
|
||||
preference: "light",
|
||||
fallback: "light",
|
||||
},
|
||||
ui: {
|
||||
theme: {
|
||||
colors: {
|
||||
primary: "blue",
|
||||
},
|
||||
},
|
||||
},
|
||||
vite: {
|
||||
// Tauri nutzt sein eigenes Dev-Server-Schema; CORS-Policy anpassen
|
||||
server: {
|
||||
strictPort: true,
|
||||
port: 1420,
|
||||
},
|
||||
},
|
||||
});
|
||||
34
apps/rebreak-magic/package.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@rebreak/magic",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"dev": "nuxt dev --port 1420",
|
||||
"build": "nuxt build",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/ui": "^4.5.1",
|
||||
"@nuxt/icon": "^1.10.0",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"@vueuse/nuxt": "^14.2.1",
|
||||
"nuxt": "4.1.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/heroicons": "^1.2.3",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
5680
apps/rebreak-magic/src-tauri/Cargo.lock
generated
Normal file
45
apps/rebreak-magic/src-tauri/Cargo.toml
Normal file
@ -0,0 +1,45 @@
|
||||
[package]
|
||||
name = "rebreak-magic"
|
||||
version = "0.1.0"
|
||||
description = "ReBreak Magic — Unified Desktop Protection (macOS + Windows)"
|
||||
authors = ["Rebreak"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "rebreak_magic_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-http = "2"
|
||||
tauri-plugin-os = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] }
|
||||
anyhow = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
base64 = "0.22"
|
||||
dirs = "5"
|
||||
tiny_http = "0.12"
|
||||
local-ip-address = "0.6"
|
||||
# Pin brotli family to avoid alloc-no-stdlib version conflict
|
||||
brotli-decompressor = "=5.0.1"
|
||||
alloc-stdlib = "=0.2.2"
|
||||
|
||||
# Plattform-spezifischer Credential-Speicher
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
keyring = { version = "3", features = ["windows-native"] }
|
||||
|
||||
[target.'cfg(not(windows))'.dependencies]
|
||||
keyring = { version = "3", features = ["apple-native"] }
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = true
|
||||
45
apps/rebreak-magic/src-tauri/binaries/README.md
Normal file
@ -0,0 +1,45 @@
|
||||
# Tauri Sidecar Binaries
|
||||
|
||||
This directory holds native binaries that are bundled with the ReBreak Magic app.
|
||||
|
||||
## `supervise-magic`
|
||||
|
||||
Go binary that puts an iPhone/iPad into supervised mode without erasing data.
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
cd ../../ops/mdm/supervise-magic
|
||||
|
||||
# macOS (Apple Silicon)
|
||||
make build-arm64
|
||||
|
||||
# macOS (Intel)
|
||||
make build-amd64
|
||||
|
||||
# Windows (x86_64)
|
||||
make build-windows-amd64
|
||||
```
|
||||
|
||||
### Naming convention for Tauri
|
||||
|
||||
Tauri expects sidecar binaries in this directory with the following naming pattern:
|
||||
|
||||
```
|
||||
supervise-magic-<target-triple>[.exe]
|
||||
```
|
||||
|
||||
Common target triples:
|
||||
- `aarch64-apple-darwin` (Apple Silicon)
|
||||
- `x86_64-apple-darwin` (Intel Mac)
|
||||
- `x86_64-pc-windows-msvc` (Windows)
|
||||
|
||||
After building, copy or symlink the binary into this directory with the correct target-triple name.
|
||||
|
||||
Example:
|
||||
```bash
|
||||
cp ops/mdm/supervise-magic/bin/supervise-magic-darwin-arm64 \
|
||||
apps/rebreak-magic/src-tauri/binaries/supervise-magic-aarch64-apple-darwin
|
||||
```
|
||||
|
||||
Tauri will bundle the matching binary at build time.
|
||||
BIN
apps/rebreak-magic/src-tauri/binaries/supervise-magic-aarch64-apple-darwin
Executable file
3
apps/rebreak-magic/src-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
21
apps/rebreak-magic/src-tauri/capabilities/default.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for ReBreak Magic",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:default",
|
||||
"shell:allow-execute",
|
||||
"shell:allow-spawn",
|
||||
"fs:default",
|
||||
"fs:allow-read-file",
|
||||
"fs:allow-write-file",
|
||||
"fs:allow-read-dir",
|
||||
"fs:allow-app-read",
|
||||
"fs:allow-app-write",
|
||||
"http:default",
|
||||
"http:allow-fetch",
|
||||
"os:default"
|
||||
]
|
||||
}
|
||||
26
apps/rebreak-magic/src-tauri/entitlements.plist
Normal file
@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Allow JIT compilation for the web runtime -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<!-- Allow unsigned executable memory (needed for Tauri + sidecars) -->
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<!-- Disable library validation so the supervise-magic sidecar can load its own libs -->
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<!-- USB device access for iPhone/iPad detection -->
|
||||
<key>com.apple.security.device.usb</key>
|
||||
<true/>
|
||||
<!-- Apple Events automation (helper tools may need this) -->
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
<!-- Files: user-selected and application-support for session/config storage -->
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.bookmarks.app-scope</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
apps/rebreak-magic/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
apps/rebreak-magic/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
apps/rebreak-magic/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
apps/rebreak-magic/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
apps/rebreak-magic/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
apps/rebreak-magic/src-tauri/icons/source.png
Normal file
|
After Width: | Height: | Size: 218 KiB |
@ -125,6 +125,18 @@ pub struct MdmLinkRequest {
|
||||
pub mdm_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReportProtectionStateRequest {
|
||||
#[serde(rename = "deviceId")]
|
||||
pub device_id: String,
|
||||
pub platform: String,
|
||||
#[serde(rename = "protectionType")]
|
||||
pub protection_type: String,
|
||||
pub active: bool,
|
||||
pub reason: Option<String>,
|
||||
pub source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ApiEnvelope<T> {
|
||||
pub success: bool,
|
||||
@ -437,6 +449,38 @@ impl MagicApiClient {
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn report_device_protection_state(
|
||||
&self,
|
||||
token: &str,
|
||||
device_id: &str,
|
||||
platform: &str,
|
||||
protection_type: &str,
|
||||
active: bool,
|
||||
reason: Option<&str>,
|
||||
) -> AppResult<()> {
|
||||
let url = format!("{}/api/devices/protection-state", self.base_url);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&ReportProtectionStateRequest {
|
||||
device_id: device_id.to_string(),
|
||||
platform: platform.to_string(),
|
||||
protection_type: protection_type.to_string(),
|
||||
active,
|
||||
reason: reason.map(|s| s.to_string()),
|
||||
source: Some("magic".to_string()),
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::new(format!("Network error: {}", e)))?;
|
||||
|
||||
Self::handle_response::<ApiEnvelope<serde_json::Value>>(response)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn handle_response<T: serde::de::DeserializeOwned>(
|
||||
response: reqwest::Response,
|
||||
) -> AppResult<T> {
|
||||
|
||||
1
apps/rebreak-magic/src-tauri/src/backend/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod api;
|
||||
43
apps/rebreak-magic/src-tauri/src/error.rs
Normal file
@ -0,0 +1,43 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AppError {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for AppError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AppError {}
|
||||
|
||||
impl AppError {
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for AppError {
|
||||
fn from(value: anyhow::Error) -> Self {
|
||||
Self::new(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for AppError {
|
||||
fn from(value: std::io::Error) -> Self {
|
||||
Self::new(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for AppError {
|
||||
fn from(value: serde_json::Error) -> Self {
|
||||
Self::new(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
410
apps/rebreak-magic/src-tauri/src/ios_device.rs
Normal file
@ -0,0 +1,410 @@
|
||||
use crate::config::AppConfig;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::sidecar::supervise_magic::run_supervise_magic_raw;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
|
||||
const CFGUTIL_CANDIDATES: &[&str] = &[
|
||||
"/Applications/Apple Configurator.app/Contents/MacOS/cfgutil",
|
||||
"/Applications/Apple Configurator 2.app/Contents/MacOS/cfgutil",
|
||||
];
|
||||
|
||||
fn first_executable(candidates: &[&str]) -> Option<String> {
|
||||
candidates
|
||||
.iter()
|
||||
.find(|path| std::path::Path::new(path).is_file())
|
||||
.map(|path| path.to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IphoneDeviceState {
|
||||
pub udid: String,
|
||||
pub name: String,
|
||||
pub product_type: String,
|
||||
pub product_version: String,
|
||||
pub is_supervised: bool,
|
||||
pub organization_name: Option<String>,
|
||||
pub find_my_enabled: Option<bool>,
|
||||
#[serde(rename = "installedProfileIDs")]
|
||||
pub installed_profile_ids: Vec<String>,
|
||||
#[serde(rename = "installedAppBundleIDs")]
|
||||
pub installed_app_bundle_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SuperviseStatus {
|
||||
pub is_supervised: bool,
|
||||
pub organization_name: Option<String>,
|
||||
pub find_my_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
fn normalize_org_name(value: &str) -> String {
|
||||
value
|
||||
.trim()
|
||||
.trim_matches(|c| c == '"' || c == '\'')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Parse ` Key: Value` (check Format).
|
||||
fn parse_colon(stdout: &str, key: &str) -> Option<String> {
|
||||
for raw in stdout.lines() {
|
||||
let trimmed = raw.trim();
|
||||
if let Some(rest) = trimmed.strip_prefix(&format!("{}:", key)) {
|
||||
return Some(rest.trim().to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse ` Key = Value` (cloud-config Format).
|
||||
fn parse_equals(stdout: &str, key: &str) -> Option<String> {
|
||||
for raw in stdout.lines() {
|
||||
let trimmed = raw.trim();
|
||||
if let Some((k, v)) = trimmed.split_once('=') {
|
||||
if k.trim() == key {
|
||||
return Some(v.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn run_supervise_magic_cmd(
|
||||
app: tauri::AppHandle,
|
||||
action: &str,
|
||||
args: &[&str],
|
||||
) -> AppResult<String> {
|
||||
let result = run_supervise_magic_raw(app, action, args).await?;
|
||||
if !result.success {
|
||||
return Err(AppError::new(format!(
|
||||
"supervise-magic {} failed: {}",
|
||||
action,
|
||||
result.stderr
|
||||
)));
|
||||
}
|
||||
Ok(result.stdout)
|
||||
}
|
||||
|
||||
fn run_cfgutil(binary: &str, args: &[&str]) -> AppResult<(String, String, bool)> {
|
||||
let output = Command::new(binary)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|e| AppError::new(format!("Failed to run cfgutil ({}): {}", binary, e)))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
Ok((stdout, stderr, output.status.success()))
|
||||
}
|
||||
|
||||
pub async fn read_supervise_status(app: tauri::AppHandle) -> AppResult<SuperviseStatus> {
|
||||
let mut status = SuperviseStatus {
|
||||
is_supervised: false,
|
||||
organization_name: None,
|
||||
find_my_enabled: None,
|
||||
};
|
||||
|
||||
// 1) cloud-config liest IsSupervised + OrganizationName direkt aus MCInstall.
|
||||
if let Ok(stdout) = run_supervise_magic_cmd(app.clone(), "cloud-config", &[]).await {
|
||||
if let Some(v) = parse_equals(&stdout, "IsSupervised") {
|
||||
status.is_supervised = v.to_lowercase() == "true";
|
||||
}
|
||||
if let Some(v) = parse_equals(&stdout, "OrganizationName") {
|
||||
status.organization_name = Some(normalize_org_name(&v));
|
||||
}
|
||||
}
|
||||
|
||||
// 2) check gibt zusätzlich FindMyEnabled. Wenn check fehlschlägt, ist kein
|
||||
// Gerät verbunden — dann liefern wir den Default-Status zurück.
|
||||
let check_stdout = match run_supervise_magic_cmd(app.clone(), "check", &[]).await {
|
||||
Ok(stdout) => stdout,
|
||||
Err(_) => return Ok(status),
|
||||
};
|
||||
|
||||
if let Some(v) = parse_colon(&check_stdout, "FindMyEnabled") {
|
||||
status.find_my_enabled = Some(v.to_lowercase() == "true");
|
||||
}
|
||||
if !status.is_supervised {
|
||||
if let Some(v) = parse_colon(&check_stdout, "IsSupervised") {
|
||||
status.is_supervised = v.to_lowercase() == "true";
|
||||
}
|
||||
}
|
||||
if status.organization_name.is_none() {
|
||||
if let Some(v) = parse_colon(&check_stdout, "OrganizationName")
|
||||
.or_else(|| parse_colon(&check_stdout, "SupervisionOrganizationName"))
|
||||
{
|
||||
status.organization_name = Some(normalize_org_name(&v));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
pub fn installed_profile_ids() -> AppResult<Vec<String>> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let cfgutil = first_executable(CFGUTIL_CANDIDATES)
|
||||
.ok_or_else(|| AppError::new("cfgutil nicht gefunden — bitte Apple Configurator installieren.".to_string()))?;
|
||||
let args = ["--foreach", "get", "configurationProfiles"];
|
||||
eprintln!("[ios_device] cfgutil profiles: binary={} args={:?}", cfgutil, args);
|
||||
let (stdout, stderr, success) = run_cfgutil(&cfgutil, &args)?;
|
||||
eprintln!(
|
||||
"[ios_device] cfgutil profiles: success={} stderr_len={} stdout_len={} stderr={:?}",
|
||||
success, stderr.len(), stdout.len(), stderr
|
||||
);
|
||||
eprintln!("[ios_device] cfgutil profiles stdout:\n{}", stdout);
|
||||
// cfgutil mit --foreach liefert auf manchen Setups Exit-Code 1, obwohl die Ausgabe gültig ist.
|
||||
// Wir behandeln es als Fehler nur, wenn stderr einen Fehler enthält.
|
||||
if !success && !stderr.trim().is_empty() {
|
||||
return Err(AppError::new(format!("cfgutil failed: {}", stderr)));
|
||||
}
|
||||
let list = parse_cfgutil_list(&stdout);
|
||||
eprintln!("[ios_device] cfgutil profiles parsed: {:?}", list);
|
||||
return Ok(list);
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
// cfgutil is only available on macOS. On Windows we cannot enumerate profiles locally.
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn installed_app_bundle_ids() -> AppResult<Vec<String>> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let cfgutil = first_executable(CFGUTIL_CANDIDATES)
|
||||
.ok_or_else(|| AppError::new("cfgutil nicht gefunden — bitte Apple Configurator installieren.".to_string()))?;
|
||||
let args = ["--foreach", "get", "installedApps"];
|
||||
eprintln!("[ios_device] cfgutil apps: binary={} args={:?}", cfgutil, args);
|
||||
let (stdout, stderr, success) = run_cfgutil(&cfgutil, &args)?;
|
||||
eprintln!(
|
||||
"[ios_device] cfgutil apps: success={} stderr_len={} stdout_len={} stderr={:?}",
|
||||
success, stderr.len(), stdout.len(), stderr
|
||||
);
|
||||
eprintln!("[ios_device] cfgutil apps stdout:\n{}", stdout);
|
||||
if !success && !stderr.trim().is_empty() {
|
||||
return Err(AppError::new(format!("cfgutil failed: {}", stderr)));
|
||||
}
|
||||
let list = parse_cfgutil_list(&stdout);
|
||||
eprintln!("[ios_device] cfgutil apps parsed: {:?}", list);
|
||||
return Ok(list);
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_cfgutil_list(stdout: &str) -> Vec<String> {
|
||||
stdout
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// First token = identifier (split by tab or space)
|
||||
trimmed
|
||||
.split(|c: char| c == '\t' || c == ' ')
|
||||
.next()
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn install_profile_via_cfgutil(path: &str) -> AppResult<()> {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
return Err(AppError::new("Lokale Profil-Installation via cfgutil ist nur auf macOS verfügbar. Bitte das Profil per QR-Code installieren.".to_string()));
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
let cfgutil = first_executable(CFGUTIL_CANDIDATES)
|
||||
.ok_or_else(|| AppError::new("cfgutil nicht gefunden — bitte Apple Configurator installieren.".to_string()))?;
|
||||
#[cfg(target_os = "macos")]
|
||||
let (_, stderr, success) = run_cfgutil(&cfgutil, &["--foreach", "install-profile", path])?;
|
||||
#[cfg(target_os = "macos")]
|
||||
if !success {
|
||||
let err = stderr.trim();
|
||||
if err.to_lowercase().contains("device is locked") {
|
||||
return Err(AppError::new("iPhone ist gesperrt. Bitte entsperren und erneut versuchen.".to_string()));
|
||||
}
|
||||
if err.to_lowercase().contains("user interaction")
|
||||
|| err.to_lowercase().contains("benutzerinteraktion")
|
||||
|| err.contains("MCInstallationErrorDomain Code: 4009")
|
||||
{
|
||||
return Err(AppError::new("iOS verlangt eine Bestätigung direkt am iPhone.".to_string()));
|
||||
}
|
||||
if err.contains("DMCInstallationErrorDomain") && err.contains("Code: 4020") {
|
||||
return Err(AppError::new("Lokale Profil-Installation ist durch iOS-Policy blockiert (DMC 4020).".to_string()));
|
||||
}
|
||||
return Err(AppError::new(format!("Profil-Installation fehlgeschlagen: {}", err)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn detect_device_state(app: tauri::AppHandle) -> AppResult<Option<IphoneDeviceState>> {
|
||||
let check_stdout = match run_supervise_magic_cmd(app.clone(), "check", &[]).await {
|
||||
Ok(stdout) => stdout,
|
||||
Err(_) => return Ok(None), // Kein Gerät verbunden
|
||||
};
|
||||
|
||||
let udid = match parse_colon(&check_stdout, "UDID") {
|
||||
Some(v) => v,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let name = parse_colon(&check_stdout, "Name").unwrap_or_else(|| "iPhone".to_string());
|
||||
let product_type = parse_colon(&check_stdout, "Type").unwrap_or_default();
|
||||
let product_version = parse_colon(&check_stdout, "iOS")
|
||||
.or_else(|| parse_colon(&check_stdout, "ProductVersion"))
|
||||
.unwrap_or_default();
|
||||
|
||||
let status = read_supervise_status(app.clone()).await?;
|
||||
|
||||
let installed_profile_ids = tokio::task::spawn_blocking(installed_profile_ids)
|
||||
.await
|
||||
.map_err(|e| AppError::new(format!("profile detection task failed: {}", e)))?
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!("[ios_device] installed_profile_ids failed: {}", e);
|
||||
Vec::new()
|
||||
});
|
||||
let installed_app_bundle_ids = tokio::task::spawn_blocking(installed_app_bundle_ids)
|
||||
.await
|
||||
.map_err(|e| AppError::new(format!("app detection task failed: {}", e)))?
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!("[ios_device] installed_app_bundle_ids failed: {}", e);
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
eprintln!(
|
||||
"[ios_device] detect result: udid={} profiles={:?} apps={:?}",
|
||||
udid, installed_profile_ids, installed_app_bundle_ids
|
||||
);
|
||||
|
||||
Ok(Some(IphoneDeviceState {
|
||||
udid,
|
||||
name,
|
||||
product_type,
|
||||
product_version,
|
||||
is_supervised: status.is_supervised,
|
||||
organization_name: status.organization_name,
|
||||
find_my_enabled: status.find_my_enabled,
|
||||
installed_profile_ids,
|
||||
installed_app_bundle_ids,
|
||||
}))
|
||||
}
|
||||
|
||||
// MARK: - Tauri Commands
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn detect_iphone_state(app: tauri::AppHandle) -> AppResult<Option<IphoneDeviceState>> {
|
||||
detect_device_state(app).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_supervise_status(app: tauri::AppHandle) -> AppResult<SuperviseStatus> {
|
||||
read_supervise_status(app).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_installed_profiles() -> AppResult<Vec<String>> {
|
||||
installed_profile_ids()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_installed_apps() -> AppResult<Vec<String>> {
|
||||
installed_app_bundle_ids()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn install_profile(path: String) -> AppResult<()> {
|
||||
install_profile_via_cfgutil(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_and_patch_enrollment_profile(url: String, udid: String) -> AppResult<String> {
|
||||
let response = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| AppError::new(format!("Failed to download enrollment profile: {}", e)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(AppError::new(format!(
|
||||
"MDM server returned status {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let mut text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::new(format!("Failed to read enrollment profile: {}", e)))?;
|
||||
|
||||
text = text.replace("%SerialNumber%", &udid);
|
||||
text = text.replace("%UDID%", &udid);
|
||||
|
||||
let config_dir = AppConfig::config_dir()?;
|
||||
std::fs::create_dir_all(&config_dir)?;
|
||||
let profile_path = config_dir.join("rebreak-enrollment.mobileconfig");
|
||||
std::fs::write(&profile_path, text)?;
|
||||
|
||||
Ok(profile_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_equals_handles_multiple_spaces() {
|
||||
let input = "OrganizationName = ReBreak\nIsSupervised = true";
|
||||
assert_eq!(
|
||||
parse_equals(input, "OrganizationName"),
|
||||
Some("ReBreak".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_equals(input, "IsSupervised"),
|
||||
Some("true".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cfgutil_list_extracts_bundle_ids() {
|
||||
let input = "com.example.app\tExample App v1\norg.rebreak.app\tReBreak v2\n\n";
|
||||
let list = parse_cfgutil_list(input);
|
||||
assert_eq!(list, vec!["com.example.app", "org.rebreak.app"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cfgutil_list_handles_empty_profile_output() {
|
||||
// cfgutil --foreach get configurationProfiles kann leere stdout + Exit 1 liefern.
|
||||
assert!(parse_cfgutil_list("").is_empty());
|
||||
assert!(parse_cfgutil_list(" \n\n").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iphone_state_serializes_id_casing() {
|
||||
let state = IphoneDeviceState {
|
||||
udid: "u".into(),
|
||||
name: "n".into(),
|
||||
product_type: "iPhone".into(),
|
||||
product_version: "26".into(),
|
||||
is_supervised: true,
|
||||
organization_name: None,
|
||||
find_my_enabled: None,
|
||||
installed_profile_ids: vec!["org.rebreak.mdm.enrollment".into()],
|
||||
installed_app_bundle_ids: vec!["org.rebreak.app".into()],
|
||||
};
|
||||
let json = serde_json::to_value(&state).unwrap();
|
||||
assert!(json.get("installedProfileIDs").is_some(), "expected installedProfileIDs");
|
||||
assert!(json.get("installedAppBundleIDs").is_some(), "expected installedAppBundleIDs");
|
||||
assert!(json.get("installedProfileIds").is_none(), "unexpected installedProfileIds");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "macos")]
|
||||
#[ignore = "requires connected iOS device"]
|
||||
fn cfgutil_live_outputs() {
|
||||
eprintln!("installed_profile_ids = {:?}", installed_profile_ids());
|
||||
eprintln!("installed_app_bundle_ids = {:?}", installed_app_bundle_ids());
|
||||
}
|
||||
}
|
||||
@ -54,6 +54,7 @@ pub fn run() {
|
||||
get_mdm_status,
|
||||
get_mdm_status_by_udid,
|
||||
link_mdm_device,
|
||||
report_device_protection_state,
|
||||
get_desktop_protection_status,
|
||||
set_desktop_protection_status,
|
||||
get_hostname,
|
||||
@ -236,6 +237,29 @@ async fn link_mdm_device(device_id: String, mdm_id: String) -> AppResult<()> {
|
||||
client.link_mdm_device(&session.access_token, &device_id, &mdm_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn report_device_protection_state(
|
||||
device_id: String,
|
||||
platform: String,
|
||||
protection_type: String,
|
||||
active: bool,
|
||||
reason: Option<String>,
|
||||
) -> AppResult<()> {
|
||||
let session = require_session()?;
|
||||
let config = AppConfig::load();
|
||||
let client = MagicApiClient::new(&config);
|
||||
client
|
||||
.report_device_protection_state(
|
||||
&session.access_token,
|
||||
&device_id,
|
||||
&platform,
|
||||
&protection_type,
|
||||
active,
|
||||
reason.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_mdm_status_by_udid(udid: String) -> AppResult<MdmStatusByUdidData> {
|
||||
let session = require_session()?;
|
||||
|
||||
6
apps/rebreak-magic/src-tauri/src/main.rs
Normal file
@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
rebreak_magic_lib::run();
|
||||
}
|
||||
142
apps/rebreak-magic/src-tauri/src/mdm/client.rs
Normal file
@ -0,0 +1,142 @@
|
||||
use crate::config::AppConfig;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const HTTP_TIMEOUT_SECONDS: u64 = 30;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MdmPushStatus {
|
||||
pub udid: String,
|
||||
pub push_result: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MdmEnqueueResult {
|
||||
pub command_uuid: String,
|
||||
pub response_body: String,
|
||||
}
|
||||
|
||||
pub struct MdmClient {
|
||||
client: reqwest::Client,
|
||||
server: String,
|
||||
auth_header: String,
|
||||
}
|
||||
|
||||
impl MdmClient {
|
||||
pub fn new() -> AppResult<Self> {
|
||||
let cfg = AppConfig::load_binder_config()?;
|
||||
let creds = format!("{}:{}", cfg.mdm_user, cfg.mdm_api_key);
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
let auth_header = format!("Basic {}", STANDARD.encode(creds));
|
||||
|
||||
Ok(Self {
|
||||
client: reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECONDS))
|
||||
.build()
|
||||
.map_err(|e| AppError::new(format!("reqwest client build: {}", e)))?,
|
||||
server: cfg.mdm_server,
|
||||
auth_header,
|
||||
})
|
||||
}
|
||||
|
||||
fn url(&self, path: &str) -> AppResult<String> {
|
||||
let base = self.server.trim_end_matches('/');
|
||||
Ok(format!("{}{}", base, path))
|
||||
}
|
||||
|
||||
fn request(&self, method: reqwest::Method, path: &str) -> AppResult<reqwest::RequestBuilder> {
|
||||
let url = self.url(path)?;
|
||||
Ok(self
|
||||
.client
|
||||
.request(method, &url)
|
||||
.header("Authorization", &self.auth_header))
|
||||
}
|
||||
|
||||
pub async fn ping(&self) -> AppResult<String> {
|
||||
let resp = self
|
||||
.request(reqwest::Method::GET, "/version")?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::new(format!("MDM ping failed: {}", e)))?;
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(AppError::new(format!("MDM ping HTTP {}: {}", status, body)));
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
pub async fn push(&self, udid: &str) -> AppResult<MdmPushStatus> {
|
||||
let resp = self
|
||||
.request(reqwest::Method::GET, &format!("/v1/push/{}", udid))?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::new(format!("MDM push failed: {}", e)))?;
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(AppError::new(format!("MDM push HTTP {}: {}", status, body)));
|
||||
}
|
||||
|
||||
// Parse NanoMDM response: { "status": { "<udid>": { "push_result": "..." } } }
|
||||
let parsed: serde_json::Value = serde_json::from_str(&body)
|
||||
.map_err(|e| AppError::new(format!("MDM push parse error: {} — body: {}", e, body)))?;
|
||||
|
||||
let push_result = parsed
|
||||
.get("status")
|
||||
.and_then(|s| s.get(udid))
|
||||
.and_then(|d| d.get("push_result"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| AppError::new(format!("MDM push response unerwartet: {}", body)))?
|
||||
.to_string();
|
||||
|
||||
Ok(MdmPushStatus {
|
||||
udid: udid.to_string(),
|
||||
push_result,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn enqueue(&self, udid: &str, command: serde_json::Value) -> AppResult<MdmEnqueueResult> {
|
||||
let command_uuid = uuid::Uuid::new_v4().to_string();
|
||||
let envelope = serde_json::json!({
|
||||
"CommandUUID": command_uuid,
|
||||
"Command": command,
|
||||
});
|
||||
|
||||
// NanoMDM expects plist, but also accepts JSON in newer versions.
|
||||
// For maximum compatibility we send JSON here; if it fails, the caller
|
||||
// will see the error.
|
||||
let resp = self
|
||||
.request(reqwest::Method::PUT, &format!("/v1/enqueue/{}?push=1", udid))?
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&envelope)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::new(format!("MDM enqueue failed: {}", e)))?;
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(AppError::new(format!("MDM enqueue HTTP {}: {}", status, body)));
|
||||
}
|
||||
|
||||
Ok(MdmEnqueueResult {
|
||||
command_uuid,
|
||||
response_body: body,
|
||||
})
|
||||
}
|
||||
}
|
||||
89
apps/rebreak-magic/src-tauri/src/mdm/mod.rs
Normal file
@ -0,0 +1,89 @@
|
||||
pub mod client;
|
||||
|
||||
use crate::error::AppResult;
|
||||
use client::MdmClient;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MdmCommandResult {
|
||||
pub command_uuid: String,
|
||||
pub response_body: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mdm_ping() -> AppResult<String> {
|
||||
MdmClient::new()?.ping().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mdm_push(udid: String) -> AppResult<client::MdmPushStatus> {
|
||||
MdmClient::new()?.push(&udid).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mdm_install_app(udid: String) -> AppResult<MdmCommandResult> {
|
||||
let command = serde_json::json!({
|
||||
"RequestType": "InstallApplication",
|
||||
"ManifestURL": "https://mdm.rebreak.org/install/manifest.plist",
|
||||
"ManagementFlags": 0,
|
||||
});
|
||||
let r = MdmClient::new()?.enqueue(&udid, command).await?;
|
||||
Ok(MdmCommandResult {
|
||||
command_uuid: r.command_uuid,
|
||||
response_body: r.response_body,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mdm_set_supervised_mode(udid: String) -> AppResult<MdmCommandResult> {
|
||||
let command = serde_json::json!({
|
||||
"RequestType": "Settings",
|
||||
"Settings": [
|
||||
{
|
||||
"Item": "ApplicationConfiguration",
|
||||
"Identifier": "org.rebreak.app",
|
||||
"Configuration": {
|
||||
"mdmSupervised": true,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
let r = MdmClient::new()?.enqueue(&udid, command).await?;
|
||||
Ok(MdmCommandResult {
|
||||
command_uuid: r.command_uuid,
|
||||
response_body: r.response_body,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mdm_take_management(udid: String) -> AppResult<MdmCommandResult> {
|
||||
let command = serde_json::json!({
|
||||
"RequestType": "InstallApplication",
|
||||
"Identifier": "org.rebreak.app",
|
||||
"ChangeManagementState": "Managed",
|
||||
"ManagementFlags": 0,
|
||||
});
|
||||
let r = MdmClient::new()?.enqueue(&udid, command).await?;
|
||||
Ok(MdmCommandResult {
|
||||
command_uuid: r.command_uuid,
|
||||
response_body: r.response_body,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mdm_install_lock_profile(udid: String, profile_path: String) -> AppResult<MdmCommandResult> {
|
||||
let bytes = std::fs::read(&profile_path)?;
|
||||
let payload_b64 = {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
STANDARD.encode(&bytes)
|
||||
};
|
||||
let command = serde_json::json!({
|
||||
"RequestType": "InstallProfile",
|
||||
"Payload": payload_b64,
|
||||
});
|
||||
let r = MdmClient::new()?.enqueue(&udid, command).await?;
|
||||
Ok(MdmCommandResult {
|
||||
command_uuid: r.command_uuid,
|
||||
response_body: r.response_body,
|
||||
})
|
||||
}
|
||||
87
apps/rebreak-magic/src-tauri/src/server/local_http.rs
Normal file
@ -0,0 +1,87 @@
|
||||
use crate::error::{AppError, AppResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::thread;
|
||||
use tiny_http::{Response, Server};
|
||||
|
||||
static SERVER_RUNNING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LocalServerInfo {
|
||||
pub url: String,
|
||||
pub qr_payload: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn start_local_profile_server(profile_path: String) -> AppResult<LocalServerInfo> {
|
||||
if SERVER_RUNNING.load(Ordering::SeqCst) {
|
||||
return Err(AppError::new("Local server is already running"));
|
||||
}
|
||||
|
||||
let path = PathBuf::from(profile_path);
|
||||
if !path.exists() {
|
||||
return Err(AppError::new(format!(
|
||||
"Profile not found at {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
// Try to bind to a port; fall back if 8123 is taken
|
||||
let port = find_free_port(8123)?;
|
||||
let addr: SocketAddr = format!("0.0.0.0:{}", port).parse().map_err(|e| {
|
||||
AppError::new(format!("Failed to parse socket address: {}", e))
|
||||
})?;
|
||||
|
||||
let server = Server::http(addr).map_err(|e| {
|
||||
AppError::new(format!("Failed to start local HTTP server: {}", e))
|
||||
})?;
|
||||
|
||||
let local_ip = local_ip_address::local_ip().ok();
|
||||
let host = local_ip
|
||||
.map(|ip| ip.to_string())
|
||||
.unwrap_or_else(|| "localhost".to_string());
|
||||
|
||||
let url = format!("http://{}:{}/profile.mobileconfig", host, port);
|
||||
let qr_payload = url.clone();
|
||||
let profile_bytes = fs::read(&path)?;
|
||||
|
||||
SERVER_RUNNING.store(true, Ordering::SeqCst);
|
||||
|
||||
thread::spawn(move || {
|
||||
for request in server.incoming_requests() {
|
||||
let response = Response::from_data(profile_bytes.clone())
|
||||
.with_header(
|
||||
tiny_http::Header::from_bytes(
|
||||
&b"Content-Type"[..],
|
||||
&b"application/x-apple-aspen-config"[..],
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
SERVER_RUNNING.store(false, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
Ok(LocalServerInfo { url, qr_payload })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn stop_local_profile_server() -> AppResult<()> {
|
||||
// tiny_http does not support graceful shutdown out of the box.
|
||||
// In a real implementation, store the server handle and close it.
|
||||
SERVER_RUNNING.store(false, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_free_port(start: u16) -> AppResult<u16> {
|
||||
for port in start..start + 100 {
|
||||
match std::net::TcpListener::bind(format!("0.0.0.0:{}", port)) {
|
||||
Ok(_) => return Ok(port),
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
Err(AppError::new("No free port found in range 8123-8222"))
|
||||
}
|
||||
1
apps/rebreak-magic/src-tauri/src/server/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod local_http;
|
||||
1
apps/rebreak-magic/src-tauri/src/sidecar/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod supervise_magic;
|
||||
63
apps/rebreak-magic/src-tauri/src/sidecar/supervise_magic.rs
Normal file
@ -0,0 +1,63 @@
|
||||
use crate::error::{AppError, AppResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SuperviseResult {
|
||||
pub success: bool,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
}
|
||||
|
||||
pub async fn run_supervise_magic_raw(
|
||||
app: tauri::AppHandle,
|
||||
action: &str,
|
||||
args: &[&str],
|
||||
) -> AppResult<SuperviseResult> {
|
||||
let sidecar = app
|
||||
.shell()
|
||||
.sidecar("supervise-magic")
|
||||
.map_err(|e| AppError::new(format!("Failed to locate supervise-magic sidecar: {}", e)))?;
|
||||
|
||||
let mut cmd = sidecar.arg(action);
|
||||
for a in args {
|
||||
cmd = cmd.arg(a);
|
||||
}
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| AppError::new(format!("Failed to run supervise-magic: {}", e)))?;
|
||||
|
||||
Ok(SuperviseResult {
|
||||
success: output.status.success(),
|
||||
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn run_supervise_magic(
|
||||
app: tauri::AppHandle,
|
||||
action: String,
|
||||
args: Option<Vec<String>>,
|
||||
) -> AppResult<SuperviseResult> {
|
||||
let args: Vec<&str> = args.as_ref().map(|v| v.iter().map(|s| s.as_str()).collect()).unwrap_or_default();
|
||||
run_supervise_magic_raw(app, &action, &args).await
|
||||
}
|
||||
|
||||
// Synchronous fallback for direct shell execution
|
||||
#[allow(dead_code)]
|
||||
pub fn run_supervise_magic_sync(binary_path: &str, action: &str) -> AppResult<SuperviseResult> {
|
||||
let output = Command::new(binary_path)
|
||||
.arg(action)
|
||||
.output()
|
||||
.map_err(|e| AppError::new(format!("Failed to run supervise-magic: {}", e)))?;
|
||||
|
||||
Ok(SuperviseResult {
|
||||
success: output.status.success(),
|
||||
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||
})
|
||||
}
|
||||
46
apps/rebreak-magic/src-tauri/tauri.conf.json
Normal file
@ -0,0 +1,46 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ReBreak Magic",
|
||||
"version": "0.1.0",
|
||||
"identifier": "org.rebreak.magic",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"frontendDist": "../.output/public"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "ReBreak Magic",
|
||||
"width": 900,
|
||||
"height": 700,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"center": true,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null,
|
||||
"capabilities": ["default"]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["app", "nsis"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.ico",
|
||||
"icons/icon.png"
|
||||
],
|
||||
"externalBin": ["binaries/supervise-magic"],
|
||||
"macOS": {
|
||||
"entitlements": "./entitlements.plist",
|
||||
"frameworks": [],
|
||||
"minimumSystemVersion": "10.13"
|
||||
}
|
||||
}
|
||||
}
|
||||
3
apps/rebreak-magic/tsconfig.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
@ -36,7 +36,13 @@ export default defineEventHandler(async (event) => {
|
||||
if (!device.mdmId) {
|
||||
return {
|
||||
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.
|
||||
if (!status.enrolled) {
|
||||
// UDID stored but the device is completely gone from NanoMDM → clear stale link.
|
||||
if (!status.exists) {
|
||||
await clearUserDeviceMdmId(user.id, deviceId);
|
||||
return {
|
||||
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 {
|
||||
success: true,
|
||||
data: {
|
||||
enrolled: true,
|
||||
company: "ReBreak",
|
||||
enrolled: status.enrolled,
|
||||
company: status.exists ? "ReBreak" : null,
|
||||
supervised: status.supervised,
|
||||
lockProfileInstalled: lockState?.active ?? false,
|
||||
lastAppPushAt: status.lastAppPushAt?.toISOString() ?? null,
|
||||
|
||||
@ -116,6 +116,7 @@ export async function getLinkedUserDevices(): Promise<
|
||||
|
||||
export interface MdmDeviceStatus {
|
||||
enrolled: boolean;
|
||||
exists: boolean;
|
||||
company: string | null;
|
||||
supervised: boolean;
|
||||
tokenUpdateAt: Date | null;
|
||||
@ -157,10 +158,12 @@ export async function getMdmStatusByUdid(
|
||||
token_update_at: Date | null;
|
||||
last_ack: Date | null;
|
||||
last_app_push_at: Date | null;
|
||||
enrolled: boolean;
|
||||
}>(
|
||||
`SELECT
|
||||
d.unlock_token,
|
||||
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(r.updated_at)
|
||||
FROM command_results r
|
||||
@ -169,17 +172,20 @@ export async function getMdmStatusByUdid(
|
||||
AND c.request_type = 'InstallApplication'
|
||||
AND r.status = 'Acknowledged') AS last_app_push_at
|
||||
FROM devices d
|
||||
LEFT JOIN enrollments e ON e.device_id = d.id
|
||||
WHERE d.id = $1`,
|
||||
[udid],
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
const enrolled = !!row;
|
||||
const exists = row !== undefined;
|
||||
const enrolled = row?.enrolled ?? false;
|
||||
|
||||
return {
|
||||
enrolled,
|
||||
exists,
|
||||
company: enrolled ? "ReBreak" : null,
|
||||
supervised: enrolled && row?.unlock_token != null,
|
||||
supervised: exists && row?.unlock_token != null,
|
||||
tokenUpdateAt: row?.token_update_at ?? null,
|
||||
lastAckAt: row?.last_ack ?? 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.
|
||||
491110
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))
|
||||
'@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)(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':
|
||||
specifier: ^2.0.4
|
||||
version: 2.0.6
|
||||
@ -45,7 +45,7 @@ importers:
|
||||
version: 14.3.0(vue@3.5.34(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)(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:
|
||||
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)
|
||||
@ -76,7 +76,7 @@ importers:
|
||||
version: 1.2.3
|
||||
'@nuxt/fonts':
|
||||
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':
|
||||
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))
|
||||
@ -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)
|
||||
'@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)(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':
|
||||
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))
|
||||
@ -94,7 +94,7 @@ importers:
|
||||
version: 3.0.3(magicast@0.5.3)(vue@3.5.34(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)(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:
|
||||
specifier: ^4.5.1
|
||||
version: 4.5.1
|
||||
@ -124,6 +124,52 @@ importers:
|
||||
specifier: ^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:
|
||||
dependencies:
|
||||
'@fontsource/nunito':
|
||||
@ -4054,6 +4100,9 @@ packages:
|
||||
'@types/prop-types@15.7.15':
|
||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
|
||||
|
||||
'@types/react-dom@18.3.7':
|
||||
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
|
||||
peerDependencies:
|
||||
@ -4959,6 +5008,9 @@ packages:
|
||||
client-only@0.0.1:
|
||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||
|
||||
cliui@6.0.0:
|
||||
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||
|
||||
cliui@8.0.1:
|
||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||
engines: {node: '>=12'}
|
||||
@ -5246,6 +5298,10 @@ packages:
|
||||
supports-color:
|
||||
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:
|
||||
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
|
||||
engines: {node: '>=0.10'}
|
||||
@ -5359,6 +5415,9 @@ packages:
|
||||
resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
dijkstrajs@1.0.3:
|
||||
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||
|
||||
dlv@1.1.3:
|
||||
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
||||
|
||||
@ -7705,6 +7764,10 @@ packages:
|
||||
resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
|
||||
pngjs@5.0.0:
|
||||
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
popmotion@11.0.5:
|
||||
resolution: {integrity: sha512-la8gPM1WYeFznb/JqF4GiTkRRPZsfaj2+kCxqQgr2MJylMmIKUwBfWW8Wa5fml/8gmtlD5yI01MP1QCZPWmppA==}
|
||||
|
||||
@ -8091,6 +8154,11 @@ packages:
|
||||
resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==}
|
||||
hasBin: true
|
||||
|
||||
qrcode@1.5.4:
|
||||
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
|
||||
qs@6.15.1:
|
||||
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
|
||||
engines: {node: '>=0.6'}
|
||||
@ -8433,6 +8501,9 @@ packages:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-main-filename@2.0.0:
|
||||
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||
|
||||
requireg@0.2.2:
|
||||
resolution: {integrity: sha512-nYzyjnFcPNGR3lx9lwPPPnuQxv6JWEZd2Ci0u9opN7N5zUEPIhY/GbL3vMGOr2UXwEg9WwSyV9X9Y/kLFgPsOg==}
|
||||
engines: {node: '>= 4.0.0'}
|
||||
@ -8631,6 +8702,9 @@ packages:
|
||||
server-only@0.0.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@ -9797,6 +9871,9 @@ packages:
|
||||
resolution: {integrity: sha512-f+Gy33Oa5Z14XY9679Zze+7VFhbsQfBFXodnU2x589l4kxGM9L5Y8zETTmcMR5pWOPQyRv4Z0lNax6xCO0NSlA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
which-module@2.0.1:
|
||||
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||
|
||||
which-typed-array@1.1.20:
|
||||
resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@ -9828,6 +9905,10 @@ packages:
|
||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
@ -9933,6 +10014,9 @@ packages:
|
||||
peerDependencies:
|
||||
yjs: ^13.0.0
|
||||
|
||||
y18n@4.0.3:
|
||||
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||
|
||||
y18n@5.0.8:
|
||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||
engines: {node: '>=10'}
|
||||
@ -9953,6 +10037,10 @@ packages:
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
yargs-parser@21.1.1:
|
||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||
engines: {node: '>=12'}
|
||||
@ -9961,6 +10049,10 @@ packages:
|
||||
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
|
||||
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:
|
||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||
engines: {node: '>=12'}
|
||||
@ -11414,6 +11506,15 @@ snapshots:
|
||||
- '@vue/composition-api'
|
||||
- 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': {}
|
||||
|
||||
'@hono/node-server@1.19.11(hono@4.12.17)':
|
||||
@ -11474,6 +11575,11 @@ snapshots:
|
||||
'@iconify/types': 2.0.0
|
||||
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': {}
|
||||
|
||||
'@internationalized/date@3.12.1':
|
||||
@ -11918,7 +12024,7 @@ snapshots:
|
||||
- utf-8-validate
|
||||
- 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:
|
||||
'@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)
|
||||
@ -12026,6 +12132,28 @@ snapshots:
|
||||
- vite
|
||||
- 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))':
|
||||
dependencies:
|
||||
'@iconify/collections': 1.0.680
|
||||
@ -12047,6 +12175,27 @@ snapshots:
|
||||
- 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.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)':
|
||||
dependencies:
|
||||
'@nuxt/kit': 3.21.4(magicast@0.5.3)
|
||||
@ -12242,7 +12391,7 @@ snapshots:
|
||||
rc9: 3.0.1
|
||||
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:
|
||||
'@floating-ui/dom': 1.7.6
|
||||
'@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))
|
||||
'@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/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))
|
||||
colortranslator: 5.0.0
|
||||
consola: 3.4.2
|
||||
@ -12356,6 +12505,120 @@ snapshots:
|
||||
- vue
|
||||
- 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)':
|
||||
dependencies:
|
||||
'@nuxt/kit': 4.1.3(magicast@0.5.3)
|
||||
@ -13679,11 +13942,21 @@ snapshots:
|
||||
'@tanstack/table-core': 8.21.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))':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.14.0
|
||||
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/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))
|
||||
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))':
|
||||
dependencies:
|
||||
'@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-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)':
|
||||
dependencies:
|
||||
lib0: 0.2.117
|
||||
@ -14031,6 +14321,10 @@ snapshots:
|
||||
|
||||
'@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)':
|
||||
dependencies:
|
||||
'@types/react': 18.3.31
|
||||
@ -14115,6 +14409,12 @@ snapshots:
|
||||
unhead: 2.1.13
|
||||
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':
|
||||
dependencies:
|
||||
'@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))':
|
||||
dependencies:
|
||||
'@vue/compiler-sfc': 3.5.34
|
||||
'@vue/compiler-sfc': 3.5.35
|
||||
ast-kit: 1.4.3
|
||||
local-pkg: 1.1.2
|
||||
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))':
|
||||
dependencies:
|
||||
'@vue/compiler-sfc': 3.5.34
|
||||
'@vue/compiler-sfc': 3.5.35
|
||||
ast-kit: 2.2.0
|
||||
local-pkg: 1.1.2
|
||||
magic-string-ast: 1.0.3
|
||||
@ -14390,7 +14690,7 @@ snapshots:
|
||||
'@babel/types': 7.29.0
|
||||
'@vue/babel-helper-vue-transform-on': 2.0.1
|
||||
'@vue/babel-plugin-resolve-type': 2.0.1(@babel/core@7.29.0)
|
||||
'@vue/shared': 3.5.34
|
||||
'@vue/shared': 3.5.35
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.29.0
|
||||
transitivePeerDependencies:
|
||||
@ -14403,7 +14703,7 @@ snapshots:
|
||||
'@babel/helper-module-imports': 7.28.6
|
||||
'@babel/helper-plugin-utils': 7.28.6
|
||||
'@babel/parser': 7.29.3
|
||||
'@vue/compiler-sfc': 3.5.34
|
||||
'@vue/compiler-sfc': 3.5.35
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@ -14513,8 +14813,8 @@ snapshots:
|
||||
'@vue/language-core@3.2.8':
|
||||
dependencies:
|
||||
'@volar/language-core': 2.4.28
|
||||
'@vue/compiler-dom': 3.5.34
|
||||
'@vue/shared': 3.5.34
|
||||
'@vue/compiler-dom': 3.5.35
|
||||
'@vue/shared': 3.5.35
|
||||
alien-signals: 3.1.2
|
||||
muggle-string: 0.4.1
|
||||
path-browserify: 1.0.1
|
||||
@ -14578,6 +14878,16 @@ snapshots:
|
||||
- '@vue/composition-api'
|
||||
- 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))':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.21
|
||||
@ -14592,13 +14902,30 @@ snapshots:
|
||||
'@vueuse/shared': 14.3.0(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:
|
||||
'@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))
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
optionalDependencies:
|
||||
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': {}
|
||||
|
||||
@ -14620,7 +14947,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
'@nuxt/kit': 4.4.4(magicast@0.5.3)
|
||||
'@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3))
|
||||
@ -14631,6 +14958,17 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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))':
|
||||
dependencies:
|
||||
vue-demi: 0.14.10(vue@3.5.34(typescript@5.9.3))
|
||||
@ -14638,6 +14976,13 @@ snapshots:
|
||||
- '@vue/composition-api'
|
||||
- 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))':
|
||||
dependencies:
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
@ -14646,6 +14991,10 @@ snapshots:
|
||||
dependencies:
|
||||
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.9.10': {}
|
||||
@ -15273,6 +15622,12 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
@ -15545,6 +15900,8 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
decode-uri-component@0.2.2: {}
|
||||
|
||||
decompress-response@6.0.0:
|
||||
@ -15634,6 +15991,8 @@ snapshots:
|
||||
|
||||
diff@9.0.0: {}
|
||||
|
||||
dijkstrajs@1.0.3: {}
|
||||
|
||||
dlv@1.1.3: {}
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
@ -15719,6 +16078,12 @@ snapshots:
|
||||
embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0)
|
||||
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):
|
||||
dependencies:
|
||||
embla-carousel: 8.6.0
|
||||
@ -17801,6 +18166,19 @@ snapshots:
|
||||
- react
|
||||
- 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: {}
|
||||
|
||||
mrmime@2.0.1: {}
|
||||
@ -18675,6 +19053,8 @@ snapshots:
|
||||
|
||||
pngjs@3.4.0: {}
|
||||
|
||||
pngjs@5.0.0: {}
|
||||
|
||||
popmotion@11.0.5:
|
||||
dependencies:
|
||||
framesync: 6.1.2
|
||||
@ -19073,6 +19453,12 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
@ -19468,12 +19854,30 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@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: {}
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
require-main-filename@2.0.0: {}
|
||||
|
||||
requireg@0.2.2:
|
||||
dependencies:
|
||||
nested-error-stacks: 2.0.1
|
||||
@ -19693,6 +20097,8 @@ snapshots:
|
||||
|
||||
server-only@0.0.1: {}
|
||||
|
||||
set-blocking@2.0.0: {}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
dependencies:
|
||||
define-data-property: 1.1.4
|
||||
@ -20379,6 +20785,18 @@ snapshots:
|
||||
'@nuxt/kit': 4.4.4(magicast@0.5.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:
|
||||
dependencies:
|
||||
pathe: 2.0.3
|
||||
@ -20404,6 +20822,21 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@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)):
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
@ -20578,6 +21011,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@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):
|
||||
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)
|
||||
@ -20815,6 +21256,10 @@ snapshots:
|
||||
dependencies:
|
||||
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-i18n@10.0.8(vue@3.5.34(typescript@5.9.3)):
|
||||
@ -20829,6 +21274,11 @@ snapshots:
|
||||
'@vue/devtools-api': 6.6.4
|
||||
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)):
|
||||
dependencies:
|
||||
vue: 3.5.35(typescript@5.9.3)
|
||||
@ -20888,6 +21338,8 @@ snapshots:
|
||||
|
||||
wheel-gestures@2.2.48: {}
|
||||
|
||||
which-module@2.0.1: {}
|
||||
|
||||
which-typed-array@1.1.20:
|
||||
dependencies:
|
||||
available-typed-arrays: 1.0.7
|
||||
@ -20919,6 +21371,12 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
@ -20990,6 +21448,8 @@ snapshots:
|
||||
lib0: 0.2.117
|
||||
yjs: 13.6.30
|
||||
|
||||
y18n@4.0.3: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
yallist@3.1.1: {}
|
||||
@ -21003,10 +21463,29 @@ snapshots:
|
||||
|
||||
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@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:
|
||||
dependencies:
|
||||
cliui: 8.0.1
|
||||
|
||||