Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions HomeAssistant.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,9 @@
13E4D63B90C14B0DA25F2827 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = C8FA7E5B23954D258050DD72 /* SFSafeSymbols */; };
14A4AE0E492F4BADB1E8A3CA /* TestFlightCommunicationCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 666F733264F8449E8E7CB985 /* TestFlightCommunicationCatalog.swift */; };
177E4B39B7BA296CCB68A27D /* Pods-iOS-Extensions-Widgets-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 6723A4E97E50C3C9141428D0 /* Pods-iOS-Extensions-Widgets-metadata.plist */; };
187CA3C02FF709A500F14FD7 /* WidgetPushHandlerModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 187CA3BF2FF709A500F14FD7 /* WidgetPushHandlerModifier.swift */; };
187CA3C12FF709A500F14FD7 /* WidgetPushHandlerModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 187CA3BF2FF709A500F14FD7 /* WidgetPushHandlerModifier.swift */; };
187CA3C32FF709DB00F14FD7 /* HAWidgetPushHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 187CA3C22FF709DB00F14FD7 /* HAWidgetPushHandler.swift */; };
1A0BF50187A921289B3BA4AE /* Pods-Tests-App-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = B67C3F1DA02199833DA64AF8 /* Pods-Tests-App-metadata.plist */; };
1A3CC1D75E8EEF7294146BD1 /* ControlFanValueProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A34A5417D650BBBE9D2D7C0 /* ControlFanValueProvider.swift */; };
1AA7D3A6102D7CC4601EEC6F /* KioskSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E70F7D3E8E4B489FA1514E7C /* KioskSettingsViewModel.swift */; };
Expand Down Expand Up @@ -1643,6 +1646,7 @@
E864AF283C52DCFA11B83545 /* KioskSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22AA95E1438BC6A0DE7EF820 /* KioskSettingsView.swift */; };
E9D0AE4CC8B1D908D258B791 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 3F08AA27DA3E77F7C8FA1059 /* GRDB */; };
EB30AC52784FBFC4C3CF251A /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = E516764DF55905E4F0FE2003 /* GRDB */; };
EF2E71982FF71AED00CAA421 /* HAWidgetPushHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 187CA3C22FF709DB00F14FD7 /* HAWidgetPushHandler.swift */; };
EF407595A931C19EBCCC9E87 /* KioskModeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CCF41D72CA895DB501333EF /* KioskModeManager.swift */; };
F0D1DD41A8F55F6D767EBF37 /* TemplatePreviewSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 332C060487B468055C487070 /* TemplatePreviewSection.swift */; };
F155B3EE44F62779FADE7DFA /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 2715B836C33E6EDA430278BD /* GRDB */; };
Expand Down Expand Up @@ -2300,6 +2304,8 @@
1396822195C7FF562AB891F2 /* BarometerSensor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarometerSensor.swift; sourceTree = "<group>"; };
14EE842480AD406D8305F377 /* TestFlightCommunicationEngine.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestFlightCommunicationEngine.test.swift; sourceTree = "<group>"; };
1861EB0361816DC9260D1F5E /* ComplicationFamilySelectView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComplicationFamilySelectView.swift; sourceTree = "<group>"; };
187CA3BF2FF709A500F14FD7 /* WidgetPushHandlerModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetPushHandlerModifier.swift; sourceTree = "<group>"; };
187CA3C22FF709DB00F14FD7 /* HAWidgetPushHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HAWidgetPushHandler.swift; sourceTree = "<group>"; };
1945E8E386664E5A88668E77 /* WebViewController+ReAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebViewController+ReAuth.swift"; sourceTree = "<group>"; };
1948705C52B43ACD36FD0B32 /* QuickActionWindowSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickActionWindowSceneDelegate.swift; sourceTree = "<group>"; };
1CCF41D72CA895DB501333EF /* KioskModeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskModeManager.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3920,6 +3926,7 @@
115560DF27010D6700A8F818 /* Common */ = {
isa = PBXGroup;
children = (
187CA3BF2FF709A500F14FD7 /* WidgetPushHandlerModifier.swift */,
115560E027010D8400A8F818 /* WidgetBasicContainerView.swift */,
42392EAA2F30A7870051D155 /* WidgetFamily+AppPreview.swift */,
42B74A612D36B08600C37304 /* WidgetBasicView.swift */,
Expand Down Expand Up @@ -4659,6 +4666,7 @@
3E02C0F82CAD9EC700102131 /* Sensor */ = {
isa = PBXGroup;
children = (
187CA3C22FF709DB00F14FD7 /* HAWidgetPushHandler.swift */,
3E02C0E42CA7FCF400102131 /* WidgetSensors.swift */,
);
path = Sensor;
Expand Down Expand Up @@ -9393,6 +9401,7 @@
42AC20012FA1000000000004 /* RenderTemplateAppIntent.swift in Sources */,
4276FEF52FE92903001F9228 /* GaugeArcView.swift in Sources */,
42AC20012FA1000000000007 /* IntentActionEntity.swift in Sources */,
187CA3C02FF709A500F14FD7 /* WidgetPushHandlerModifier.swift in Sources */,
42AC20012FA1000000000008 /* IntentCameraEntity.swift in Sources */,
42AC20012FA1000000000009 /* ShortcutAppIntentSupport.swift in Sources */,
42AC20012FA1000000000005 /* UpdateLocationAppIntent.swift in Sources */,
Expand All @@ -9408,6 +9417,7 @@
4223688E2D40FB00005911E4 /* WidgetCustomTimelineProvider.swift in Sources */,
428CB3382CF7FC0800F1320E /* WidgetFamilySizes.swift in Sources */,
4223688B2D40F9B7005911E4 /* WidgetCustom.swift in Sources */,
187CA3C32FF709DB00F14FD7 /* HAWidgetPushHandler.swift in Sources */,
429BA2AF2C800CAB00A50996 /* SFSymbolEntity.swift in Sources */,
420461692C8F29440062E89F /* ControlLight.swift in Sources */,
3BB66E53094D6C7C2F395D54 /* ControlFan.swift in Sources */,
Expand Down Expand Up @@ -9575,6 +9585,7 @@
428CB3372CF7FC0800F1320E /* WidgetFamilySizes.swift in Sources */,
424627332C98D8E900EF7B43 /* WidgetBasicViewTintedWrapper.swift in Sources */,
4285C5532D35658000DADE45 /* TileCard.swift in Sources */,
EF2E71982FF71AED00CAA421 /* HAWidgetPushHandler.swift in Sources */,
42D57FC22E0589DE0061FF3B /* WebRTCClient.swift in Sources */,
42F5C0892F02BCDE00C50310 /* IntentFanEntity.swift in Sources */,
426BB0C52D393F0E0062D905 /* EntityPicker.swift in Sources */,
Expand Down Expand Up @@ -9783,6 +9794,7 @@
4289DDAA2C85AB4C003591C2 /* AssistAppIntent.swift in Sources */,
4273C4882C8857B00065A5B4 /* ControlOpenPage.swift in Sources */,
3E02C0E22CA7FCBF00102131 /* IntentSensorsAppEntity.swift in Sources */,
187CA3C12FF709A500F14FD7 /* WidgetPushHandlerModifier.swift in Sources */,
4280E06F2DC23B120004BE7D /* SensorListViewModel.swift in Sources */,
11A71C8B24A5848B00D9565F /* ZoneManagerProcessor.swift in Sources */,
42A47A852C45218D00C9B43D /* WebViewExternalMessageHandler.swift in Sources */,
Expand Down
12 changes: 12 additions & 0 deletions Sources/Extensions/Widgets/Common/WidgetPushHandlerModifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import SwiftUI
import WidgetKit

extension WidgetConfiguration {
func haWidgetPushHandlerIfAvailable() -> some WidgetConfiguration {
if #available(iOS 26.0, *) {
return self.pushHandler(HAWidgetPushHandler.self)
} else {
return self
}
}
}
1 change: 1 addition & 0 deletions Sources/Extensions/Widgets/Custom/WidgetCustom.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ struct WidgetCustom: Widget {
.configurationDisplayName(L10n.Widgets.Preview.Custom.title)
.description(L10n.Widgets.Preview.Custom.description)
.supportedFamilies(WidgetCustomSupportedFamilies.families)
.haWidgetPushHandlerIfAvailable()
}

private var emptyView: some View {
Expand Down
111 changes: 111 additions & 0 deletions Sources/Extensions/Widgets/Sensor/HAWidgetPushHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import Shared
import WidgetKit

@available(iOS 26.0, *)
struct HAWidgetPushHandler: WidgetPushHandler {
static let webhookType = "register_push_subscription"

/// One push subscription per widget kind. Every entity a kind tracks is
/// registered under a stable, kind-specific subscription_id so kinds do not
/// clobber each other's entity sets in core (registration is idempotent on
/// subscription_id).
struct Subscription {
let subscriptionID: String
let target: String
let entityIDs: Set<String>
}

func pushTokenDidChange(_ pushInfo: WidgetPushInfo, widgets: [WidgetInfo]) {
let tokenHex = pushInfo.token.map { String(format: "%02x", $0) }.joined()
let subscriptions = Self.subscriptions(from: widgets).filter { !$0.entityIDs.isEmpty }

Current.Log.info("HAWidgetPushHandler: token changed -> \(subscriptions.count) subscription(s)")

guard !subscriptions.isEmpty else {
Current.Log.info("HAWidgetPushHandler: no trackable entities, skipping register")
return
}
guard !Current.servers.all.isEmpty else {
Current.Log.error("HAWidgetPushHandler: no servers available in extension")
return
}

for server in Current.servers.all {
for subscription in subscriptions {
let request = WebhookRequest(
type: Self.webhookType,
data: [
"subscription_id": subscription.subscriptionID,
"push_token": tokenHex,
"entity_ids": subscription.entityIDs.sorted(),
"target": subscription.target,
]
)
Current.Log.info(
"HAWidgetPushHandler: registering \(subscription.subscriptionID) " +
"(\(subscription.entityIDs.count) entities) with \(server.identifier.rawValue)"
)
Task {
do {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
Current.webhooks.sendEphemeral(server: server, request: request)

@bgoncal bgoncal Jul 6, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this fails, when will it be retried?

.done { continuation.resume() }
.catch { continuation.resume(throwing: $0) }
}
} catch {
Current.Log.error(
"HAWidgetPushHandler: failed to register \(subscription.subscriptionID) " +
"with \(server.identifier.rawValue): \(error)"
)
}
}
Comment thread
hariharanjagan marked this conversation as resolved.
}
}
}

/// Collect tracked entity IDs for every push-capable widget kind present,
/// grouped into one subscription per kind.
///
/// Only kinds whose entities are known at configuration time are included.
/// Template-driven kinds (gauge, details) render Jinja server-side and have
/// no fixed entity list, and commonlyUsedEntities is computed dynamically, so
/// those cannot be expressed as entity subscriptions and are excluded.
static func subscriptions(from widgets: [WidgetInfo]) -> [Subscription] {
var entitiesByKind: [WidgetsKind: Set<String>] = [:]

for info in widgets {
guard let kind = WidgetsKind(rawValue: info.kind) else { continue }
switch kind {
case .sensors:
if let intent = try? info.widgetConfigurationIntent(of: WidgetSensorsAppIntent.self),
let sensors = intent.sensors {
entitiesByKind[.sensors, default: []].formUnion(sensors.map(\.entityId))
}
case .todoList:
if let intent = try? info.widgetConfigurationIntent(of: WidgetTodoListAppIntent.self),
let entityId = intent.list?.entityId {
entitiesByKind[.todoList, default: []].insert(entityId)
}
case .custom:
if let intent = try? info.widgetConfigurationIntent(of: WidgetCustomAppIntent.self),
let widgetId = intent.widget?.id,
let widget = try? CustomWidget.widgets()?.first(where: { $0.id == widgetId }) {
let entityIDs = widget.items
.filter { $0.type == .entity }
.map(\.id)
entitiesByKind[.custom, default: []].formUnion(entityIDs)
}
default:
continue
}
}

return entitiesByKind.map { kind, entityIDs in
Subscription(
subscriptionID: "ios-widget-\(kind.rawValue)",
target: kind.rawValue,
entityIDs: entityIDs
)
}
}
}
1 change: 1 addition & 0 deletions Sources/Extensions/Widgets/Sensor/WidgetSensors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ struct WidgetSensors: Widget {
.description(L10n.Widgets.Sensors.description)
.supportedFamilies(WidgetDetailsTableSupportedFamilies.families)
.disfavoredInCarPlayIfAvailable(for: WidgetDetailsTableSupportedFamilies.families)
.haWidgetPushHandlerIfAvailable()
}

private func appendUnitOfMeasurementToValue(sensor: WidgetSensorsEntry.SensorData) -> String {
Expand Down
1 change: 1 addition & 0 deletions Sources/Extensions/Widgets/TodoList/WidgetTodoList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ struct WidgetTodoList: Widget {
.configurationDisplayName(L10n.Widgets.TodoList.title)
.description(L10n.Widgets.TodoList.description)
.supportedFamilies(supportedFamilies)
.haWidgetPushHandlerIfAvailable()
}

private var supportedFamilies: [WidgetFamily] {
Expand Down
Loading