feat: add App Intents and device control widget
This commit is contained in:
1 parent
838a25b990
commit
db4af0ef6a
43 files changed
+852
-1070
No files matched your search
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user