Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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
20 changes: 20 additions & 0 deletions Sources/Extensions/Widgets/Common/WidgetPushHandlerModifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// WidgetPushHandlerModifier.swift
// HomeAssistant
//
// Created by Hariharan on 29/06/26.
// Copyright © 2026 Home Assistant. All rights reserved.
//

import SwiftUI
import WidgetKit

Comment thread
hariharanjagan marked this conversation as resolved.
Outdated
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
113 changes: 113 additions & 0 deletions Sources/Extensions/Widgets/Sensor/HAWidgetPushHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
//
// HAWidgetPushHandler.swift
// HomeAssistant
//
// Created by Hariharan on 29/06/26.
// Copyright © 2026 Home Assistant. All rights reserved.
//


import Shared
import WidgetKit

Comment thread
hariharanjagan marked this conversation as resolved.
Outdated
@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 \(tokenHex.prefix(8))… -> \(subscriptions.count) subscription(s)")
Comment thread
hariharanjagan marked this conversation as resolved.
Outdated

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 {
try? await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
Current.webhooks.sendEphemeral(server: server, request: request)
.done { continuation.resume() }
.catch { continuation.resume(throwing: $0) }
}
}
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