Skip to content

Commit

Permalink
Actions widget with background execution (#2617)
Browse files Browse the repository at this point in the history
<!-- Thank you for submitting a Pull Request and helping to improve Home
Assistant. Please complete the following sections to help the processing
and review of your changes. Please do not delete anything from this
template. -->

## Summary
<!-- Provide a brief summary of the changes you have made and most
importantly what they aim to achieve -->
This PR upgrades 2 Siri intents to AppIntents and adds a new widget
which can run actions (and scene actions) in background
## Screenshots
<!-- If this is a user-facing change not in the frontend, please include
screenshots in light and dark mode. -->

## Link to pull request in Documentation repository
<!-- Pull requests that add, change or remove functionality must have a
corresponding pull request in the Companion App Documentation repository
(https://github.com/home-assistant/companion.home-assistant). Please add
the number of this pull request after the "#" -->
Documentation: home-assistant/companion.home-assistant#

## Any other notes
<!-- If there is any other information of note, like if this Pull
Request is part of a bigger change, please include it here. -->
  • Loading branch information
bgoncal authored Mar 4, 2024
1 parent 212ff94 commit 78826d2
Show file tree
Hide file tree
Showing 10 changed files with 381 additions and 19 deletions.
46 changes: 46 additions & 0 deletions HomeAssistant.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,13 @@
4278DFD32B45C7AE0087C9D7 /* Core.strings in Resources */ = {isa = PBXBuildFile; fileRef = 4278DFAF2B45C6680087C9D7 /* Core.strings */; };
4279407F2B8369EC001D7E14 /* AppIntentVocabulary.plist in Resources */ = {isa = PBXBuildFile; fileRef = 42805A142B0226050095414C /* AppIntentVocabulary.plist */; };
427940812B836A1A001D7E14 /* AppIntentVocabulary.plist in Resources */ = {isa = PBXBuildFile; fileRef = 42805A142B0226050095414C /* AppIntentVocabulary.plist */; };
4296C36D2B90DB640051B63C /* IntentActionAppEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4296C36B2B90DB630051B63C /* IntentActionAppEntity.swift */; };
4296C36E2B90DB640051B63C /* PerformAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4296C36C2B90DB630051B63C /* PerformAction.swift */; };
4296C3762B91F0F50051B63C /* WidgetActionsAppIntentTimelineProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4296C3742B91F0860051B63C /* WidgetActionsAppIntentTimelineProvider.swift */; };
4296C3772B91F26A0051B63C /* IntentActionAppEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4296C36B2B90DB630051B63C /* IntentActionAppEntity.swift */; };
4296C3782B91F6260051B63C /* PerformAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4296C36C2B90DB630051B63C /* PerformAction.swift */; };
4296C37A2B9205450051B63C /* WidgetActionsAppIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4296C3792B9205450051B63C /* WidgetActionsAppIntent.swift */; };
4296C37B2B92054C0051B63C /* WidgetActionsAppIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4296C3792B9205450051B63C /* WidgetActionsAppIntent.swift */; };
429C72202B28D0EC00BCD558 /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429C721F2B28D0EC00BCD558 /* Haptics.swift */; };
42CA28BB2B1028330093B31A /* SimulatorThreadClientService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CA28BA2B1028330093B31A /* SimulatorThreadClientService.swift */; };
42CE8FA72B45D1E900C707F9 /* CoreStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FA52B45D1E900C707F9 /* CoreStrings.swift */; };
Expand Down Expand Up @@ -1672,6 +1679,10 @@
4278DFD02B45C66A0087C9D7 /* ml */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ml; path = ml.lproj/Core.strings; sourceTree = "<group>"; };
4279407E2B8369EA001D7E14 /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = bg; path = bg.lproj/AppIntentVocabulary.plist; sourceTree = "<group>"; };
42805A132B0226050095414C /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Base; path = Base.lproj/AppIntentVocabulary.plist; sourceTree = "<group>"; };
4296C36B2B90DB630051B63C /* IntentActionAppEntity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IntentActionAppEntity.swift; sourceTree = "<group>"; };
4296C36C2B90DB630051B63C /* PerformAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PerformAction.swift; sourceTree = "<group>"; };
4296C3742B91F0860051B63C /* WidgetActionsAppIntentTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetActionsAppIntentTimelineProvider.swift; sourceTree = "<group>"; };
4296C3792B9205450051B63C /* WidgetActionsAppIntent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = WidgetActionsAppIntent.swift; path = Sources/Extensions/AppIntents/WidgetActionsAppIntent.swift; sourceTree = SOURCE_ROOT; };
429C721F2B28D0EC00BCD558 /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = "<group>"; };
42CA28A62B1012DE0093B31A /* ThreadCredentialsSharingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadCredentialsSharingView.swift; sourceTree = "<group>"; };
42CA28AD2B101D4D0093B31A /* HACornerRadius.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HACornerRadius.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2350,6 +2361,7 @@
111501A72528412C00DCFA94 /* Extensions */ = {
isa = PBXGroup;
children = (
4296C36A2B90DB630051B63C /* AppIntents */,
B66C58A6215086F0004AB261 /* Intents */,
11B6B5872948FB4B00B8B552 /* Matter */,
B627CB0C1D83C87B0057173E /* NotificationContent */,
Expand Down Expand Up @@ -3159,6 +3171,33 @@
path = Extensions;
sourceTree = "<group>";
};
4296C36A2B90DB630051B63C /* AppIntents */ = {
isa = PBXGroup;
children = (
4296C3722B91F06D0051B63C /* Widget */,
4296C36B2B90DB630051B63C /* IntentActionAppEntity.swift */,
4296C36C2B90DB630051B63C /* PerformAction.swift */,
);
path = AppIntents;
sourceTree = "<group>";
};
4296C3722B91F06D0051B63C /* Widget */ = {
isa = PBXGroup;
children = (
4296C3732B91F0730051B63C /* Actions */,
);
path = Widget;
sourceTree = "<group>";
};
4296C3732B91F0730051B63C /* Actions */ = {
isa = PBXGroup;
children = (
4296C3792B9205450051B63C /* WidgetActionsAppIntent.swift */,
4296C3742B91F0860051B63C /* WidgetActionsAppIntentTimelineProvider.swift */,
);
path = Actions;
sourceTree = "<group>";
};
42CA28A52B1012B00093B31A /* ThreadCredentialsSharing */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -5574,16 +5613,20 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
4296C3762B91F0F50051B63C /* WidgetActionsAppIntentTimelineProvider.swift in Sources */,
110E694424E77125004AA96D /* WidgetActionsProvider.swift in Sources */,
424A7F482B188BF3008C8DF3 /* WidgetContentMargin.swift in Sources */,
115560E127010D8400A8F818 /* WidgetBasicContainerView.swift in Sources */,
115560EE27012F7300A8F818 /* WidgetOpenPage.swift in Sources */,
115560E327010DAB00A8F818 /* WidgetBasicView.swift in Sources */,
1171507024DFCDE60065E874 /* Widgets.swift in Sources */,
424A7F462B188946008C8DF3 /* WidgetBackground.swift in Sources */,
4296C3772B91F26A0051B63C /* IntentActionAppEntity.swift in Sources */,
115560F227012FE100A8F818 /* WidgetOpenPageProvider.swift in Sources */,
1165705627018C4E003906A7 /* WidgetEmptyView.swift in Sources */,
1171508124DFCEC50065E874 /* WidgetActions.swift in Sources */,
4296C3782B91F6260051B63C /* PerformAction.swift in Sources */,
4296C37B2B92054C0051B63C /* WidgetActionsAppIntent.swift in Sources */,
110E694624E771AB004AA96D /* Color+Hex.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down Expand Up @@ -5666,6 +5709,7 @@
42266B252B7A4BA900E94A71 /* BarcodeScannerViewModel.swift in Sources */,
11DE823024FAE66F00E636B8 /* UIWindow+Additions.swift in Sources */,
11DA6B4F2713912F008ADFAF /* OnboardingPermissionViewController.swift in Sources */,
4296C36E2B90DB640051B63C /* PerformAction.swift in Sources */,
42CA28BB2B1028330093B31A /* SimulatorThreadClientService.swift in Sources */,
1127383C2625512600F5E312 /* ButtonRowWithLoading.swift in Sources */,
420FE84E2B556CE500878E06 /* CarPlayEntitiesListViewModel.swift in Sources */,
Expand Down Expand Up @@ -5726,6 +5770,7 @@
425573E62B5838B600145217 /* MaterialDesignIcons+CarPlay.swift in Sources */,
42F1DA612B4D4F31002729BC /* CarPlayNoServerAlert.swift in Sources */,
11C590ED24A832CA0066085D /* YamlSection.swift in Sources */,
4296C36D2B90DB640051B63C /* IntentActionAppEntity.swift in Sources */,
42F1DA5B2B4BF7DF002729BC /* WindowSizeObserver.swift in Sources */,
42266B1D2B741FB400E94A71 /* BarcodeScannerCameraView.swift in Sources */,
11EFCDD624F5FA8D00314D85 /* WebViewSceneDelegate.swift in Sources */,
Expand Down Expand Up @@ -5765,6 +5810,7 @@
42F5CAEB2B10CED600409816 /* ThreadCredentialsSharing+build.swift in Sources */,
117EBC32261D398B00F5334A /* ZoneManagerAccuracyFuzzer.swift in Sources */,
113FB1132515A065000AC680 /* ScaleFactorMutator.swift in Sources */,
4296C37A2B9205450051B63C /* WidgetActionsAppIntent.swift in Sources */,
1185DFB3271FF53800ED7D9A /* OnboardingAuthStepSensors.swift in Sources */,
11108D632634C8FE009DAB0F /* LearnMoreButtonRow.swift in Sources */,
425573D12B5576E600145217 /* CarPlayDomainsListTemplate+Build.swift in Sources */,
Expand Down
40 changes: 40 additions & 0 deletions Sources/Extensions/AppIntents/IntentActionAppEntity.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import AppIntents
import Foundation
import Shared

@available(iOS 16.0, macOS 13.0, watchOS 9.0, tvOS 16.0, *)
struct IntentActionAppEntity: AppEntity {
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Action")

struct IntentActionAppEntityQuery: EntityQuery, EntityStringQuery {
func entities(for identifiers: [IntentActionAppEntity.ID]) async throws -> [IntentActionAppEntity] {
getActionEntities().filter { identifiers.contains($0.id) }
}

func entities(matching string: String) async throws -> [IntentActionAppEntity] {
getActionEntities().filter { $0.displayString.contains(string) }
}

func suggestedEntities() async throws -> [IntentActionAppEntity] {
getActionEntities()
}

private func getActionEntities() -> [IntentActionAppEntity] {
let actions = Current.realm().objects(Action.self).sorted(byKeyPath: #keyPath(Action.Position))
return Array(actions.map { IntentActionAppEntity(id: $0.ID, displayString: $0.Name) })
}
}

static var defaultQuery = IntentActionAppEntityQuery()

var id: String // if your identifier is not a String, conform the entity to EntityIdentifierConvertible.
var displayString: String
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(displayString)")
}

init(id: String, displayString: String) {
self.id = id
self.displayString = displayString
}
}
77 changes: 77 additions & 0 deletions Sources/Extensions/AppIntents/PerformAction.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import AppIntents
import Foundation
import PromiseKit
import Shared

@available(iOS 16.0, macOS 13.0, watchOS 9.0, tvOS 16.0, *)
struct PerformAction: AppIntent, CustomIntentMigratedAppIntent, PredictableIntent {
static let intentClassName = "PerformActionIntent"

static var title: LocalizedStringResource = "Perform Action"
static var description = IntentDescription("Performs an action defined in the app")

@Parameter(title: "Action")
var action: IntentActionAppEntity?

static var parameterSummary: some ParameterSummary {
Summary("Perform \(\.$action)")
}

static var predictionConfiguration: some IntentPredictionConfiguration {
IntentPrediction(parameters: \.$action) { action in
DisplayRepresentation(
title: "\(action!)",
subtitle: "Perform the action"
)
}
}

func perform() async throws -> some IntentResult {
guard let intentAction = $action.wrappedValue,
let action = Current.realm().object(ofType: Action.self, forPrimaryKey: intentAction.id),
let server = Current.servers.server(for: action) else {
Current.Log.warning("ActionID either does not exist or is not a string in the payload")
return .result()
}

try await withCheckedThrowingContinuation { continuation in
Current.api(for: server).HandleAction(actionID: action.ID, source: .AppShortcut).pipe { result in
switch result {
case .fulfilled:
continuation.resume()
case let .rejected(error):
Current.Log
.error(
"Failed to run action \(intentAction.displayString), error: \(error.localizedDescription)"
)
continuation.resume(throwing: error)
}
}
}

return .result()
}
}

@available(iOS 16.0, macOS 13.0, watchOS 9.0, tvOS 16.0, *)
private extension IntentDialog {
static func actionParameterDisambiguationIntro(count: Int, action: IntentActionAppEntity) -> Self {
"There are \(count) options matching ‘\(action)’."
}

static func actionParameterConfirmation(action: IntentActionAppEntity) -> Self {
"Just to confirm, you wanted ‘\(action)’?"
}

static var actionParameterConfiguration: Self {
"Which action?"
}

static func responseSuccess(action: IntentActionAppEntity) -> Self {
"Done"
}

static func responseFailure(error: String) -> Self {
"Failed: \(error)"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import AppIntents
import Shared
import WidgetKit

@available(iOS 17, *)
struct WidgetActionsAppIntentTimelineProvider: AppIntentTimelineProvider {
typealias Entry = WidgetActionsEntry
typealias Intent = WidgetActionsAppIntent

func snapshot(for configuration: WidgetActionsAppIntent, in context: Context) async -> WidgetActionsEntry {
Self.entry(for: configuration, in: context)
}

func timeline(for configuration: Intent, in context: Context) async -> Timeline<Entry> {
.init(entries: [Self.entry(for: configuration, in: context)], policy: .never)
}

func placeholder(in context: Context) -> WidgetActionsEntry {
let count = WidgetBasicContainerView.maximumCount(family: context.family)
let actions = stride(from: 0, to: count, by: 1).map { _ in
with(Action()) {
$0.Text = "Redacted Text"
$0.IconName = MaterialDesignIcons.bedEmptyIcon.name
}
}

return WidgetActionsEntry(actions: actions)
}

private static func entry(for configuration: Intent, in context: Context) -> Entry {
if let existing = configuration.actions?.compactMap({ $0.asAction() }), !existing.isEmpty {
return .init(actions: existing)
} else {
return .init(actions: Self.defaultActions(in: context))
}
}

private static func defaultActions(in context: Context) -> [Action] {
let allActions = Current.realm().objects(Action.self).sorted(byKeyPath: #keyPath(Action.Position))
let maxCount = WidgetBasicContainerView.maximumCount(family: context.family)

switch allActions.count {
case 0: return []
case ...maxCount: return Array(allActions)
default: return Array(allActions[0 ..< maxCount])
}
}
}

@available(iOS 17, *)
extension IntentActionAppEntity {
func asAction() -> Action? {
guard id.isEmpty == false else {
return nil
}

guard let result = Current.realm().object(ofType: Action.self, forPrimaryKey: id) else {
return nil
}

return result
}
}
44 changes: 44 additions & 0 deletions Sources/Extensions/AppIntents/WidgetActionsAppIntent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import AppIntents
import Foundation

@available(iOS 17.0, macOS 14.0, watchOS 10.0, *)
struct WidgetActionsAppIntent: AppIntent, WidgetConfigurationIntent, CustomIntentMigratedAppIntent {
static let intentClassName = "WidgetActionsIntent"

static var title: LocalizedStringResource = "Actions"
static var description = IntentDescription("View and run actions")

@Parameter(
title: "Actions",
size: [
.systemSmall: 1,
.systemMedium: 8,
.systemLarge: 16,
.systemExtraLarge: 32,
.accessoryInline: 1,
.accessoryCorner: 1,
.accessoryCircular: 1,
.accessoryRectangular: 2,
]
)
var actions: [IntentActionAppEntity]?

static var parameterSummary: some ParameterSummary {
Summary()
}

func perform() async throws -> some IntentResult {
guard let action = $actions.wrappedValue?.first else { return .result() }
let intent = PerformAction()
intent.action = action
let result = try await intent.perform()
return .result()
}
}

@available(iOS 16.0, macOS 13.0, watchOS 9.0, tvOS 16.0, *)
private extension IntentDialog {
static var actionsParameterConfiguration: Self {
"Which actions?"
}
}
Loading

0 comments on commit 78826d2

Please sign in to comment.