- backend: /api/magic/{register,devices,profile,release} + AdGuard provisioning + 24h cooldown
- prisma: magic_binding_fields migration (additive on UserDevice)
- mac-app: Phase 2 - Login + MacRegistration + Profile install
- marketing: landing section + /download/rebreakmagic + DMG
- lyra: forbidden phrases + RebreakMagic coach guidance
248 lines
8.1 KiB
Swift
248 lines
8.1 KiB
Swift
import SwiftUI
|
|
|
|
struct ManageBindingsView: View {
|
|
@State private var devices: [MagicDevice] = []
|
|
@State private var isLoading = false
|
|
@State private var errorMessage: String?
|
|
|
|
let onDismiss: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Header
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Gebundene Geräte verwalten")
|
|
.font(.title2.bold())
|
|
Text("Hier kannst du bestehende Magic-Bindings freigeben.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button("Schließen", action: onDismiss)
|
|
.keyboardShortcut(.escape)
|
|
}
|
|
.padding()
|
|
.background(Color(nsColor: .windowBackgroundColor))
|
|
|
|
Divider()
|
|
|
|
// Content
|
|
if isLoading && devices.isEmpty {
|
|
VStack(spacing: 12) {
|
|
ProgressView()
|
|
Text("Lade Geräte...")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if let error = errorMessage {
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "exclamationmark.triangle")
|
|
.font(.largeTitle)
|
|
.foregroundStyle(.red)
|
|
Text(error)
|
|
.font(.caption)
|
|
.multilineTextAlignment(.center)
|
|
|
|
Button("Erneut versuchen", action: loadDevices)
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.padding()
|
|
} else if devices.isEmpty {
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "laptopcomputer.slash")
|
|
.font(.largeTitle)
|
|
.foregroundStyle(.secondary)
|
|
Text("Keine gebundenen Geräte")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
ScrollView {
|
|
VStack(spacing: 12) {
|
|
ForEach(devices) { device in
|
|
DeviceRow(device: device, onAction: handleDeviceAction)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
}
|
|
.frame(minWidth: 600, minHeight: 400)
|
|
.onAppear(perform: loadDevices)
|
|
}
|
|
|
|
private func loadDevices() {
|
|
Task {
|
|
isLoading = true
|
|
errorMessage = nil
|
|
|
|
do {
|
|
devices = try await MagicAPIClient.shared.listDevices()
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
|
|
isLoading = false
|
|
}
|
|
}
|
|
|
|
private func handleDeviceAction(_ action: DeviceAction, for device: MagicDevice) {
|
|
Task {
|
|
do {
|
|
switch action {
|
|
case .requestRelease:
|
|
_ = try await MagicAPIClient.shared.requestRelease(deviceId: device.deviceId)
|
|
case .cancelRelease:
|
|
try await MagicAPIClient.shared.cancelRelease(deviceId: device.deviceId)
|
|
}
|
|
// Reload list
|
|
try await Task.sleep(for: .milliseconds(500))
|
|
devices = try await MagicAPIClient.shared.listDevices()
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
enum DeviceAction {
|
|
case requestRelease
|
|
case cancelRelease
|
|
}
|
|
|
|
private struct DeviceRow: View {
|
|
let device: MagicDevice
|
|
let onAction: (DeviceAction, MagicDevice) -> Void
|
|
|
|
@State private var timeRemaining: String = ""
|
|
@State private var timer: Timer?
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Image(systemName: "laptopcomputer")
|
|
.foregroundStyle(.blue)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(device.hostname)
|
|
.font(.headline)
|
|
|
|
if let model = device.model {
|
|
Text(model)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if device.isReleasing {
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
Text("Freigabe läuft")
|
|
.font(.caption.bold())
|
|
.foregroundStyle(.orange)
|
|
|
|
if !timeRemaining.isEmpty {
|
|
Text(timeRemaining)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
} else {
|
|
Text("Aktiv")
|
|
.font(.caption)
|
|
.foregroundStyle(.green)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(Color.green.opacity(0.1))
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
|
|
HStack(spacing: 16) {
|
|
Label(formatDate(device.enrolledDate), systemImage: "calendar")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
if let os = device.osVersion {
|
|
Label(os, systemImage: "info.circle")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if device.isReleasing {
|
|
Button("Freigabe abbrechen") {
|
|
onAction(.cancelRelease, device)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.foregroundStyle(.blue)
|
|
.font(.caption)
|
|
} else {
|
|
Button("Freigabe anfordern") {
|
|
onAction(.requestRelease, device)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.foregroundStyle(.red)
|
|
.font(.caption)
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Color(nsColor: .controlBackgroundColor))
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
.onAppear {
|
|
updateTimeRemaining()
|
|
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
|
|
updateTimeRemaining()
|
|
}
|
|
}
|
|
.onDisappear {
|
|
timer?.invalidate()
|
|
}
|
|
}
|
|
|
|
private func formatDate(_ date: Date?) -> String {
|
|
guard let date = date else { return "—" }
|
|
let formatter = DateFormatter()
|
|
formatter.dateStyle = .short
|
|
formatter.timeStyle = .short
|
|
return formatter.string(from: date)
|
|
}
|
|
|
|
private func updateTimeRemaining() {
|
|
guard let releaseDate = device.releaseDate else {
|
|
timeRemaining = ""
|
|
return
|
|
}
|
|
|
|
let remaining = releaseDate.timeIntervalSince(Date())
|
|
if remaining <= 0 {
|
|
timeRemaining = "Läuft ab..."
|
|
return
|
|
}
|
|
|
|
let hours = Int(remaining) / 3600
|
|
let minutes = (Int(remaining) % 3600) / 60
|
|
|
|
if hours > 0 {
|
|
timeRemaining = "noch \(hours)h \(minutes)m"
|
|
} else {
|
|
timeRemaining = "noch \(minutes)m"
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
ManageBindingsView {
|
|
print("Dismissed")
|
|
}
|
|
}
|