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 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?
/// Widget diagnostics: the extension's last makeEntry beacon PER intent
/// type (keychain) + the installed-widget inventory from WidgetCenter.
@State private var smallWidgetDiag: String?
@State private var mediumWidgetDiag: String?
@State private var installedWidgets: String = ""
private func refreshWidgetDiag() {
widgetDiagBeacon = FuelStore.loadWidgetDiag()
smallWidgetDiag = FuelStore.loadWidgetDiag(intentType: "FuelBoardSmallWidgetConfigurationIntent")
mediumWidgetDiag = FuelStore.loadWidgetDiag(intentType: "FuelBoardWidgetConfigurationIntent")
WidgetCenter.shared.getCurrentConfigurations { result in
let text: String
switch result {
@@ -139,13 +141,23 @@ struct SettingsView: View {
} label: {
Label("Refresh widget diagnostics", systemImage: "arrow.clockwise")
}
if let widgetDiagBeacon {
Text(widgetDiagBeacon)
if let smallWidgetDiag {
Text("SMALL: \(smallWidgetDiag)")
.font(.caption2)
.foregroundStyle(.secondary)
.textSelection(.enabled)
} 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)
.foregroundStyle(.secondary)
}
@@ -155,7 +167,7 @@ struct SettingsView: View {
} 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.")
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() }
}
+20 -7
View File
@@ -133,22 +133,35 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
}
/// 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.
/// (app-readable, one slot per intent type) 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 intentType = String(describing: Configuration.self)
let first = entry.stations.first
let json = """
{"intent":"\(Configuration.self)","source":"\(entry.locationSource)",\
{"intent":"\(intentType)","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(
FuelStore.saveWidgetDiag(json, intentType: intentType)
guard var components = URLComponents(
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/widget-diag"),
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)
request.timeoutInterval = 2
Task {
+10 -8
View File
@@ -697,15 +697,17 @@ struct FuelStore {
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")
// Widget diagnostics beacons the widget extension writes its last
// makeEntry state PER INTENT TYPE (keychain survives on free SideStore
// accounts where the app-group container isn't provisioned); the app's
// Debug section reads them to see whether each widget kind's timeline
// actually ran and what it produced. Service key embeds the intent type
// 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? {
loadString(service: "widget.diag")
static func loadWidgetDiag(intentType: String) -> String? {
loadString(service: "widget.diag.\(intentType)")
}
}