501 lines
15 KiB
Markdown
501 lines
15 KiB
Markdown
# Magic Dashboard iOS Section – 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:** Redesign the Magic dashboard so iOS devices are shown in a dedicated section under the desktop hero, with live USB status, action buttons, and sync/offboarding flows while keeping detection on-demand only.
|
||
|
||
**Architecture:** A new `IosDeviceSection` component owns the iOS list. `useDeviceStatus` derives `iosDevices` and `desktopDevices` from the backend list. `IosDeviceCard` renders each device and its action button. `UnknownIosDeviceCard` handles USB-connected devices that are not registered in the backend. Existing `DeviceDetailSheet` is extended to show iOS stars for any connected iOS device.
|
||
|
||
**Tech Stack:** Nuxt 3, Vue 3, Nuxt UI v4, Tauri 2, TypeScript, pnpm.
|
||
|
||
---
|
||
|
||
## Task 1: Extend `useDeviceStatus.ts` to split devices by platform
|
||
|
||
**Files:**
|
||
- Modify: `apps/rebreak-magic/app/composables/useDeviceStatus.ts`
|
||
|
||
**Why:** The dashboard needs separate `iosDevices` and `desktopDevices` lists instead of one mixed `otherDevices` list.
|
||
|
||
- [ ] **Step 1: Add platform-based derived lists**
|
||
|
||
Replace the single `otherDevices` derived with `iosDevices` and `desktopDevices`. Keep `otherDevices` as the union for backward compatibility or remove it if unused after status.vue update.
|
||
|
||
```ts
|
||
const iosDevices = computed<ComputedDevice[]>(() =>
|
||
devices.value
|
||
.filter((d) => normalizePlatform(d.model ?? d.hostname) === "ios")
|
||
.map((d) => mapToComputedDevice(d, false)),
|
||
);
|
||
|
||
const desktopDevices = computed<ComputedDevice[]>(() =>
|
||
devices.value
|
||
.filter((d) => {
|
||
const p = normalizePlatform(d.model ?? d.hostname);
|
||
return p === "mac" || p === "windows";
|
||
})
|
||
.filter((d) => d.deviceId !== currentBackendDevice.value?.deviceId)
|
||
.map((d) => mapToComputedDevice(d, false)),
|
||
);
|
||
```
|
||
|
||
Introduce a small helper `mapToComputedDevice(d, isCurrent)` to avoid duplicating the mapping object.
|
||
|
||
- [ ] **Step 2: Update return object**
|
||
|
||
```ts
|
||
return {
|
||
currentBackendDevice,
|
||
iosDevices,
|
||
desktopDevices,
|
||
otherDevices: desktopDevices, // temporary alias until status.vue is updated
|
||
iosStars,
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 3: Typecheck**
|
||
|
||
Run: `cd apps/rebreak-magic && pnpm nuxi typecheck`
|
||
Expected: same pre-existing errors as before, no new ones.
|
||
|
||
---
|
||
|
||
## Task 2: Create `IosDeviceCard.vue`
|
||
|
||
**Files:**
|
||
- Create: `apps/rebreak-magic/app/components/IosDeviceCard.vue`
|
||
|
||
**Why:** Each backend iOS device needs its own card with status, stars (if USB-connected), and a context-aware action button.
|
||
|
||
- [ ] **Step 1: Write the component**
|
||
|
||
Props:
|
||
|
||
```ts
|
||
const props = defineProps<{
|
||
device: ComputedDevice;
|
||
iphone: IphoneDeviceState | null;
|
||
isConnected: boolean;
|
||
}>();
|
||
```
|
||
|
||
Compute `iosStars` from `iphone` when connected. Derive the action label/target from `device` and `iosStars`:
|
||
|
||
```ts
|
||
const action = computed(() => {
|
||
if (!props.isConnected || !props.iphone) {
|
||
return { label: "Verbinden", to: "/detect", icon: "i-heroicons-link" };
|
||
}
|
||
if (!props.iphone.isSupervised) {
|
||
return { label: "Supervisen", to: "/supervise", icon: "i-heroicons-shield-check" };
|
||
}
|
||
if (!props.iphone.installedProfileIDs?.includes("org.rebreak.mdm.enrollment")) {
|
||
return { label: "Enrollen", to: "/enroll", icon: "i-heroicons-document-check" };
|
||
}
|
||
if (!props.iphone.installedProfileIDs?.includes("org.rebreak.protection.contentfilter.sideload")) {
|
||
return { label: "Sideload installieren", to: "/sideload", icon: "i-heroicons-lock-closed" };
|
||
}
|
||
if (!props.iphone.installedAppBundleIDs?.includes("org.rebreak.app")) {
|
||
return { label: "App installieren", to: "/sideload", icon: "i-heroicons-arrow-down-tray" };
|
||
}
|
||
return { label: "Synchronisieren", icon: "i-heroicons-arrow-path" };
|
||
});
|
||
```
|
||
|
||
Template: device icon, name/model, status badge, optional `IosStarRating`, last-seen text, and the action button. If the label is "Synchronisieren" use `@click` to emit `sync`; otherwise use `to` navigation.
|
||
|
||
- [ ] **Step 2: Add emits**
|
||
|
||
```ts
|
||
const emit = defineEmits<{
|
||
(e: "sync", device: ComputedDevice): void;
|
||
(e: "open", device: ComputedDevice): void;
|
||
}>();
|
||
```
|
||
|
||
- [ ] **Step 3: Typecheck**
|
||
|
||
Run: `cd apps/rebreak-magic && pnpm nuxi typecheck`
|
||
Expected: no new errors.
|
||
|
||
---
|
||
|
||
## Task 3: Create `UnknownIosDeviceCard.vue`
|
||
|
||
**Files:**
|
||
- Create: `apps/rebreak-magic/app/components/UnknownIosDeviceCard.vue`
|
||
|
||
**Why:** A USB-connected iOS device that is not registered to the user's ReBreak account must be shown as unrecognizable with a clear next-step message.
|
||
|
||
- [ ] **Step 1: Write the component**
|
||
|
||
Props:
|
||
|
||
```ts
|
||
const props = defineProps<{
|
||
iphone: IphoneDeviceState;
|
||
}>();
|
||
```
|
||
|
||
Template: warning icon, title "Dieses iPhone ist nicht erkennbar", model/iOS version/UDID as read-only info, and helper text:
|
||
|
||
> "Mit keinem ReBreak-Konto verbunden. Um es zu verwalten: ReBreak-App installieren, anmelden und Gerät registrieren."
|
||
|
||
No action buttons.
|
||
|
||
- [ ] **Step 2: Typecheck**
|
||
|
||
Run: `cd apps/rebreak-magic && pnpm nuxi typecheck`
|
||
|
||
---
|
||
|
||
## Task 4: Create `IosDeviceSection.vue`
|
||
|
||
**Files:**
|
||
- Create: `apps/rebreak-magic/app/components/IosDeviceSection.vue`
|
||
|
||
**Why:** This component owns the iOS section header, list, matching logic, and empty/unknown states.
|
||
|
||
- [ ] **Step 1: Write the component**
|
||
|
||
Props:
|
||
|
||
```ts
|
||
const props = defineProps<{
|
||
devices: ComputedDevice[];
|
||
iphone: IphoneDeviceState | null;
|
||
loading: boolean;
|
||
hasRefreshed: boolean;
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
(e: "sync", device: ComputedDevice): void;
|
||
(e: "open", device: ComputedDevice): void;
|
||
}>();
|
||
```
|
||
|
||
Implement matching helper:
|
||
|
||
```ts
|
||
function matchesIphone(device: ComputedDevice, iphone: IphoneDeviceState): boolean {
|
||
const modelMatch = (device.model ?? "").toLowerCase() === iphone.productType.toLowerCase();
|
||
const nameMatch = (device.name ?? "").toLowerCase() === iphone.name.toLowerCase();
|
||
return modelMatch || nameMatch;
|
||
}
|
||
```
|
||
|
||
Compute:
|
||
|
||
```ts
|
||
const connectedDeviceId = computed(() => {
|
||
if (!props.iphone) return null;
|
||
return props.devices.find((d) => matchesIphone(d, props.iphone!))?.deviceId ?? null;
|
||
});
|
||
|
||
const hasUnknownUsbDevice = computed(() => {
|
||
return !!props.iphone && !connectedDeviceId.value;
|
||
});
|
||
```
|
||
|
||
Template:
|
||
|
||
- Section title "Meine iOS-Geräte"
|
||
- If `!hasRefreshed && devices.length === 0`: "Noch keine iOS-Geräte geladen."
|
||
- If `hasRefreshed && devices.length === 0`: "Keine iOS-Geräte registriert. ReBreak-App installieren und Gerät hinzufügen."
|
||
- If `hasUnknownUsbDevice`: render `UnknownIosDeviceCard` first.
|
||
- Render `IosDeviceCard` for each device with `isConnected = device.deviceId === connectedDeviceId`.
|
||
|
||
- [ ] **Step 2: Typecheck**
|
||
|
||
Run: `cd apps/rebreak-magic && pnpm nuxi typecheck`
|
||
|
||
---
|
||
|
||
## Task 5: Update `status.vue`
|
||
|
||
**Files:**
|
||
- Modify: `apps/rebreak-magic/app/pages/status.vue`
|
||
|
||
**Why:** The page must render the new iOS section and use `desktopDevices` instead of `otherDevices` for the remaining list.
|
||
|
||
- [ ] **Step 1: Replace `otherDevices` usage with `iosDevices` and `desktopDevices`**
|
||
|
||
Update import from `useDeviceStatus`:
|
||
|
||
```ts
|
||
const { currentBackendDevice, iosDevices, desktopDevices, iosStars } =
|
||
useDeviceStatus(devices, localHostname, iphone, currentDeviceId);
|
||
```
|
||
|
||
- [ ] **Step 2: Insert iOS section under hero**
|
||
|
||
After the hero section and before "Weitere Geräte", add:
|
||
|
||
```vue
|
||
<IosDeviceSection
|
||
:devices="iosDevices"
|
||
:iphone="iphone"
|
||
:loading="loading"
|
||
:has-refreshed="hasRefreshed"
|
||
@sync="onIosSync"
|
||
@open="openDevice"
|
||
/>
|
||
```
|
||
|
||
- [ ] **Step 3: Change "Weitere Geräte" list to `desktopDevices`**
|
||
|
||
Replace `otherDevices` references in the list with `desktopDevices`. Update empty copy to "Keine weiteren Computer geladen." / "Keine weiteren Computer registriert." depending on `hasRefreshed`.
|
||
|
||
- [ ] **Step 4: Add `onIosSync` handler**
|
||
|
||
```ts
|
||
async function onIosSync(device: ComputedDevice) {
|
||
loading.value = true;
|
||
error.value = null;
|
||
try {
|
||
await protection.refreshIphone();
|
||
// TODO: push missing MDM components and compare MDM version once the backend exposes it.
|
||
await protection.refreshBackendDevices();
|
||
} catch (e: any) {
|
||
error.value = e?.message ?? "Synchronisierung fehlgeschlagen";
|
||
} finally {
|
||
loading.value = false;
|
||
hasRefreshed.value = true;
|
||
}
|
||
}
|
||
```
|
||
|
||
Expose `refreshIphone` and `refreshBackendDevices` from `useProtectionStatus` if not already exported.
|
||
|
||
- [ ] **Step 5: Typecheck**
|
||
|
||
Run: `cd apps/rebreak-magic && pnpm nuxi typecheck`
|
||
Expected: no new errors.
|
||
|
||
---
|
||
|
||
## Task 6: Extend `useProtectionStatus.ts` exports
|
||
|
||
**Files:**
|
||
- Modify: `apps/rebreak-magic/app/composables/useProtectionStatus.ts`
|
||
|
||
**Why:** `status.vue` needs to call `refreshIphone` and `refreshBackendDevices` independently for the sync action.
|
||
|
||
- [ ] **Step 1: Export the two refresh functions**
|
||
|
||
Add to the return object:
|
||
|
||
```ts
|
||
return {
|
||
// ... existing returns
|
||
refreshIphone,
|
||
refreshBackendDevices,
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 2: Typecheck**
|
||
|
||
Run: `cd apps/rebreak-magic && pnpm nuxi typecheck`
|
||
|
||
---
|
||
|
||
## Task 7: Adjust `DeviceDetailSheet.vue` for iOS stars
|
||
|
||
**Files:**
|
||
- Modify: `apps/rebreak-magic/app/components/DeviceDetailSheet.vue`
|
||
|
||
**Why:** iOS devices are never `isCurrent`, but the sheet should still show stars when the opened device is connected via USB.
|
||
|
||
- [ ] **Step 1: Change `showIosStars` condition**
|
||
|
||
Accept a new prop or use the existing `iosStars` prop directly. The current condition is:
|
||
|
||
```ts
|
||
const showIosStars = computed(() => props.device?.isCurrent && props.device?.platform === "ios");
|
||
```
|
||
|
||
Change to:
|
||
|
||
```ts
|
||
const showIosStars = computed(() => props.device?.platform === "ios" && !!props.iosStars);
|
||
```
|
||
|
||
Ensure the parent passes `iosStars` for the opened iOS device when it is connected.
|
||
|
||
- [ ] **Step 2: Hide desktop-only sections for iOS devices**
|
||
|
||
`showDesktopToggle` should remain as is (only mac/windows + isCurrent).
|
||
Cooldown controls should only show for `device.isCurrent` (desktop), which is already the case.
|
||
|
||
- [ ] **Step 3: Typecheck**
|
||
|
||
Run: `cd apps/rebreak-magic && pnpm nuxi typecheck`
|
||
|
||
---
|
||
|
||
## Task 8: Update `DeviceHeroCard.vue` and `DeviceListItem.vue`
|
||
|
||
**Files:**
|
||
- Modify: `apps/rebreak-magic/app/components/DeviceHeroCard.vue`
|
||
- Modify: `apps/rebreak-magic/app/components/DeviceListItem.vue`
|
||
|
||
**Why:** These components are now desktop-only. Remove iOS-specific rendering if it is no longer needed or keep it defensive.
|
||
|
||
- [ ] **Step 1: In `DeviceHeroCard.vue`, keep `showIosStars` defensive**
|
||
|
||
No functional change needed because the hero only receives desktop devices, but confirm `showIosStars` still computes correctly.
|
||
|
||
- [ ] **Step 2: In `DeviceListItem.vue`, no change required**
|
||
|
||
It will only be rendered with desktop devices.
|
||
|
||
---
|
||
|
||
## Task 9: Add MDM version awareness (frontend foundation)
|
||
|
||
**Files:**
|
||
- Modify: `apps/rebreak-magic/app/composables/useTauri.ts`
|
||
- Modify: `apps/rebreak-magic/app/components/IosDeviceCard.vue`
|
||
|
||
**Why:** The sync action must later compare installed MDM version with the latest ReBreak MDM version.
|
||
|
||
- [ ] **Step 1: Add a constant and helper in `useTauri.ts`**
|
||
|
||
```ts
|
||
export const REBREAK_MDM_VERSION = "0.1";
|
||
|
||
export function getInstalledMdmVersion(installedProfileIDs: string[]): string | null {
|
||
const versionId = installedProfileIDs.find((id) => id.startsWith("org.rebreak.mdm.version."));
|
||
return versionId?.replace("org.rebreak.mdm.version.", "") ?? null;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Use it in `IosDeviceCard.vue` action logic**
|
||
|
||
When connected and all core checks pass, compare `getInstalledMdmVersion(...)` with `REBREAK_MDM_VERSION`. If outdated or missing, return `{ label: "MDM-Update installieren", icon: "i-heroicons-arrow-up-tray" }` and emit `sync`.
|
||
|
||
- [ ] **Step 3: Typecheck**
|
||
|
||
Run: `cd apps/rebreak-magic && pnpm nuxi typecheck`
|
||
|
||
---
|
||
|
||
## Task 10: Grace-period / Offboarding placeholder
|
||
|
||
**Files:**
|
||
- Modify: `apps/rebreak-magic/app/components/IosDeviceCard.vue`
|
||
- Modify: `apps/rebreak-magic/app/pages/status.vue`
|
||
|
||
**Why:** The spec requires a "ReBreak entfernen" action during the 3-day grace period after cancellation. The backend endpoint does not exist yet, so we add a safe placeholder.
|
||
|
||
- [ ] **Step 1: Add `subscriptionInGracePeriod` prop**
|
||
|
||
```ts
|
||
const props = defineProps<{
|
||
device: ComputedDevice;
|
||
iphone: IphoneDeviceState | null;
|
||
isConnected: boolean;
|
||
inGracePeriod?: boolean;
|
||
}>();
|
||
```
|
||
|
||
- [ ] **Step 2: Show offboarding button when in grace period**
|
||
|
||
At the top of the action derivation:
|
||
|
||
```ts
|
||
if (props.inGracePeriod) {
|
||
return { label: "ReBreak entfernen", icon: "i-heroicons-trash", variant: "danger" };
|
||
}
|
||
```
|
||
|
||
Emit a new `remove` event. The parent shows a placeholder toast or logs until the backend endpoint is ready.
|
||
|
||
- [ ] **Step 3: Stub grace-period state in `status.vue`**
|
||
|
||
```ts
|
||
const subscriptionInGracePeriod = ref(false);
|
||
// TODO: populate from backend once subscription status endpoint exists.
|
||
```
|
||
|
||
Pass it to `IosDeviceSection` and down to each card.
|
||
|
||
- [ ] **Step 4: Typecheck**
|
||
|
||
Run: `cd apps/rebreak-magic && pnpm nuxi typecheck`
|
||
|
||
---
|
||
|
||
## Task 11: Verification and build
|
||
|
||
**Files:** n/a
|
||
|
||
**Why:** Ensure the frontend compiles and the Tauri bundle can be built.
|
||
|
||
- [ ] **Step 1: Typecheck**
|
||
|
||
Run: `cd apps/rebreak-magic && pnpm nuxi typecheck`
|
||
Expected: only pre-existing errors.
|
||
|
||
- [ ] **Step 2: Build Tauri bundle**
|
||
|
||
Run: `cd apps/rebreak-magic && pnpm tauri:build`
|
||
Expected: completes without new frontend errors. This may take several minutes on first run.
|
||
|
||
- [ ] **Step 3: Manual smoke test**
|
||
|
||
Launch the built app with the debug pairing code `000000`, open the dashboard, click **Aktualisieren**, and confirm:
|
||
|
||
- Desktop hero still renders.
|
||
- iOS section appears.
|
||
- If no iOS devices: correct empty message.
|
||
- If a USB iPhone is connected and registered: stars and action button render.
|
||
|
||
---
|
||
|
||
## Task 12: Commit changes
|
||
|
||
**Files:** n/a
|
||
|
||
- [ ] **Step 1: Stage and commit**
|
||
|
||
```bash
|
||
git add apps/rebreak-magic/app/composables/useDeviceStatus.ts \
|
||
apps/rebreak-magic/app/composables/useProtectionStatus.ts \
|
||
apps/rebreak-magic/app/composables/useTauri.ts \
|
||
apps/rebreak-magic/app/components/IosDeviceSection.vue \
|
||
apps/rebreak-magic/app/components/IosDeviceCard.vue \
|
||
apps/rebreak-magic/app/components/UnknownIosDeviceCard.vue \
|
||
apps/rebreak-magic/app/components/DeviceDetailSheet.vue \
|
||
apps/rebreak-magic/app/components/DeviceHeroCard.vue \
|
||
apps/rebreak-magic/app/components/DeviceListItem.vue \
|
||
apps/rebreak-magic/app/pages/status.vue \
|
||
docs/superpowers/specs/2026-06-16-magic-dashboard-ios-section-design.md \
|
||
docs/superpowers/plans/2026-06-16-magic-dashboard-ios-section.md
|
||
|
||
git commit -m "feat(magic): dedicated iOS section in dashboard with on-demand sync"
|
||
```
|
||
|
||
---
|
||
|
||
## Spec Coverage Check
|
||
|
||
| Spec requirement | Task |
|
||
|---|---|
|
||
| Desktop hero remains | Task 5 |
|
||
| Dedicated iOS section under hero | Tasks 4, 5 |
|
||
| Backend iOS devices listed | Tasks 1, 4 |
|
||
| USB live status synced to matching device | Tasks 2, 4 |
|
||
| Unknown USB device shown as unrecognizable | Task 3 |
|
||
| Action buttons for supervise/enroll/sideload/app/sync | Task 2 |
|
||
| On-demand detection preserved | Task 5, existing code |
|
||
| Grace-period offboarding placeholder | Task 10 |
|
||
| MDM version foundation | Task 9 |
|
||
| DeviceDetailSheet iOS stars | Task 7 |
|
||
|
||
## Known Backend Dependencies (out of scope for this frontend plan)
|
||
|
||
- Subscription cancellation / grace-period endpoint.
|
||
- Offboarding endpoint: remove MDM profiles, unsupervise, clean DB entry.
|
||
- Central `REBREAK_MDM_VERSION` value injected into MDM enrollment profiles.
|