diff --git a/FollowMyFriends.xcodeproj/project.pbxproj b/FollowMyFriends.xcodeproj/project.pbxproj index cc22722..231c96d 100644 --- a/FollowMyFriends.xcodeproj/project.pbxproj +++ b/FollowMyFriends.xcodeproj/project.pbxproj @@ -30,6 +30,7 @@ 990809514EFC4DE13481EC52 /* WALMerger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17266AFDD2027B69BD4D4616 /* WALMerger.swift */; }; 9C38EDC36D8F6CE0400D1220 /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F46A699A6D4720AAD2DCC29 /* KeychainHelper.swift */; }; A6DAE137136C4B2DB14A353B /* LoginItemService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FD60FC9C5689FDADBCAC6A9 /* LoginItemService.swift */; }; + B1A2C3D4E5F60718293A4B5C /* MenuBarService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2B3D4E5F6071829304A5B6D /* MenuBarService.swift */; }; A98728D334B7E4C977B67DF4 /* DatabaseReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4343EB60AAA752F7EECBB254 /* DatabaseReader.swift */; }; AED9122813F901C4CC0F643E /* LogRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F724BD5CC0ACF8884246FDF8 /* LogRow.swift */; }; B3CFDA1D152DBC8089DF7648 /* PersonCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D6D77CE802EAE33A09B296E /* PersonCard.swift */; }; @@ -47,6 +48,7 @@ 025B3E814A5B7DA7BBC4F63C /* LogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogEntry.swift; sourceTree = ""; }; 0606256C27AC4041C758939A /* LocationUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationUpdate.swift; sourceTree = ""; }; 1011CB86759E8D5D1C6F2B28 /* MQTTService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MQTTService.swift; sourceTree = ""; }; + C2B3D4E5F6071829304A5B6D /* MenuBarService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarService.swift; sourceTree = ""; }; 12D26167DC0574D67753BFA8 /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = ""; }; 17266AFDD2027B69BD4D4616 /* WALMerger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WALMerger.swift; sourceTree = ""; }; 1AF5D34EFAB614B82BE6626A /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; @@ -109,6 +111,7 @@ 00F78775FC54756A38213D07 /* FindMyLauncher.swift */, 6F46A699A6D4720AAD2DCC29 /* KeychainHelper.swift */, 6FD60FC9C5689FDADBCAC6A9 /* LoginItemService.swift */, + C2B3D4E5F6071829304A5B6D /* MenuBarService.swift */, 1011CB86759E8D5D1C6F2B28 /* MQTTService.swift */, 6CBC9622345EC1B216D33B4E /* NotificationService.swift */, 840D9238E2C9840063842808 /* TrackingScheduler.swift */, @@ -337,6 +340,7 @@ AED9122813F901C4CC0F643E /* LogRow.swift in Sources */, 0C09483E162D21A679FE39FC /* LogView.swift in Sources */, A6DAE137136C4B2DB14A353B /* LoginItemService.swift in Sources */, + B1A2C3D4E5F60718293A4B5C /* MenuBarService.swift in Sources */, 3A258B8F6FD44EDA35BAC482 /* MQTTService.swift in Sources */, CBDF01D5EFDB0D70CD2F19F1 /* MQTTStatusBadge.swift in Sources */, 5D04E7ADCF859C94F613EC3F /* MainWindow.swift in Sources */, diff --git a/FollowMyFriends/Models/AppSettings.swift b/FollowMyFriends/Models/AppSettings.swift index 626946a..7fcd61a 100644 --- a/FollowMyFriends/Models/AppSettings.swift +++ b/FollowMyFriends/Models/AppSettings.swift @@ -29,6 +29,7 @@ struct AppSettings: Codable, Sendable { // Application var launchAtStartup: Bool = false var showInDock: Bool = true + var autoStartTracking: Bool = false // Tracked People var trackedPeople: [TrackedPerson] = [] diff --git a/FollowMyFriends/Services/MenuBarService.swift b/FollowMyFriends/Services/MenuBarService.swift new file mode 100644 index 0000000..739095e --- /dev/null +++ b/FollowMyFriends/Services/MenuBarService.swift @@ -0,0 +1,152 @@ +import AppKit + +@MainActor +final class MenuBarService: NSObject, NSWindowDelegate { + private var statusItem: NSStatusItem? + private var trackingMenuItem: NSMenuItem? + private var statusMenuItem: NSMenuItem? + private var durationMenuItem: NSMenuItem? + private var durationTimer: Timer? + private var trackingStartTime: Date? + private weak var mainWindow: NSWindow? + + var onToggleTracking: (() -> Void)? + var onQuit: (() -> Void)? + + func setup() { + guard statusItem == nil else { return } + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + if let button = item.button { + button.image = NSImage(systemSymbolName: "location", accessibilityDescription: "FollowMyFriends") + button.image?.size = NSSize(width: 16, height: 16) + } + item.menu = buildMenu() + statusItem = item + + DispatchQueue.main.async { [weak self] in + self?.hookMainWindow() + } + } + + private func hookMainWindow() { + guard let window = NSApp.windows.first(where: { $0.canBecomeMain }) else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.hookMainWindow() + } + return + } + mainWindow = window + window.delegate = self + } + + nonisolated func windowShouldClose(_ sender: NSWindow) -> Bool { + Task { @MainActor in + sender.orderOut(nil) + NSApp.setActivationPolicy(.accessory) + } + return false + } + + func showWindow() { + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + if let window = mainWindow { + window.makeKeyAndOrderFront(nil) + } + } + + func updateStatus(isTracking: Bool, mqttStatus: String) { + let iconName = isTracking ? "location.fill" : "location" + if let button = statusItem?.button { + button.image = NSImage(systemSymbolName: iconName, accessibilityDescription: "FollowMyFriends") + button.image?.size = NSSize(width: 16, height: 16) + } + + trackingMenuItem?.title = isTracking ? "Stop Tracking" : "Start Tracking" + trackingMenuItem?.image = NSImage(systemSymbolName: isTracking ? "stop.fill" : "play.fill", accessibilityDescription: nil) + statusMenuItem?.title = mqttStatus + + if isTracking && trackingStartTime == nil { + trackingStartTime = Date() + startDurationTimer() + } else if !isTracking { + trackingStartTime = nil + stopDurationTimer() + durationMenuItem?.title = "Inactive" + } + } + + private func startDurationTimer() { + stopDurationTimer() + updateDurationLabel() + durationTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + self?.updateDurationLabel() + } + } + } + + private func stopDurationTimer() { + durationTimer?.invalidate() + durationTimer = nil + } + + private func updateDurationLabel() { + guard let start = trackingStartTime else { return } + let elapsed = Int(-start.timeIntervalSinceNow) + let hours = elapsed / 3600 + let minutes = (elapsed % 3600) / 60 + if hours > 0 { + durationMenuItem?.title = "Active since \(hours)h \(minutes)m" + } else { + durationMenuItem?.title = "Active since \(minutes)m" + } + } + + private func buildMenu() -> NSMenu { + let menu = NSMenu() + + let status = NSMenuItem(title: "MQTT: Disconnected", action: nil, keyEquivalent: "") + status.isEnabled = false + statusMenuItem = status + menu.addItem(status) + + let duration = NSMenuItem(title: "Inactive", action: nil, keyEquivalent: "") + duration.isEnabled = false + durationMenuItem = duration + menu.addItem(duration) + + menu.addItem(.separator()) + + let tracking = NSMenuItem(title: "Start Tracking", action: #selector(toggleTracking), keyEquivalent: "t") + tracking.target = self + tracking.image = NSImage(systemSymbolName: "play.fill", accessibilityDescription: nil) + trackingMenuItem = tracking + menu.addItem(tracking) + + let show = NSMenuItem(title: "Show Window", action: #selector(showWindowAction), keyEquivalent: "o") + show.target = self + show.image = NSImage(systemSymbolName: "macwindow", accessibilityDescription: nil) + menu.addItem(show) + + menu.addItem(.separator()) + + let quit = NSMenuItem(title: "Quit FollowMyFriends", action: #selector(quitApp), keyEquivalent: "q") + quit.target = self + menu.addItem(quit) + + return menu + } + + @objc private func toggleTracking() { + onToggleTracking?() + } + + @objc private func showWindowAction() { + showWindow() + } + + @objc private func quitApp() { + onQuit?() + } +} diff --git a/FollowMyFriends/ViewModels/AppState.swift b/FollowMyFriends/ViewModels/AppState.swift index 7fe08c5..fa8fd1c 100644 --- a/FollowMyFriends/ViewModels/AppState.swift +++ b/FollowMyFriends/ViewModels/AppState.swift @@ -38,6 +38,7 @@ final class AppState { let launcher: FindMyLauncher let walWatcher = WALWatcher() let notificationService = NotificationService.shared + let menuBarService = MenuBarService() // MARK: - Init @@ -50,6 +51,7 @@ final class AppState { self.trackedPeople = loaded.trackedPeople setupServiceCallbacks() + setupMenuBar() notificationService.requestPermission() addLog(.info, "FollowMyFriends started") @@ -67,6 +69,9 @@ final class AppState { Task { await validateKey() + if settings.autoStartTracking && keyIsValid { + startTracking() + } } } @@ -82,13 +87,13 @@ final class AppState { mqttService.onConnected = { [weak self] in Task { @MainActor [weak self] in guard let self, self.isTracking else { return } - // Re-publish discovery + last known location for all active people for person in self.trackedPeople where person.isActive { self.mqttService.publishDiscovery(for: person) if let loc = person.lastLocation { self.mqttService.publishLocation(for: person, location: loc) } } + self.updateMenuBarStatus() } } launcher.onLog = { [weak self] msg in @@ -110,6 +115,28 @@ final class AppState { } } + // MARK: - Menu Bar + + private func setupMenuBar() { + menuBarService.onToggleTracking = { [weak self] in + guard let self else { return } + if self.isTracking { self.stopTracking() } else { self.startTracking() } + } + menuBarService.onQuit = { + NSApp.terminate(nil) + } + menuBarService.setup() + } + + func updateMenuBarStatus() { + let mqttStatus: String = switch mqttConnectionState { + case .connected: "MQTT: Connected" + case .disconnected: "MQTT: Disconnected" + case .reconnecting: "MQTT: Reconnecting…" + } + menuBarService.updateStatus(isTracking: isTracking, mqttStatus: mqttStatus) + } + // MARK: - Key Management func validateKey() async { @@ -167,9 +194,10 @@ final class AppState { } isTracking = true scheduler.start() - mqttService.connect() // discovery + attributes re-published via onConnected + mqttService.connect() startWALWatcher() addLog(.info, "Tracking started") + updateMenuBarStatus() } func stopTracking() { @@ -178,6 +206,7 @@ final class AppState { walWatcher.stop() mqttService.disconnect() addLog(.info, "Tracking stopped") + updateMenuBarStatus() } private func startWALWatcher() { @@ -317,12 +346,100 @@ final class AppState { settings.save() mqttService.configure(with: settings) scheduler.configure(with: settings) + + do { + try LoginItemService.setEnabled(settings.launchAtStartup) + } catch { + addLog(.error, "Login item: \(error.localizedDescription)") + } } func saveSettings() { persistSettings() } + // MARK: - Config Export / Import + + func exportConfig() { + var exportSettings = settings + exportSettings.trackedPeople = trackedPeople + exportSettings.keyFilePath = "" + exportSettings.customDBPath = "" + exportSettings.dbBookmarkData = nil + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + guard let data = try? encoder.encode(exportSettings) else { + addLog(.error, "Export failed: could not encode settings") + return + } + + let panel = NSSavePanel() + panel.nameFieldStringValue = "FollowMyFriends-Config.json" + panel.allowedContentTypes = [.json] + guard panel.runModal() == .OK, let url = panel.url else { return } + + do { + try data.write(to: url, options: .atomic) + addLog(.info, "Config exported to \(url.lastPathComponent)") + } catch { + addLog(.error, "Export failed: \(error.localizedDescription)") + } + } + + func importConfig() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.allowedContentTypes = [.json] + panel.message = "Select a FollowMyFriends config file" + guard panel.runModal() == .OK, let url = panel.url else { return } + + guard let data = try? Data(contentsOf: url), + let imported = try? JSONDecoder().decode(AppSettings.self, from: data) else { + addLog(.error, "Import failed: invalid config file") + return + } + + settings.mqttHost = imported.mqttHost + settings.mqttPort = imported.mqttPort + settings.mqttUsername = imported.mqttUsername + settings.mqttTopicPrefix = imported.mqttTopicPrefix + settings.pollIntervalMin = imported.pollIntervalMin + settings.pollIntervalMax = imported.pollIntervalMax + settings.randomizeInterval = imported.randomizeInterval + settings.findMyWaitSeconds = imported.findMyWaitSeconds + settings.manualRefreshThrottleSeconds = imported.manualRefreshThrottleSeconds + settings.nightModeEnabled = imported.nightModeEnabled + settings.nightModeStart = imported.nightModeStart + settings.nightModeEnd = imported.nightModeEnd + settings.launchAtStartup = imported.launchAtStartup + settings.autoStartTracking = imported.autoStartTracking + + var added = 0 + var updated = 0 + for importedPerson in imported.trackedPeople { + if let idx = trackedPeople.firstIndex(where: { $0.identifier == importedPerson.identifier }) { + if trackedPeople[idx].name.isEmpty && !importedPerson.name.isEmpty { + trackedPeople[idx].name = importedPerson.name + trackedPeople[idx].avatarColorName = importedPerson.avatarColorName + updated += 1 + } + } else { + trackedPeople.append(TrackedPerson( + name: importedPerson.name, + identifier: importedPerson.identifier, + avatarColorName: importedPerson.avatarColorName, + isActive: importedPerson.isActive + )) + added += 1 + } + } + + persistSettings() + addLog(.info, "Config imported: \(added) people added, \(updated) updated") + } + // MARK: - Database File Picker /// Opens NSOpenPanel so the user can explicitly grant access to the FindMy diff --git a/FollowMyFriends/Views/People/PeopleRow.swift b/FollowMyFriends/Views/People/PeopleRow.swift index 720ad91..1a3ac84 100644 --- a/FollowMyFriends/Views/People/PeopleRow.swift +++ b/FollowMyFriends/Views/People/PeopleRow.swift @@ -21,9 +21,9 @@ struct PeopleRow: View { .font(.system(size: 13, weight: .medium)) .foregroundStyle(Color.textPrimary) .textFieldStyle(.plain) - .onSubmit { commitName() } .onAppear { editedName = person.name } .onChange(of: person.name) { _, new in editedName = new } + .onChange(of: editedName) { _, _ in commitName() } // Identifier Text(person.identifier) diff --git a/FollowMyFriends/Views/Settings/SettingsView.swift b/FollowMyFriends/Views/Settings/SettingsView.swift index a31f0c2..81713e4 100644 --- a/FollowMyFriends/Views/Settings/SettingsView.swift +++ b/FollowMyFriends/Views/Settings/SettingsView.swift @@ -11,6 +11,24 @@ struct SettingsView: View { @State private var isTesting = false @State private var saveConfirmation = false + private var hasUnsavedChanges: Bool { + form.mqttHost != appState.settings.mqttHost || + form.mqttPort != appState.settings.mqttPort || + form.mqttUsername != appState.settings.mqttUsername || + form.mqttTopicPrefix != appState.settings.mqttTopicPrefix || + form.pollIntervalMin != appState.settings.pollIntervalMin || + form.pollIntervalMax != appState.settings.pollIntervalMax || + form.randomizeInterval != appState.settings.randomizeInterval || + form.findMyWaitSeconds != appState.settings.findMyWaitSeconds || + form.nightModeEnabled != appState.settings.nightModeEnabled || + form.nightModeStart != appState.settings.nightModeStart || + form.nightModeEnd != appState.settings.nightModeEnd || + form.keyFilePath != appState.settings.keyFilePath || + form.launchAtStartup != appState.settings.launchAtStartup || + form.autoStartTracking != appState.settings.autoStartTracking || + mqttPassword != (KeychainHelper.readPassword() ?? "") + } + var body: some View { VStack(spacing: 0) { // ── Header ──────────────────────────────────────────────────── @@ -19,6 +37,12 @@ struct SettingsView: View { .font(.system(size: 17, weight: .semibold)) .foregroundStyle(Color.textPrimary) Spacer() + if hasUnsavedChanges { + Label("Unsaved changes", systemImage: "exclamationmark.triangle.fill") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Color.accentAmber) + .transition(.opacity) + } if saveConfirmation { Label("Saved", systemImage: "checkmark.circle.fill") .font(.system(size: 12, weight: .medium)) @@ -31,7 +55,7 @@ struct SettingsView: View { .font(.system(size: 12, weight: .semibold)) .padding(.horizontal, 14) .padding(.vertical, 6) - .background(Color.accentBlue) + .background(hasUnsavedChanges ? Color.accentAmber : Color.accentBlue) .foregroundStyle(.white) .cornerRadius(7) .buttonStyle(.plain) @@ -254,10 +278,9 @@ struct SettingsView: View { ToggleRow(label: "Launch at macOS startup", detail: "Uses SMAppService", value: $form.launchAtStartup) - ToggleRow(label: "Show in Dock", - detail: "Uncheck to run as menu-bar-only app", - value: $form.showInDock) - + ToggleRow(label: "Auto-start tracking", + detail: "Begin tracking when app launches", + value: $form.autoStartTracking) Divider() Button { @@ -289,6 +312,40 @@ struct SettingsView: View { .foregroundStyle(Color.accentBlue) } .buttonStyle(.plain) + + Divider() + + HStack(spacing: 12) { + Button { + appState.exportConfig() + } label: { + HStack(spacing: 5) { + Image(systemName: "square.and.arrow.up") + Text("Export Config…") + } + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Color.accentBlue) + } + .buttonStyle(.plain) + + Button { + appState.importConfig() + form = appState.settings + mqttPassword = KeychainHelper.readPassword() ?? "" + } label: { + HStack(spacing: 5) { + Image(systemName: "square.and.arrow.down") + Text("Import Config…") + } + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Color.accentBlue) + } + .buttonStyle(.plain) + } + + Text("Export/Import includes MQTT, polling, night mode and people. Machine-specific paths are excluded.") + .font(.system(size: 10)) + .foregroundStyle(Color.textTertiary) } } }