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:
@@ -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() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user