feat: add App Intents and device control widget

This commit is contained in:
joshuafhiggins committed 2026-10-22 18:37:28 -04:00 Verified
1 parent 6c3a474bb0
commit f57e7c19f0
43 files changed
+852 -1070

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
internal import ExpoAppIntents
internal import ExpoModulesCore
/// Registered Expo inline module that wires app-target App Intents code to expo-app-intents.
/// Do not change the name of this class.
final class AppIntentsSetup: Module {
public func definition() -> ExpoModulesCore.ModuleDefinition {
Name("AppIntentsSetup")
OnCreate {
if #available(iOS 18.0, *) {
Task {
await AppIntentDispatcher.shared.setShortcutsRefreshHandler {
AppShortcuts.updateAppShortcutParameters()
}
AppShortcuts.updateAppShortcutParameters()
}
}
}
}
}
+43
View File
@@ -0,0 +1,43 @@
import AppIntents
@available(iOS 18.0, *)
struct AppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: WakeDeviceIntent(),
phrases: [
"Wake a device in \(.applicationName)",
"Wake \(\.$device) in \(.applicationName)",
],
shortTitle: "Wake Device",
systemImageName: "bolt.fill"
)
AppShortcut(
intent: SleepDeviceIntent(),
phrases: [
"Sleep a device in \(.applicationName)",
"Sleep \(\.$device) in \(.applicationName)",
],
shortTitle: "Sleep Device",
systemImageName: "moon.fill"
)
AppShortcut(
intent: RestartDeviceIntent(),
phrases: [
"Restart a device in \(.applicationName)",
"Restart \(\.$device) in \(.applicationName)",
],
shortTitle: "Restart Device",
systemImageName: "arrow.clockwise"
)
AppShortcut(
intent: ShutdownDeviceIntent(),
phrases: [
"Shut down a device in \(.applicationName)",
"Shut down \(\.$device) in \(.applicationName)",
],
shortTitle: "Shut Down Device",
systemImageName: "power"
)
}
}
+105
View File
@@ -0,0 +1,105 @@
import AppIntents
enum DeviceActionPath: String {
case wake
case sleep
case restart
case shutdown
}
enum DeviceActionIntentError: LocalizedError {
case invalidDevice
var errorDescription: String? {
"That device can't be opened."
}
}
func deviceActionURL(_ action: DeviceActionPath, deviceId: String) throws -> URL {
let encoded = deviceId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? deviceId
guard !encoded.isEmpty,
let url = URL(string: "remotewol-upsnap://action/\(action.rawValue)/\(encoded)") else {
throw DeviceActionIntentError.invalidDevice
}
return url
}
@available(iOS 18.0, *)
struct WakeDeviceIntent: AppIntent {
static let title: LocalizedStringResource = "Wake Device"
static let description = IntentDescription("Wakes the selected device using Wake-on-LAN.")
static let openAppWhenRun = true
@Parameter(title: "Device", requestValueDialog: "Which device should be woken?")
var device: DeviceEntity
static var parameterSummary: some ParameterSummary {
Summary("Wake \(\.$device)")
}
@MainActor
func perform() async throws -> some IntentResult & OpensIntent {
let url = try deviceActionURL(.wake, deviceId: device.id)
return .result(opensIntent: OpenURLIntent(url))
}
}
@available(iOS 18.0, *)
struct SleepDeviceIntent: AppIntent {
static let title: LocalizedStringResource = "Sleep Device"
static let description = IntentDescription("Sends the selected device to sleep.")
static let openAppWhenRun = true
@Parameter(title: "Device", requestValueDialog: "Which device should sleep?")
var device: DeviceEntity
static var parameterSummary: some ParameterSummary {
Summary("Sleep \(\.$device)")
}
@MainActor
func perform() async throws -> some IntentResult & OpensIntent {
let url = try deviceActionURL(.sleep, deviceId: device.id)
return .result(opensIntent: OpenURLIntent(url))
}
}
@available(iOS 18.0, *)
struct RestartDeviceIntent: AppIntent {
static let title: LocalizedStringResource = "Restart Device"
static let description = IntentDescription("Restarts the selected device.")
static let openAppWhenRun = true
@Parameter(title: "Device", requestValueDialog: "Which device should restart?")
var device: DeviceEntity
static var parameterSummary: some ParameterSummary {
Summary("Restart \(\.$device)")
}
@MainActor
func perform() async throws -> some IntentResult & OpensIntent {
let url = try deviceActionURL(.restart, deviceId: device.id)
return .result(opensIntent: OpenURLIntent(url))
}
}
@available(iOS 18.0, *)
struct ShutdownDeviceIntent: AppIntent {
static let title: LocalizedStringResource = "Shut Down Device"
static let description = IntentDescription("Shuts down the selected device.")
static let openAppWhenRun = true
@Parameter(title: "Device", requestValueDialog: "Which device should shut down?")
var device: DeviceEntity
static var parameterSummary: some ParameterSummary {
Summary("Shut down \(\.$device)")
}
@MainActor
func perform() async throws -> some IntentResult & OpensIntent {
let url = try deviceActionURL(.shutdown, deviceId: device.id)
return .result(opensIntent: OpenURLIntent(url))
}
}
+59
View File
@@ -0,0 +1,59 @@
import AppIntents
internal import ExpoAppIntents
import Foundation
private func normalizeDeviceText(_ value: String) -> String {
value
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
.trimmingCharacters(in: .whitespacesAndNewlines)
}
struct DeviceEntity: AppEntity {
static let typeDisplayRepresentation: TypeDisplayRepresentation = "Device"
static let defaultQuery = DeviceQuery()
var id: String
@Property(title: "Name")
var name: String
var subtitle: String?
var synonyms: [String]
var displayRepresentation: DisplayRepresentation {
if let subtitle {
if #available(iOS 17.0, *) {
return DisplayRepresentation(
title: "\(name)",
subtitle: "\(subtitle)",
synonyms: synonyms.map { LocalizedStringResource(stringLiteral: $0) }
)
}
return DisplayRepresentation(title: "\(name)", subtitle: "\(subtitle)")
}
if #available(iOS 17.0, *) {
return DisplayRepresentation(
title: "\(name)",
synonyms: synonyms.map { LocalizedStringResource(stringLiteral: $0) }
)
}
return DisplayRepresentation(title: "\(name)")
}
init(record: AppIntentEntityRecord) {
self.id = record.id
self.subtitle = record.subtitle
self.synonyms = record.synonyms
self.name = record.title
}
func matches(_ searchText: String) -> Bool {
let normalizedSearch = normalizeDeviceText(searchText)
guard !normalizedSearch.isEmpty else {
return false
}
return ([name, id] + synonyms).map(normalizeDeviceText).contains { term in
normalizedSearch == term || normalizedSearch.contains(term) || term.contains(normalizedSearch)
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import AppIntents
internal import ExpoAppIntents
/// Resolves devices from the catalog published by `syncDevicesToWidget`.
struct DeviceQuery: EntityStringQuery {
func entities(for identifiers: [String]) async throws -> [DeviceEntity] {
try await AppIntentEntityStore.shared
.entities(ofKind: "device", matching: identifiers)
.map(DeviceEntity.init(record:))
}
func suggestedEntities() async throws -> [DeviceEntity] {
try await AppIntentEntityStore.shared
.entities(ofKind: "device")
.map(DeviceEntity.init(record:))
}
func entities(matching string: String) async throws -> [DeviceEntity] {
let search = string.trimmingCharacters(in: .whitespacesAndNewlines)
guard !search.isEmpty else {
return []
}
return try await suggestedEntities().filter { $0.matches(search) }
}
}