Widget diagnostics: per-intent beacons (keychain slots + relay query)

The single-slot beacon couldn't distinguish small vs medium (last writer
wins). Beacon keychain service now embeds the intent type name so small
and medium never overwrite each other; the relay GET now carries the
intent type + entry state as query params so the access log shows exactly
which widget kind ran makeEntry, when, and with what. Diagnostics screen
shows SMALL and MEDIUM beacons side by side — a missing SMALL beacon
after adding the small widget proves its timeline never reaches the
provider (system-level config resolution failure).
This commit is contained in:
FuelBoard Contributor
2026-08-13 20:13:24 +01:00
parent 335a9751dc
commit 3cf1d5c9af
3 changed files with 50 additions and 23 deletions
+20 -8
View File
@@ -45,13 +45,15 @@ struct SettingsView: View {
@State private var debugMode: Bool = FuelStore.loadDebugMode() @State private var debugMode: Bool = FuelStore.loadDebugMode()
@State private var versionTapCount = 0 @State private var versionTapCount = 0
@State private var lastVersionTap = Date.distantPast @State private var lastVersionTap = Date.distantPast
/// Widget diagnostics: the extension's last makeEntry beacon (keychain) /// Widget diagnostics: the extension's last makeEntry beacon PER intent
/// + the installed-widget inventory from WidgetCenter. /// type (keychain) + the installed-widget inventory from WidgetCenter.
@State private var widgetDiagBeacon: String? @State private var smallWidgetDiag: String?
@State private var mediumWidgetDiag: String?
@State private var installedWidgets: String = "" @State private var installedWidgets: String = ""
private func refreshWidgetDiag() { private func refreshWidgetDiag() {
widgetDiagBeacon = FuelStore.loadWidgetDiag() smallWidgetDiag = FuelStore.loadWidgetDiag(intentType: "FuelBoardSmallWidgetConfigurationIntent")
mediumWidgetDiag = FuelStore.loadWidgetDiag(intentType: "FuelBoardWidgetConfigurationIntent")
WidgetCenter.shared.getCurrentConfigurations { result in WidgetCenter.shared.getCurrentConfigurations { result in
let text: String let text: String
switch result { switch result {
@@ -139,13 +141,23 @@ struct SettingsView: View {
} label: { } label: {
Label("Refresh widget diagnostics", systemImage: "arrow.clockwise") Label("Refresh widget diagnostics", systemImage: "arrow.clockwise")
} }
if let widgetDiagBeacon { if let smallWidgetDiag {
Text(widgetDiagBeacon) Text("SMALL: \(smallWidgetDiag)")
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.textSelection(.enabled) .textSelection(.enabled)
} else { } else {
Text("No widget beacon yet — add a widget on the home screen, then refresh.") Text("SMALL: no beacon yet")
.font(.caption2)
.foregroundStyle(.secondary)
}
if let mediumWidgetDiag {
Text("MEDIUM: \(mediumWidgetDiag)")
.font(.caption2)
.foregroundStyle(.secondary)
.textSelection(.enabled)
} else {
Text("MEDIUM: no beacon yet")
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -155,7 +167,7 @@ struct SettingsView: View {
} header: { } header: {
Text("Widget Diagnostics") Text("Widget Diagnostics")
} footer: { } 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.") Text("Each beacon is written by the widget extension at the end of every timeline entry for that intent type (keychain, so it survives free-SideStore installs). A missing SMALL beacon after adding the small widget means its timeline never reaches the provider. Installed widgets come from WidgetCenter.")
} }
.onAppear { refreshWidgetDiag() } .onAppear { refreshWidgetDiag() }
} }
+20 -7
View File
@@ -133,22 +133,35 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
} }
/// Fire-and-forget diagnostics beacon: writes the entry state to keychain /// Fire-and-forget diagnostics beacon: writes the entry state to keychain
/// (app-readable) and GETs the relay so its access log records that a /// (app-readable, one slot per intent type) and GETs the relay so its
/// widget timeline actually ran in the extension and what it produced. /// access log records that a widget timeline actually ran in the
/// Deliberately outside the timeline result can never affect rendering. /// extension and what it produced. Deliberately outside the timeline
/// result can never affect rendering.
private static func writeDiagBeacon(entry: FuelPriceEntry) { private static func writeDiagBeacon(entry: FuelPriceEntry) {
let intentType = String(describing: Configuration.self)
let first = entry.stations.first let first = entry.stations.first
let json = """ let json = """
{"intent":"\(Configuration.self)","source":"\(entry.locationSource)",\ {"intent":"\(intentType)","source":"\(entry.locationSource)",\
"n":\(entry.stations.count),"fuel":"\(entry.fuel.rawValue)",\ "n":\(entry.stations.count),"fuel":"\(entry.fuel.rawValue)",\
"sort":"\(entry.sort.rawValue)","fav":\(entry.isFavourites),\ "sort":"\(entry.sort.rawValue)","fav":\(entry.isFavourites),\
"station":"\(first?.name ?? "")","price":\(first?.prices[entry.fuel] ?? -1)} "station":"\(first?.name ?? "")","price":\(first?.prices[entry.fuel] ?? -1)}
""" """
FuelStore.saveWidgetDiag(json) FuelStore.saveWidgetDiag(json, intentType: intentType)
guard let url = URLComponents( guard var components = URLComponents(
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/widget-diag"), url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/widget-diag"),
resolvingAgainstBaseURL: false resolvingAgainstBaseURL: false
)?.url else { return } ) else { return }
components.queryItems = [
URLQueryItem(name: "intent", value: intentType),
URLQueryItem(name: "source", value: entry.locationSource),
URLQueryItem(name: "n", value: String(entry.stations.count)),
URLQueryItem(name: "fuel", value: entry.fuel.rawValue),
URLQueryItem(name: "sort", value: entry.sort.rawValue),
URLQueryItem(name: "fav", value: entry.isFavourites ? "1" : "0"),
URLQueryItem(name: "station", value: first?.name ?? ""),
URLQueryItem(name: "price", value: String(first?.prices[entry.fuel] ?? -1)),
]
guard let url = components.url else { return }
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.timeoutInterval = 2 request.timeoutInterval = 2
Task { Task {
+10 -8
View File
@@ -697,15 +697,17 @@ struct FuelStore {
writeKeychain(data: Data(value.utf8), service: service) writeKeychain(data: Data(value.utf8), service: service)
} }
// Widget diagnostics beacon the widget extension writes its last // Widget diagnostics beacons the widget extension writes its last
// makeEntry state here (keychain survives on free SideStore accounts where // makeEntry state PER INTENT TYPE (keychain survives on free SideStore
// the app-group container isn't provisioned); the app's Debug section // accounts where the app-group container isn't provisioned); the app's
// reads it to see whether a widget timeline ran and what it produced. // Debug section reads them to see whether each widget kind's timeline
static func saveWidgetDiag(_ json: String) { // actually ran and what it produced. Service key embeds the intent type
saveString(json, service: "widget.diag") // so small and medium widgets never overwrite each other.
static func saveWidgetDiag(_ json: String, intentType: String) {
saveString(json, service: "widget.diag.\(intentType)")
} }
static func loadWidgetDiag() -> String? { static func loadWidgetDiag(intentType: String) -> String? {
loadString(service: "widget.diag") loadString(service: "widget.diag.\(intentType)")
} }
} }