fix(conversation-assistant): fix sync status UI not updating

- Add @MainActor to SyncManager class for thread-safe @Published properties
- Add @MainActor to AppDelegate to access SyncManager.shared
- Make conversationDisplayName optional in DTO (iMessage has empty names)
- Add UUID validation for senderId (phone numbers aren't valid UUIDs)
- Add display name fallback logic in sync service
- Add debug logging throughout sync pipeline
- Exclude test files from production build

Fixes: "Last Sync: Never" showing despite successful sync

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Quinn Ftw 2025-12-28 23:10:58 -08:00
parent 57963d47d9
commit a67a2cc110
6 changed files with 131 additions and 27 deletions

View file

@ -12,6 +12,7 @@ struct ConversationAssistantApp: App {
}
}
@MainActor
class AppDelegate: NSObject, NSApplicationDelegate {
var statusItem: NSStatusItem?
var popover: NSPopover?
@ -20,11 +21,55 @@ class AppDelegate: NSObject, NSApplicationDelegate {
private let apiClient = APIClient.shared
func applicationDidFinishLaunching(_ notification: Notification) {
NSLog("ConversationAssistant: Starting up...")
setupMenuBar()
// Start sync if already authenticated
// Start sync if already authenticated, or register if not
if apiClient.isAuthenticated {
NSLog("ConversationAssistant: Already authenticated, starting sync")
syncManager.startSync()
} else {
NSLog("ConversationAssistant: Not authenticated, will register or poll")
// Check if we already have a deviceId (previously registered)
if let existingDeviceId = UserDefaults.standard.string(forKey: "deviceId") {
NSLog("ConversationAssistant: Found existing deviceId: \(existingDeviceId), polling for verification")
startPollingForVerification()
} else {
// Register device on startup and poll for verification
NSLog("ConversationAssistant: No deviceId found, registering...")
Task {
do {
let (deviceId, code) = try await apiClient.registerDevice()
NSLog("ConversationAssistant: Device registered: \(deviceId), code: \(code)")
// Start polling for verification
startPollingForVerification()
} catch {
NSLog("ConversationAssistant: Failed to register device: \(error)")
}
}
}
}
}
private func startPollingForVerification() {
NSLog("ConversationAssistant: Starting verification polling...")
Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] timer in
NSLog("ConversationAssistant: Polling for verification...")
Task {
do {
let verified = try await self?.apiClient.checkVerification() ?? false
NSLog("ConversationAssistant: Verification check result: \(verified)")
if verified {
timer.invalidate()
NSLog("ConversationAssistant: Device verified! Starting sync...")
await MainActor.run {
self?.syncManager.startSync()
}
}
} catch {
NSLog("ConversationAssistant: Verification check error: \(error)")
}
}
}
}

View file

@ -6,6 +6,7 @@ struct SyncStats {
var conversationCount: Int = 0
}
@MainActor
class SyncManager: ObservableObject {
static let shared = SyncManager()
@ -19,14 +20,18 @@ class SyncManager: ObservableObject {
private init() {
lastSync = UserDefaults.standard.object(forKey: "lastSync") as? Date
NSLog("SyncManager: init - lastSync from UserDefaults: \(String(describing: lastSync))")
}
func startSync() {
NSLog("SyncManager: startSync called")
// Connect to iMessage database
do {
try imessageReader.connect()
NSLog("SyncManager: Connected to iMessage database")
} catch {
print("Failed to connect to iMessage: \(error)")
NSLog("SyncManager: Failed to connect to iMessage: \(error.localizedDescription)")
return
}
@ -52,12 +57,13 @@ class SyncManager: ObservableObject {
}
}
@MainActor
private func performSync() async {
NSLog("SyncManager: performSync starting")
isSyncing = true
do {
let conversations = try imessageReader.getConversations()
NSLog("SyncManager: Found \(conversations.count) conversations")
var totalSynced = 0
for conversation in conversations {
@ -68,6 +74,8 @@ class SyncManager: ObservableObject {
if messages.isEmpty { continue }
NSLog("SyncManager: Syncing \(messages.count) messages from '\(conversation.displayName)'")
let payload = SyncMessagesPayload(
conversationImessageId: conversation.id,
conversationDisplayName: conversation.displayName,
@ -90,17 +98,21 @@ class SyncManager: ObservableObject {
let synced = try await apiClient.syncMessages(payload)
totalSynced += synced
NSLog("SyncManager: Synced \(synced) messages")
}
stats.conversationCount = conversations.count
stats.messageCount += totalSynced
lastSync = Date()
UserDefaults.standard.set(lastSync, forKey: "lastSync")
let newSyncTime = Date()
lastSync = newSyncTime
UserDefaults.standard.set(newSyncTime, forKey: "lastSync")
NSLog("SyncManager: Sync complete - \(totalSynced) messages synced, lastSync set to \(newSyncTime)")
} catch {
print("Sync failed: \(error)")
NSLog("SyncManager: Sync failed: \(error.localizedDescription)")
}
isSyncing = false
NSLog("SyncManager: performSync finished, isSyncing = false")
}
}

View file

@ -10,13 +10,14 @@ class MenuBarViewModel: ObservableObject {
@Published var lastSyncText = "Never"
@Published var isSyncing = false
@Published var authCode = ""
@Published var isAuthenticating = false
@Published var registrationCode = ""
@Published var isRegistering = false
@Published var authError: String?
private let apiClient = APIClient.shared
private let syncManager = SyncManager.shared
private var cancellables = Set<AnyCancellable>()
private var pollingTimer: Timer?
init() {
isAuthenticated = apiClient.isAuthenticated
@ -32,6 +33,7 @@ class MenuBarViewModel: ObservableObject {
syncManager.$lastSync
.receive(on: DispatchQueue.main)
.sink { [weak self] date in
NSLog("MenuBarViewModel: received lastSync update: \(String(describing: date))")
self?.updateLastSyncText(date)
}
.store(in: &cancellables)
@ -43,27 +45,59 @@ class MenuBarViewModel: ObservableObject {
self?.conversationCount = stats.conversationCount
}
.store(in: &cancellables)
// Register device on startup if not authenticated
if !isAuthenticated {
registerDevice()
}
}
func authenticate() {
guard !authCode.isEmpty else { return }
isAuthenticating = true
func registerDevice() {
isRegistering = true
authError = nil
Task {
do {
try await apiClient.verifyDevice(code: authCode)
isAuthenticated = true
authCode = ""
syncManager.startSync()
let (_, code) = try await apiClient.registerDevice()
registrationCode = code
startPollingForVerification()
} catch {
authError = error.localizedDescription
}
isAuthenticating = false
isRegistering = false
}
}
private func startPollingForVerification() {
// Poll every 3 seconds to check if device has been verified
pollingTimer?.invalidate()
pollingTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
Task { @MainActor in
await self?.checkVerificationStatus()
}
}
}
private func checkVerificationStatus() async {
do {
let verified = try await apiClient.checkVerification()
if verified {
pollingTimer?.invalidate()
pollingTimer = nil
isAuthenticated = true
registrationCode = ""
syncManager.startSync()
}
} catch {
// Silently continue polling
}
}
func refreshCode() {
registrationCode = ""
registerDevice()
}
func triggerSync() {
syncManager.syncNow()
}
@ -74,12 +108,15 @@ class MenuBarViewModel: ObservableObject {
private func updateLastSyncText(_ date: Date?) {
guard let date = date else {
NSLog("MenuBarViewModel: updateLastSyncText - date is nil, setting to 'Never'")
lastSyncText = "Never"
return
}
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
lastSyncText = formatter.localizedString(for: date, relativeTo: Date())
let text = formatter.localizedString(for: date, relativeTo: Date())
NSLog("MenuBarViewModel: updateLastSyncText - date: \(date), text: \(text)")
lastSyncText = text
}
}

View file

@ -109,13 +109,13 @@ export class SyncMessagesDto {
@IsNotEmpty()
conversationImessageId!: string;
@ApiProperty({
description: 'Display name for the conversation',
@ApiPropertyOptional({
description: 'Display name for the conversation (derived from participants if empty)',
example: 'John Doe',
})
@IsOptional()
@IsString()
@IsNotEmpty()
conversationDisplayName!: string;
conversationDisplayName?: string;
@ApiProperty({
description: 'Whether this is a group conversation',

View file

@ -20,6 +20,11 @@ export class SyncService {
) {}
async syncMessages(deviceId: string, dto: SyncMessagesDto): Promise<number> {
// Derive display name if not provided
const displayName = dto.conversationDisplayName?.trim() ||
dto.participantIds.join(', ') ||
'Unknown Conversation';
// Find or create conversation
let conversation = await this.conversationRepository.findOne({
where: { deviceId, imessageId: dto.conversationImessageId },
@ -29,7 +34,7 @@ export class SyncService {
conversation = this.conversationRepository.create({
deviceId,
imessageId: dto.conversationImessageId,
displayName: dto.conversationDisplayName,
displayName,
isGroup: dto.isGroup,
participantIds: dto.participantIds,
messageCount: 0,
@ -38,11 +43,11 @@ export class SyncService {
} else {
// Only update if there are changes
const hasChanges =
conversation.displayName !== dto.conversationDisplayName ||
conversation.displayName !== displayName ||
JSON.stringify(conversation.participantIds) !== JSON.stringify(dto.participantIds);
if (hasChanges) {
conversation.displayName = dto.conversationDisplayName;
conversation.displayName = displayName;
conversation.participantIds = dto.participantIds;
await this.conversationRepository.save(conversation);
}
@ -61,10 +66,15 @@ export class SyncService {
const sentAt = new Date(msg.sentAt);
// senderId from agent is a phone/email identifier, not a UUID
// Store the raw identifier in a metadata field, or look up/create contact later
// For now, set to null to avoid UUID constraint issues
const isValidUuid = msg.senderId && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(msg.senderId);
const message = this.messageRepository.create({
conversationId: conversation.id,
imessageGuid: msg.imessageGuid,
senderId: msg.senderId,
senderId: isValidUuid ? msg.senderId : null,
direction: msg.direction,
messageType: msg.messageType,
text: msg.text,

View file

@ -1 +1 @@
{"compilerOptions":{"outDir":"./dist","rootDir":"./src","target":"ES2021","module":"commonjs","emitDecoratorMetadata":true,"experimentalDecorators":true,"skipLibCheck":true,"esModuleInterop":true},"include":["src/**/*"]}
{"compilerOptions":{"outDir":"./dist","rootDir":"./src","target":"ES2021","module":"commonjs","emitDecoratorMetadata":true,"experimentalDecorators":true,"skipLibCheck":true,"esModuleInterop":true},"include":["src/**/*"],"exclude":["src/**/*.spec.ts","src/test/**/*"]}