@@ -6,7 +6,6 @@ struct HealthProbeApp: App {
|
||
| 6 | 6 |
@State private var appSettings = AppSettings() |
| 7 | 7 |
|
| 8 | 8 |
var sharedModelContainer: ModelContainer = {
|
| 9 |
- destroyAllStoresAndRecreate() |
|
| 10 | 9 |
do {
|
| 11 | 10 |
return try createModelContainer() |
| 12 | 11 |
} catch {
|
@@ -22,44 +21,19 @@ struct HealthProbeApp: App {
|
||
| 22 | 21 |
.modelContainer(sharedModelContainer) |
| 23 | 22 |
} |
| 24 | 23 |
|
| 25 |
- // Wipes all SwiftData stores and CoreData-CloudKit caches so that schema-mismatched |
|
| 26 |
- // records are not re-imported from CloudKit after the store is deleted. |
|
| 27 |
- private static func destroyAllStoresAndRecreate() {
|
|
| 28 |
- let fm = FileManager.default |
|
| 29 |
- let appSupportURL = URL.applicationSupportDirectory |
|
| 30 |
- |
|
| 31 |
- // Remove the entire Application Support directory (store files, WAL, SHM) |
|
| 32 |
- try? fm.removeItem(at: appSupportURL) |
|
| 33 |
- try? fm.createDirectory(at: appSupportURL, withIntermediateDirectories: true) |
|
| 34 |
- |
|
| 35 |
- // Also wipe CoreData-CloudKit transaction log metadata so stale remote records |
|
| 36 |
- // are not re-applied to the fresh store on the next launch. |
|
| 37 |
- let ckMetadataURL = appSupportURL.appending(path: "com.apple.coredata.cloudkit", directoryHint: .isDirectory) |
|
| 38 |
- try? fm.removeItem(at: ckMetadataURL) |
|
| 39 |
- } |
|
| 40 |
- |
|
| 41 | 24 |
// Two separate ModelConfiguration instances: |
| 42 |
- // cloudKitConfig — CloudKit-enabled for audit data; OperationLog intentionally excluded |
|
| 43 |
- // localConfig — local-only for OperationLog audit trail; never synced |
|
| 25 |
+ // cloudConfig - audit data |
|
| 26 |
+ // localConfig - local-only settings and operation metadata |
|
| 44 | 27 |
// |
| 45 | 28 |
// ⚠️ DeviceProfile is kept local-only (not synced to CloudKit) since it's device-specific |
| 46 | 29 |
// cosmetic data (name, color tag) that should not cross devices. |
| 47 | 30 |
private static func createModelContainer() throws -> ModelContainer {
|
| 48 |
- // TEMPORARY: Use in-memory storage to debug SwiftData schema issues |
|
| 49 |
- // Once working, switch to disk-based storage below |
|
| 50 | 31 |
let fullSchema = Schema([ |
| 51 | 32 |
HealthSnapshot.self, TypeCount.self, YearlyCount.self, |
| 52 | 33 |
SnapshotDelta.self, TypeDelta.self, AnomalyRecord.self, |
| 53 | 34 |
OperationLog.self, DeviceProfile.self, MetricTimeoutProfile.self, |
| 54 | 35 |
]) |
| 55 | 36 |
|
| 56 |
- #if DEBUG |
|
| 57 |
- // In-memory for development/testing (no persistence, clears on app relaunch) |
|
| 58 |
- return try ModelContainer(for: fullSchema, configurations: [ |
|
| 59 |
- ModelConfiguration(schema: fullSchema, isStoredInMemoryOnly: true) |
|
| 60 |
- ]) |
|
| 61 |
- #else |
|
| 62 |
- // Disk-based for production |
|
| 63 | 37 |
let appSupportURL = URL.applicationSupportDirectory |
| 64 | 38 |
try FileManager.default.createDirectory(at: appSupportURL, withIntermediateDirectories: true) |
| 65 | 39 |
|
@@ -96,6 +70,5 @@ struct HealthProbeApp: App {
|
||
| 96 | 70 |
for url in candidates { try? FileManager.default.removeItem(at: url) }
|
| 97 | 71 |
return try ModelContainer(for: fullSchema, configurations: [cloudConfig, localConfig]) |
| 98 | 72 |
} |
| 99 |
- #endif |
|
| 100 | 73 |
} |
| 101 | 74 |
} |
@@ -21,6 +21,10 @@ final class AppSettings {
|
||
| 21 | 21 |
} |
| 22 | 22 |
} |
| 23 | 23 |
|
| 24 |
+ static var currentDeviceID: String {
|
|
| 25 |
+ KeychainService.resolveDeviceID(swiftDataStoreIsEmpty: false).id |
|
| 26 |
+ } |
|
| 27 |
+ |
|
| 24 | 28 |
init() {
|
| 25 | 29 |
if let data = UserDefaults.standard.data(forKey: Self.selectedTypeIDsKey), |
| 26 | 30 |
let ids = try? JSONDecoder().decode([String].self, from: data) {
|
@@ -31,9 +35,15 @@ final class AppSettings {
|
||
| 31 | 35 |
|
| 32 | 36 |
if let data = UserDefaults.standard.data(forKey: Self.selectedDeviceIDsKey), |
| 33 | 37 |
let ids = try? JSONDecoder().decode([String].self, from: data) {
|
| 34 |
- selectedDeviceIDs = Set(ids) |
|
| 38 |
+ let storedIDs = Set(ids) |
|
| 39 |
+ let oldCurrentID = UIDevice.current.identifierForVendor?.uuidString |
|
| 40 |
+ if let oldCurrentID, storedIDs == [oldCurrentID] {
|
|
| 41 |
+ selectedDeviceIDs = [Self.currentDeviceID] |
|
| 42 |
+ } else {
|
|
| 43 |
+ selectedDeviceIDs = storedIDs |
|
| 44 |
+ } |
|
| 35 | 45 |
} else {
|
| 36 |
- let currentID = UIDevice.current.identifierForVendor?.uuidString ?? "" |
|
| 46 |
+ let currentID = Self.currentDeviceID |
|
| 37 | 47 |
selectedDeviceIDs = currentID.isEmpty ? [] : [currentID] |
| 38 | 48 |
} |
| 39 | 49 |
|
@@ -42,6 +52,8 @@ final class AppSettings {
|
||
| 42 | 52 |
} else {
|
| 43 | 53 |
adaptiveTimeoutsEnabled = UserDefaults.standard.bool(forKey: Self.adaptiveTimeoutsEnabledKey) |
| 44 | 54 |
} |
| 55 |
+ |
|
| 56 |
+ persistDevices() |
|
| 45 | 57 |
} |
| 46 | 58 |
|
| 47 | 59 |
func isEnabled(_ type: MonitoredType) -> Bool { selectedTypeIDs.contains(type.id) }
|
@@ -7,13 +7,14 @@ struct DashboardView: View {
|
||
| 7 | 7 |
@Environment(\.modelContext) private var modelContext |
| 8 | 8 |
@Environment(AppSettings.self) private var appSettings |
| 9 | 9 |
@Query(sort: \HealthSnapshot.timestamp, order: .reverse) private var snapshots: [HealthSnapshot] |
| 10 |
+ @Query private var deviceProfiles: [DeviceProfile] |
|
| 10 | 11 |
@State private var viewModel = DashboardViewModel() |
| 11 | 12 |
@State private var didAutoRequestPermissions = false |
| 12 | 13 |
@State private var snapshotSheetTab: SnapshotSheetTab = .progress |
| 13 | 14 |
@State private var expandedIssueIDs: Set<String> = [] |
| 14 | 15 |
|
| 15 | 16 |
init() {
|
| 16 |
- let deviceID = KeychainService.resolveDeviceID(swiftDataStoreIsEmpty: false).id |
|
| 17 |
+ let deviceID = AppSettings.currentDeviceID |
|
| 17 | 18 |
_snapshots = Query( |
| 18 | 19 |
filter: #Predicate<HealthSnapshot> { $0.deviceID == deviceID },
|
| 19 | 20 |
sort: \HealthSnapshot.timestamp, |
@@ -23,6 +24,15 @@ struct DashboardView: View {
|
||
| 23 | 24 |
|
| 24 | 25 |
private var latest: HealthSnapshot? { snapshots.first }
|
| 25 | 26 |
private var previous: HealthSnapshot? { snapshots.dropFirst().first }
|
| 27 |
+ private var currentDeviceProfile: DeviceProfile? {
|
|
| 28 |
+ deviceProfiles.first { $0.deviceID == AppSettings.currentDeviceID }
|
|
| 29 |
+ } |
|
| 30 |
+ |
|
| 31 |
+ private var currentDeviceDisplayName: String {
|
|
| 32 |
+ if let name = currentDeviceProfile?.name, !name.isEmpty { return name }
|
|
| 33 |
+ guard let latest, !latest.deviceName.isEmpty else { return "Unknown device" }
|
|
| 34 |
+ return latest.deviceName |
|
| 35 |
+ } |
|
| 26 | 36 |
|
| 27 | 37 |
var body: some View {
|
| 28 | 38 |
NavigationStack {
|
@@ -901,7 +911,7 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 901 | 911 |
.foregroundStyle(.secondary) |
| 902 | 912 |
} |
| 903 | 913 |
InfoRow(label: "Device") {
|
| 904 |
- Text(latest.deviceName) |
|
| 914 |
+ Text(currentDeviceDisplayName) |
|
| 905 | 915 |
.foregroundStyle(.secondary) |
| 906 | 916 |
} |
| 907 | 917 |
if latest.snapshotQuality != SnapshotQuality.complete {
|
@@ -1,6 +1,5 @@ |
||
| 1 | 1 |
import SwiftUI |
| 2 | 2 |
import SwiftData |
| 3 |
-import UIKit |
|
| 4 | 3 |
|
| 5 | 4 |
struct DataTypesView: View {
|
| 6 | 5 |
@Environment(AppSettings.self) private var appSettings |
@@ -23,7 +22,7 @@ struct DataTypesView: View {
|
||
| 23 | 22 |
private var latest: HealthSnapshot? { displayedSnapshots.first }
|
| 24 | 23 |
|
| 25 | 24 |
private var knownDevices: [DeviceEntry] {
|
| 26 |
- let currentID = UIDevice.current.identifierForVendor?.uuidString ?? "" |
|
| 25 |
+ let currentID = AppSettings.currentDeviceID |
|
| 27 | 26 |
var ids = Set(allSnapshots.map { $0.deviceID })
|
| 28 | 27 |
if !currentID.isEmpty { ids.insert(currentID) }
|
| 29 | 28 |
return ids.map { id in
|
@@ -13,7 +13,7 @@ struct SettingsView: View {
|
||
| 13 | 13 |
@State private var showDeleteConfirm = false |
| 14 | 14 |
|
| 15 | 15 |
private var currentDeviceID: String {
|
| 16 |
- UIDevice.current.identifierForVendor?.uuidString ?? "" |
|
| 16 |
+ AppSettings.currentDeviceID |
|
| 17 | 17 |
} |
| 18 | 18 |
|
| 19 | 19 |
private var currentDeviceProfile: DeviceProfile? {
|
@@ -58,7 +58,10 @@ struct SettingsView: View {
|
||
| 58 | 58 |
Spacer() |
| 59 | 59 |
TextField("Device name", text: Binding(
|
| 60 | 60 |
get: { profile.name },
|
| 61 |
- set: { profile.name = $0 }
|
|
| 61 |
+ set: {
|
|
| 62 |
+ profile.name = $0 |
|
| 63 |
+ try? modelContext.save() |
|
| 64 |
+ } |
|
| 62 | 65 |
)) |
| 63 | 66 |
.multilineTextAlignment(.trailing) |
| 64 | 67 |
.foregroundStyle(.secondary) |
@@ -79,7 +82,10 @@ struct SettingsView: View {
|
||
| 79 | 82 |
.frame(width: 24, height: 24) |
| 80 | 83 |
} |
| 81 | 84 |
} |
| 82 |
- .onTapGesture { profile.colorTag = dc.rawValue }
|
|
| 85 |
+ .onTapGesture {
|
|
| 86 |
+ profile.colorTag = dc.rawValue |
|
| 87 |
+ try? modelContext.save() |
|
| 88 |
+ } |
|
| 83 | 89 |
.accessibilityLabel(dc.rawValue.capitalized) |
| 84 | 90 |
.accessibilityAddTraits(profile.colorTag == dc.rawValue ? .isSelected : []) |
| 85 | 91 |
} |
@@ -177,6 +183,7 @@ struct SettingsView: View {
|
||
| 177 | 183 |
private func ensureCurrentDeviceProfile() {
|
| 178 | 184 |
guard currentDeviceProfile == nil, !currentDeviceID.isEmpty else { return }
|
| 179 | 185 |
modelContext.insert(DeviceProfile(deviceID: currentDeviceID)) |
| 186 |
+ try? modelContext.save() |
|
| 180 | 187 |
} |
| 181 | 188 |
|
| 182 | 189 |
private func ensureTimeoutProfiles() {
|
@@ -22,7 +22,7 @@ struct SnapshotsView: View {
|
||
| 22 | 22 |
} |
| 23 | 23 |
|
| 24 | 24 |
private var knownDevices: [DeviceEntry] {
|
| 25 |
- let currentID = KeychainService.resolveDeviceID(swiftDataStoreIsEmpty: false).id |
|
| 25 |
+ let currentID = AppSettings.currentDeviceID |
|
| 26 | 26 |
var ids = Set(allSnapshots.map { $0.deviceID })
|
| 27 | 27 |
ids.insert(currentID) |
| 28 | 28 |
return ids.map { id in
|