Compare commits

...

10 Commits

Author SHA1 Message Date
chahinebrini
709f8cb32e chore: remove accidentally added embedded repo
Some checks failed
Deploy Staging / Build backend (Nitro) (push) Has been cancelled
Deploy Staging / Deploy zu Hetzner (push) Has been cancelled
2026-06-18 07:10:27 +02:00
chahinebrini
e6fad4f51e fix(magic): always show supervised + lock profile true/false; company from exists 2026-06-18 07:10:12 +02:00
chahinebrini
dd84f8ec38 fix(backend): mdm.get must use status.enrolled, not hardcoded true 2026-06-18 06:47:05 +02:00
chahinebrini
0efdf2f8f1 fix(magic): show complete protection button for known disenrolled devices 2026-06-18 06:28:04 +02:00
chahinebrini
e14a36f95a fix(backend): keep mdmId when device exists but is disenrolled; add exists flag 2026-06-18 06:28:03 +02:00
chahinebrini
bb8e0d3f62 fix(backend): mdm.get always returns full status shape even when not enrolled 2026-06-18 06:10:33 +02:00
chahinebrini
ac7bd800bc fix(magic): inline iPhone search in device card instead of detect page 2026-06-18 06:03:04 +02:00
chahinebrini
5117c7b37c fix(backend): getMdmStatusByUdid must check enrollments.enabled 2026-06-18 06:03:01 +02:00
chahinebrini
2919ce45b8 feat(magic): sync current ReBreak Magic app state
Include recent Magic app work: Tauri native shell, iOS device detection
via supervise-magic sidecar, MDM client, local HTTP server, new pages
(detect, enroll, supervise, sideload, pair, preflight, configure, done),
and updated device section/status UI.
2026-06-18 05:23:26 +02:00
chahinebrini
b9dddc00e7 ci: remove temporary test workflows 2026-06-18 05:17:33 +02:00
66 changed files with 306251 additions and 199869 deletions

View File

@ -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

View File

@ -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"

View File

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

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

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

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

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

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

View File

@ -58,8 +58,12 @@
v-if="!isConnected" v-if="!isConnected"
class="mt-3 text-sm text-gray-600 dark:text-gray-300 flex items-start gap-2" class="mt-3 text-sm text-gray-600 dark:text-gray-300 flex items-start gap-2"
> >
<UIcon name="i-heroicons-information-circle" class="w-5 h-5 text-[var(--rebreak-primary)] shrink-0 mt-0.5" /> <UIcon
<span>Verbinde dein iPhone mit USB, um den Schutz zu vervollständigen.</span> :name="isSearching ? 'i-heroicons-arrow-path' : 'i-heroicons-information-circle'"
:class="isSearching ? 'animate-spin' : ''"
class="w-5 h-5 text-[var(--rebreak-primary)] shrink-0 mt-0.5"
/>
<span>{{ isSearching ? "Suche nach verbundenem iPhone…" : "Verbinde dein iPhone mit USB, um den Schutz zu vervollständigen." }}</span>
</div> </div>
<!-- Backend-MDM always visible; local USB only when connected --> <!-- Backend-MDM always visible; local USB only when connected -->
@ -190,8 +194,8 @@
:variant="action.variant" :variant="action.variant"
size="sm" size="sm"
:icon="action.icon" :icon="action.icon"
:loading="manualSyncing || autoSyncing" :loading="manualSyncing || autoSyncing || isSearching"
:disabled="autoSyncing" :disabled="autoSyncing || isSearching"
@click="onActionClick" @click="onActionClick"
> >
{{ action.label }} {{ action.label }}
@ -222,6 +226,7 @@ const props = defineProps<{
device: ComputedDevice; device: ComputedDevice;
iphone: IphoneDeviceState | null; iphone: IphoneDeviceState | null;
isConnected: boolean; isConnected: boolean;
isSearching?: boolean;
inGracePeriod?: boolean; inGracePeriod?: boolean;
}>(); }>();
@ -232,6 +237,7 @@ const emit = defineEmits<{
(e: "sync", device: ComputedDevice): void; (e: "sync", device: ComputedDevice): void;
(e: "open", device: ComputedDevice): void; (e: "open", device: ComputedDevice): void;
(e: "remove", device: ComputedDevice): void; (e: "remove", device: ComputedDevice): void;
(e: "connect", device: ComputedDevice): void;
}>(); }>();
const ENROLLMENT_PROFILE_ID = "org.rebreak.mdm.enrollment"; const ENROLLMENT_PROFILE_ID = "org.rebreak.mdm.enrollment";
@ -264,12 +270,10 @@ const backendRows = computed(() => {
}, },
{ {
label: "Supervised", label: "Supervised",
value: data?.enrolled ? (data.supervised ? "Ja" : "Nein") : "—", value: data?.supervised ? "Ja" : "Nein",
valueClass: data?.enrolled valueClass: data?.supervised
? data.supervised ? "text-green-600 dark:text-green-400 font-medium"
? "text-green-600 dark:text-green-400 font-medium" : "text-red-600 dark:text-red-400 font-medium",
: "text-red-600 dark:text-red-400 font-medium"
: "text-gray-400 dark:text-gray-500",
}, },
{ {
label: "Organisation", label: "Organisation",
@ -280,16 +284,10 @@ const backendRows = computed(() => {
}, },
{ {
label: "Lock-Profil", label: "Lock-Profil",
value: data?.enrolled value: data?.lockProfileInstalled ? "Installiert" : "Fehlt",
? data.lockProfileInstalled valueClass: data?.lockProfileInstalled
? "Installiert" ? "text-green-600 dark:text-green-400 font-medium"
: "Fehlt" : "text-red-600 dark:text-red-400 font-medium",
: "—",
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",
}, },
{ {
label: "ReBreak App", label: "ReBreak App",
@ -472,13 +470,21 @@ const action = computed<IosAction>(() => {
}; };
} }
if (props.isSearching) {
return {
label: "iPhone suchen…",
icon: "i-heroicons-arrow-path",
color: "neutral",
variant: "soft",
};
}
if (!props.isConnected || !props.iphone) { if (!props.isConnected || !props.iphone) {
return { return {
label: "iPhone verbinden, um ReBreak Cloud zu synchronisieren", label: "iPhone verbinden, um ReBreak Cloud zu synchronisieren",
icon: "i-heroicons-link", icon: "i-heroicons-link",
color: "primary", color: "primary",
variant: "solid", variant: "solid",
to: "/detect",
}; };
} }
@ -505,12 +511,13 @@ const action = computed<IosAction>(() => {
} }
if (!backend?.enrolled || !localEnrollment.value) { if (!backend?.enrolled || !localEnrollment.value) {
const isKnownDevice = !!props.device.mdmId;
return { return {
label: "Enrollen", label: isKnownDevice ? "Schutz vervollständigen" : "Enrollen",
icon: "i-heroicons-document-check", icon: isKnownDevice ? "i-heroicons-shield-check" : "i-heroicons-document-check",
color: "primary", color: "primary",
variant: "solid", variant: "solid",
to: "/enroll", to: isKnownDevice ? "/preflight" : "/enroll",
}; };
} }
@ -615,6 +622,11 @@ function onActionClick() {
if (autoSyncing.value) return; if (autoSyncing.value) return;
if (!props.isConnected || !props.iphone) {
emit("connect", props.device);
return;
}
manualSyncing.value = true; manualSyncing.value = true;
emit("sync", props.device); emit("sync", props.device);
setTimeout(() => { setTimeout(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -36,7 +36,13 @@ export default defineEventHandler(async (event) => {
if (!device.mdmId) { if (!device.mdmId) {
return { return {
success: true, success: true,
data: { enrolled: false }, data: {
enrolled: false,
company: null,
supervised: false,
lockProfileInstalled: false,
lastAppPushAt: null,
},
}; };
} }
@ -52,12 +58,18 @@ export default defineEventHandler(async (event) => {
}); });
} }
// UDID stored but no longer present in NanoMDM → clear stale link. // UDID stored but the device is completely gone from NanoMDM → clear stale link.
if (!status.enrolled) { if (!status.exists) {
await clearUserDeviceMdmId(user.id, deviceId); await clearUserDeviceMdmId(user.id, deviceId);
return { return {
success: true, success: true,
data: { enrolled: false }, data: {
enrolled: false,
company: null,
supervised: false,
lockProfileInstalled: false,
lastAppPushAt: null,
},
}; };
} }
@ -72,8 +84,8 @@ export default defineEventHandler(async (event) => {
return { return {
success: true, success: true,
data: { data: {
enrolled: true, enrolled: status.enrolled,
company: "ReBreak", company: status.exists ? "ReBreak" : null,
supervised: status.supervised, supervised: status.supervised,
lockProfileInstalled: lockState?.active ?? false, lockProfileInstalled: lockState?.active ?? false,
lastAppPushAt: status.lastAppPushAt?.toISOString() ?? null, lastAppPushAt: status.lastAppPushAt?.toISOString() ?? null,

View File

@ -116,6 +116,7 @@ export async function getLinkedUserDevices(): Promise<
export interface MdmDeviceStatus { export interface MdmDeviceStatus {
enrolled: boolean; enrolled: boolean;
exists: boolean;
company: string | null; company: string | null;
supervised: boolean; supervised: boolean;
tokenUpdateAt: Date | null; tokenUpdateAt: Date | null;
@ -157,10 +158,12 @@ export async function getMdmStatusByUdid(
token_update_at: Date | null; token_update_at: Date | null;
last_ack: Date | null; last_ack: Date | null;
last_app_push_at: Date | null; last_app_push_at: Date | null;
enrolled: boolean;
}>( }>(
`SELECT `SELECT
d.unlock_token, d.unlock_token,
d.token_update_at, d.token_update_at,
COALESCE(e.enabled = TRUE, FALSE) AS enrolled,
(SELECT max(updated_at) FROM command_results WHERE id = d.id) AS last_ack, (SELECT max(updated_at) FROM command_results WHERE id = d.id) AS last_ack,
(SELECT max(r.updated_at) (SELECT max(r.updated_at)
FROM command_results r FROM command_results r
@ -169,17 +172,20 @@ export async function getMdmStatusByUdid(
AND c.request_type = 'InstallApplication' AND c.request_type = 'InstallApplication'
AND r.status = 'Acknowledged') AS last_app_push_at AND r.status = 'Acknowledged') AS last_app_push_at
FROM devices d FROM devices d
LEFT JOIN enrollments e ON e.device_id = d.id
WHERE d.id = $1`, WHERE d.id = $1`,
[udid], [udid],
); );
const row = result.rows[0]; const row = result.rows[0];
const enrolled = !!row; const exists = row !== undefined;
const enrolled = row?.enrolled ?? false;
return { return {
enrolled, enrolled,
exists,
company: enrolled ? "ReBreak" : null, company: enrolled ? "ReBreak" : null,
supervised: enrolled && row?.unlock_token != null, supervised: exists && row?.unlock_token != null,
tokenUpdateAt: row?.token_update_at ?? null, tokenUpdateAt: row?.token_update_at ?? null,
lastAckAt: row?.last_ack ?? null, lastAckAt: row?.last_ack ?? null,
lastAppPushAt: row?.last_app_push_at ?? null, lastAppPushAt: row?.last_app_push_at ?? null,

View File

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

View File

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

View 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 35 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`

View File

@ -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.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

511
pnpm-lock.yaml generated
View File

@ -36,7 +36,7 @@ importers:
version: 1.15.0(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3)) version: 1.15.0(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))
'@nuxt/ui': '@nuxt/ui':
specifier: ^4.5.1 specifier: ^4.5.1
version: 4.7.1(@internationalized/date@3.12.1)(@internationalized/number@3.6.6)(@tiptap/extensions@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.2.4)(typescript@5.9.3)(valibot@1.4.1(typescript@5.9.3))(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue-router@4.6.4(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3))(yjs@13.6.30)(zod@3.25.76) version: 4.7.1(@internationalized/date@3.12.1)(@internationalized/number@3.6.6)(@tiptap/extensions@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.3)(qrcode@1.5.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.2.4)(typescript@5.9.3)(valibot@1.4.1(typescript@5.9.3))(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue-router@4.6.4(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3))(yjs@13.6.30)(zod@3.25.76)
'@nuxtjs/supabase': '@nuxtjs/supabase':
specifier: ^2.0.4 specifier: ^2.0.4
version: 2.0.6 version: 2.0.6
@ -45,7 +45,7 @@ importers:
version: 14.3.0(vue@3.5.34(typescript@5.9.3)) version: 14.3.0(vue@3.5.34(typescript@5.9.3))
'@vueuse/nuxt': '@vueuse/nuxt':
specifier: ^14.2.1 specifier: ^14.2.1
version: 14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3)) version: 14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))
nuxt: nuxt:
specifier: 4.1.3 specifier: 4.1.3
version: 4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4) version: 4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4)
@ -76,7 +76,7 @@ importers:
version: 1.2.3 version: 1.2.3
'@nuxt/fonts': '@nuxt/fonts':
specifier: ^0.11.4 specifier: ^0.11.4
version: 0.11.4(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4)) version: 0.11.4(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.3)
'@nuxt/icon': '@nuxt/icon':
specifier: ^1.10.0 specifier: ^1.10.0
version: 1.15.0(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3)) version: 1.15.0(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))
@ -85,7 +85,7 @@ importers:
version: 1.11.0(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.3) version: 1.11.0(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.3)
'@nuxt/ui': '@nuxt/ui':
specifier: ^4.5.1 specifier: ^4.5.1
version: 4.7.1(@internationalized/date@3.12.1)(@internationalized/number@3.6.6)(@tiptap/extensions@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.2.4)(typescript@5.9.3)(valibot@1.4.1(typescript@5.9.3))(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue-router@4.6.4(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3))(yjs@13.6.30)(zod@3.25.76) version: 4.7.1(@internationalized/date@3.12.1)(@internationalized/number@3.6.6)(@tiptap/extensions@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.3)(qrcode@1.5.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.2.4)(typescript@5.9.3)(valibot@1.4.1(typescript@5.9.3))(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue-router@4.6.4(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3))(yjs@13.6.30)(zod@3.25.76)
'@nuxtjs/i18n': '@nuxtjs/i18n':
specifier: ^9.5.6 specifier: ^9.5.6
version: 9.5.6(@vue/compiler-dom@3.5.35)(eslint@10.3.0(jiti@2.7.0))(magicast@0.5.3)(rollup@4.60.3)(vue@3.5.34(typescript@5.9.3)) version: 9.5.6(@vue/compiler-dom@3.5.35)(eslint@10.3.0(jiti@2.7.0))(magicast@0.5.3)(rollup@4.60.3)(vue@3.5.34(typescript@5.9.3))
@ -94,7 +94,7 @@ importers:
version: 3.0.3(magicast@0.5.3)(vue@3.5.34(typescript@5.9.3)) version: 3.0.3(magicast@0.5.3)(vue@3.5.34(typescript@5.9.3))
'@vueuse/nuxt': '@vueuse/nuxt':
specifier: ^14.2.1 specifier: ^14.2.1
version: 14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3)) version: 14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))
chart.js: chart.js:
specifier: ^4.5.1 specifier: ^4.5.1
version: 4.5.1 version: 4.5.1
@ -124,6 +124,52 @@ importers:
specifier: ^5.9.3 specifier: ^5.9.3
version: 5.9.3 version: 5.9.3
apps/rebreak-magic:
dependencies:
'@nuxt/icon':
specifier: ^1.10.0
version: 1.15.0(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.35(typescript@5.9.3))
'@nuxt/ui':
specifier: ^4.5.1
version: 4.7.1(@internationalized/date@3.12.1)(@internationalized/number@3.6.6)(@tiptap/extensions@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.3)(qrcode@1.5.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.2.4)(typescript@5.9.3)(valibot@1.4.1(typescript@5.9.3))(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue-router@4.6.4(vue@3.5.35(typescript@5.9.3)))(vue@3.5.35(typescript@5.9.3))(yjs@13.6.30)(zod@3.25.76)
'@vueuse/core':
specifier: ^14.2.1
version: 14.3.0(vue@3.5.35(typescript@5.9.3))
'@vueuse/nuxt':
specifier: ^14.2.1
version: 14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4))(vue@3.5.35(typescript@5.9.3))
nuxt:
specifier: 4.1.3
version: 4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4)
qrcode:
specifier: ^1.5.4
version: 1.5.4
tailwindcss:
specifier: ^4.1.18
version: 4.2.4
vue:
specifier: ^3.5.22
version: 3.5.35(typescript@5.9.3)
vue-router:
specifier: ^4.5.1
version: 4.6.4(vue@3.5.35(typescript@5.9.3))
devDependencies:
'@iconify-json/heroicons':
specifier: ^1.2.3
version: 1.2.3
'@tauri-apps/api':
specifier: ^2.0.0
version: 2.11.0
'@tauri-apps/cli':
specifier: ^2.0.0
version: 2.11.2
'@types/qrcode':
specifier: ^1.5.5
version: 1.5.6
typescript:
specifier: ^5.9.3
version: 5.9.3
apps/rebreak-magic-win: apps/rebreak-magic-win:
dependencies: dependencies:
'@fontsource/nunito': '@fontsource/nunito':
@ -4054,6 +4100,9 @@ packages:
'@types/prop-types@15.7.15': '@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/react-dom@18.3.7': '@types/react-dom@18.3.7':
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
peerDependencies: peerDependencies:
@ -4959,6 +5008,9 @@ packages:
client-only@0.0.1: client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
cliui@8.0.1: cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -5246,6 +5298,10 @@ packages:
supports-color: supports-color:
optional: true optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decode-uri-component@0.2.2: decode-uri-component@0.2.2:
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
engines: {node: '>=0.10'} engines: {node: '>=0.10'}
@ -5359,6 +5415,9 @@ packages:
resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==}
engines: {node: '>=0.3.1'} engines: {node: '>=0.3.1'}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dlv@1.1.3: dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
@ -7705,6 +7764,10 @@ packages:
resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==}
engines: {node: '>=4.0.0'} engines: {node: '>=4.0.0'}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
popmotion@11.0.5: popmotion@11.0.5:
resolution: {integrity: sha512-la8gPM1WYeFznb/JqF4GiTkRRPZsfaj2+kCxqQgr2MJylMmIKUwBfWW8Wa5fml/8gmtlD5yI01MP1QCZPWmppA==} resolution: {integrity: sha512-la8gPM1WYeFznb/JqF4GiTkRRPZsfaj2+kCxqQgr2MJylMmIKUwBfWW8Wa5fml/8gmtlD5yI01MP1QCZPWmppA==}
@ -8091,6 +8154,11 @@ packages:
resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==} resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==}
hasBin: true hasBin: true
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
qs@6.15.1: qs@6.15.1:
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
engines: {node: '>=0.6'} engines: {node: '>=0.6'}
@ -8433,6 +8501,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
requireg@0.2.2: requireg@0.2.2:
resolution: {integrity: sha512-nYzyjnFcPNGR3lx9lwPPPnuQxv6JWEZd2Ci0u9opN7N5zUEPIhY/GbL3vMGOr2UXwEg9WwSyV9X9Y/kLFgPsOg==} resolution: {integrity: sha512-nYzyjnFcPNGR3lx9lwPPPnuQxv6JWEZd2Ci0u9opN7N5zUEPIhY/GbL3vMGOr2UXwEg9WwSyV9X9Y/kLFgPsOg==}
engines: {node: '>= 4.0.0'} engines: {node: '>= 4.0.0'}
@ -8631,6 +8702,9 @@ packages:
server-only@0.0.1: server-only@0.0.1:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
set-function-length@1.2.2: set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -9797,6 +9871,9 @@ packages:
resolution: {integrity: sha512-f+Gy33Oa5Z14XY9679Zze+7VFhbsQfBFXodnU2x589l4kxGM9L5Y8zETTmcMR5pWOPQyRv4Z0lNax6xCO0NSlA==} resolution: {integrity: sha512-f+Gy33Oa5Z14XY9679Zze+7VFhbsQfBFXodnU2x589l4kxGM9L5Y8zETTmcMR5pWOPQyRv4Z0lNax6xCO0NSlA==}
engines: {node: '>=18'} engines: {node: '>=18'}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which-typed-array@1.1.20: which-typed-array@1.1.20:
resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -9828,6 +9905,10 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrap-ansi@7.0.0: wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -9933,6 +10014,9 @@ packages:
peerDependencies: peerDependencies:
yjs: ^13.0.0 yjs: ^13.0.0
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
y18n@5.0.8: y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -9953,6 +10037,10 @@ packages:
engines: {node: '>= 14.6'} engines: {node: '>= 14.6'}
hasBin: true hasBin: true
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs-parser@21.1.1: yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -9961,6 +10049,10 @@ packages:
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23} engines: {node: ^20.19.0 || ^22.12.0 || >=23}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yargs@17.7.2: yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -11414,6 +11506,15 @@ snapshots:
- '@vue/composition-api' - '@vue/composition-api'
- vue - vue
'@floating-ui/vue@1.1.11(vue@3.5.35(typescript@5.9.3))':
dependencies:
'@floating-ui/dom': 1.7.6
'@floating-ui/utils': 0.2.11
vue-demi: 0.14.10(vue@3.5.35(typescript@5.9.3))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@fontsource/nunito@5.2.7': {} '@fontsource/nunito@5.2.7': {}
'@hono/node-server@1.19.11(hono@4.12.17)': '@hono/node-server@1.19.11(hono@4.12.17)':
@ -11474,6 +11575,11 @@ snapshots:
'@iconify/types': 2.0.0 '@iconify/types': 2.0.0
vue: 3.5.34(typescript@5.9.3) vue: 3.5.34(typescript@5.9.3)
'@iconify/vue@5.0.1(vue@3.5.35(typescript@5.9.3))':
dependencies:
'@iconify/types': 2.0.0
vue: 3.5.35(typescript@5.9.3)
'@ide/backoff@1.0.0': {} '@ide/backoff@1.0.0': {}
'@internationalized/date@3.12.1': '@internationalized/date@3.12.1':
@ -11918,7 +12024,7 @@ snapshots:
- utf-8-validate - utf-8-validate
- vue - vue
'@nuxt/fonts@0.11.4(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))': '@nuxt/fonts@0.11.4(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.3)':
dependencies: dependencies:
'@nuxt/devtools-kit': 2.7.0(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4)) '@nuxt/devtools-kit': 2.7.0(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))
'@nuxt/kit': 3.21.4(magicast@0.5.3) '@nuxt/kit': 3.21.4(magicast@0.5.3)
@ -12026,6 +12132,28 @@ snapshots:
- vite - vite
- vue - vue
'@nuxt/icon@1.15.0(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.35(typescript@5.9.3))':
dependencies:
'@iconify/collections': 1.0.680
'@iconify/types': 2.0.0
'@iconify/utils': 2.3.0
'@iconify/vue': 5.0.1(vue@3.5.35(typescript@5.9.3))
'@nuxt/devtools-kit': 2.7.0(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))
'@nuxt/kit': 3.21.4(magicast@0.5.3)
consola: 3.4.2
local-pkg: 1.1.2
mlly: 1.8.2
ohash: 2.0.11
pathe: 2.0.3
picomatch: 4.0.4
std-env: 3.10.0
tinyglobby: 0.2.16
transitivePeerDependencies:
- magicast
- supports-color
- vite
- vue
'@nuxt/icon@2.2.2(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))': '@nuxt/icon@2.2.2(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))':
dependencies: dependencies:
'@iconify/collections': 1.0.680 '@iconify/collections': 1.0.680
@ -12047,6 +12175,27 @@ snapshots:
- vite - vite
- vue - vue
'@nuxt/icon@2.2.2(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.35(typescript@5.9.3))':
dependencies:
'@iconify/collections': 1.0.680
'@iconify/types': 2.0.0
'@iconify/utils': 3.1.3
'@iconify/vue': 5.0.1(vue@3.5.35(typescript@5.9.3))
'@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))
'@nuxt/kit': 4.4.4(magicast@0.5.3)
consola: 3.4.2
local-pkg: 1.1.2
mlly: 1.8.2
ohash: 2.0.11
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.1.0
tinyglobby: 0.2.16
transitivePeerDependencies:
- magicast
- vite
- vue
'@nuxt/image@1.11.0(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.3)': '@nuxt/image@1.11.0(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.3)':
dependencies: dependencies:
'@nuxt/kit': 3.21.4(magicast@0.5.3) '@nuxt/kit': 3.21.4(magicast@0.5.3)
@ -12242,7 +12391,7 @@ snapshots:
rc9: 3.0.1 rc9: 3.0.1
std-env: 4.1.0 std-env: 4.1.0
'@nuxt/ui@4.7.1(@internationalized/date@3.12.1)(@internationalized/number@3.6.6)(@tiptap/extensions@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.2.4)(typescript@5.9.3)(valibot@1.4.1(typescript@5.9.3))(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue-router@4.6.4(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3))(yjs@13.6.30)(zod@3.25.76)': '@nuxt/ui@4.7.1(@internationalized/date@3.12.1)(@internationalized/number@3.6.6)(@tiptap/extensions@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.3)(qrcode@1.5.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.2.4)(typescript@5.9.3)(valibot@1.4.1(typescript@5.9.3))(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue-router@4.6.4(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3))(yjs@13.6.30)(zod@3.25.76)':
dependencies: dependencies:
'@floating-ui/dom': 1.7.6 '@floating-ui/dom': 1.7.6
'@iconify/vue': 5.0.1(vue@3.5.34(typescript@5.9.3)) '@iconify/vue': 5.0.1(vue@3.5.34(typescript@5.9.3))
@ -12275,7 +12424,7 @@ snapshots:
'@tiptap/vue-3': 3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(vue@3.5.34(typescript@5.9.3)) '@tiptap/vue-3': 3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(vue@3.5.34(typescript@5.9.3))
'@unhead/vue': 2.1.13(vue@3.5.34(typescript@5.9.3)) '@unhead/vue': 2.1.13(vue@3.5.34(typescript@5.9.3))
'@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3)) '@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3))
'@vueuse/integrations': 14.3.0(fuse.js@7.3.0)(vue@3.5.34(typescript@5.9.3)) '@vueuse/integrations': 14.3.0(fuse.js@7.3.0)(qrcode@1.5.4)(vue@3.5.34(typescript@5.9.3))
'@vueuse/shared': 14.3.0(vue@3.5.34(typescript@5.9.3)) '@vueuse/shared': 14.3.0(vue@3.5.34(typescript@5.9.3))
colortranslator: 5.0.0 colortranslator: 5.0.0
consola: 3.4.2 consola: 3.4.2
@ -12356,6 +12505,120 @@ snapshots:
- vue - vue
- yjs - yjs
'@nuxt/ui@4.7.1(@internationalized/date@3.12.1)(@internationalized/number@3.6.6)(@tiptap/extensions@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.3)(qrcode@1.5.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.2.4)(typescript@5.9.3)(valibot@1.4.1(typescript@5.9.3))(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue-router@4.6.4(vue@3.5.35(typescript@5.9.3)))(vue@3.5.35(typescript@5.9.3))(yjs@13.6.30)(zod@3.25.76)':
dependencies:
'@floating-ui/dom': 1.7.6
'@iconify/vue': 5.0.1(vue@3.5.35(typescript@5.9.3))
'@nuxt/fonts': 0.14.0(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))
'@nuxt/icon': 2.2.2(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.35(typescript@5.9.3))
'@nuxt/kit': 4.4.4(magicast@0.5.3)
'@nuxt/schema': 4.4.4
'@nuxtjs/color-mode': 3.5.2(magicast@0.5.3)
'@standard-schema/spec': 1.1.0
'@tailwindcss/postcss': 4.2.4
'@tailwindcss/vite': 4.2.4(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))
'@tanstack/vue-table': 8.21.3(vue@3.5.35(typescript@5.9.3))
'@tanstack/vue-virtual': 3.13.24(vue@3.5.35(typescript@5.9.3))
'@tiptap/core': 3.23.1(@tiptap/pm@3.23.1)
'@tiptap/extension-bubble-menu': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)
'@tiptap/extension-code': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))
'@tiptap/extension-collaboration': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30)
'@tiptap/extension-drag-handle': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/extension-collaboration@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30))(@tiptap/extension-node-range@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))
'@tiptap/extension-drag-handle-vue-3': 3.23.1(@tiptap/extension-drag-handle@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/extension-collaboration@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30))(@tiptap/extension-node-range@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30)))(@tiptap/pm@3.23.1)(@tiptap/vue-3@3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(vue@3.5.35(typescript@5.9.3)))(vue@3.5.35(typescript@5.9.3))
'@tiptap/extension-floating-menu': 3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)
'@tiptap/extension-horizontal-rule': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)
'@tiptap/extension-image': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))
'@tiptap/extension-mention': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/suggestion@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))
'@tiptap/extension-node-range': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)
'@tiptap/extension-placeholder': 3.23.1(@tiptap/extensions@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))
'@tiptap/markdown': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)
'@tiptap/pm': 3.23.1
'@tiptap/starter-kit': 3.23.1
'@tiptap/suggestion': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)
'@tiptap/vue-3': 3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(vue@3.5.35(typescript@5.9.3))
'@unhead/vue': 2.1.13(vue@3.5.35(typescript@5.9.3))
'@vueuse/core': 14.3.0(vue@3.5.35(typescript@5.9.3))
'@vueuse/integrations': 14.3.0(fuse.js@7.3.0)(qrcode@1.5.4)(vue@3.5.35(typescript@5.9.3))
'@vueuse/shared': 14.3.0(vue@3.5.35(typescript@5.9.3))
colortranslator: 5.0.0
consola: 3.4.2
defu: 6.1.7
embla-carousel-auto-height: 8.6.0(embla-carousel@8.6.0)
embla-carousel-auto-scroll: 8.6.0(embla-carousel@8.6.0)
embla-carousel-autoplay: 8.6.0(embla-carousel@8.6.0)
embla-carousel-class-names: 8.6.0(embla-carousel@8.6.0)
embla-carousel-fade: 8.6.0(embla-carousel@8.6.0)
embla-carousel-vue: 8.6.0(vue@3.5.35(typescript@5.9.3))
embla-carousel-wheel-gestures: 8.1.0(embla-carousel@8.6.0)
fuse.js: 7.3.0
hookable: 6.1.1
knitwork: 1.3.0
magic-string: 0.30.21
mlly: 1.8.2
motion-v: 2.2.1(@vueuse/core@14.3.0(vue@3.5.35(typescript@5.9.3)))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vue@3.5.35(typescript@5.9.3))
ohash: 2.0.11
pathe: 2.0.3
reka-ui: 2.9.6(vue@3.5.35(typescript@5.9.3))
scule: 1.3.0
tailwind-merge: 3.5.0
tailwind-variants: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.4)
tailwindcss: 4.2.4
tinyglobby: 0.2.16
typescript: 5.9.3
ufo: 1.6.4
unplugin: 3.0.0
unplugin-auto-import: 21.0.0(@nuxt/kit@4.4.4(magicast@0.5.3))(@vueuse/core@14.3.0(vue@3.5.35(typescript@5.9.3)))
unplugin-vue-components: 32.0.0(@nuxt/kit@4.4.4(magicast@0.5.3))(vue@3.5.35(typescript@5.9.3))
vaul-vue: 0.4.1(reka-ui@2.9.6(vue@3.5.35(typescript@5.9.3)))(vue@3.5.35(typescript@5.9.3))
vue-component-type-helpers: 3.2.8
optionalDependencies:
'@internationalized/date': 3.12.1
'@internationalized/number': 3.6.6
valibot: 1.4.1(typescript@5.9.3)
vue-router: 4.6.4(vue@3.5.35(typescript@5.9.3))
zod: 3.25.76
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
- '@azure/data-tables'
- '@azure/identity'
- '@azure/keyvault-secrets'
- '@azure/storage-blob'
- '@capacitor/preferences'
- '@deno/kv'
- '@emotion/is-prop-valid'
- '@netlify/blobs'
- '@planetscale/database'
- '@tiptap/extensions'
- '@tiptap/y-tiptap'
- '@upstash/redis'
- '@vercel/blob'
- '@vercel/functions'
- '@vercel/kv'
- '@vue/composition-api'
- async-validator
- aws4fetch
- axios
- change-case
- db0
- drauu
- embla-carousel
- focus-trap
- idb-keyval
- ioredis
- jwt-decode
- magicast
- nprogress
- qrcode
- react
- react-dom
- sortablejs
- universal-cookie
- uploadthing
- vite
- vue
- yjs
'@nuxt/vite-builder@4.1.3(@types/node@22.19.17)(eslint@10.3.0(jiti@2.7.0))(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))(yaml@2.8.4)': '@nuxt/vite-builder@4.1.3(@types/node@22.19.17)(eslint@10.3.0(jiti@2.7.0))(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))(yaml@2.8.4)':
dependencies: dependencies:
'@nuxt/kit': 4.1.3(magicast@0.5.3) '@nuxt/kit': 4.1.3(magicast@0.5.3)
@ -13679,11 +13942,21 @@ snapshots:
'@tanstack/table-core': 8.21.3 '@tanstack/table-core': 8.21.3
vue: 3.5.34(typescript@5.9.3) vue: 3.5.34(typescript@5.9.3)
'@tanstack/vue-table@8.21.3(vue@3.5.35(typescript@5.9.3))':
dependencies:
'@tanstack/table-core': 8.21.3
vue: 3.5.35(typescript@5.9.3)
'@tanstack/vue-virtual@3.13.24(vue@3.5.34(typescript@5.9.3))': '@tanstack/vue-virtual@3.13.24(vue@3.5.34(typescript@5.9.3))':
dependencies: dependencies:
'@tanstack/virtual-core': 3.14.0 '@tanstack/virtual-core': 3.14.0
vue: 3.5.34(typescript@5.9.3) vue: 3.5.34(typescript@5.9.3)
'@tanstack/vue-virtual@3.13.24(vue@3.5.35(typescript@5.9.3))':
dependencies:
'@tanstack/virtual-core': 3.14.0
vue: 3.5.35(typescript@5.9.3)
'@tauri-apps/api@2.11.0': {} '@tauri-apps/api@2.11.0': {}
'@tauri-apps/cli-darwin-arm64@2.11.2': '@tauri-apps/cli-darwin-arm64@2.11.2':
@ -13782,6 +14055,13 @@ snapshots:
'@tiptap/vue-3': 3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(vue@3.5.34(typescript@5.9.3)) '@tiptap/vue-3': 3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(vue@3.5.34(typescript@5.9.3))
vue: 3.5.34(typescript@5.9.3) vue: 3.5.34(typescript@5.9.3)
'@tiptap/extension-drag-handle-vue-3@3.23.1(@tiptap/extension-drag-handle@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/extension-collaboration@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30))(@tiptap/extension-node-range@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30)))(@tiptap/pm@3.23.1)(@tiptap/vue-3@3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(vue@3.5.35(typescript@5.9.3)))(vue@3.5.35(typescript@5.9.3))':
dependencies:
'@tiptap/extension-drag-handle': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/extension-collaboration@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30))(@tiptap/extension-node-range@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))
'@tiptap/pm': 3.23.1
'@tiptap/vue-3': 3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(vue@3.5.35(typescript@5.9.3))
vue: 3.5.35(typescript@5.9.3)
'@tiptap/extension-drag-handle@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/extension-collaboration@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30))(@tiptap/extension-node-range@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))': '@tiptap/extension-drag-handle@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/extension-collaboration@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30))(@tiptap/extension-node-range@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))':
dependencies: dependencies:
'@floating-ui/dom': 1.7.6 '@floating-ui/dom': 1.7.6
@ -13948,6 +14228,16 @@ snapshots:
'@tiptap/extension-bubble-menu': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1) '@tiptap/extension-bubble-menu': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)
'@tiptap/extension-floating-menu': 3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1) '@tiptap/extension-floating-menu': 3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)
'@tiptap/vue-3@3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)(vue@3.5.35(typescript@5.9.3))':
dependencies:
'@floating-ui/dom': 1.7.6
'@tiptap/core': 3.23.1(@tiptap/pm@3.23.1)
'@tiptap/pm': 3.23.1
vue: 3.5.35(typescript@5.9.3)
optionalDependencies:
'@tiptap/extension-bubble-menu': 3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)
'@tiptap/extension-floating-menu': 3.23.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1)
'@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30)': '@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30)':
dependencies: dependencies:
lib0: 0.2.117 lib0: 0.2.117
@ -14031,6 +14321,10 @@ snapshots:
'@types/prop-types@15.7.15': {} '@types/prop-types@15.7.15': {}
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 22.19.17
'@types/react-dom@18.3.7(@types/react@18.3.31)': '@types/react-dom@18.3.7(@types/react@18.3.31)':
dependencies: dependencies:
'@types/react': 18.3.31 '@types/react': 18.3.31
@ -14115,6 +14409,12 @@ snapshots:
unhead: 2.1.13 unhead: 2.1.13
vue: 3.5.34(typescript@5.9.3) vue: 3.5.34(typescript@5.9.3)
'@unhead/vue@2.1.13(vue@3.5.35(typescript@5.9.3))':
dependencies:
hookable: 6.1.1
unhead: 2.1.13
vue: 3.5.35(typescript@5.9.3)
'@urql/core@5.2.0': '@urql/core@5.2.0':
dependencies: dependencies:
'@0no-co/graphql.web': 1.2.0 '@0no-co/graphql.web': 1.2.0
@ -14359,7 +14659,7 @@ snapshots:
'@vue-macros/common@1.16.1(vue@3.5.34(typescript@5.9.3))': '@vue-macros/common@1.16.1(vue@3.5.34(typescript@5.9.3))':
dependencies: dependencies:
'@vue/compiler-sfc': 3.5.34 '@vue/compiler-sfc': 3.5.35
ast-kit: 1.4.3 ast-kit: 1.4.3
local-pkg: 1.1.2 local-pkg: 1.1.2
magic-string-ast: 0.7.1 magic-string-ast: 0.7.1
@ -14370,7 +14670,7 @@ snapshots:
'@vue-macros/common@3.0.0-beta.16(vue@3.5.34(typescript@5.9.3))': '@vue-macros/common@3.0.0-beta.16(vue@3.5.34(typescript@5.9.3))':
dependencies: dependencies:
'@vue/compiler-sfc': 3.5.34 '@vue/compiler-sfc': 3.5.35
ast-kit: 2.2.0 ast-kit: 2.2.0
local-pkg: 1.1.2 local-pkg: 1.1.2
magic-string-ast: 1.0.3 magic-string-ast: 1.0.3
@ -14390,7 +14690,7 @@ snapshots:
'@babel/types': 7.29.0 '@babel/types': 7.29.0
'@vue/babel-helper-vue-transform-on': 2.0.1 '@vue/babel-helper-vue-transform-on': 2.0.1
'@vue/babel-plugin-resolve-type': 2.0.1(@babel/core@7.29.0) '@vue/babel-plugin-resolve-type': 2.0.1(@babel/core@7.29.0)
'@vue/shared': 3.5.34 '@vue/shared': 3.5.35
optionalDependencies: optionalDependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
transitivePeerDependencies: transitivePeerDependencies:
@ -14403,7 +14703,7 @@ snapshots:
'@babel/helper-module-imports': 7.28.6 '@babel/helper-module-imports': 7.28.6
'@babel/helper-plugin-utils': 7.28.6 '@babel/helper-plugin-utils': 7.28.6
'@babel/parser': 7.29.3 '@babel/parser': 7.29.3
'@vue/compiler-sfc': 3.5.34 '@vue/compiler-sfc': 3.5.35
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@ -14513,8 +14813,8 @@ snapshots:
'@vue/language-core@3.2.8': '@vue/language-core@3.2.8':
dependencies: dependencies:
'@volar/language-core': 2.4.28 '@volar/language-core': 2.4.28
'@vue/compiler-dom': 3.5.34 '@vue/compiler-dom': 3.5.35
'@vue/shared': 3.5.34 '@vue/shared': 3.5.35
alien-signals: 3.1.2 alien-signals: 3.1.2
muggle-string: 0.4.1 muggle-string: 0.4.1
path-browserify: 1.0.1 path-browserify: 1.0.1
@ -14578,6 +14878,16 @@ snapshots:
- '@vue/composition-api' - '@vue/composition-api'
- vue - vue
'@vueuse/core@10.11.1(vue@3.5.35(typescript@5.9.3))':
dependencies:
'@types/web-bluetooth': 0.0.20
'@vueuse/metadata': 10.11.1
'@vueuse/shared': 10.11.1(vue@3.5.35(typescript@5.9.3))
vue-demi: 0.14.10(vue@3.5.35(typescript@5.9.3))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@vueuse/core@13.9.0(vue@3.5.34(typescript@5.9.3))': '@vueuse/core@13.9.0(vue@3.5.34(typescript@5.9.3))':
dependencies: dependencies:
'@types/web-bluetooth': 0.0.21 '@types/web-bluetooth': 0.0.21
@ -14592,13 +14902,30 @@ snapshots:
'@vueuse/shared': 14.3.0(vue@3.5.34(typescript@5.9.3)) '@vueuse/shared': 14.3.0(vue@3.5.34(typescript@5.9.3))
vue: 3.5.34(typescript@5.9.3) vue: 3.5.34(typescript@5.9.3)
'@vueuse/integrations@14.3.0(fuse.js@7.3.0)(vue@3.5.34(typescript@5.9.3))': '@vueuse/core@14.3.0(vue@3.5.35(typescript@5.9.3))':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 14.3.0
'@vueuse/shared': 14.3.0(vue@3.5.35(typescript@5.9.3))
vue: 3.5.35(typescript@5.9.3)
'@vueuse/integrations@14.3.0(fuse.js@7.3.0)(qrcode@1.5.4)(vue@3.5.34(typescript@5.9.3))':
dependencies: dependencies:
'@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3)) '@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3))
'@vueuse/shared': 14.3.0(vue@3.5.34(typescript@5.9.3)) '@vueuse/shared': 14.3.0(vue@3.5.34(typescript@5.9.3))
vue: 3.5.34(typescript@5.9.3) vue: 3.5.34(typescript@5.9.3)
optionalDependencies: optionalDependencies:
fuse.js: 7.3.0 fuse.js: 7.3.0
qrcode: 1.5.4
'@vueuse/integrations@14.3.0(fuse.js@7.3.0)(qrcode@1.5.4)(vue@3.5.35(typescript@5.9.3))':
dependencies:
'@vueuse/core': 14.3.0(vue@3.5.35(typescript@5.9.3))
'@vueuse/shared': 14.3.0(vue@3.5.35(typescript@5.9.3))
vue: 3.5.35(typescript@5.9.3)
optionalDependencies:
fuse.js: 7.3.0
qrcode: 1.5.4
'@vueuse/metadata@10.11.1': {} '@vueuse/metadata@10.11.1': {}
@ -14620,7 +14947,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- magicast - magicast
'@vueuse/nuxt@14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))': '@vueuse/nuxt@14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))':
dependencies: dependencies:
'@nuxt/kit': 4.4.4(magicast@0.5.3) '@nuxt/kit': 4.4.4(magicast@0.5.3)
'@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3)) '@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3))
@ -14631,6 +14958,17 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- magicast - magicast
'@vueuse/nuxt@14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4))(vue@3.5.35(typescript@5.9.3))':
dependencies:
'@nuxt/kit': 4.4.4(magicast@0.5.3)
'@vueuse/core': 14.3.0(vue@3.5.35(typescript@5.9.3))
'@vueuse/metadata': 14.3.0
local-pkg: 1.1.2
nuxt: 4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4)
vue: 3.5.35(typescript@5.9.3)
transitivePeerDependencies:
- magicast
'@vueuse/shared@10.11.1(vue@3.5.34(typescript@5.9.3))': '@vueuse/shared@10.11.1(vue@3.5.34(typescript@5.9.3))':
dependencies: dependencies:
vue-demi: 0.14.10(vue@3.5.34(typescript@5.9.3)) vue-demi: 0.14.10(vue@3.5.34(typescript@5.9.3))
@ -14638,6 +14976,13 @@ snapshots:
- '@vue/composition-api' - '@vue/composition-api'
- vue - vue
'@vueuse/shared@10.11.1(vue@3.5.35(typescript@5.9.3))':
dependencies:
vue-demi: 0.14.10(vue@3.5.35(typescript@5.9.3))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@vueuse/shared@13.9.0(vue@3.5.34(typescript@5.9.3))': '@vueuse/shared@13.9.0(vue@3.5.34(typescript@5.9.3))':
dependencies: dependencies:
vue: 3.5.34(typescript@5.9.3) vue: 3.5.34(typescript@5.9.3)
@ -14646,6 +14991,10 @@ snapshots:
dependencies: dependencies:
vue: 3.5.34(typescript@5.9.3) vue: 3.5.34(typescript@5.9.3)
'@vueuse/shared@14.3.0(vue@3.5.35(typescript@5.9.3))':
dependencies:
vue: 3.5.35(typescript@5.9.3)
'@xmldom/xmldom@0.8.13': {} '@xmldom/xmldom@0.8.13': {}
'@xmldom/xmldom@0.9.10': {} '@xmldom/xmldom@0.9.10': {}
@ -15273,6 +15622,12 @@ snapshots:
client-only@0.0.1: {} client-only@0.0.1: {}
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
cliui@8.0.1: cliui@8.0.1:
dependencies: dependencies:
string-width: 4.2.3 string-width: 4.2.3
@ -15545,6 +15900,8 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
decamelize@1.2.0: {}
decode-uri-component@0.2.2: {} decode-uri-component@0.2.2: {}
decompress-response@6.0.0: decompress-response@6.0.0:
@ -15634,6 +15991,8 @@ snapshots:
diff@9.0.0: {} diff@9.0.0: {}
dijkstrajs@1.0.3: {}
dlv@1.1.3: {} dlv@1.1.3: {}
dom-serializer@2.0.0: dom-serializer@2.0.0:
@ -15719,6 +16078,12 @@ snapshots:
embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0)
vue: 3.5.34(typescript@5.9.3) vue: 3.5.34(typescript@5.9.3)
embla-carousel-vue@8.6.0(vue@3.5.35(typescript@5.9.3)):
dependencies:
embla-carousel: 8.6.0
embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0)
vue: 3.5.35(typescript@5.9.3)
embla-carousel-wheel-gestures@8.1.0(embla-carousel@8.6.0): embla-carousel-wheel-gestures@8.1.0(embla-carousel@8.6.0):
dependencies: dependencies:
embla-carousel: 8.6.0 embla-carousel: 8.6.0
@ -17801,6 +18166,19 @@ snapshots:
- react - react
- react-dom - react-dom
motion-v@2.2.1(@vueuse/core@14.3.0(vue@3.5.35(typescript@5.9.3)))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vue@3.5.35(typescript@5.9.3)):
dependencies:
'@vueuse/core': 14.3.0(vue@3.5.35(typescript@5.9.3))
framer-motion: 12.38.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
hey-listen: 1.0.8
motion-dom: 12.38.0
motion-utils: 12.36.0
vue: 3.5.35(typescript@5.9.3)
transitivePeerDependencies:
- '@emotion/is-prop-valid'
- react
- react-dom
mri@1.2.0: {} mri@1.2.0: {}
mrmime@2.0.1: {} mrmime@2.0.1: {}
@ -18675,6 +19053,8 @@ snapshots:
pngjs@3.4.0: {} pngjs@3.4.0: {}
pngjs@5.0.0: {}
popmotion@11.0.5: popmotion@11.0.5:
dependencies: dependencies:
framesync: 6.1.2 framesync: 6.1.2
@ -19073,6 +19453,12 @@ snapshots:
qrcode-terminal@0.11.0: {} qrcode-terminal@0.11.0: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
qs@6.15.1: qs@6.15.1:
dependencies: dependencies:
side-channel: 1.1.0 side-channel: 1.1.0
@ -19468,12 +19854,30 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@vue/composition-api' - '@vue/composition-api'
reka-ui@2.9.6(vue@3.5.35(typescript@5.9.3)):
dependencies:
'@floating-ui/dom': 1.7.6
'@floating-ui/vue': 1.1.11(vue@3.5.35(typescript@5.9.3))
'@internationalized/date': 3.12.1
'@internationalized/number': 3.6.6
'@tanstack/vue-virtual': 3.13.24(vue@3.5.35(typescript@5.9.3))
'@vueuse/core': 14.3.0(vue@3.5.35(typescript@5.9.3))
'@vueuse/shared': 14.3.0(vue@3.5.35(typescript@5.9.3))
aria-hidden: 1.2.6
defu: 6.1.7
ohash: 2.0.11
vue: 3.5.35(typescript@5.9.3)
transitivePeerDependencies:
- '@vue/composition-api'
remeda@2.33.4: {} remeda@2.33.4: {}
require-directory@2.1.1: {} require-directory@2.1.1: {}
require-from-string@2.0.2: {} require-from-string@2.0.2: {}
require-main-filename@2.0.0: {}
requireg@0.2.2: requireg@0.2.2:
dependencies: dependencies:
nested-error-stacks: 2.0.1 nested-error-stacks: 2.0.1
@ -19693,6 +20097,8 @@ snapshots:
server-only@0.0.1: {} server-only@0.0.1: {}
set-blocking@2.0.0: {}
set-function-length@1.2.2: set-function-length@1.2.2:
dependencies: dependencies:
define-data-property: 1.1.4 define-data-property: 1.1.4
@ -20379,6 +20785,18 @@ snapshots:
'@nuxt/kit': 4.4.4(magicast@0.5.3) '@nuxt/kit': 4.4.4(magicast@0.5.3)
'@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3)) '@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3))
unplugin-auto-import@21.0.0(@nuxt/kit@4.4.4(magicast@0.5.3))(@vueuse/core@14.3.0(vue@3.5.35(typescript@5.9.3))):
dependencies:
local-pkg: 1.1.2
magic-string: 0.30.21
picomatch: 4.0.4
unimport: 5.7.0
unplugin: 2.3.11
unplugin-utils: 0.3.1
optionalDependencies:
'@nuxt/kit': 4.4.4(magicast@0.5.3)
'@vueuse/core': 14.3.0(vue@3.5.35(typescript@5.9.3))
unplugin-utils@0.2.5: unplugin-utils@0.2.5:
dependencies: dependencies:
pathe: 2.0.3 pathe: 2.0.3
@ -20404,6 +20822,21 @@ snapshots:
optionalDependencies: optionalDependencies:
'@nuxt/kit': 4.4.4(magicast@0.5.3) '@nuxt/kit': 4.4.4(magicast@0.5.3)
unplugin-vue-components@32.0.0(@nuxt/kit@4.4.4(magicast@0.5.3))(vue@3.5.35(typescript@5.9.3)):
dependencies:
chokidar: 5.0.0
local-pkg: 1.1.2
magic-string: 0.30.21
mlly: 1.8.2
obug: 2.1.1
picomatch: 4.0.4
tinyglobby: 0.2.16
unplugin: 3.0.0
unplugin-utils: 0.3.1
vue: 3.5.35(typescript@5.9.3)
optionalDependencies:
'@nuxt/kit': 4.4.4(magicast@0.5.3)
unplugin-vue-router@0.12.0(vue-router@4.6.4(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3)): unplugin-vue-router@0.12.0(vue-router@4.6.4(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3)):
dependencies: dependencies:
'@babel/types': 7.29.0 '@babel/types': 7.29.0
@ -20578,6 +21011,14 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@vue/composition-api' - '@vue/composition-api'
vaul-vue@0.4.1(reka-ui@2.9.6(vue@3.5.35(typescript@5.9.3)))(vue@3.5.35(typescript@5.9.3)):
dependencies:
'@vueuse/core': 10.11.1(vue@3.5.35(typescript@5.9.3))
reka-ui: 2.9.6(vue@3.5.35(typescript@5.9.3))
vue: 3.5.35(typescript@5.9.3)
transitivePeerDependencies:
- '@vue/composition-api'
vaul@1.1.2(@types/react-dom@18.3.7(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): vaul@1.1.2(@types/react-dom@18.3.7(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies: dependencies:
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -20815,6 +21256,10 @@ snapshots:
dependencies: dependencies:
vue: 3.5.34(typescript@5.9.3) vue: 3.5.34(typescript@5.9.3)
vue-demi@0.14.10(vue@3.5.35(typescript@5.9.3)):
dependencies:
vue: 3.5.35(typescript@5.9.3)
vue-devtools-stub@0.1.0: {} vue-devtools-stub@0.1.0: {}
vue-i18n@10.0.8(vue@3.5.34(typescript@5.9.3)): vue-i18n@10.0.8(vue@3.5.34(typescript@5.9.3)):
@ -20829,6 +21274,11 @@ snapshots:
'@vue/devtools-api': 6.6.4 '@vue/devtools-api': 6.6.4
vue: 3.5.34(typescript@5.9.3) vue: 3.5.34(typescript@5.9.3)
vue-router@4.6.4(vue@3.5.35(typescript@5.9.3)):
dependencies:
'@vue/devtools-api': 6.6.4
vue: 3.5.35(typescript@5.9.3)
vue-virtual-scroller@3.0.4(vue@3.5.35(typescript@5.9.3)): vue-virtual-scroller@3.0.4(vue@3.5.35(typescript@5.9.3)):
dependencies: dependencies:
vue: 3.5.35(typescript@5.9.3) vue: 3.5.35(typescript@5.9.3)
@ -20888,6 +21338,8 @@ snapshots:
wheel-gestures@2.2.48: {} wheel-gestures@2.2.48: {}
which-module@2.0.1: {}
which-typed-array@1.1.20: which-typed-array@1.1.20:
dependencies: dependencies:
available-typed-arrays: 1.0.7 available-typed-arrays: 1.0.7
@ -20919,6 +21371,12 @@ snapshots:
word-wrap@1.2.5: {} word-wrap@1.2.5: {}
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@7.0.0: wrap-ansi@7.0.0:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
@ -20990,6 +21448,8 @@ snapshots:
lib0: 0.2.117 lib0: 0.2.117
yjs: 13.6.30 yjs: 13.6.30
y18n@4.0.3: {}
y18n@5.0.8: {} y18n@5.0.8: {}
yallist@3.1.1: {} yallist@3.1.1: {}
@ -21003,10 +21463,29 @@ snapshots:
yaml@2.8.4: {} yaml@2.8.4: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs-parser@21.1.1: {} yargs-parser@21.1.1: {}
yargs-parser@22.0.0: {} yargs-parser@22.0.0: {}
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
yargs@17.7.2: yargs@17.7.2:
dependencies: dependencies:
cliui: 8.0.1 cliui: 8.0.1