feat: add App Intents and device control widget

This commit is contained in:
joshuafhiggins authored and Joshua Higgins committed 2026-10-22 18:58:48 -04:00
1 parent 838a25b990
commit db4af0ef6a
43 files changed
+852 -1070

No files matched your search

+4
View File
@@ -41,3 +41,7 @@ app-example
# generated native folders
/ios
/android
# @generated expo skills start
.agents/skills/expo-app-intents
.claude/skills/expo-app-intents
# @generated expo skills end
+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) }
}
}
+45 -21
View File
@@ -14,7 +14,6 @@
"appleTeamId": "8S7C654DQ4",
"entitlements": {
"com.apple.security.application-groups": [
"group.abunchofknowitalls.remotewol-upsnap",
"group.abunchofknowitalls.remotewol-upsnap"
]
},
@@ -50,35 +49,60 @@
}
}
],
"@bacons/apple-targets",
"./plugins/withWakeControl.js",
[
"expo-widgets",
{
"bundleIdentifier": "com.abunchofknowitalls.remotewol-upsnap.widget",
"groupIdentifier": "group.abunchofknowitalls.remotewol-upsnap",
"widgets": [
{
"name": "DeviceControl",
"displayName": "Device Control",
"description": "Control your devices with wake, sleep, restart, and shutdown actions.",
"ios": {
"supportedFamilies": ["systemSmall", "systemMedium"],
"initialLayout": "./src/widgets/DeviceControl.tsx",
"configuration": {
"title": "Device Control",
"description": "Select a device to control from your home screen.",
"parameters": {
"device": {
"title": "Device",
"type": "enum",
"default": "none",
"dynamic": true,
"values": [
{
"name": "Select a Device",
"value": "none"
}
]
}
}
}
}
}
]
}
],
"expo-font",
"expo-image",
"expo-web-browser"
"expo-web-browser",
"expo-app-intents"
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
"reactCompiler": true,
"inlineModules": {
"watchedDirectories": [
"app-intents"
]
}
},
"extra": {
"router": {},
"eas": {
"build": {
"experimental": {
"ios": {
"appExtensions": [
{
"bundleIdentifier": "com.abunchofknowitalls.remotewol-upsnap.widget",
"targetName": "widget",
"entitlements": {
"com.apple.security.application-groups": [
"group.abunchofknowitalls.remotewol-upsnap"
]
}
}
]
}
}
},
"projectId": "b2c99399-8470-4a78-9253-492056fc1c35"
}
}
+75 -394
View File
@@ -8,12 +8,12 @@
"name": "remote-wol",
"version": "1.0.0",
"dependencies": {
"@bacons/apple-targets": "^3.0.6",
"@expo/ui": "~58.0.21",
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "^2.2.0",
"burnt": "^0.13.0",
"expo": "^58.0.16",
"expo-app-intents": "~0.5.10",
"expo-constants": "~58.0.12",
"expo-dev-client": "~58.0.14",
"expo-font": "~58.0.9",
@@ -27,6 +27,7 @@
"expo-symbols": "~58.0.4",
"expo-system-ui": "~58.0.6",
"expo-web-browser": "~58.0.6",
"expo-widgets": "~58.0.21",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-native": "0.88.0",
@@ -1137,29 +1138,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@bacons/apple-targets": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@bacons/apple-targets/-/apple-targets-3.0.7.tgz",
"integrity": "sha512-RpzofKGvll8BPrvgf1Xj5raoHffJRW2qfWl7Q9F/25eo6lZeRa9R5NRIZCpUYoVnsL7whCQV9NIp0/ai4O+Ejw==",
"license": "MIT",
"dependencies": {
"@bacons/xcode": "1.0.0-alpha.27",
"@react-native/normalize-colors": "^0.79.2",
"debug": "^4.3.4",
"glob": "^10.4.2"
}
},
"node_modules/@bacons/xcode": {
"version": "1.0.0-alpha.27",
"resolved": "https://registry.npmjs.org/@bacons/xcode/-/xcode-1.0.0-alpha.27.tgz",
"integrity": "sha512-WlemTiwpwwVH8IMvg4VBsyxGUuFWUfzeWKzxhU+dRxd8MHvnX7F9PlWCf7T0ZXmtjbl39iEgQB+/Tf3wuGJbyg==",
"license": "MIT",
"dependencies": {
"@expo/plist": "^0.0.18",
"debug": "^4.3.4",
"uuid": "^8.3.2"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -2165,17 +2143,6 @@
"integrity": "sha512-KtLrtAvpShq16l9lTxd2fZSjULxcGxXrJTHA31iUVqrc+Af7aTAJW4ZB3yQBgi/w+kSb6BB/CG9RXI0ohbMm6g==",
"license": "MIT"
},
"node_modules/@expo/plist": {
"version": "0.0.18",
"resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.0.18.tgz",
"integrity": "sha512-+48gRqUiz65R21CZ/IXa7RNBXgAI/uPSdvJqoN9x1hfL44DNbUoWHgHiEXTx7XelcATpDwNTz6sHLfy0iNqf+w==",
"license": "MIT",
"dependencies": {
"@xmldom/xmldom": "~0.7.0",
"base64-js": "^1.2.3",
"xmlbuilder": "^14.0.0"
}
},
"node_modules/@expo/prebuild-config": {
"version": "58.0.14",
"resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-58.0.14.tgz",
@@ -2380,23 +2347,6 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
"strip-ansi": "^7.0.1",
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
"wrap-ansi": "^8.1.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@isaacs/ttlcache": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz",
@@ -2671,16 +2621,6 @@
"url": "https://github.com/sponsors/oxc-project"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14"
}
},
"node_modules/@radix-ui/primitive": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.2.0.tgz",
@@ -3251,12 +3191,6 @@
"node": "^22.13.0 || ^24.3.0 || >= 26.0.0"
}
},
"node_modules/@react-native/normalize-colors": {
"version": "0.79.7",
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.79.7.tgz",
"integrity": "sha512-RrvewhdanEWhlyrHNWGXGZCc6MY0JGpNgRzA8y6OomDz0JmlnlIsbBHbNpPnIrt9Jh2KaV10KTscD1Ry8xU9gQ==",
"license": "MIT"
},
"node_modules/@react-native/virtualized-lists": {
"version": "0.88.0",
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.88.0.tgz",
@@ -4035,16 +3969,6 @@
"win32"
]
},
"node_modules/@xmldom/xmldom": {
"version": "0.7.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.13.tgz",
"integrity": "sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==",
"deprecated": "this version has critical issues, please update to the latest version",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/2g": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/2g/-/2g-1.0.1.tgz",
@@ -4554,6 +4478,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
@@ -5388,12 +5313,6 @@
"node": ">= 0.4"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -5406,12 +5325,6 @@
"integrity": "sha512-9qpCBlXPy5v7n1Q7WTO5rW2StI4WNQhl96HpgXRZLPlNoXAD0LbsQUflovQYAO44Yia073ISusqohncvGSMbGA==",
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
@@ -6169,6 +6082,34 @@
}
}
},
"node_modules/expo-app-intents": {
"version": "0.5.10",
"resolved": "https://registry.npmjs.org/expo-app-intents/-/expo-app-intents-0.5.10.tgz",
"integrity": "sha512-PNxAbIaNjLDeajnVJGClDXnB1CFkGScuAYcEORFsmaLmABMjlxq/jNC708cgWVyDgX2XUvDBXifq2rqvQAsPmQ==",
"license": "MIT",
"dependencies": {
"@expo/ui": "^58.0.21",
"commander": "^12.1.0",
"prompts": "^2.4.2"
},
"bin": {
"expo-app-intents": "bin/expo-app-intents.js"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-app-intents/node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/expo-asset": {
"version": "58.0.17",
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-58.0.17.tgz",
@@ -6604,6 +6545,50 @@
"react-native": "*"
}
},
"node_modules/expo-widgets": {
"version": "58.0.21",
"resolved": "https://registry.npmjs.org/expo-widgets/-/expo-widgets-58.0.21.tgz",
"integrity": "sha512-0DiTq5jxNgiX7KPcu8GewlvDMxLcrcT4Q2gVyBTg67hzrC8KS4Du6ZbOCYflvaVrdqN8Mhhwtnrl4UFydoYK7Q==",
"license": "MIT",
"dependencies": {
"@expo/plist": "^0.10.1",
"@expo/ui": "~58.0.21"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-widgets/node_modules/@expo/plist": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.10.1.tgz",
"integrity": "sha512-nuNOqj+r/hGJbZ1Ax3jUpZaOB8UAMHUrISALJEUKaCieTflYj7224EqFWmMpriCGk9wXS1gH+9GGVnSj0tWzuQ==",
"license": "MIT",
"dependencies": {
"@xmldom/xmldom": "^0.8.8",
"base64-js": "^1.5.1",
"xmlbuilder": "^15.1.1"
}
},
"node_modules/expo-widgets/node_modules/@xmldom/xmldom": {
"version": "0.8.15",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
"integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/expo-widgets/node_modules/xmlbuilder": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
"integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==",
"license": "MIT",
"engines": {
"node": ">=8.0"
}
},
"node_modules/expo/node_modules/@expo/cli": {
"version": "58.1.13",
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-58.1.13.tgz",
@@ -7215,22 +7200,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -7390,27 +7359,6 @@
"node": ">=6"
}
},
"node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -7424,30 +7372,6 @@
"node": ">=10.13.0"
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
@@ -8274,21 +8198,6 @@
"node": ">= 0.4"
}
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jest-get-type": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz",
@@ -9928,12 +9837,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -9993,28 +9896,6 @@
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"license": "MIT"
},
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -11112,18 +10993,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/simple-plist": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz",
@@ -11259,56 +11128,6 @@
"node": ">= 0.10.0"
}
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/string-width-cjs": {
"name": "string-width",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/string-width-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string.prototype.matchall": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.1.0.tgz",
@@ -11408,46 +11227,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/strip-ansi-cjs": {
"name": "strip-ansi",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi/node_modules/ansi-regex": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz",
"integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/strip-bom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
@@ -11977,16 +11756,6 @@
"node": ">= 0.4.0"
}
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/validate-npm-package-name": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz",
@@ -12177,85 +11946,6 @@
"node": ">=0.10.0"
}
},
"node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/ws": {
"version": "7.5.13",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz",
@@ -12322,15 +12012,6 @@
"node": ">=4.0"
}
},
"node_modules/xmlbuilder": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-14.0.0.tgz",
"integrity": "sha512-ts+B2rSe4fIckR6iquDjsKbQFK2NlUk6iG5nf14mDEyldgoc2nEKZ3jZWMPTxGQwVgToSjt6VGIho1H8/fNFTg==",
"license": "MIT",
"engines": {
"node": ">=8.0"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+2 -1
View File
@@ -11,12 +11,12 @@
"lint": "expo lint"
},
"dependencies": {
"@bacons/apple-targets": "^3.0.6",
"@expo/ui": "~58.0.21",
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "^2.2.0",
"burnt": "^0.13.0",
"expo": "^58.0.16",
"expo-app-intents": "~0.5.10",
"expo-constants": "~58.0.12",
"expo-dev-client": "~58.0.14",
"expo-font": "~58.0.9",
@@ -30,6 +30,7 @@
"expo-symbols": "~58.0.4",
"expo-system-ui": "~58.0.6",
"expo-web-browser": "~58.0.6",
"expo-widgets": "~58.0.21",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-native": "0.88.0",
+143
View File
@@ -0,0 +1,143 @@
// jumpstart-wake-control
// Control Center wake button. Device choices are the home-screen widget's dynamic
// configuration, which JavaScript updates when the device list loads.
@available(iOS 18.0, *)
struct WakeDeviceControl: ControlWidget {
static let kind: String = "wakecontrol"
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
kind: Self.kind,
provider: WakeDeviceControlProvider()
) { value in
ControlWidgetButton(action: WakeControlIntent(deviceId: value.deviceId, deviceName: value.deviceName)) {
Label(value.deviceName, systemImage: "bolt.fill")
}
}
.displayName("Wake Device")
.description("Quickly wake a device from Control Center.")
}
}
@available(iOS 18.0, *)
extension WakeDeviceControl {
struct Value {
var deviceId: String
var deviceName: String
}
}
@available(iOS 18.0, *)
struct WakeDeviceControlProvider: AppIntentControlValueProvider {
func previewValue(configuration: WakeControlConfiguration) -> WakeDeviceControl.Value {
WakeDeviceControl.Value(
deviceId: configuration.device ?? "",
deviceName: deviceName(for: configuration.device)
)
}
func currentValue(configuration: WakeControlConfiguration) async throws -> WakeDeviceControl.Value {
WakeDeviceControl.Value(
deviceId: configuration.device ?? "",
deviceName: deviceName(for: configuration.device)
)
}
private func deviceName(for deviceId: String?) -> String {
guard let deviceId, !deviceId.isEmpty, deviceId != "none" else {
return "Select Device"
}
let options = widgetConfigurationOptions(
name: "DeviceControl",
parameter: "device",
fallback: []
)
return options.first { $0.value == deviceId }?.name ?? "Select Device"
}
}
@available(iOS 18.0, *)
struct WakeControlConfiguration: ControlConfigurationIntent {
static let title: LocalizedStringResource = "Device to Wake"
@Parameter(title: "Device", optionsProvider: WakeControlDeviceOptionsProvider())
var device: String?
}
@available(iOS 18.0, *)
struct WakeControlDeviceOptionsProvider: DynamicOptionsProvider {
func results() async throws -> IntentItemCollection<String> {
let options = widgetConfigurationOptions(
name: "DeviceControl",
parameter: "device",
fallback: [
WidgetConfigurationOption(name: "Select a Device", value: "none")
]
)
return IntentItemCollection(sections: [
IntentItemSection(items: options.map { option in
IntentItem(
option.value,
title: LocalizedStringResource(stringLiteral: option.name),
subtitle: option.subtitle.map { LocalizedStringResource(stringLiteral: $0) }
)
})
])
}
func defaultResult() async -> String? {
let options = widgetConfigurationOptions(
name: "DeviceControl",
parameter: "device",
fallback: []
)
return options.first { $0.value != "none" }?.value ?? options.first?.value
}
}
@available(iOS 18.0, *)
struct WakeControlIntent: AppIntent {
static let title: LocalizedStringResource = "Wake Device"
static let description = IntentDescription("Wakes the selected device using Wake-on-LAN.")
static let openAppWhenRun: Bool = true
static let isDiscoverable: Bool = false
@Parameter(title: "Device ID")
var deviceId: String
@Parameter(title: "Device Name")
var deviceName: String
init() {
self.deviceId = ""
self.deviceName = ""
}
init(deviceId: String, deviceName: String) {
self.deviceId = deviceId
self.deviceName = deviceName
}
func perform() async throws -> some IntentResult & OpensIntent {
let encoded = deviceId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? deviceId
if !encoded.isEmpty, encoded != "none", let url = URL(string: "remotewol-upsnap://action/wake/\(encoded)") {
return .result(opensIntent: OpenURLIntent(url))
}
guard let fallback = URL(string: "remotewol-upsnap://") else {
throw WakeControlIntentError.invalidDevice
}
return .result(opensIntent: OpenURLIntent(fallback))
}
}
enum WakeControlIntentError: LocalizedError {
case invalidDevice
var errorDescription: String? {
"Select a device to wake."
}
}
+61
View File
@@ -0,0 +1,61 @@
const fs = require('fs');
const path = require('path');
const { withDangerousMod } = require('expo/config-plugins');
const MARKER = 'jumpstart-wake-control';
/**
* Adds the Control Center wake control to the extension expo-widgets generates.
* This plugin must be listed before expo-widgets so it runs after that extension is written.
*/
function withWakeControl(config) {
return withDangerousMod(config, [
'ios',
async config => {
const indexPath = path.join(
config.modRequest.platformProjectRoot,
'ExpoWidgetsTarget',
'index.swift'
);
if (!fs.existsSync(indexPath)) {
throw new Error(
'expo-widgets has not generated ios/ExpoWidgetsTarget/index.swift yet. List ./plugins/withWakeControl before expo-widgets in the plugins array.'
);
}
const source = fs.readFileSync(indexPath, 'utf8');
if (source.includes(MARKER)) {
return config;
}
if (!source.includes('WidgetLiveActivity()')) {
throw new Error(
'Could not find WidgetLiveActivity() in ios/ExpoWidgetsTarget/index.swift.'
);
}
const controlPath = path.join(
config.modRequest.projectRoot,
'plugins',
'WakeDeviceControl.swift'
);
const controlSource = fs.readFileSync(controlPath, 'utf8');
let next = source;
if (!next.includes('import AppIntents')) {
next = next.replace('import SwiftUI\n', 'import SwiftUI\nimport AppIntents\n');
}
next = next.replace(
/^([ \t]*)WidgetLiveActivity\(\)/m,
`$1if #available(iOS 18.0, *) {\n$1 WakeDeviceControl()\n$1}\n$1WidgetLiveActivity()`
);
next = `${next.trimEnd()}\n\n${controlSource.trim()}\n`;
fs.writeFileSync(indexPath, next);
return config;
},
]);
}
module.exports = withWakeControl;
+2
View File
@@ -1,6 +1,7 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import api from '../services/api';
import { syncDevicesToWidget } from '../services/widgetSync';
import { AuthResponse, User } from '../types';
interface AuthContextType {
@@ -103,6 +104,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const logout = async () => {
try {
await clearSession();
syncDevicesToWidget([]);
} catch (error) {
console.error('Failed to logout', error);
}
+86 -35
View File
@@ -1,64 +1,115 @@
import { ExtensionStorage } from '@bacons/apple-targets';
import { setEntityCatalogAsync, isAvailable as appIntentsAvailable } from 'expo-app-intents';
import type { AppIntentEntity } from 'expo-app-intents';
import { Platform } from 'react-native';
import { Device } from '../types';
import DeviceControlWidget, { type WidgetDevice } from '../widgets/DeviceControl';
// Create a storage object with the App Group
const storage =
Platform.OS === 'ios'
? new ExtensionStorage('group.abunchofknowitalls.remotewol-upsnap')
: null;
const DEVICE_KIND = 'device';
interface WidgetDevice {
id: string;
name: string;
mac: string;
ip: string;
canSleep: boolean;
canShutdown: boolean;
status: string;
let pendingCatalog: AppIntentEntity[] | null = null;
let catalogWrite: Promise<void> | null = null;
function toWidgetDevice(device: Device): WidgetDevice | null {
const id = device.id.trim();
if (!id) {
return null;
}
return {
id,
name: device.name.trim() || device.mac.trim() || 'Device',
mac: device.mac,
ip: device.ip,
canSleep: device.sol_enabled,
canShutdown: device.shutdown_cmd !== '',
status: device.status || 'unknown',
};
}
function toCatalogEntity(device: WidgetDevice): AppIntentEntity {
const synonyms = [device.mac, device.ip].filter(value => {
const trimmed = value.trim();
return trimmed.length > 0 && trimmed !== device.name;
});
return {
id: device.id,
title: device.name,
subtitle: device.mac.trim() || undefined,
synonyms,
};
}
function publishDeviceCatalog(entities: AppIntentEntity[]): void {
if (!appIntentsAvailable()) {
return;
}
pendingCatalog = entities;
if (catalogWrite) {
return;
}
catalogWrite = drainDeviceCatalog();
}
async function drainDeviceCatalog(): Promise<void> {
while (pendingCatalog) {
const next = pendingCatalog;
pendingCatalog = null;
try {
await setEntityCatalogAsync(DEVICE_KIND, next);
} catch (error) {
console.warn('Could not publish the device catalog.', error);
break;
}
}
catalogWrite = null;
if (pendingCatalog) {
catalogWrite = drainDeviceCatalog();
}
}
/**
* Syncs device data to the iOS widget via App Group shared storage.
* This allows the widget to display device information and create action deep links.
* Syncs device data to the iOS widget and Siri device catalog.
* Called after the device list loads.
*/
export function syncDevicesToWidget(devices: Device[]): void {
if (!storage) {
if (Platform.OS !== 'ios') {
return;
}
try {
// Transform devices to the format expected by the widget
const widgetDevices: WidgetDevice[] = devices.map(device => ({
id: device.id,
const widgetDevices = devices
.map(toWidgetDevice)
.filter((device): device is WidgetDevice => device !== null);
DeviceControlWidget.setConfigurationParameterEnum(
'device',
widgetDevices.map(device => ({
name: device.name,
mac: device.mac,
ip: device.ip,
canSleep: device.sol_enabled,
canShutdown: device.shutdown_cmd !== "",
status: device.status || 'unknown',
}));
const devicesJson = JSON.stringify(widgetDevices);
storage.set('devices', devicesJson);
// Refresh widgets to pick up the new data
ExtensionStorage.reloadWidget();
value: device.id,
subtitle: device.mac.trim() || undefined,
}))
);
DeviceControlWidget.updateSnapshot({ devices: widgetDevices });
publishDeviceCatalog(widgetDevices.map(toCatalogEntity));
} catch (error) {
console.error('Failed to sync devices to widget:', error);
}
}
/**
* Refreshes all widget timelines to pick up new data.
* Refreshes widget timelines so they pick up the latest snapshot.
*/
export function refreshWidgets(): void {
if (!storage) {
if (Platform.OS !== 'ios') {
return;
}
try {
ExtensionStorage.reloadWidget();
DeviceControlWidget.reload();
} catch (error) {
console.error('Failed to refresh widgets:', error);
}
+181
View File
@@ -0,0 +1,181 @@
import { HStack, Image, Link, Spacer, Text, VStack } from '@expo/ui/swift-ui';
import {
background,
containerBackground,
font,
foregroundStyle,
frame,
lineLimit,
padding,
shapes,
} from '@expo/ui/swift-ui/modifiers';
import { createWidget, type WidgetEnvironment } from 'expo-widgets';
import type { SFSymbol } from 'sf-symbols-typescript';
export type WidgetDevice = {
id: string;
name: string;
mac: string;
ip: string;
canSleep: boolean;
canShutdown: boolean;
status: string;
};
type DeviceControlProps = {
devices: WidgetDevice[];
};
type DeviceControlConfiguration = {
device?: string | null;
};
type DeviceAction = {
id: 'wake' | 'sleep' | 'restart' | 'shutdown';
symbol: SFSymbol;
color: string;
enabled: boolean;
};
const DeviceControlWidgetView = (
props: DeviceControlProps,
environment: WidgetEnvironment<DeviceControlConfiguration>
) => {
'widget';
const rootModifiers = [
containerBackground({ type: 'hierarchical', style: 'quaternary' }, 'widget'),
padding({ top: 4, bottom: 4 }),
];
const devices = Array.isArray(props.devices) ? props.devices : [];
const selectedId = environment.configuration?.device;
const device = devices.find(candidate => candidate.id === selectedId);
if (!device || !selectedId || selectedId === 'none') {
return (
<VStack
spacing={8}
modifiers={[...rootModifiers, frame({ maxWidth: 10000, maxHeight: 10000 })]}
>
<Image systemName="desktopcomputer" size={32} color="#8E8E93" />
<Text
modifiers={[
font({ size: 14, weight: 'medium' }),
foregroundStyle({ type: 'hierarchical', style: 'secondary' }),
]}
>
Select a Device
</Text>
<Text
modifiers={[
font({ size: 11 }),
foregroundStyle({ type: 'hierarchical', style: 'tertiary' }),
]}
>
Long press to configure
</Text>
</VStack>
);
}
const isOnline = device.status === 'online';
const isOffline = device.status === 'offline';
const actions: DeviceAction[] = [
{ id: 'wake', symbol: 'bolt.fill', color: '#4CAF50', enabled: isOffline },
{
id: 'sleep',
symbol: 'moon.fill',
color: '#FF9800',
enabled: isOnline && Boolean(device.canSleep),
},
{
id: 'restart',
symbol: 'arrow.clockwise',
color: '#2196F3',
enabled: isOnline && Boolean(device.canShutdown),
},
{
id: 'shutdown',
symbol: 'power',
color: '#F44336',
enabled: isOnline && Boolean(device.canShutdown),
},
];
const buttons = actions.map(action => {
const tint = action.enabled ? action.color : '#8E8E93';
const button = (
<VStack
alignment="center"
modifiers={[
frame({ maxWidth: 10000, minHeight: 36 }),
background(`${tint}26`, shapes.roundedRectangle({ cornerRadius: 8 })),
]}
>
<Image systemName={action.symbol} size={16} color={tint} />
</VStack>
);
if (!action.enabled) {
return <VStack key={action.id}>{button}</VStack>;
}
return (
<Link
key={action.id}
destination={`remotewol-upsnap://action/${action.id}/${encodeURIComponent(device.id)}`}
>
{button}
</Link>
);
});
const header = (
<VStack alignment="leading" spacing={2}>
<Text modifiers={[font({ size: 15, weight: 'semibold' }), lineLimit(1)]}>{device.name}</Text>
<Text
modifiers={[
font({ size: 11 }),
foregroundStyle({ type: 'hierarchical', style: 'secondary' }),
lineLimit(1),
]}
>
{device.mac}
</Text>
</VStack>
);
if (environment.widgetFamily === 'systemSmall') {
return (
<VStack alignment="leading" spacing={8} modifiers={rootModifiers}>
{header}
<Spacer minLength={4} />
<VStack spacing={6}>
<HStack spacing={6}>
{buttons[0]}
{buttons[1]}
</HStack>
<HStack spacing={6}>
{buttons[2]}
{buttons[3]}
</HStack>
</VStack>
</VStack>
);
}
return (
<VStack alignment="leading" spacing={8} modifiers={rootModifiers}>
{header}
<Spacer minLength={4} />
<HStack spacing={8}>{buttons}</HStack>
</VStack>
);
};
const DeviceControlWidget = createWidget<DeviceControlProps, DeviceControlConfiguration>(
'DeviceControl',
DeviceControlWidgetView,
{ devices: [] }
);
export default DeviceControlWidget;
-82
View File
@@ -1,82 +0,0 @@
import WidgetKit
import AppIntents
// MARK: - Shared Data Access
struct SharedDeviceData {
static let appGroupIdentifier = "group.abunchofknowitalls.remotewol-upsnap"
static var sharedDefaults: UserDefaults? {
UserDefaults(suiteName: appGroupIdentifier)
}
static func getDevices() -> [DeviceInfo] {
guard let defaults = sharedDefaults,
let jsonString = defaults.string(forKey: "devices"),
let data = jsonString.data(using: .utf8),
let devices = try? JSONDecoder().decode([DeviceInfo].self, from: data) else {
return []
}
return devices
}
static func getDevice(id: String) -> DeviceInfo? {
return getDevices().first { $0.id == id }
}
}
// MARK: - Device Model
struct DeviceInfo: Codable, Hashable, Identifiable {
let id: String
let name: String
let mac: String
let ip: String
let canSleep: Bool
let canShutdown: Bool
let status: String
}
// MARK: - Device Entity for AppIntents
struct DeviceEntity: AppEntity {
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Device"
static var defaultQuery = DeviceQuery()
var id: String
var name: String
var mac: String
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(name)", subtitle: "\(mac)")
}
}
struct DeviceQuery: EntityQuery {
func entities(for identifiers: [String]) async throws -> [DeviceEntity] {
let devices = SharedDeviceData.getDevices()
return devices
.filter { identifiers.contains($0.id) }
.map { DeviceEntity(id: $0.id, name: $0.name, mac: $0.mac) }
}
func suggestedEntities() async throws -> [DeviceEntity] {
let devices = SharedDeviceData.getDevices()
return devices.map { DeviceEntity(id: $0.id, name: $0.name, mac: $0.mac) }
}
func defaultResult() async -> DeviceEntity? {
let devices = SharedDeviceData.getDevices()
return devices.first.map { DeviceEntity(id: $0.id, name: $0.name, mac: $0.mac) }
}
}
// MARK: - Widget Configuration Intent
struct ConfigurationAppIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource { "Device Control" }
static var description: IntentDescription { "Select a device to control from your home screen." }
@Parameter(title: "Device")
var device: DeviceEntity?
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 340 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 703 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 963 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 703 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

@@ -1,122 +0,0 @@
{
"images": [
{
"idiom": "iphone",
"size": "20x20",
"scale": "2x",
"filename": "App-Icon-20x20@2x.png"
},
{
"idiom": "iphone",
"size": "20x20",
"scale": "3x",
"filename": "App-Icon-20x20@3x.png"
},
{
"idiom": "iphone",
"size": "29x29",
"scale": "1x",
"filename": "App-Icon-29x29@1x.png"
},
{
"idiom": "iphone",
"size": "29x29",
"scale": "2x",
"filename": "App-Icon-29x29@2x.png"
},
{
"idiom": "iphone",
"size": "29x29",
"scale": "3x",
"filename": "App-Icon-29x29@3x.png"
},
{
"idiom": "iphone",
"size": "40x40",
"scale": "2x",
"filename": "App-Icon-40x40@2x.png"
},
{
"idiom": "iphone",
"size": "40x40",
"scale": "3x",
"filename": "App-Icon-40x40@3x.png"
},
{
"idiom": "iphone",
"size": "60x60",
"scale": "2x",
"filename": "App-Icon-60x60@2x.png"
},
{
"idiom": "iphone",
"size": "60x60",
"scale": "3x",
"filename": "App-Icon-60x60@3x.png"
},
{
"idiom": "ipad",
"size": "20x20",
"scale": "1x",
"filename": "App-Icon-20x20@1x.png"
},
{
"idiom": "ipad",
"size": "20x20",
"scale": "2x",
"filename": "App-Icon-20x20@2x.png"
},
{
"idiom": "ipad",
"size": "29x29",
"scale": "1x",
"filename": "App-Icon-29x29@1x.png"
},
{
"idiom": "ipad",
"size": "29x29",
"scale": "2x",
"filename": "App-Icon-29x29@2x.png"
},
{
"idiom": "ipad",
"size": "40x40",
"scale": "1x",
"filename": "App-Icon-40x40@1x.png"
},
{
"idiom": "ipad",
"size": "40x40",
"scale": "2x",
"filename": "App-Icon-40x40@2x.png"
},
{
"idiom": "ipad",
"size": "76x76",
"scale": "1x",
"filename": "App-Icon-76x76@1x.png"
},
{
"idiom": "ipad",
"size": "76x76",
"scale": "2x",
"filename": "App-Icon-76x76@2x.png"
},
{
"idiom": "ipad",
"size": "83.5x83.5",
"scale": "2x",
"filename": "App-Icon-83.5x83.5@2x.png"
},
{
"idiom": "ios-marketing",
"size": "1024x1024",
"scale": "1x",
"filename": "ItunesArtwork@2x.png"
}
],
"info": {
"version": 1,
"author": "expo"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
-11
View File
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
-93
View File
@@ -1,93 +0,0 @@
import AppIntents
import SwiftUI
import WidgetKit
// MARK: - Control Widget for Quick Wake Action
struct widgetControl: ControlWidget {
static let kind: String = "wakecontrol"
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
kind: Self.kind,
provider: WakeControlProvider()
) { value in
ControlWidgetButton(action: WakeDeviceIntent(deviceId: value.deviceId, deviceName: value.deviceName)) {
Label(value.deviceName, systemImage: "bolt.fill")
}
}
.displayName("Wake Device")
.description("Quickly wake a device from Control Center.")
}
}
// MARK: - Control Widget Value
extension widgetControl {
struct Value {
var deviceId: String
var deviceName: String
}
}
// MARK: - Control Widget Provider
struct WakeControlProvider: AppIntentControlValueProvider {
func previewValue(configuration: WakeControlConfiguration) -> widgetControl.Value {
widgetControl.Value(
deviceId: configuration.device?.id ?? "",
deviceName: configuration.device?.name ?? "Select Device"
)
}
func currentValue(configuration: WakeControlConfiguration) async throws -> widgetControl.Value {
widgetControl.Value(
deviceId: configuration.device?.id ?? "",
deviceName: configuration.device?.name ?? "Select Device"
)
}
}
// MARK: - Control Widget Configuration Intent
struct WakeControlConfiguration: ControlConfigurationIntent {
static let title: LocalizedStringResource = "Device to Wake"
@Parameter(title: "Device")
var device: DeviceEntity?
}
// MARK: - Wake Device Intent (Opens Deep Link)
struct WakeDeviceIntent: AppIntent {
static var title: LocalizedStringResource = "Wake Device"
static var description = IntentDescription("Wakes the selected device using Wake-on-LAN.")
static var openAppWhenRun: Bool = true
static var isDiscoverable: Bool = true
@Parameter(title: "Device ID")
var deviceId: String
@Parameter(title: "Device Name")
var deviceName: String
// static var parameterSummary: some ParameterSummary {
// Summary("Wake \(\.$deviceName)")
// }
init() {
self.deviceId = ""
self.deviceName = ""
}
init(deviceId: String, deviceName: String) {
self.deviceId = deviceId
self.deviceName = deviceName
}
func perform() async throws -> some IntentResult & OpensIntent {
print("WakeDeviceIntent performed for device: \(deviceName) (id: \(deviceId))")
let url = URL(string: "remotewol-upsnap://actions/wake/\(deviceId)")!
return .result(opensIntent: OpenURLIntent(url))
}
}
-10
View File
@@ -1,10 +0,0 @@
/** @type {import('@bacons/apple-targets/app.plugin').ConfigFunction} */
module.exports = config => ({
type: "widget",
icon: 'https://github.com/expo.png',
entitlements: {
"com.apple.security.application-groups": [
"group.abunchofknowitalls.remotewol-upsnap"
]
},
});
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.abunchofknowitalls.remotewol-upsnap</string>
</array>
</dict>
</plist>
-11
View File
@@ -1,11 +0,0 @@
import WidgetKit
import SwiftUI
@main
struct exportWidgets: WidgetBundle {
var body: some Widget {
// Export widgets here
widget()
// widgetControl()
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

@@ -1,66 +0,0 @@
{
"fill" : {
"automatic-gradient" : "extended-gray:1.00000,1.00000"
},
"groups" : [
{
"layers" : [
{
"hidden" : false,
"image-name" : "Image.png",
"name" : "Image",
"position" : {
"scale" : 0.65,
"translation-in-points" : [
0,
-52.942187500000045
]
}
},
{
"fill-specializations" : [
{
"appearance" : "dark",
"value" : "none"
}
],
"image-name-specializations" : [
{
"value" : "display 2.png"
},
{
"appearance" : "dark",
"value" : "display.png"
},
{
"appearance" : "tinted",
"value" : "display 3.png"
}
],
"name" : "display 3",
"position" : {
"scale" : 1.25,
"translation-in-points" : [
0,
-10.625
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}
-208
View File
@@ -1,208 +0,0 @@
import WidgetKit
import SwiftUI
// MARK: - Timeline Entry
struct DeviceEntry: TimelineEntry {
let date: Date
let configuration: ConfigurationAppIntent
let device: DeviceInfo?
}
// MARK: - Timeline Provider
struct Provider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> DeviceEntry {
DeviceEntry(
date: Date(),
configuration: ConfigurationAppIntent(),
device: DeviceInfo(id: "placeholder", name: "My Computer", mac: "AA:BB:CC:DD:EE:FF", ip: "192.168.1.100", canSleep: true, canShutdown: true, status: "unknown")
)
}
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> DeviceEntry {
let device = configuration.device.flatMap { SharedDeviceData.getDevice(id: $0.id) }
return DeviceEntry(date: Date(), configuration: configuration, device: device)
}
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<DeviceEntry> {
let device = configuration.device.flatMap { SharedDeviceData.getDevice(id: $0.id) }
let entry = DeviceEntry(date: Date(), configuration: configuration, device: device)
// Widgets don't update frequently enough for status, so we just provide a static entry
// Refresh every 30 minutes to pick up device list changes
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 30, to: Date())!
return Timeline(entries: [entry], policy: .after(nextUpdate))
}
}
// MARK: - Deep Link Helper
enum DeviceAction: String {
case wake
case sleep
case restart
case shutdown
func url(for deviceId: String) -> URL {
URL(string: "remotewol-upsnap://action/\(self.rawValue)/\(deviceId)")!
}
}
// MARK: - Widget View
struct DeviceWidgetEntryView: View {
var entry: Provider.Entry
@Environment(\.widgetFamily) var widgetFamily
var body: some View {
if let device = entry.device {
VStack(alignment: .leading, spacing: 8) {
// Device Info Header
VStack(alignment: .leading, spacing: 2) {
Text(device.name)
.font(.system(size: 15, weight: .semibold))
.lineLimit(1)
Text(device.mac)
.font(.system(size: 11))
.foregroundColor(.secondary)
.lineLimit(1)
}
Spacer(minLength: 4)
// Action Buttons
if widgetFamily == .systemSmall {
// Small widget: 2x2 grid
VStack(spacing: 6) {
HStack(spacing: 6) {
ActionButton(action: .wake, deviceId: device.id, enabled: device.status == "offline")
ActionButton(action: .sleep, deviceId: device.id, enabled: device.status == "online" && device.canSleep)
}
HStack(spacing: 6) {
ActionButton(action: .restart, deviceId: device.id, enabled: device.status == "online" && device.canShutdown)
ActionButton(action: .shutdown, deviceId: device.id, enabled: device.status == "online" && device.canShutdown)
}
}
} else {
// Medium/Large widget: horizontal layout
HStack(spacing: 8) {
ActionButton(action: .wake, deviceId: device.id, enabled: device.status == "offline")
ActionButton(action: .sleep, deviceId: device.id, enabled: device.status == "online" && device.canSleep)
ActionButton(action: .restart, deviceId: device.id, enabled: device.status == "online" && device.canShutdown)
ActionButton(action: .shutdown, deviceId: device.id, enabled: device.status == "online" && device.canShutdown)
}
}
}
.padding(.vertical, 4)
} else {
// No device selected
VStack(spacing: 8) {
Image(systemName: "desktopcomputer")
.font(.system(size: 32))
.foregroundColor(.secondary)
Text("Select a Device")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.secondary)
Text("Long press to configure")
.font(.system(size: 11))
.foregroundColor(.secondary.opacity(0.7))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
// MARK: - Action Button
struct ActionButton: View {
let action: DeviceAction
let deviceId: String
let enabled: Bool
var icon: String {
switch action {
case .wake: return "bolt.fill"
case .sleep: return "moon.fill"
case .restart: return "arrow.clockwise"
case .shutdown: return "power"
}
}
var color: Color {
switch action {
case .wake: return .green
case .sleep: return .orange
case .restart: return .blue
case .shutdown: return .red
}
}
var disabled: Color {
return .gray
}
var label: String {
switch action {
case .wake: return "Wake"
case .sleep: return "Sleep"
case .restart: return "Restart"
case .shutdown: return "Shut Down"
}
}
var body: some View {
Link(destination: enabled ? action.url(for: deviceId) : URL("")!) {
VStack(spacing: 2) {
Image(systemName: icon)
.font(.system(size: 16, weight: .medium))
.foregroundColor(enabled ? color : disabled)
}
.frame(maxWidth: .infinity, minHeight: 36)
.background((enabled ? color : disabled).opacity(0.15))
.cornerRadius(8)
.disabled(!enabled)
}
}
}
// MARK: - Widget Definition
struct DeviceControlWidget: Widget {
let kind: String = "widget"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
DeviceWidgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Device Control")
.description("Control your devices with wake, sleep, restart, and shutdown actions.")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
// Keep legacy name for backwards compatibility with existing widget installations
typealias widget = DeviceControlWidget
// MARK: - Preview
#Preview(as: .systemSmall) {
DeviceControlWidget()
} timeline: {
DeviceEntry(
date: .now,
configuration: ConfigurationAppIntent(),
device: DeviceInfo(id: "preview", name: "Gaming PC", mac: "AA:BB:CC:DD:EE:FF", ip: "192.168.1.100", canSleep: true, canShutdown: true, status: "online")
)
}
#Preview(as: .systemMedium) {
DeviceControlWidget()
} timeline: {
DeviceEntry(
date: .now,
configuration: ConfigurationAppIntent(),
device: DeviceInfo(id: "preview", name: "Home Server", mac: "11:22:33:44:55:66", ip: "192.168.1.50", canSleep: true, canShutdown: true, status: "offline")
)
}