-
Notifications
You must be signed in to change notification settings - Fork 302
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Actions widget with background execution (#2617)
<!-- 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
Showing
10 changed files
with
381 additions
and
19 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)" | ||
} | ||
} |
63 changes: 63 additions & 0 deletions
63
Sources/Extensions/AppIntents/Widget/Actions/WidgetActionsAppIntentTimelineProvider.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
44
Sources/Extensions/AppIntents/WidgetActionsAppIntent.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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?" | ||
} | ||
} |
Oops, something went wrong.