Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions FollowMyFriends.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand All @@ -47,6 +48,7 @@
025B3E814A5B7DA7BBC4F63C /* LogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogEntry.swift; sourceTree = "<group>"; };
0606256C27AC4041C758939A /* LocationUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationUpdate.swift; sourceTree = "<group>"; };
1011CB86759E8D5D1C6F2B28 /* MQTTService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MQTTService.swift; sourceTree = "<group>"; };
C2B3D4E5F6071829304A5B6D /* MenuBarService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarService.swift; sourceTree = "<group>"; };
12D26167DC0574D67753BFA8 /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = "<group>"; };
17266AFDD2027B69BD4D4616 /* WALMerger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WALMerger.swift; sourceTree = "<group>"; };
1AF5D34EFAB614B82BE6626A /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
1 change: 1 addition & 0 deletions FollowMyFriends/Models/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down
152 changes: 152 additions & 0 deletions FollowMyFriends/Services/MenuBarService.swift
Original file line number Diff line number Diff line change
@@ -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?()
}
}
121 changes: 119 additions & 2 deletions FollowMyFriends/ViewModels/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ final class AppState {
let launcher: FindMyLauncher
let walWatcher = WALWatcher()
let notificationService = NotificationService.shared
let menuBarService = MenuBarService()

// MARK: - Init

Expand All @@ -50,6 +51,7 @@ final class AppState {
self.trackedPeople = loaded.trackedPeople

setupServiceCallbacks()
setupMenuBar()
notificationService.requestPermission()
addLog(.info, "FollowMyFriends started")

Expand All @@ -67,6 +69,9 @@ final class AppState {

Task {
await validateKey()
if settings.autoStartTracking && keyIsValid {
startTracking()
}
}
}

Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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() {
Expand All @@ -178,6 +206,7 @@ final class AppState {
walWatcher.stop()
mqttService.disconnect()
addLog(.info, "Tracking stopped")
updateMenuBarStatus()
}

private func startWALWatcher() {
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion FollowMyFriends/Views/People/PeopleRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading