Compare commits

...
45 changed files with 1388 additions and 1203 deletions

No files matched your search

+4
View File
@@ -41,3 +41,7 @@ app-example
# generated native folders # generated native folders
/ios /ios
/android /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", "appleTeamId": "8S7C654DQ4",
"entitlements": { "entitlements": {
"com.apple.security.application-groups": [ "com.apple.security.application-groups": [
"group.abunchofknowitalls.remotewol-upsnap",
"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-font",
"expo-image", "expo-image",
"expo-web-browser" "expo-web-browser",
"expo-app-intents"
], ],
"experiments": { "experiments": {
"typedRoutes": true, "typedRoutes": true,
"reactCompiler": true "reactCompiler": true,
"inlineModules": {
"watchedDirectories": [
"app-intents"
]
}
}, },
"extra": { "extra": {
"router": {}, "router": {},
"eas": { "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" "projectId": "b2c99399-8470-4a78-9253-492056fc1c35"
} }
} }
+75 -394
View File
@@ -8,12 +8,12 @@
"name": "remote-wol", "name": "remote-wol",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@bacons/apple-targets": "^3.0.6",
"@expo/ui": "~58.0.21", "@expo/ui": "~58.0.21",
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "^2.2.0", "@react-native-async-storage/async-storage": "^2.2.0",
"burnt": "^0.13.0", "burnt": "^0.13.0",
"expo": "^58.0.16", "expo": "^58.0.16",
"expo-app-intents": "~0.5.10",
"expo-constants": "~58.0.12", "expo-constants": "~58.0.12",
"expo-dev-client": "~58.0.14", "expo-dev-client": "~58.0.14",
"expo-font": "~58.0.9", "expo-font": "~58.0.9",
@@ -27,6 +27,7 @@
"expo-symbols": "~58.0.4", "expo-symbols": "~58.0.4",
"expo-system-ui": "~58.0.6", "expo-system-ui": "~58.0.6",
"expo-web-browser": "~58.0.6", "expo-web-browser": "~58.0.6",
"expo-widgets": "~58.0.21",
"react": "19.3.0", "react": "19.3.0",
"react-dom": "19.3.0", "react-dom": "19.3.0",
"react-native": "0.88.0", "react-native": "0.88.0",
@@ -1137,29 +1138,6 @@
"node": ">=6.9.0" "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": { "node_modules/@emnapi/core": {
"version": "1.10.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -2165,17 +2143,6 @@
"integrity": "sha512-KtLrtAvpShq16l9lTxd2fZSjULxcGxXrJTHA31iUVqrc+Af7aTAJW4ZB3yQBgi/w+kSb6BB/CG9RXI0ohbMm6g==", "integrity": "sha512-KtLrtAvpShq16l9lTxd2fZSjULxcGxXrJTHA31iUVqrc+Af7aTAJW4ZB3yQBgi/w+kSb6BB/CG9RXI0ohbMm6g==",
"license": "MIT" "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": { "node_modules/@expo/prebuild-config": {
"version": "58.0.14", "version": "58.0.14",
"resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-58.0.14.tgz", "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-58.0.14.tgz",
@@ -2380,23 +2347,6 @@
"url": "https://github.com/sponsors/nzakas" "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": { "node_modules/@isaacs/ttlcache": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz",
@@ -2671,16 +2621,6 @@
"url": "https://github.com/sponsors/oxc-project" "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": { "node_modules/@radix-ui/primitive": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.2.0.tgz", "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": "^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": { "node_modules/@react-native/virtualized-lists": {
"version": "0.88.0", "version": "0.88.0",
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.88.0.tgz", "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.88.0.tgz",
@@ -4035,16 +3969,6 @@
"win32" "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": { "node_modules/2g": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/2g/-/2g-1.0.1.tgz", "resolved": "https://registry.npmjs.org/2g/-/2g-1.0.1.tgz",
@@ -4554,6 +4478,7 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-js": { "node_modules/base64-js": {
@@ -5388,12 +5313,6 @@
"node": ">= 0.4" "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": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -5406,12 +5325,6 @@
"integrity": "sha512-9qpCBlXPy5v7n1Q7WTO5rW2StI4WNQhl96HpgXRZLPlNoXAD0LbsQUflovQYAO44Yia073ISusqohncvGSMbGA==", "integrity": "sha512-9qpCBlXPy5v7n1Q7WTO5rW2StI4WNQhl96HpgXRZLPlNoXAD0LbsQUflovQYAO44Yia073ISusqohncvGSMbGA==",
"license": "ISC" "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": { "node_modules/encodeurl": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", "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": { "node_modules/expo-asset": {
"version": "58.0.17", "version": "58.0.17",
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-58.0.17.tgz", "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-58.0.17.tgz",
@@ -6604,6 +6545,50 @@
"react-native": "*" "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": { "node_modules/expo/node_modules/@expo/cli": {
"version": "58.1.13", "version": "58.1.13",
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-58.1.13.tgz", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-58.1.13.tgz",
@@ -7215,22 +7200,6 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/fresh": {
"version": "0.5.2", "version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -7390,27 +7359,6 @@
"node": ">=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": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -7424,30 +7372,6 @@
"node": ">=10.13.0" "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": { "node_modules/globals": {
"version": "14.0.0", "version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
@@ -8274,21 +8198,6 @@
"node": ">= 0.4" "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": { "node_modules/jest-get-type": {
"version": "29.6.3", "version": "29.6.3",
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", "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" "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": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -9993,28 +9896,6 @@
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"license": "MIT" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -11112,18 +10993,6 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/simple-plist": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz", "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz",
@@ -11259,56 +11128,6 @@
"node": ">= 0.10.0" "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": { "node_modules/string.prototype.matchall": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.1.0.tgz", "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" "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": { "node_modules/strip-bom": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
@@ -11977,16 +11756,6 @@
"node": ">= 0.4.0" "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": { "node_modules/validate-npm-package-name": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", "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": ">=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": { "node_modules/ws": {
"version": "7.5.13", "version": "7.5.13",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz",
@@ -12322,15 +12012,6 @@
"node": ">=4.0" "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": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+2 -1
View File
@@ -11,12 +11,12 @@
"lint": "expo lint" "lint": "expo lint"
}, },
"dependencies": { "dependencies": {
"@bacons/apple-targets": "^3.0.6",
"@expo/ui": "~58.0.21", "@expo/ui": "~58.0.21",
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "^2.2.0", "@react-native-async-storage/async-storage": "^2.2.0",
"burnt": "^0.13.0", "burnt": "^0.13.0",
"expo": "^58.0.16", "expo": "^58.0.16",
"expo-app-intents": "~0.5.10",
"expo-constants": "~58.0.12", "expo-constants": "~58.0.12",
"expo-dev-client": "~58.0.14", "expo-dev-client": "~58.0.14",
"expo-font": "~58.0.9", "expo-font": "~58.0.9",
@@ -30,6 +30,7 @@
"expo-symbols": "~58.0.4", "expo-symbols": "~58.0.4",
"expo-system-ui": "~58.0.6", "expo-system-ui": "~58.0.6",
"expo-web-browser": "~58.0.6", "expo-web-browser": "~58.0.6",
"expo-widgets": "~58.0.21",
"react": "19.3.0", "react": "19.3.0",
"react-dom": "19.3.0", "react-dom": "19.3.0",
"react-native": "0.88.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;
+246 -36
View File
@@ -1,6 +1,9 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
import { AppState } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import api from '../services/api'; import api, { isAuthCollection, isRevokedAuthError } from '../services/api';
import { getJwtExpiryMs } from '../services/jwtExpiry';
import { syncDevicesToWidget } from '../services/widgetSync';
import { AuthResponse, User } from '../types'; import { AuthResponse, User } from '../types';
interface AuthContextType { interface AuthContextType {
@@ -16,6 +19,22 @@ interface AuthContextType {
const AuthContext = createContext<AuthContextType | undefined>(undefined); const AuthContext = createContext<AuthContextType | undefined>(undefined);
const AUTH_COLLECTION_KEY = 'auth_collection';
const REFRESH_BEFORE_EXPIRY_MS = 60_000;
const MAX_REFRESH_DELAY_MS = 2_147_483_647;
const REFRESH_RETRY_MS = 30_000;
const REFRESH_BACKOFF_MS = 15_000;
function msUntilRefresh(token: string): number | null {
const expiresAt = getJwtExpiryMs(token);
if (expiresAt == null) return null;
return expiresAt - Date.now() - REFRESH_BEFORE_EXPIRY_MS;
}
function collectionFromUser(user: User | null): 'users' | '_superusers' | null {
return isAuthCollection(user?.collectionName) ? user.collectionName : null;
}
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [serverAddress, setServerAddress] = useState<string | null>(null); const [serverAddress, setServerAddress] = useState<string | null>(null);
@@ -23,44 +42,192 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [canCreate, setCanCreate] = useState<boolean | null>(null); const [canCreate, setCanCreate] = useState<boolean | null>(null);
const clearSession = useCallback(async () => { const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
await AsyncStorage.multiRemove(['auth_token', 'auth_user', 'auth_can_create']); const refreshingRef = useRef(false);
const sessionWriteRef = useRef<Promise<void>>(Promise.resolve());
const refreshSessionRef = useRef<() => Promise<void>>(async () => {});
const armRefreshTimerRef = useRef<(token: string, overdueDelay?: number) => void>(() => {});
const enqueueSessionWrite = useCallback((task: () => Promise<void>) => {
const run = sessionWriteRef.current.then(task, task);
sessionWriteRef.current = run.then(
() => undefined,
() => undefined,
);
return run;
}, []);
const clearRefreshTimer = useCallback(() => {
if (refreshTimerRef.current != null) {
clearTimeout(refreshTimerRef.current);
refreshTimerRef.current = null;
}
}, []);
const clearSession = useCallback(async () => {
clearRefreshTimer();
api.clearToken(); api.clearToken();
api.clearCanCreate(); api.clearCanCreate();
api.clearAuthCollection();
setToken(null); setToken(null);
setUser(null); setUser(null);
setCanCreate(null); setCanCreate(null);
await enqueueSessionWrite(async () => {
await AsyncStorage.multiRemove([
'auth_token',
'auth_user',
'auth_can_create',
AUTH_COLLECTION_KEY,
]);
});
}, [clearRefreshTimer, enqueueSessionWrite]);
const armRefreshTimer = useCallback((nextToken: string, overdueDelay = 0) => {
clearRefreshTimer();
const untilRefresh = msUntilRefresh(nextToken);
if (untilRefresh == null) return;
const delay = untilRefresh <= 0 ? overdueDelay : Math.min(untilRefresh, MAX_REFRESH_DELAY_MS);
const rescheduleOnly = untilRefresh > MAX_REFRESH_DELAY_MS;
refreshTimerRef.current = setTimeout(() => {
refreshTimerRef.current = null;
const latest = api.getToken();
if (!latest) return;
if (rescheduleOnly) {
armRefreshTimerRef.current(latest);
return;
}
void refreshSessionRef.current();
}, delay);
}, [clearRefreshTimer]);
const refreshSession = useCallback(async () => {
if (refreshingRef.current || !api.getToken()) return;
refreshingRef.current = true;
try {
await api.refreshAuth();
} catch {
// The failure listener either keeps the session and retries, or signs out.
} finally {
refreshingRef.current = false;
}
}, []);
const handleRefreshFailure = useCallback(async (error: unknown) => {
if (isRevokedAuthError(error)) {
await clearSession();
return;
}
if (!api.getToken()) return;
clearRefreshTimer();
refreshTimerRef.current = setTimeout(() => {
refreshTimerRef.current = null;
void refreshSessionRef.current();
}, REFRESH_RETRY_MS);
}, [clearRefreshTimer, clearSession]);
const persistRefreshedAuth = useCallback(async (auth: AuthResponse) => {
await enqueueSessionWrite(async () => {
if (api.getToken() !== auth.token) return;
const collection = api.getAuthCollection();
const pairs: [string, string][] = [
['auth_token', auth.token],
['auth_user', JSON.stringify(auth.record)],
];
if (collection) {
pairs.push([AUTH_COLLECTION_KEY, collection]);
}
if (collection === '_superusers') {
pairs.push(['auth_can_create', 'true']);
}
await AsyncStorage.multiSet(pairs);
if (api.getToken() !== auth.token) return;
setToken(auth.token);
setUser(auth.record);
if (collection === '_superusers') {
setCanCreate(true);
}
armRefreshTimerRef.current(auth.token, REFRESH_BACKOFF_MS);
});
}, [enqueueSessionWrite]);
const watchToken = useCallback((nextToken: string) => {
const untilRefresh = msUntilRefresh(nextToken);
if (untilRefresh != null && untilRefresh <= 0) {
void refreshSessionRef.current();
return;
}
armRefreshTimerRef.current(nextToken);
}, []); }, []);
useEffect(() => {
refreshSessionRef.current = refreshSession;
armRefreshTimerRef.current = armRefreshTimer;
}, [armRefreshTimer, refreshSession]);
useEffect(() => { useEffect(() => {
api.setUnauthorizedHandler(clearSession); api.setUnauthorizedHandler(clearSession);
api.setAuthUpdateListener(persistRefreshedAuth);
api.setAuthFailureListener(handleRefreshFailure);
return () => { return () => {
api.setUnauthorizedHandler(null); api.setUnauthorizedHandler(null);
api.setAuthUpdateListener(null);
api.setAuthFailureListener(null);
clearRefreshTimer();
}; };
}, [clearSession]); }, [clearRefreshTimer, clearSession, handleRefreshFailure, persistRefreshedAuth]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
void AsyncStorage.multiGet([ void (async () => {
'auth_token', try {
'auth_user', const [
'auth_server_address', [, storedToken],
'auth_can_create', [, storedUser],
]).then( [, storedServerAddress],
([[, storedToken], [, storedUser], [, storedServerAddress], [, storedCanCreate]]) => { [, storedCanCreate],
[, storedCollection],
] = await AsyncStorage.multiGet([
'auth_token',
'auth_user',
'auth_server_address',
'auth_can_create',
AUTH_COLLECTION_KEY,
]);
if (cancelled) return; if (cancelled) return;
if (storedCanCreate) { let restoredUser: User | null = null;
setCanCreate(storedCanCreate === 'true'); if (storedToken && storedUser) {
restoredUser = JSON.parse(storedUser) as User;
api.setToken(storedToken);
setToken(storedToken);
setUser(restoredUser);
} }
if (storedToken && storedUser) { const collection = isAuthCollection(storedCollection)
setToken(storedToken); ? storedCollection
api.setToken(storedToken); : collectionFromUser(restoredUser);
setUser(JSON.parse(storedUser)); if (collection) {
api.setAuthCollection(collection);
}
if (collection === '_superusers') {
api.setCanCreate(true);
setCanCreate(true);
} else if (storedCanCreate) {
const restoredCanCreate = storedCanCreate === 'true';
api.setCanCreate(restoredCanCreate);
setCanCreate(restoredCanCreate);
} }
if (storedServerAddress) { if (storedServerAddress) {
@@ -68,41 +235,84 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
api.setAddress(storedServerAddress + '/api'); api.setAddress(storedServerAddress + '/api');
} }
setIsLoading(false); const untilRefresh = storedToken ? msUntilRefresh(storedToken) : null;
}, const refreshIsDue = !!storedToken && !!restoredUser && untilRefresh != null && untilRefresh <= 0;
error => {
if (refreshIsDue) {
try {
// Hold the loading gate so an expired token does not flash the device list.
await api.refreshAuth();
} catch {
// Revoked tokens sign out; transient failures keep the session and retry.
}
} else if (storedToken && restoredUser) {
watchToken(storedToken);
}
if (!cancelled) {
setIsLoading(false);
}
} catch (error) {
if (cancelled) return; if (cancelled) return;
console.error('Failed to load auth', error); console.error('Failed to load auth', error);
setIsLoading(false); setIsLoading(false);
} }
); })();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [watchToken]);
useEffect(() => {
const subscription = AppState.addEventListener('change', nextState => {
if (nextState !== 'active') return;
const currentToken = api.getToken();
if (!currentToken) return;
const untilRefresh = msUntilRefresh(currentToken);
if (untilRefresh == null) return;
if (untilRefresh <= 0) {
void refreshSessionRef.current();
return;
}
armRefreshTimerRef.current(currentToken);
});
return () => {
subscription.remove();
};
}, []); }, []);
const login = async (serverAddress: string, identity: string, password: string) => { const login = async (nextServerAddress: string, identity: string, password: string) => {
try { const response: AuthResponse = await api.authenticate(nextServerAddress, identity, password);
const response: AuthResponse = await api.authenticate(serverAddress, identity, password); const collection = api.getAuthCollection();
await AsyncStorage.setItem('auth_token', response.token); await enqueueSessionWrite(async () => {
await AsyncStorage.setItem('auth_user', JSON.stringify(response.record)); const pairs: [string, string][] = [
await AsyncStorage.setItem('auth_server_address', serverAddress); ['auth_token', response.token],
await AsyncStorage.setItem('auth_can_create', api.getCanCreate() ? 'true' : 'false'); ['auth_user', JSON.stringify(response.record)],
['auth_server_address', nextServerAddress],
['auth_can_create', api.getCanCreate() ? 'true' : 'false'],
];
if (collection) {
pairs.push([AUTH_COLLECTION_KEY, collection]);
}
await AsyncStorage.multiSet(pairs);
});
setToken(response.token); setToken(response.token);
setUser(response.record); setUser(response.record);
setServerAddress(serverAddress); setServerAddress(nextServerAddress);
setCanCreate(api.getCanCreate() || false); setCanCreate(api.getCanCreate() || false);
} catch (error) { watchToken(response.token);
throw error;
}
}; };
const logout = async () => { const logout = async () => {
try { try {
await clearSession(); await clearSession();
syncDevicesToWidget([]);
} catch (error) { } catch (error) {
console.error('Failed to logout', error); console.error('Failed to logout', error);
} }
+251 -97
View File
@@ -5,12 +5,47 @@ import {
PermissionResponse, PermissionResponse,
} from '../types'; } from '../types';
export type AuthCollection = 'users' | '_superusers';
export type ApiError = Error & {
status?: number;
isAuthError?: boolean;
};
export type AuthUpdateListener = (auth: AuthResponse) => void | Promise<void>;
export type AuthFailureListener = (error: unknown) => void | Promise<void>;
const AUTH_ERROR_MESSAGE = /authorization token|invalid token|unauthorized|not authenticated/i;
export function isAuthCollection(value: unknown): value is AuthCollection {
return value === 'users' || value === '_superusers';
}
export function isRevokedAuthError(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false;
const status = (error as { status?: unknown }).status;
return status === 401 || status === 403 || status === 404;
}
function isSessionChanged(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
(error as { sessionChanged?: boolean }).sessionChanged === true
);
}
class UpSnapAPI { class UpSnapAPI {
private token: string | null = null; private token: string | null = null;
private address: string | null = null; private address: string | null = null;
private canCreate: boolean | null = null; private canCreate: boolean | null = null;
private authCollection: AuthCollection | null = null;
private authEpoch = 0;
private unauthorizedHandler: (() => Promise<void>) | null = null; private unauthorizedHandler: (() => Promise<void>) | null = null;
private authUpdateListener: AuthUpdateListener | null = null;
private authFailureListener: AuthFailureListener | null = null;
private isHandlingUnauthorized = false; private isHandlingUnauthorized = false;
private refreshInFlight: Promise<AuthResponse> | null = null;
setToken(token: string) { setToken(token: string) {
this.token = token; this.token = token;
@@ -22,6 +57,7 @@ class UpSnapAPI {
clearToken() { clearToken() {
this.token = null; this.token = null;
this.authEpoch += 1;
} }
setAddress(address: string) { setAddress(address: string) {
@@ -48,10 +84,30 @@ class UpSnapAPI {
this.canCreate = null; this.canCreate = null;
} }
setAuthCollection(collection: AuthCollection | null) {
this.authCollection = collection;
}
getAuthCollection(): AuthCollection | null {
return this.authCollection;
}
clearAuthCollection() {
this.authCollection = null;
}
setUnauthorizedHandler(handler: (() => Promise<void>) | null) { setUnauthorizedHandler(handler: (() => Promise<void>) | null) {
this.unauthorizedHandler = handler; this.unauthorizedHandler = handler;
} }
setAuthUpdateListener(listener: AuthUpdateListener | null) {
this.authUpdateListener = listener;
}
setAuthFailureListener(listener: AuthFailureListener | null) {
this.authFailureListener = listener;
}
private async handleUnauthorized() { private async handleUnauthorized() {
if (!this.unauthorizedHandler || this.isHandlingUnauthorized) { if (!this.unauthorizedHandler || this.isHandlingUnauthorized) {
return; return;
@@ -66,7 +122,7 @@ class UpSnapAPI {
} }
} }
private async throwApiError(response: Response, fallbackMessage: string): Promise<never> { private async errorFromResponse(response: Response, fallbackMessage: string): Promise<ApiError> {
let message = fallbackMessage; let message = fallbackMessage;
try { try {
@@ -76,23 +132,26 @@ class UpSnapAPI {
// Ignore JSON parsing errors and use the fallback message. // Ignore JSON parsing errors and use the fallback message.
} }
const isAuthError = const apiError = new Error(message) as ApiError;
apiError.status = response.status;
apiError.isAuthError =
response.status === 401 || response.status === 401 ||
response.status === 403 || response.status === 403 ||
/authorization token|invalid token|unauthorized|not authenticated/i.test(message); AUTH_ERROR_MESSAGE.test(message);
return apiError;
}
if (isAuthError) { private async throwApiError(
response: Response,
fallbackMessage: string,
skipUnauthorizedHandler = false,
): Promise<never> {
const error = await this.errorFromResponse(response, fallbackMessage);
if (error.isAuthError && !skipUnauthorizedHandler) {
await this.handleUnauthorized(); await this.handleUnauthorized();
} }
const error = new Error(message) as Error & {
status?: number;
isAuthError?: boolean;
};
error.status = response.status;
error.isAuthError = isAuthError;
throw error; throw error;
} }
@@ -108,11 +167,166 @@ class UpSnapAPI {
return headers; return headers;
} }
private sessionChanged(): ApiError {
const error = new Error('Auth session changed') as ApiError & { sessionChanged: true };
error.sessionChanged = true;
error.isAuthError = true;
return error;
}
/**
* One refresh is shared by the expiry timer and every request that receives a 401.
* PocketBase returns a new auth token and record for the same collection.
*/
async refreshAuth(): Promise<AuthResponse> {
if (!this.refreshInFlight) {
this.refreshInFlight = this.performRefresh().finally(() => {
this.refreshInFlight = null;
});
}
return this.refreshInFlight;
}
private async performRefresh(): Promise<AuthResponse> {
const epoch = this.authEpoch;
const tokenAtStart = this.token;
try {
if (!tokenAtStart || !this.address) {
throw Object.assign(new Error('Not authenticated'), {
status: 401,
isAuthError: true,
}) as ApiError;
}
const collections: AuthCollection[] = this.authCollection
? [this.authCollection]
: ['users', '_superusers'];
let lastError: ApiError | null = null;
for (let index = 0; index < collections.length; index += 1) {
const collection = collections[index];
const response = await fetch(
`${this.address}/collections/${collection}/auth-refresh`,
{
method: 'POST',
headers: this.getHeaders(),
},
);
if (this.authEpoch !== epoch || this.token !== tokenAtStart) {
throw this.sessionChanged();
}
if (response.ok) {
const data = (await response.json()) as AuthResponse;
if (this.authEpoch !== epoch || this.token !== tokenAtStart) {
throw this.sessionChanged();
}
if (!data?.token) {
throw Object.assign(new Error('Auth refresh failed'), {
status: 401,
isAuthError: true,
}) as ApiError;
}
this.authCollection = isAuthCollection(data.record?.collectionName)
? data.record.collectionName
: collection;
this.token = data.token;
if (this.authCollection === '_superusers') {
this.canCreate = true;
}
try {
await this.authUpdateListener?.(data);
} catch (error) {
console.error('Failed to persist refreshed auth', error);
}
return data;
}
lastError = await this.errorFromResponse(response, 'Auth refresh failed');
const tryNextCollection =
!this.authCollection && index < collections.length - 1 && response.status === 403;
if (!tryNextCollection) {
throw lastError;
}
}
throw (
lastError ??
(Object.assign(new Error('Auth refresh failed'), {
status: 401,
isAuthError: true,
}) as ApiError)
);
} catch (error) {
if (!isSessionChanged(error)) {
try {
await this.authFailureListener?.(error);
} catch (listenerError) {
console.error('Failed to handle auth refresh failure', listenerError);
}
}
throw error;
}
}
/**
* On 401, refresh once and retry the original request.
* A second 401, or a refresh the server rejects, ends the session.
*/
private async request(url: string, fallbackMessage: string, init?: RequestInit): Promise<Response> {
const send = () =>
fetch(url, {
...init,
headers: this.getHeaders(),
});
const response = await send();
if (response.ok) {
return response;
}
if (response.status === 401 && this.token) {
try {
await this.refreshAuth();
} catch (error) {
if (isSessionChanged(error)) {
throw error;
}
if (!isRevokedAuthError(error)) {
throw error;
}
await this.handleUnauthorized();
return this.throwApiError(response, fallbackMessage, true);
}
const retried = await send();
if (!retried.ok) {
if (retried.status === 401) {
await this.handleUnauthorized();
return this.throwApiError(retried, fallbackMessage, true);
}
return this.throwApiError(retried, fallbackMessage);
}
return retried;
}
return this.throwApiError(response, fallbackMessage);
}
async authenticate( async authenticate(
serverAddress: string, serverAddress: string,
identity: string, identity: string,
password: string password: string
): Promise<AuthResponse> { ): Promise<AuthResponse> {
this.authEpoch += 1;
this.token = null;
this.authCollection = null;
this.address = serverAddress + '/api'; this.address = serverAddress + '/api';
const response = await fetch( const response = await fetch(
@@ -125,7 +339,7 @@ class UpSnapAPI {
); );
if (!response.ok) { if (!response.ok) {
const response = await fetch( const superuserResponse = await fetch(
`${this.address}/collections/_superusers/auth-with-password`, `${this.address}/collections/_superusers/auth-with-password`,
{ {
method: 'POST', method: 'POST',
@@ -134,18 +348,20 @@ class UpSnapAPI {
} }
); );
if (!response.ok) { if (!superuserResponse.ok) {
await this.throwApiError(response, 'Authentication failed'); await this.throwApiError(superuserResponse, 'Authentication failed');
} }
const data: AuthResponse = await response.json(); const data: AuthResponse = await superuserResponse.json();
this.token = data.token; this.token = data.token;
this.authCollection = '_superusers';
this.canCreate = true; this.canCreate = true;
return data; return data;
} }
const data: AuthResponse = await response.json(); const data: AuthResponse = await response.json();
this.token = data.token; this.token = data.token;
this.authCollection = 'users';
const userID = data.record.id; const userID = data.record.id;
const userPermissionResponse = await fetch( const userPermissionResponse = await fetch(
@@ -165,142 +381,80 @@ class UpSnapAPI {
} }
async getDevices(page = 1, perPage = 100): Promise<Device[]> { async getDevices(page = 1, perPage = 100): Promise<Device[]> {
const response = await fetch( const response = await this.request(
`${this.address}/collections/devices/records?page=${page}&perPage=${perPage}`, `${this.address}/collections/devices/records?page=${page}&perPage=${perPage}`,
{ 'Failed to fetch devices',
headers: this.getHeaders(),
}
); );
if (!response.ok) {
await this.throwApiError(response, 'Failed to fetch devices');
}
const data = await response.json(); const data = await response.json();
return data.items; return data.items;
} }
async getDevice(id: string): Promise<Device> { async getDevice(id: string): Promise<Device> {
const response = await fetch( const response = await this.request(
`${this.address}/collections/devices/records/${id}`, `${this.address}/collections/devices/records/${id}`,
{ 'Failed to fetch device',
headers: this.getHeaders(),
}
); );
if (!response.ok) {
await this.throwApiError(response, 'Failed to fetch device');
}
return response.json(); return response.json();
} }
async createDevice(device: Partial<Device>): Promise<Device> { async createDevice(device: Partial<Device>): Promise<Device> {
const response = await fetch( const response = await this.request(
`${this.address}/collections/devices/records`, `${this.address}/collections/devices/records`,
'Failed to create device',
{ {
method: 'POST', method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(device), body: JSON.stringify(device),
} },
); );
if (!response.ok) {
await this.throwApiError(response, 'Failed to create device');
}
return response.json(); return response.json();
} }
async updateDevice(id: string, device: Partial<Device>): Promise<Device> { async updateDevice(id: string, device: Partial<Device>): Promise<Device> {
const response = await fetch( const response = await this.request(
`${this.address}/collections/devices/records/${id}`, `${this.address}/collections/devices/records/${id}`,
'Failed to update device',
{ {
method: 'PATCH', method: 'PATCH',
headers: this.getHeaders(),
body: JSON.stringify(device), body: JSON.stringify(device),
} },
); );
if (!response.ok) {
await this.throwApiError(response, 'Failed to update device');
}
return response.json(); return response.json();
} }
async deleteDevice(id: string): Promise<void> { async deleteDevice(id: string): Promise<void> {
const response = await fetch( await this.request(
`${this.address}/collections/devices/records/${id}`, `${this.address}/collections/devices/records/${id}`,
{ 'Failed to delete device',
method: 'DELETE', { method: 'DELETE' },
headers: this.getHeaders(),
}
); );
if (!response.ok) {
await this.throwApiError(response, 'Failed to delete device');
}
} }
async wakeDevice(id: string): Promise<void> { async wakeDevice(id: string): Promise<void> {
const response = await fetch(`${this.address}/upsnap/wake/${id}`, { await this.request(`${this.address}/upsnap/wake/${id}`, 'Failed to wake device');
headers: this.getHeaders(),
});
if (!response.ok) {
await this.throwApiError(response, 'Failed to wake device');
}
} }
async wakeGroup(id: string): Promise<void> { async wakeGroup(id: string): Promise<void> {
const response = await fetch(`${this.address}/upsnap/wakegroup/${id}`, { await this.request(`${this.address}/upsnap/wakegroup/${id}`, 'Failed to wake group');
headers: this.getHeaders(),
});
if (!response.ok) {
await this.throwApiError(response, 'Failed to wake group');
}
} }
async sleepDevice(id: string): Promise<void> { async sleepDevice(id: string): Promise<void> {
const response = await fetch(`${this.address}/upsnap/sleep/${id}`, { await this.request(`${this.address}/upsnap/sleep/${id}`, 'Failed to sleep device');
headers: this.getHeaders(),
});
if (!response.ok) {
await this.throwApiError(response, 'Failed to sleep device');
}
} }
async rebootDevice(id: string): Promise<void> { async rebootDevice(id: string): Promise<void> {
const response = await fetch(`${this.address}/upsnap/reboot/${id}`, { await this.request(`${this.address}/upsnap/reboot/${id}`, 'Failed to reboot device');
headers: this.getHeaders(),
});
if (!response.ok) {
await this.throwApiError(response, 'Failed to reboot device');
}
} }
async shutdownDevice(id: string): Promise<void> { async shutdownDevice(id: string): Promise<void> {
const response = await fetch(`${this.address}/upsnap/shutdown/${id}`, { await this.request(`${this.address}/upsnap/shutdown/${id}`, 'Failed to shutdown device');
headers: this.getHeaders(),
});
if (!response.ok) {
await this.throwApiError(response, 'Failed to shutdown device');
}
} }
async scanNetwork(): Promise<NetworkScanResult[]> { async scanNetwork(): Promise<NetworkScanResult[]> {
const response = await fetch(`${this.address}/upsnap/scan`, { const response = await this.request(`${this.address}/upsnap/scan`, 'Failed to scan network');
headers: this.getHeaders(),
});
if (!response.ok) {
await this.throwApiError(response, 'Failed to scan network');
}
const data = await response.json(); const data = await response.json();
if (data.devices && Array.isArray(data.devices)) { if (data.devices && Array.isArray(data.devices)) {
+41
View File
@@ -0,0 +1,41 @@
const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
function decodeBase64Url(segment: string): string {
const normalized = segment.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
const bytes: number[] = [];
for (let index = 0; index < padded.length; index += 4) {
const a = BASE64_ALPHABET.indexOf(padded[index]);
const b = BASE64_ALPHABET.indexOf(padded[index + 1]);
const c = padded[index + 2] === '=' ? 0 : BASE64_ALPHABET.indexOf(padded[index + 2]);
const d = padded[index + 3] === '=' ? 0 : BASE64_ALPHABET.indexOf(padded[index + 3]);
if (a < 0 || b < 0 || c < 0 || d < 0) {
throw new Error('Invalid base64url');
}
const chunk = (a << 18) | (b << 12) | (c << 6) | d;
bytes.push((chunk >> 16) & 255);
if (padded[index + 2] !== '=') bytes.push((chunk >> 8) & 255);
if (padded[index + 3] !== '=') bytes.push(chunk & 255);
}
return String.fromCharCode(...bytes);
}
/**
* Reads the JWT `exp` claim in milliseconds.
* Scheduling only; the signature is not verified.
*/
export function getJwtExpiryMs(token: string): number | null {
const segment = token.split('.')[1];
if (!segment) return null;
try {
const payload = JSON.parse(decodeBase64Url(segment)) as { exp?: unknown };
if (typeof payload.exp !== 'number' || !Number.isFinite(payload.exp)) return null;
return payload.exp * 1000;
} catch {
return null;
}
}
+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 { Platform } from 'react-native';
import { Device } from '../types'; import { Device } from '../types';
import DeviceControlWidget, { type WidgetDevice } from '../widgets/DeviceControl';
// Create a storage object with the App Group const DEVICE_KIND = 'device';
const storage =
Platform.OS === 'ios'
? new ExtensionStorage('group.abunchofknowitalls.remotewol-upsnap')
: null;
interface WidgetDevice { let pendingCatalog: AppIntentEntity[] | null = null;
id: string; let catalogWrite: Promise<void> | null = null;
name: string;
mac: string; function toWidgetDevice(device: Device): WidgetDevice | null {
ip: string; const id = device.id.trim();
canSleep: boolean; if (!id) {
canShutdown: boolean; return null;
status: string; }
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. * Syncs device data to the iOS widget and Siri device catalog.
* This allows the widget to display device information and create action deep links. * Called after the device list loads.
*/ */
export function syncDevicesToWidget(devices: Device[]): void { export function syncDevicesToWidget(devices: Device[]): void {
if (!storage) { if (Platform.OS !== 'ios') {
return; return;
} }
try { try {
// Transform devices to the format expected by the widget const widgetDevices = devices
const widgetDevices: WidgetDevice[] = devices.map(device => ({ .map(toWidgetDevice)
id: device.id, .filter((device): device is WidgetDevice => device !== null);
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); DeviceControlWidget.setConfigurationParameterEnum(
storage.set('devices', devicesJson); 'device',
widgetDevices.map(device => ({
// Refresh widgets to pick up the new data name: device.name,
ExtensionStorage.reloadWidget(); value: device.id,
subtitle: device.mac.trim() || undefined,
}))
);
DeviceControlWidget.updateSnapshot({ devices: widgetDevices });
publishDeviceCatalog(widgetDevices.map(toCatalogEntity));
} catch (error) { } catch (error) {
console.error('Failed to sync devices to widget:', 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 { export function refreshWidgets(): void {
if (!storage) { if (Platform.OS !== 'ios') {
return; return;
} }
try { try {
ExtensionStorage.reloadWidget(); DeviceControlWidget.reload();
} catch (error) { } catch (error) {
console.error('Failed to refresh widgets:', 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")
)
}