Widget diagnostics: beacon from makeEntry (keychain + relay) + app diagnostics section
The small-widget skeleton survived four resolution fixes; instrument the widget instead of guessing. Every makeEntry now writes its entry state (intent type, location source, station count, fuel, sort, favourites flag, first station) to a keychain beacon (app-readable) and fire-and-forgets a GET to the relay's /api/v1/widget-diag so its access log records whether a widget timeline actually runs in the extension and what it produced. Settings → Debug gains a Widget Diagnostics section: the keychain beacon + the installed-widget inventory from WidgetCenter. Relay gains the /api/v1/widget-diag route (204, log-only).
This commit is contained in:
@@ -45,6 +45,28 @@ 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)
|
||||||
|
/// + 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 {
|
var body: some View {
|
||||||
NavigationStack {
|
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.")
|
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() }
|
.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 {
|
Section {
|
||||||
|
|||||||
@@ -127,6 +127,36 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func makeEntry(configuration: Configuration) async -> FuelPriceEntry {
|
private func makeEntry(configuration: Configuration) async -> 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.
|
// Per-widget config: fuel + sort + distance come from THIS widget instance.
|
||||||
let fuel = FuelType(rawValue: configuration.fuel.rawValue) ?? .e10
|
let fuel = FuelType(rawValue: configuration.fuel.rawValue) ?? .e10
|
||||||
let isFavourites = configuration.sort == .favourites
|
let isFavourites = configuration.sort == .favourites
|
||||||
|
|||||||
@@ -696,4 +696,16 @@ struct FuelStore {
|
|||||||
UserDefaults(suiteName: appGroupSuite)?.set(value, forKey: service)
|
UserDefaults(suiteName: appGroupSuite)?.set(value, forKey: service)
|
||||||
writeKeychain(data: Data(value.utf8), service: 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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user