Compare commits
No files matched your search
@@ -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
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
internal import ExpoAppIntents
|
||||||
|
internal import ExpoModulesCore
|
||||||
|
|
||||||
|
/// Registered Expo inline module that wires app-target App Intents code to expo-app-intents.
|
||||||
|
/// Do not change the name of this class.
|
||||||
|
final class AppIntentsSetup: Module {
|
||||||
|
public func definition() -> ExpoModulesCore.ModuleDefinition {
|
||||||
|
Name("AppIntentsSetup")
|
||||||
|
|
||||||
|
OnCreate {
|
||||||
|
if #available(iOS 18.0, *) {
|
||||||
|
Task {
|
||||||
|
await AppIntentDispatcher.shared.setShortcutsRefreshHandler {
|
||||||
|
AppShortcuts.updateAppShortcutParameters()
|
||||||
|
}
|
||||||
|
AppShortcuts.updateAppShortcutParameters()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import AppIntents
|
||||||
|
|
||||||
|
@available(iOS 18.0, *)
|
||||||
|
struct AppShortcuts: AppShortcutsProvider {
|
||||||
|
static var appShortcuts: [AppShortcut] {
|
||||||
|
AppShortcut(
|
||||||
|
intent: WakeDeviceIntent(),
|
||||||
|
phrases: [
|
||||||
|
"Wake a device in \(.applicationName)",
|
||||||
|
"Wake \(\.$device) in \(.applicationName)",
|
||||||
|
],
|
||||||
|
shortTitle: "Wake Device",
|
||||||
|
systemImageName: "bolt.fill"
|
||||||
|
)
|
||||||
|
AppShortcut(
|
||||||
|
intent: SleepDeviceIntent(),
|
||||||
|
phrases: [
|
||||||
|
"Sleep a device in \(.applicationName)",
|
||||||
|
"Sleep \(\.$device) in \(.applicationName)",
|
||||||
|
],
|
||||||
|
shortTitle: "Sleep Device",
|
||||||
|
systemImageName: "moon.fill"
|
||||||
|
)
|
||||||
|
AppShortcut(
|
||||||
|
intent: RestartDeviceIntent(),
|
||||||
|
phrases: [
|
||||||
|
"Restart a device in \(.applicationName)",
|
||||||
|
"Restart \(\.$device) in \(.applicationName)",
|
||||||
|
],
|
||||||
|
shortTitle: "Restart Device",
|
||||||
|
systemImageName: "arrow.clockwise"
|
||||||
|
)
|
||||||
|
AppShortcut(
|
||||||
|
intent: ShutdownDeviceIntent(),
|
||||||
|
phrases: [
|
||||||
|
"Shut down a device in \(.applicationName)",
|
||||||
|
"Shut down \(\.$device) in \(.applicationName)",
|
||||||
|
],
|
||||||
|
shortTitle: "Shut Down Device",
|
||||||
|
systemImageName: "power"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import AppIntents
|
||||||
|
|
||||||
|
enum DeviceActionPath: String {
|
||||||
|
case wake
|
||||||
|
case sleep
|
||||||
|
case restart
|
||||||
|
case shutdown
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DeviceActionIntentError: LocalizedError {
|
||||||
|
case invalidDevice
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
"That device can't be opened."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deviceActionURL(_ action: DeviceActionPath, deviceId: String) throws -> URL {
|
||||||
|
let encoded = deviceId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? deviceId
|
||||||
|
guard !encoded.isEmpty,
|
||||||
|
let url = URL(string: "remotewol-upsnap://action/\(action.rawValue)/\(encoded)") else {
|
||||||
|
throw DeviceActionIntentError.invalidDevice
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 18.0, *)
|
||||||
|
struct WakeDeviceIntent: AppIntent {
|
||||||
|
static let title: LocalizedStringResource = "Wake Device"
|
||||||
|
static let description = IntentDescription("Wakes the selected device using Wake-on-LAN.")
|
||||||
|
static let openAppWhenRun = true
|
||||||
|
|
||||||
|
@Parameter(title: "Device", requestValueDialog: "Which device should be woken?")
|
||||||
|
var device: DeviceEntity
|
||||||
|
|
||||||
|
static var parameterSummary: some ParameterSummary {
|
||||||
|
Summary("Wake \(\.$device)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func perform() async throws -> some IntentResult & OpensIntent {
|
||||||
|
let url = try deviceActionURL(.wake, deviceId: device.id)
|
||||||
|
return .result(opensIntent: OpenURLIntent(url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 18.0, *)
|
||||||
|
struct SleepDeviceIntent: AppIntent {
|
||||||
|
static let title: LocalizedStringResource = "Sleep Device"
|
||||||
|
static let description = IntentDescription("Sends the selected device to sleep.")
|
||||||
|
static let openAppWhenRun = true
|
||||||
|
|
||||||
|
@Parameter(title: "Device", requestValueDialog: "Which device should sleep?")
|
||||||
|
var device: DeviceEntity
|
||||||
|
|
||||||
|
static var parameterSummary: some ParameterSummary {
|
||||||
|
Summary("Sleep \(\.$device)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func perform() async throws -> some IntentResult & OpensIntent {
|
||||||
|
let url = try deviceActionURL(.sleep, deviceId: device.id)
|
||||||
|
return .result(opensIntent: OpenURLIntent(url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 18.0, *)
|
||||||
|
struct RestartDeviceIntent: AppIntent {
|
||||||
|
static let title: LocalizedStringResource = "Restart Device"
|
||||||
|
static let description = IntentDescription("Restarts the selected device.")
|
||||||
|
static let openAppWhenRun = true
|
||||||
|
|
||||||
|
@Parameter(title: "Device", requestValueDialog: "Which device should restart?")
|
||||||
|
var device: DeviceEntity
|
||||||
|
|
||||||
|
static var parameterSummary: some ParameterSummary {
|
||||||
|
Summary("Restart \(\.$device)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func perform() async throws -> some IntentResult & OpensIntent {
|
||||||
|
let url = try deviceActionURL(.restart, deviceId: device.id)
|
||||||
|
return .result(opensIntent: OpenURLIntent(url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 18.0, *)
|
||||||
|
struct ShutdownDeviceIntent: AppIntent {
|
||||||
|
static let title: LocalizedStringResource = "Shut Down Device"
|
||||||
|
static let description = IntentDescription("Shuts down the selected device.")
|
||||||
|
static let openAppWhenRun = true
|
||||||
|
|
||||||
|
@Parameter(title: "Device", requestValueDialog: "Which device should shut down?")
|
||||||
|
var device: DeviceEntity
|
||||||
|
|
||||||
|
static var parameterSummary: some ParameterSummary {
|
||||||
|
Summary("Shut down \(\.$device)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func perform() async throws -> some IntentResult & OpensIntent {
|
||||||
|
let url = try deviceActionURL(.shutdown, deviceId: device.id)
|
||||||
|
return .result(opensIntent: OpenURLIntent(url))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import AppIntents
|
||||||
|
internal import ExpoAppIntents
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
private func normalizeDeviceText(_ value: String) -> String {
|
||||||
|
value
|
||||||
|
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DeviceEntity: AppEntity {
|
||||||
|
static let typeDisplayRepresentation: TypeDisplayRepresentation = "Device"
|
||||||
|
static let defaultQuery = DeviceQuery()
|
||||||
|
|
||||||
|
var id: String
|
||||||
|
@Property(title: "Name")
|
||||||
|
var name: String
|
||||||
|
var subtitle: String?
|
||||||
|
var synonyms: [String]
|
||||||
|
|
||||||
|
var displayRepresentation: DisplayRepresentation {
|
||||||
|
if let subtitle {
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
|
return DisplayRepresentation(
|
||||||
|
title: "\(name)",
|
||||||
|
subtitle: "\(subtitle)",
|
||||||
|
synonyms: synonyms.map { LocalizedStringResource(stringLiteral: $0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return DisplayRepresentation(title: "\(name)", subtitle: "\(subtitle)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
|
return DisplayRepresentation(
|
||||||
|
title: "\(name)",
|
||||||
|
synonyms: synonyms.map { LocalizedStringResource(stringLiteral: $0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return DisplayRepresentation(title: "\(name)")
|
||||||
|
}
|
||||||
|
|
||||||
|
init(record: AppIntentEntityRecord) {
|
||||||
|
self.id = record.id
|
||||||
|
self.subtitle = record.subtitle
|
||||||
|
self.synonyms = record.synonyms
|
||||||
|
self.name = record.title
|
||||||
|
}
|
||||||
|
|
||||||
|
func matches(_ searchText: String) -> Bool {
|
||||||
|
let normalizedSearch = normalizeDeviceText(searchText)
|
||||||
|
guard !normalizedSearch.isEmpty else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return ([name, id] + synonyms).map(normalizeDeviceText).contains { term in
|
||||||
|
normalizedSearch == term || normalizedSearch.contains(term) || term.contains(normalizedSearch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import AppIntents
|
||||||
|
internal import ExpoAppIntents
|
||||||
|
|
||||||
|
/// Resolves devices from the catalog published by `syncDevicesToWidget`.
|
||||||
|
struct DeviceQuery: EntityStringQuery {
|
||||||
|
func entities(for identifiers: [String]) async throws -> [DeviceEntity] {
|
||||||
|
try await AppIntentEntityStore.shared
|
||||||
|
.entities(ofKind: "device", matching: identifiers)
|
||||||
|
.map(DeviceEntity.init(record:))
|
||||||
|
}
|
||||||
|
|
||||||
|
func suggestedEntities() async throws -> [DeviceEntity] {
|
||||||
|
try await AppIntentEntityStore.shared
|
||||||
|
.entities(ofKind: "device")
|
||||||
|
.map(DeviceEntity.init(record:))
|
||||||
|
}
|
||||||
|
|
||||||
|
func entities(matching string: String) async throws -> [DeviceEntity] {
|
||||||
|
let search = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !search.isEmpty else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return try await suggestedEntities().filter { $0.matches(search) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,64 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"@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",
|
||||||
|
"expo-status-bar"
|
||||||
],
|
],
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,25 +11,26 @@
|
|||||||
"lint": "expo lint"
|
"lint": "expo lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bacons/apple-targets": "^3.0.6",
|
"@expo/ui": "~58.0.22",
|
||||||
"@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.17",
|
||||||
|
"expo-app-intents": "~0.5.11",
|
||||||
"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",
|
||||||
"expo-glass-effect": "~58.0.4",
|
"expo-glass-effect": "~58.0.4",
|
||||||
"expo-haptics": "~58.0.7",
|
"expo-haptics": "~58.0.7",
|
||||||
"expo-image": "~58.0.16",
|
"expo-image": "~58.0.16",
|
||||||
"expo-linking": "~58.0.13",
|
"expo-linking": "~58.0.14",
|
||||||
"expo-router": "~58.0.24",
|
"expo-router": "~58.0.25",
|
||||||
"expo-splash-screen": "~58.0.10",
|
"expo-splash-screen": "~58.0.11",
|
||||||
"expo-status-bar": "~58.0.3",
|
"expo-status-bar": "~58.0.4",
|
||||||
"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",
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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],
|
||||||
setToken(response.token);
|
['auth_can_create', api.getCanCreate() ? 'true' : 'false'],
|
||||||
setUser(response.record);
|
];
|
||||||
setServerAddress(serverAddress);
|
if (collection) {
|
||||||
setCanCreate(api.getCanCreate() || false);
|
pairs.push([AUTH_COLLECTION_KEY, collection]);
|
||||||
} catch (error) {
|
}
|
||||||
throw error;
|
await AsyncStorage.multiSet(pairs);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
setToken(response.token);
|
||||||
|
setUser(response.record);
|
||||||
|
setServerAddress(nextServerAddress);
|
||||||
|
setCanCreate(api.getCanCreate() || false);
|
||||||
|
watchToken(response.token);
|
||||||
};
|
};
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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?
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 340 B |
|
Before Width: | Height: | Size: 703 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 503 B |
|
Before Width: | Height: | Size: 963 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 703 B |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 38 KiB |
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import WidgetKit
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@main
|
|
||||||
struct exportWidgets: WidgetBundle {
|
|
||||||
var body: some Widget {
|
|
||||||
// Export widgets here
|
|
||||||
widget()
|
|
||||||
// widgetControl()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
)
|
|
||||||
}
|
|
||||||