Magic-Mac-Hub (/api/magic/devices): - Filter boundToPlan war zu eng \u2014 iPhone/iPad ohne aktiven Plan-Lock fielen raus. Jetzt: alle UserDevice-Rows des Users ausser den magic-enrolled, plus ProtectedDevice mit Dedupe. Native /devices Page: - MacBook erschien doppelt: einmal als UserDevice (registriert via Magic-Mac, model=Mac14,9) und einmal als ProtectedDevice (alter DNS-Flow). Dedupe per platform-key (mac/ios/android/win): wenn UserDevice mit gleicher Plattform existiert, blende ProtectedDevice aus. - Slot-Counter zaehlt jetzt nach dedupe (totalRegistered).
192 lines
6.6 KiB
Swift
192 lines
6.6 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
struct LoginView: View {
|
|
@State private var code: String = ""
|
|
@FocusState private var isFocused: Bool
|
|
@State private var isLoading = false
|
|
@State private var errorMessage: String?
|
|
|
|
let onSuccess: (AuthSession) -> Void
|
|
|
|
private var isComplete: Bool { code.count == 6 && code.allSatisfy(\.isNumber) }
|
|
|
|
var body: some View {
|
|
VStack(spacing: 28) {
|
|
Spacer().frame(height: 20)
|
|
|
|
VStack(spacing: 14) {
|
|
appIconView
|
|
.frame(width: 84, height: 84)
|
|
|
|
Text("Rebreak Magic")
|
|
.font(.title.bold())
|
|
|
|
VStack(spacing: 4) {
|
|
Text("Mit der Rebreak-App verbinden")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
Text("Öffne in der App: Einstellungen → Rebreak Magic")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
|
|
// ─── Code-Input ─────────────────────────────────────────────
|
|
// EIN unsichtbares TextField empfängt alle Tasten. Die 6 Boxen
|
|
// sind reine Anzeige → kein Focus-Race, kein System-Focus-Ring.
|
|
VStack(spacing: 14) {
|
|
ZStack {
|
|
HStack(spacing: 10) {
|
|
ForEach(0..<6, id: \.self) { index in
|
|
digitBox(index: index)
|
|
}
|
|
}
|
|
|
|
TextField("", text: $code)
|
|
.textFieldStyle(.plain)
|
|
.focused($isFocused)
|
|
.focusEffectDisabled()
|
|
.frame(width: 380, height: 60)
|
|
.opacity(0.01)
|
|
.onChange(of: code) { _, newValue in
|
|
handleCodeChange(newValue)
|
|
}
|
|
.onSubmit { handleSubmit() }
|
|
}
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { isFocused = true }
|
|
|
|
if let error = errorMessage {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(.red)
|
|
Text(error)
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
|
|
Button(action: handleSubmit) {
|
|
HStack(spacing: 8) {
|
|
if isLoading {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
.tint(.white)
|
|
}
|
|
Text(isLoading ? "Verbinde…" : "Verbinden")
|
|
.fontWeight(.medium)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 6)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.keyboardShortcut(.return, modifiers: [])
|
|
.disabled(!isComplete || isLoading)
|
|
}
|
|
.frame(maxWidth: 380)
|
|
|
|
Spacer()
|
|
|
|
Text("Noch keine Rebreak-App? Lade sie im App Store / Play Store.")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
.padding(.bottom, 18)
|
|
}
|
|
.padding(.horizontal, 32)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(Color(nsColor: .windowBackgroundColor))
|
|
.onAppear {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
isFocused = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Components
|
|
|
|
@ViewBuilder
|
|
private var appIconView: some View {
|
|
if let icon = NSApplication.shared.applicationIconImage,
|
|
icon.size.width > 2 {
|
|
Image(nsImage: icon)
|
|
.resizable()
|
|
.interpolation(.high)
|
|
.frame(width: 84, height: 84)
|
|
} else {
|
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
.fill(LinearGradient(colors: [.blue, .purple], startPoint: .topLeading, endPoint: .bottomTrailing))
|
|
.overlay(
|
|
Image(systemName: "wand.and.stars")
|
|
.font(.system(size: 40, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func digitBox(index: Int) -> some View {
|
|
let chars = Array(code)
|
|
let digit: String = index < chars.count ? String(chars[index]) : ""
|
|
let activeIndex = min(chars.count, 5)
|
|
let isActive = isFocused && index == activeIndex
|
|
let isFilled = !digit.isEmpty
|
|
|
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
.fill(Color(nsColor: .controlBackgroundColor))
|
|
.frame(width: 48, height: 60)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
.strokeBorder(
|
|
isActive ? Color.accentColor :
|
|
(isFilled ? Color.gray.opacity(0.4) : Color.gray.opacity(0.25)),
|
|
lineWidth: isActive ? 2 : 1
|
|
)
|
|
)
|
|
.overlay(
|
|
Text(digit)
|
|
.font(.system(size: 28, weight: .semibold, design: .rounded))
|
|
.foregroundStyle(.primary)
|
|
)
|
|
}
|
|
|
|
// MARK: - Logic
|
|
|
|
private func handleCodeChange(_ newValue: String) {
|
|
let digits = newValue.filter(\.isNumber)
|
|
let clipped = String(digits.prefix(6))
|
|
if clipped != newValue {
|
|
code = clipped
|
|
return
|
|
}
|
|
if clipped.count == 6 && !isLoading {
|
|
handleSubmit()
|
|
}
|
|
}
|
|
|
|
private func handleSubmit() {
|
|
guard isComplete, !isLoading else { return }
|
|
let toSend = code
|
|
Task {
|
|
isLoading = true
|
|
errorMessage = nil
|
|
do {
|
|
let session = try await AuthService.shared.signInWithPairingCode(toSend)
|
|
onSuccess(session)
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
code = ""
|
|
isFocused = true
|
|
}
|
|
isLoading = false
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
LoginView { session in
|
|
print("Magic-Session: \(session.sessionId)")
|
|
}
|
|
.frame(width: 720, height: 600)
|
|
}
|