Compare commits

..
6 Commits
43 changed files with 1107 additions and 1750 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) }
}
}
+50 -22
View File
@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Jumpstart", "name": "Jumpstart",
"slug": "remote-wol", "slug": "remote-wol",
"version": "1.0.2", "version": "1.0.3",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "remotewol-upsnap", "scheme": "remotewol-upsnap",
@@ -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"
} }
} }
+319 -1067
View File
File diff suppressed because it is too large. Load diff
+8 -7
View File
@@ -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",
+143
View File
@@ -0,0 +1,143 @@
// jumpstart-wake-control
// Control Center wake button. Device choices are the home-screen widget's dynamic
// configuration, which JavaScript updates when the device list loads.
@available(iOS 18.0, *)
struct WakeDeviceControl: ControlWidget {
static let kind: String = "wakecontrol"
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
kind: Self.kind,
provider: WakeDeviceControlProvider()
) { value in
ControlWidgetButton(action: WakeControlIntent(deviceId: value.deviceId, deviceName: value.deviceName)) {
Label(value.deviceName, systemImage: "bolt.fill")
}
}
.displayName("Wake Device")
.description("Quickly wake a device from Control Center.")
}
}
@available(iOS 18.0, *)
extension WakeDeviceControl {
struct Value {
var deviceId: String
var deviceName: String
}
}
@available(iOS 18.0, *)
struct WakeDeviceControlProvider: AppIntentControlValueProvider {
func previewValue(configuration: WakeControlConfiguration) -> WakeDeviceControl.Value {
WakeDeviceControl.Value(
deviceId: configuration.device ?? "",
deviceName: deviceName(for: configuration.device)
)
}
func currentValue(configuration: WakeControlConfiguration) async throws -> WakeDeviceControl.Value {
WakeDeviceControl.Value(
deviceId: configuration.device ?? "",
deviceName: deviceName(for: configuration.device)
)
}
private func deviceName(for deviceId: String?) -> String {
guard let deviceId, !deviceId.isEmpty, deviceId != "none" else {
return "Select Device"
}
let options = widgetConfigurationOptions(
name: "DeviceControl",
parameter: "device",
fallback: []
)
return options.first { $0.value == deviceId }?.name ?? "Select Device"
}
}
@available(iOS 18.0, *)
struct WakeControlConfiguration: ControlConfigurationIntent {
static let title: LocalizedStringResource = "Device to Wake"
@Parameter(title: "Device", optionsProvider: WakeControlDeviceOptionsProvider())
var device: String?
}
@available(iOS 18.0, *)
struct WakeControlDeviceOptionsProvider: DynamicOptionsProvider {
func results() async throws -> IntentItemCollection<String> {
let options = widgetConfigurationOptions(
name: "DeviceControl",
parameter: "device",
fallback: [
WidgetConfigurationOption(name: "Select a Device", value: "none")
]
)
return IntentItemCollection(sections: [
IntentItemSection(items: options.map { option in
IntentItem(
option.value,
title: LocalizedStringResource(stringLiteral: option.name),
subtitle: option.subtitle.map { LocalizedStringResource(stringLiteral: $0) }
)
})
])
}
func defaultResult() async -> String? {
let options = widgetConfigurationOptions(
name: "DeviceControl",
parameter: "device",
fallback: []
)
return options.first { $0.value != "none" }?.value ?? options.first?.value
}
}
@available(iOS 18.0, *)
struct WakeControlIntent: AppIntent {
static let title: LocalizedStringResource = "Wake Device"
static let description = IntentDescription("Wakes the selected device using Wake-on-LAN.")
static let openAppWhenRun: Bool = true
static let isDiscoverable: Bool = false
@Parameter(title: "Device ID")
var deviceId: String
@Parameter(title: "Device Name")
var deviceName: String
init() {
self.deviceId = ""
self.deviceName = ""
}
init(deviceId: String, deviceName: String) {
self.deviceId = deviceId
self.deviceName = deviceName
}
func perform() async throws -> some IntentResult & OpensIntent {
let encoded = deviceId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? deviceId
if !encoded.isEmpty, encoded != "none", let url = URL(string: "remotewol-upsnap://action/wake/\(encoded)") {
return .result(opensIntent: OpenURLIntent(url))
}
guard let fallback = URL(string: "remotewol-upsnap://") else {
throw WakeControlIntentError.invalidDevice
}
return .result(opensIntent: OpenURLIntent(fallback))
}
}
enum WakeControlIntentError: LocalizedError {
case invalidDevice
var errorDescription: String? {
"Select a device to wake."
}
}
+61
View File
@@ -0,0 +1,61 @@
const fs = require('fs');
const path = require('path');
const { withDangerousMod } = require('expo/config-plugins');
const MARKER = 'jumpstart-wake-control';
/**
* Adds the Control Center wake control to the extension expo-widgets generates.
* This plugin must be listed before expo-widgets so it runs after that extension is written.
*/
function withWakeControl(config) {
return withDangerousMod(config, [
'ios',
async config => {
const indexPath = path.join(
config.modRequest.platformProjectRoot,
'ExpoWidgetsTarget',
'index.swift'
);
if (!fs.existsSync(indexPath)) {
throw new Error(
'expo-widgets has not generated ios/ExpoWidgetsTarget/index.swift yet. List ./plugins/withWakeControl before expo-widgets in the plugins array.'
);
}
const source = fs.readFileSync(indexPath, 'utf8');
if (source.includes(MARKER)) {
return config;
}
if (!source.includes('WidgetLiveActivity()')) {
throw new Error(
'Could not find WidgetLiveActivity() in ios/ExpoWidgetsTarget/index.swift.'
);
}
const controlPath = path.join(
config.modRequest.projectRoot,
'plugins',
'WakeDeviceControl.swift'
);
const controlSource = fs.readFileSync(controlPath, 'utf8');
let next = source;
if (!next.includes('import AppIntents')) {
next = next.replace('import SwiftUI\n', 'import SwiftUI\nimport AppIntents\n');
}
next = next.replace(
/^([ \t]*)WidgetLiveActivity\(\)/m,
`$1if #available(iOS 18.0, *) {\n$1 WakeDeviceControl()\n$1}\n$1WidgetLiveActivity()`
);
next = `${next.trimEnd()}\n\n${controlSource.trim()}\n`;
fs.writeFileSync(indexPath, next);
return config;
},
]);
}
module.exports = withWakeControl;
+2
View File
@@ -3,6 +3,7 @@ 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, { isAuthCollection, isRevokedAuthError } from '../services/api'; import api, { isAuthCollection, isRevokedAuthError } from '../services/api';
import { getJwtExpiryMs } from '../services/jwtExpiry'; import { getJwtExpiryMs } from '../services/jwtExpiry';
import { syncDevicesToWidget } from '../services/widgetSync';
import { AuthResponse, User } from '../types'; import { AuthResponse, User } from '../types';
interface AuthContextType { interface AuthContextType {
@@ -311,6 +312,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
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);
} }
+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")
)
}