When normal sync doesn't pick up new data (only syncs since lastSync), Force Sync clears server data and resyncs all messages from scratch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
257 lines
8.2 KiB
Swift
257 lines
8.2 KiB
Swift
import SwiftUI
|
|
|
|
struct MenuBarView: View {
|
|
@StateObject private var viewModel = MenuBarViewModel()
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Header
|
|
HStack {
|
|
Image(systemName: "bubble.left.and.bubble.right.fill")
|
|
.foregroundColor(.accentColor)
|
|
.accessibilityHidden(true)
|
|
Text("Conversation Assistant")
|
|
.font(.headline)
|
|
Spacer()
|
|
}
|
|
.padding()
|
|
.background(Color(.windowBackgroundColor))
|
|
|
|
Divider()
|
|
|
|
if viewModel.isAuthenticated {
|
|
authenticatedView
|
|
} else {
|
|
authenticationView
|
|
}
|
|
|
|
Divider()
|
|
|
|
// Footer
|
|
HStack {
|
|
// Open Settings - use SettingsLink on macOS 14+, fallback to NSApp.sendAction
|
|
if #available(macOS 14, *) {
|
|
SettingsLink {
|
|
Image(systemName: "gear")
|
|
.accessibilityLabel("Settings")
|
|
}
|
|
.buttonStyle(.borderless)
|
|
} else {
|
|
Button {
|
|
// Close popover first
|
|
if let appDelegate = NSApp.delegate as? AppDelegate {
|
|
appDelegate.closePopover()
|
|
}
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
|
|
} label: {
|
|
Image(systemName: "gear")
|
|
.accessibilityLabel("Settings")
|
|
}
|
|
.buttonStyle(.borderless)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Text(AppVersion.displayVersion)
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
|
|
Spacer()
|
|
|
|
Button("Quit") {
|
|
NSApplication.shared.terminate(nil)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
}
|
|
.padding()
|
|
}
|
|
.frame(width: 320)
|
|
}
|
|
|
|
private var authenticatedView: some View {
|
|
VStack(spacing: 12) {
|
|
// Sync Status
|
|
HStack {
|
|
Circle()
|
|
.fill(viewModel.syncStatus.color)
|
|
.frame(width: 8, height: 8)
|
|
Text(viewModel.syncStatus.text)
|
|
.font(.subheadline)
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal)
|
|
|
|
// Full Disk Access Warning
|
|
if viewModel.needsFullDiskAccess {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "exclamationmark.shield.fill")
|
|
.font(.system(size: 32))
|
|
.foregroundColor(.orange)
|
|
.accessibilityLabel("Warning")
|
|
|
|
Text("Full Disk Access Required")
|
|
.font(.headline)
|
|
|
|
Text("Grant access to read iMessage database")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
|
|
HStack(spacing: 12) {
|
|
Button("Open Settings") {
|
|
viewModel.openFullDiskAccessSettings()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
|
|
Button("Retry") {
|
|
viewModel.retryConnection()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Color.orange.opacity(0.1))
|
|
.cornerRadius(8)
|
|
.padding(.horizontal)
|
|
} else {
|
|
// Stats
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
StatRow(label: "Messages Synced", value: "\(viewModel.messageCount)")
|
|
StatRow(label: "Conversations", value: "\(viewModel.conversationCount)")
|
|
StatRow(label: "Last Sync", value: viewModel.lastSyncText)
|
|
}
|
|
.padding(.horizontal)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Sync Buttons
|
|
HStack(spacing: 8) {
|
|
Button(action: viewModel.triggerSync) {
|
|
HStack {
|
|
if viewModel.isSyncing && !viewModel.isResetting {
|
|
ProgressView()
|
|
.scaleEffect(0.7)
|
|
}
|
|
Text(viewModel.isSyncing && !viewModel.isResetting ? "Syncing..." : "Sync Now")
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(viewModel.isSyncing || viewModel.isResetting || viewModel.needsFullDiskAccess)
|
|
|
|
Button(action: viewModel.forceSync) {
|
|
HStack {
|
|
if viewModel.isResetting {
|
|
ProgressView()
|
|
.scaleEffect(0.7)
|
|
}
|
|
Text(viewModel.isResetting ? "Resetting..." : "Force Sync")
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(viewModel.isSyncing || viewModel.isResetting || viewModel.needsFullDiskAccess)
|
|
}
|
|
.padding()
|
|
}
|
|
.padding(.vertical)
|
|
}
|
|
|
|
private var authenticationView: some View {
|
|
VStack(spacing: 16) {
|
|
Image(systemName: "link.badge.plus")
|
|
.font(.system(size: 48))
|
|
.foregroundColor(.accentColor)
|
|
.accessibilityLabel("Connect device")
|
|
|
|
Text("Connect This Device")
|
|
.font(.headline)
|
|
|
|
if viewModel.isRegistering {
|
|
ProgressView("Registering...")
|
|
} else if !viewModel.registrationCode.isEmpty {
|
|
Text("Enter this code in your admin panel:")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
Text(viewModel.registrationCode)
|
|
.font(.system(size: 32, weight: .bold, design: .monospaced))
|
|
.foregroundColor(.accentColor)
|
|
.padding(.vertical, 8)
|
|
.onTapGesture {
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(viewModel.registrationCode, forType: .string)
|
|
}
|
|
|
|
Text("Tap to copy • Waiting for verification...")
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
|
|
Button("Get New Code") {
|
|
viewModel.refreshCode()
|
|
}
|
|
.buttonStyle(.link)
|
|
} else {
|
|
Text("Unable to connect to server")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
Button("Retry") {
|
|
viewModel.registerDevice()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
|
|
if let error = viewModel.authError {
|
|
Text(error)
|
|
.font(.caption)
|
|
.foregroundColor(.red)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
struct StatRow: View {
|
|
let label: String
|
|
let value: String
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Text(label)
|
|
.foregroundColor(.secondary)
|
|
Spacer()
|
|
Text(value)
|
|
.fontWeight(.medium)
|
|
}
|
|
}
|
|
}
|
|
|
|
enum SyncStatus {
|
|
case idle
|
|
case syncing
|
|
case error(String)
|
|
|
|
var color: Color {
|
|
switch self {
|
|
case .idle: return .green
|
|
case .syncing: return .orange
|
|
case .error: return .red
|
|
}
|
|
}
|
|
|
|
var text: String {
|
|
switch self {
|
|
case .idle: return "Connected"
|
|
case .syncing: return "Syncing..."
|
|
case .error(let message): return message
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
MenuBarView()
|
|
.frame(height: 400)
|
|
}
|