diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index 4df1a37..7b1d326 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -45,6 +45,28 @@ struct SettingsView: View { @State private var debugMode: Bool = FuelStore.loadDebugMode() @State private var versionTapCount = 0 @State private var lastVersionTap = Date.distantPast + /// Widget diagnostics: the extension's last makeEntry beacon (keychain) + /// + the installed-widget inventory from WidgetCenter. + @State private var widgetDiagBeacon: String? + @State private var installedWidgets: String = "" + + private func refreshWidgetDiag() { + widgetDiagBeacon = FuelStore.loadWidgetDiag() + WidgetCenter.shared.getCurrentConfigurations { result in + let text: String + switch result { + case .success(let widgets): + text = widgets.isEmpty + ? "No widgets installed" + : widgets.map { "\($0.kind) · \($0.family)" }.joined(separator: "\n") + case .failure(let error): + text = "Error: \(error.localizedDescription)" + } + Task { @MainActor in + installedWidgets = text + } + } + } var body: some View { NavigationStack { @@ -110,6 +132,32 @@ struct SettingsView: View { Text("The first button applies the real criteria — cheapest \(alertsFuel.displayName.lowercased()) station within \(distanceUnit.format(alertsRadiusKM)) of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (nearest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions. Cheapest in range mirrors the app list (selected fuel + distance); the in-range count mirrors the alert radius.") } .onAppear { onRefreshDebugStatus() } + + Section { + Button { + refreshWidgetDiag() + } label: { + Label("Refresh widget diagnostics", systemImage: "arrow.clockwise") + } + if let widgetDiagBeacon { + Text(widgetDiagBeacon) + .font(.caption2) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } else { + Text("No widget beacon yet — add a widget on the home screen, then refresh.") + .font(.caption2) + .foregroundStyle(.secondary) + } + Text(installedWidgets.isEmpty ? "No widgets installed" : installedWidgets) + .font(.caption2) + .foregroundStyle(.secondary) + } header: { + Text("Widget Diagnostics") + } footer: { + Text("The beacon is written by the widget extension at the end of every timeline entry (keychain, so it survives free-SideStore installs). Installed widgets come from WidgetCenter.") + } + .onAppear { refreshWidgetDiag() } } Section { diff --git a/FuelBoardWidgets/FuelPriceWidget.swift b/FuelBoardWidgets/FuelPriceWidget.swift index 68e87e9..0c4186c 100644 --- a/FuelBoardWidgets/FuelPriceWidget.swift +++ b/FuelBoardWidgets/FuelPriceWidget.swift @@ -127,6 +127,36 @@ struct FuelPriceTimelineProvider FuelPriceEntry { + let entry = await makeEntryCore(configuration: configuration) + Self.writeDiagBeacon(entry: entry) + return entry + } + + /// Fire-and-forget diagnostics beacon: writes the entry state to keychain + /// (app-readable) and GETs the relay so its access log records that a + /// widget timeline actually ran in the extension and what it produced. + /// Deliberately outside the timeline result — can never affect rendering. + private static func writeDiagBeacon(entry: FuelPriceEntry) { + let first = entry.stations.first + let json = """ + {"intent":"\(Configuration.self)","source":"\(entry.locationSource)",\ + "n":\(entry.stations.count),"fuel":"\(entry.fuel.rawValue)",\ + "sort":"\(entry.sort.rawValue)","fav":\(entry.isFavourites),\ + "station":"\(first?.name ?? "")","price":\(first?.prices[entry.fuel] ?? -1)} + """ + FuelStore.saveWidgetDiag(json) + guard let url = URLComponents( + url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/widget-diag"), + resolvingAgainstBaseURL: false + )?.url else { return } + var request = URLRequest(url: url) + request.timeoutInterval = 2 + Task { + _ = try? await URLSession.shared.data(for: request) + } + } + + private func makeEntryCore(configuration: Configuration) async -> FuelPriceEntry { // Per-widget config: fuel + sort + distance come from THIS widget instance. let fuel = FuelType(rawValue: configuration.fuel.rawValue) ?? .e10 let isFavourites = configuration.sort == .favourites diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index b6a7b05..f68615a 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -696,4 +696,16 @@ struct FuelStore { UserDefaults(suiteName: appGroupSuite)?.set(value, forKey: service) writeKeychain(data: Data(value.utf8), service: service) } + + // Widget diagnostics beacon — the widget extension writes its last + // makeEntry state here (keychain survives on free SideStore accounts where + // the app-group container isn't provisioned); the app's Debug section + // reads it to see whether a widget timeline ran and what it produced. + static func saveWidgetDiag(_ json: String) { + saveString(json, service: "widget.diag") + } + + static func loadWidgetDiag() -> String? { + loadString(service: "widget.diag") + } }