diff --git a/app.json b/app.json
index 75ad4f2..4832fff 100644
--- a/app.json
+++ b/app.json
@@ -5,14 +5,19 @@
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
- "scheme": "remotewol",
+ "scheme": "remotewol-upsnap",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"icon": "./assets/remotewol-ios.icon",
"bundleIdentifier": "com.abunchofknowitalls.remotewol-upsnap",
- "appleTeamId": "8S7C654DQ4"
+ "appleTeamId": "8S7C654DQ4",
+ "entitlements": {
+ "com.apple.security.application-groups": [
+ "group.abunchofknowitalls.remotewol-upsnap"
+ ]
+ }
},
"android": {
"adaptiveIcon": {
@@ -38,6 +43,7 @@
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
+ "image": "./assets/images/splash-icon-dark.png",
"backgroundColor": "#000000"
}
}
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index 29d2854..03f8337 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -1,5 +1,6 @@
import { ContextMenu, Host, Button as SwiftUIButton } from '@expo/ui/swift-ui';
import { Ionicons } from '@expo/vector-icons';
+import * as Burnt from 'burnt';
import { SymbolView } from 'expo-symbols';
import React, { useCallback, useEffect, useState } from 'react';
import {
@@ -15,8 +16,8 @@ import {
} from 'react-native';
import { useColorScheme } from '../../hooks/use-color-scheme';
import api from '../../src/services/api';
+import { syncDevicesToWidget } from '../../src/services/widgetSync';
import { Device } from '../../src/types';
-import * as Burnt from 'burnt';
export default function DeviceListScreen() {
const colorScheme = useColorScheme() ?? 'light';
@@ -36,6 +37,8 @@ export default function DeviceListScreen() {
if (showLoading) setIsLoading(true);
const data = await api.getDevices();
setDevices(data);
+ // Sync devices to iOS widget
+ syncDevicesToWidget(data);
} catch (error: any) {
// For background/periodic refreshes, avoid interruptive alerts
if (showLoading) {
@@ -272,29 +275,33 @@ export default function DeviceListScreen() {
);
- const renderDevice = ({ item }: { item: Device }) => (
-
-
-
- handleDelete(item)}
- >
- Delete Device
-
-
-
-
-
+ const renderDevice = ({ item }: { item: Device }) => {
+ const isOnline = item.status?.toLowerCase() === 'online';
+ const isOffline = item.status?.toLowerCase() === 'offline';
+ const hasActions = isOnline || isOffline;
+
+ return (
+
+
+
+
+ handleDelete(item)}
+ >
+ Delete Device
+
+
+
@@ -330,49 +337,51 @@ export default function DeviceListScreen() {
]}
/>
+
+
+
-
- {item.status?.toLowerCase() === 'offline' && (
- handleWake(item)}
- />
- )}
- {item.status?.toLowerCase() === 'online' && (
- <>
- handleSleep(item)}
- />
- handleReboot(item)}
- />
- handleShutdown(item)}
- />
- >
- )}
-
-
+ {hasActions && (
+
+ {isOffline && (
+ handleWake(item)}
+ />
+ )}
+ {isOnline && (
+ <>
+ handleSleep(item)}
+ />
+ handleReboot(item)}
+ />
+ handleShutdown(item)}
+ />
+ >
+ )}
-
-
-
- );
+ )}
+
+ );
+ };
if (isLoading) {
return (
@@ -389,9 +398,11 @@ export default function DeviceListScreen() {
renderItem={renderDevice}
keyExtractor={item => item.id}
contentContainerStyle={styles.list}
+ contentInsetAdjustmentBehavior="automatic"
refreshControl={
}
+ ListFooterComponent={}
ListEmptyComponent={
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 6bf398e..a6d51ed 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,77 +1,82 @@
-import { Ionicons } from "@expo/vector-icons";
-import { Stack, useRouter, useSegments } from "expo-router";
-import { TouchableOpacity, Text } from "react-native";
-import { useColorScheme } from "../hooks/use-color-scheme";
-import { AuthProvider } from "../src/context/AuthContext";
+import { Ionicons } from '@expo/vector-icons';
+import { Stack, useRouter, useSegments } from 'expo-router';
+import { Text, TouchableOpacity } from 'react-native';
+import { useColorScheme } from '../hooks/use-color-scheme';
+import { AuthProvider } from '../src/context/AuthContext';
function DevicesHeader() {
- const router = useRouter();
- const isDark = useColorScheme() === "dark";
- const activityColor = isDark ? "#0A84FF" : "#007AFF";
+ const router = useRouter();
+ const isDark = useColorScheme() === 'dark';
+ const activityColor = isDark ? '#0A84FF' : '#007AFF';
- return (
- router.push("/scan-devices")}
- style={{ paddingHorizontal: 8 }}
- >
-
-
- );
+ return (
+ router.push('/scan-devices')}
+ style={{ paddingHorizontal: 8 }}
+ >
+
+
+ );
}
function TabsTitle(props: { title: string }) {
- const segments = useSegments();
- const last = segments[segments.length - 1];
- const isDark = useColorScheme() === "dark";
- const titleColor = isDark ? "#fff" : "#000";
- const title = last === "settings" ? "Settings" : props.title;
- return (
-
- {title}
-
- );
+ const segments = useSegments();
+ const last = segments[segments.length - 1];
+ const isDark = useColorScheme() === 'dark';
+ const titleColor = isDark ? '#fff' : '#000';
+ const title = last === 'settings' ? 'Settings' : props.title;
+ return (
+
+ {title}
+
+ );
}
export default function RootLayout() {
- const isDark = useColorScheme() === "dark";
- const bgColor = isDark ? "#0b0b0d" : "#fff";
- const titleColor = isDark ? "#fff" : "#000";
+ const isDark = useColorScheme() === 'dark';
+ const bgColor = isDark ? '#0b0b0d' : '#fff';
+ const titleColor = isDark ? '#fff' : '#000';
- const segments = useSegments();
- const last = segments[segments.length - 1];
- const isIndex = last === "(tabs)" || last === undefined;
+ const segments = useSegments();
+ const last = segments[segments.length - 1];
+ const isIndex = last === '(tabs)' || last === undefined;
- return (
-
-
- {/* Root index that performs auth redirect (app/index.tsx) */}
-
- {/* Tabs parent - render tabs with dynamic header */}
- ,
- headerRight: isIndex ? () => : undefined,
- headerRightContainerStyle: isIndex
- ? undefined
- : { width: 0, paddingRight: 0 },
- headerStyle: { backgroundColor: bgColor },
- headerTintColor: titleColor,
- } as any
- }
- />{" "}
-
- ,
- headerStyle: { backgroundColor: bgColor },
- headerBackButtonDisplayMode: "minimal",
- }}
- />
-
-
- );
+ return (
+
+
+ {/* Root index that performs auth redirect (app/index.tsx) */}
+
+ {/* Tabs parent - render tabs with dynamic header */}
+ ,
+ headerRight: isIndex ? () => : undefined,
+ headerRightContainerStyle: isIndex
+ ? undefined
+ : { width: 0, paddingRight: 0 },
+ headerStyle: { backgroundColor: bgColor },
+ headerTintColor: titleColor,
+ } as any
+ }
+ />{' '}
+
+ ,
+ headerStyle: { backgroundColor: bgColor },
+ headerBackButtonDisplayMode: 'minimal',
+ }}
+ />
+ {/* Deep link action handler - no header, immediately redirects */}
+
+
+
+ );
}
diff --git a/app/action/[action]/[deviceId].tsx b/app/action/[action]/[deviceId].tsx
new file mode 100644
index 0000000..ef367ae
--- /dev/null
+++ b/app/action/[action]/[deviceId].tsx
@@ -0,0 +1,108 @@
+import { CommonActions, useNavigation } from '@react-navigation/native';
+import * as Burnt from 'burnt';
+import { Redirect, useLocalSearchParams } from 'expo-router';
+import { useEffect, useRef } from 'react';
+import api from '../../../src/services/api';
+
+type DeviceAction = 'wake' | 'sleep' | 'restart' | 'shutdown';
+
+export default function ActionHandler() {
+ const params = useLocalSearchParams<{ action: string; deviceId: string }>();
+ const navigation = useNavigation();
+ const hasExecuted = useRef(false);
+
+ const { action, deviceId } = params;
+
+ useEffect(() => {
+ // Fire action once
+ if (!hasExecuted.current && action && deviceId) {
+ hasExecuted.current = true;
+ executeAction(action as DeviceAction, deviceId);
+ }
+
+ // Reset navigation to tabs - clears entire stack so no back button
+ navigation.dispatch(
+ CommonActions.reset({
+ index: 0,
+ routes: [{ name: '(tabs)' }],
+ })
+ );
+ }, [action, deviceId, navigation]);
+
+ // Show nothing while redirecting
+ return ;
+}
+
+async function executeAction(
+ action: DeviceAction,
+ deviceId: string
+): Promise {
+ // Get device name for toast
+ let deviceName = 'device';
+ try {
+ const device = await api.getDevice(deviceId);
+ deviceName = device.name;
+ } catch (error) {
+ console.warn('Could not fetch device name:', error);
+ }
+
+ // Show sending toast
+ const sendingMessages: Record = {
+ wake: `Sending wake command to ${deviceName}...`,
+ sleep: `Sending sleep command to ${deviceName}...`,
+ restart: `Sending restart command to ${deviceName}...`,
+ shutdown: `Sending shutdown command to ${deviceName}...`,
+ };
+
+ Burnt.toast({
+ title: sendingMessages[action],
+ preset: 'none',
+ });
+
+ // Execute the action
+ try {
+ switch (action) {
+ case 'wake':
+ await api.wakeDevice(deviceId);
+ Burnt.toast({
+ title: 'Success',
+ preset: 'done',
+ message: `Waking ${deviceName} up.`,
+ });
+ break;
+
+ case 'sleep':
+ await api.sleepDevice(deviceId);
+ Burnt.toast({
+ title: 'Success',
+ preset: 'done',
+ message: `Sending ${deviceName} to sleep.`,
+ });
+ break;
+
+ case 'restart':
+ await api.rebootDevice(deviceId);
+ Burnt.toast({
+ title: 'Success',
+ preset: 'done',
+ message: `Rebooting ${deviceName}.`,
+ });
+ break;
+
+ case 'shutdown':
+ await api.shutdownDevice(deviceId);
+ Burnt.toast({
+ title: 'Success',
+ preset: 'done',
+ message: `Shutting down ${deviceName}.`,
+ });
+ break;
+ }
+ } catch (error: any) {
+ Burnt.toast({
+ title: 'Error',
+ preset: 'error',
+ message: error.message || `Failed to ${action} ${deviceName}.`,
+ });
+ }
+}
diff --git a/assets/images/splash-icon-dark.png b/assets/images/splash-icon-dark.png
new file mode 100644
index 0000000..527a767
Binary files /dev/null and b/assets/images/splash-icon-dark.png differ
diff --git a/assets/images/splash-icon.png b/assets/images/splash-icon.png
index ff83c47..3f61356 100644
Binary files a/assets/images/splash-icon.png and b/assets/images/splash-icon.png differ
diff --git a/src/services/widgetSync.ts b/src/services/widgetSync.ts
new file mode 100644
index 0000000..7a0a161
--- /dev/null
+++ b/src/services/widgetSync.ts
@@ -0,0 +1,61 @@
+import { ExtensionStorage } from '@bacons/apple-targets';
+import { Platform } from 'react-native';
+import { Device } from '../types';
+
+// Create a storage object with the App Group
+const storage =
+ Platform.OS === 'ios'
+ ? new ExtensionStorage('group.abunchofknowitalls.remotewol-upsnap')
+ : null;
+
+interface WidgetDevice {
+ id: string;
+ name: string;
+ mac: string;
+ ip: string;
+ status: string;
+}
+
+/**
+ * Syncs device data to the iOS widget via App Group shared storage.
+ * This allows the widget to display device information and create action deep links.
+ */
+export function syncDevicesToWidget(devices: Device[]): void {
+ if (!storage) {
+ return;
+ }
+
+ try {
+ // Transform devices to the format expected by the widget
+ const widgetDevices: WidgetDevice[] = devices.map(device => ({
+ id: device.id,
+ name: device.name,
+ mac: device.mac,
+ ip: device.ip,
+ status: device.status || 'unknown',
+ }));
+
+ const devicesJson = JSON.stringify(widgetDevices);
+ storage.set('devices', devicesJson);
+
+ // Refresh widgets to pick up the new data
+ ExtensionStorage.reloadWidget();
+ } catch (error) {
+ console.error('Failed to sync devices to widget:', error);
+ }
+}
+
+/**
+ * Refreshes all widget timelines to pick up new data.
+ */
+export function refreshWidgets(): void {
+ if (!storage) {
+ return;
+ }
+
+ try {
+ ExtensionStorage.reloadWidget();
+ } catch (error) {
+ console.error('Failed to refresh widgets:', error);
+ }
+}
diff --git a/targets/widget/AppIntent.swift b/targets/widget/AppIntent.swift
index 35f1b88..58f4ede 100644
--- a/targets/widget/AppIntent.swift
+++ b/targets/widget/AppIntent.swift
@@ -1,11 +1,80 @@
import WidgetKit
import AppIntents
-struct ConfigurationAppIntent: WidgetConfigurationIntent {
- static var title: LocalizedStringResource { "Configuration" }
- static var description: IntentDescription { "This is an example widget." }
+// MARK: - Shared Data Access
- // An example configurable parameter.
- @Parameter(title: "Favorite Emoji", default: "π")
- var favoriteEmoji: String
+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 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?
}
diff --git a/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-20x20@1x.png b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-20x20@1x.png
new file mode 100644
index 0000000..8ebf072
Binary files /dev/null and b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-20x20@1x.png differ
diff --git a/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-20x20@2x.png b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-20x20@2x.png
new file mode 100644
index 0000000..577dc45
Binary files /dev/null and b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-20x20@2x.png differ
diff --git a/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-20x20@3x.png b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-20x20@3x.png
new file mode 100644
index 0000000..61f57d1
Binary files /dev/null and b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-20x20@3x.png differ
diff --git a/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-29x29@1x.png b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-29x29@1x.png
new file mode 100644
index 0000000..97f681b
Binary files /dev/null and b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-29x29@1x.png differ
diff --git a/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-29x29@2x.png b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-29x29@2x.png
new file mode 100644
index 0000000..dd08555
Binary files /dev/null and b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-29x29@2x.png differ
diff --git a/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-29x29@3x.png b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-29x29@3x.png
new file mode 100644
index 0000000..1833711
Binary files /dev/null and b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-29x29@3x.png differ
diff --git a/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-40x40@1x.png b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-40x40@1x.png
new file mode 100644
index 0000000..577dc45
Binary files /dev/null and b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-40x40@1x.png differ
diff --git a/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-40x40@2x.png b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-40x40@2x.png
new file mode 100644
index 0000000..0b4cd57
Binary files /dev/null and b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-40x40@2x.png differ
diff --git a/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-40x40@3x.png b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-40x40@3x.png
new file mode 100644
index 0000000..92d2906
Binary files /dev/null and b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-40x40@3x.png differ
diff --git a/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-60x60@2x.png b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-60x60@2x.png
new file mode 100644
index 0000000..92d2906
Binary files /dev/null and b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-60x60@2x.png differ
diff --git a/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-60x60@3x.png b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-60x60@3x.png
new file mode 100644
index 0000000..458d612
Binary files /dev/null and b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-60x60@3x.png differ
diff --git a/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-76x76@1x.png b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-76x76@1x.png
new file mode 100644
index 0000000..4656986
Binary files /dev/null and b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-76x76@1x.png differ
diff --git a/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-76x76@2x.png b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-76x76@2x.png
new file mode 100644
index 0000000..b177c29
Binary files /dev/null and b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-76x76@2x.png differ
diff --git a/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-83.5x83.5@2x.png b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-83.5x83.5@2x.png
new file mode 100644
index 0000000..4d4bedd
Binary files /dev/null and b/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-83.5x83.5@2x.png differ
diff --git a/targets/widget/Assets.xcassets/AppIcon.appiconset/Contents.json b/targets/widget/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..f920cb0
--- /dev/null
+++ b/targets/widget/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,122 @@
+{
+ "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"
+ }
+}
\ No newline at end of file
diff --git a/targets/widget/Assets.xcassets/AppIcon.appiconset/ItunesArtwork@2x.png b/targets/widget/Assets.xcassets/AppIcon.appiconset/ItunesArtwork@2x.png
new file mode 100644
index 0000000..05ad320
Binary files /dev/null and b/targets/widget/Assets.xcassets/AppIcon.appiconset/ItunesArtwork@2x.png differ
diff --git a/targets/widget/Assets.xcassets/Contents.json b/targets/widget/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/targets/widget/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/targets/widget/WidgetControl.swift b/targets/widget/WidgetControl.swift
index f6ccce2..4c6cb8c 100644
--- a/targets/widget/WidgetControl.swift
+++ b/targets/widget/WidgetControl.swift
@@ -2,69 +2,92 @@ import AppIntents
import SwiftUI
import WidgetKit
+// MARK: - Control Widget for Quick Wake Action
+
struct widgetControl: ControlWidget {
- static let kind: String = "com.developer.example.widget"
+ static let kind: String = "wakecontrol"
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
kind: Self.kind,
- provider: Provider()
+ provider: WakeControlProvider()
) { value in
- ControlWidgetToggle(
- "Start Timer",
- isOn: value.isRunning,
- action: StartTimerIntent(value.name)
- ) { isRunning in
- Label(isRunning ? "On" : "Off", systemImage: "timer")
+ ControlWidgetButton(action: WakeDeviceIntent(deviceId: value.deviceId, deviceName: value.deviceName)) {
+ Label(value.deviceName, systemImage: "bolt.fill")
}
}
- .displayName("Timer")
- .description("A an example control that runs a timer.")
+ .displayName("Wake Device")
+ .description("Quickly wake a device from Control Center.")
}
}
+// MARK: - Control Widget Value
+
extension widgetControl {
struct Value {
- var isRunning: Bool
- var name: String
- }
-
- struct Provider: AppIntentControlValueProvider {
- func previewValue(configuration: TimerConfiguration) -> Value {
- widgetControl.Value(isRunning: false, name: configuration.timerName)
- }
-
- func currentValue(configuration: TimerConfiguration) async throws -> Value {
- let isRunning = true // Check if the timer is running
- return widgetControl.Value(isRunning: isRunning, name: configuration.timerName)
- }
+ var deviceId: String
+ var deviceName: String
}
}
-struct TimerConfiguration: ControlConfigurationIntent {
- static let title: LocalizedStringResource = "Timer Name Configuration"
+// MARK: - Control Widget Provider
- @Parameter(title: "Timer Name", default: "Timer")
- var timerName: String
-}
-
-struct StartTimerIntent: SetValueIntent {
- static let title: LocalizedStringResource = "Start a timer"
-
- @Parameter(title: "Timer Name")
- var name: String
-
- @Parameter(title: "Timer is running")
- var value: Bool
-
- init() {}
-
- init(_ name: String) {
- self.name = name
+struct WakeControlProvider: AppIntentControlValueProvider {
+ func previewValue(configuration: WakeControlConfiguration) -> widgetControl.Value {
+ widgetControl.Value(
+ deviceId: configuration.device?.id ?? "",
+ deviceName: configuration.device?.name ?? "Select Device"
+ )
}
- func perform() async throws -> some IntentResult {
- // Start the timerβ¦
- return .result()
+ 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))
+ }
+}
diff --git a/targets/widget/WidgetLiveActivity.swift b/targets/widget/WidgetLiveActivity.swift
deleted file mode 100644
index 1ed76e4..0000000
--- a/targets/widget/WidgetLiveActivity.swift
+++ /dev/null
@@ -1,73 +0,0 @@
-import ActivityKit
-import WidgetKit
-import SwiftUI
-
-struct WidgetAttributes: ActivityAttributes {
- public struct ContentState: Codable, Hashable {
- // Dynamic stateful properties about your activity go here!
- var emoji: String
- }
-
- // Fixed non-changing properties about your activity go here!
- var name: String
-}
-
-struct WidgetLiveActivity: Widget {
- var body: some WidgetConfiguration {
- ActivityConfiguration(for: WidgetAttributes.self) { context in
- // Lock screen/banner UI goes here
- VStack {
- Text("Hello \(context.state.emoji)")
- }
- .activityBackgroundTint(Color.cyan)
- .activitySystemActionForegroundColor(Color.black)
-
- } dynamicIsland: { context in
- DynamicIsland {
- // Expanded UI goes here. Compose the expanded UI through
- // various regions, like leading/trailing/center/bottom
- DynamicIslandExpandedRegion(.leading) {
- Text("Leading")
- }
- DynamicIslandExpandedRegion(.trailing) {
- Text("Trailing")
- }
- DynamicIslandExpandedRegion(.bottom) {
- Text("Bottom \(context.state.emoji)")
- // more content
- }
- } compactLeading: {
- Text("L")
- } compactTrailing: {
- Text("T \(context.state.emoji)")
- } minimal: {
- Text(context.state.emoji)
- }
- .widgetURL(URL(string: "https://www.expo.dev"))
- .keylineTint(Color.red)
- }
- }
-}
-
-extension WidgetAttributes {
- fileprivate static var preview: WidgetAttributes {
- WidgetAttributes(name: "World")
- }
-}
-
-extension WidgetAttributes.ContentState {
- fileprivate static var smiley: WidgetAttributes.ContentState {
- WidgetAttributes.ContentState(emoji: "π")
- }
-
- fileprivate static var starEyes: WidgetAttributes.ContentState {
- WidgetAttributes.ContentState(emoji: "π€©")
- }
-}
-
-#Preview("Notification", as: .content, using: WidgetAttributes.preview) {
- WidgetLiveActivity()
-} contentStates: {
- WidgetAttributes.ContentState.smiley
- WidgetAttributes.ContentState.starEyes
-}
diff --git a/targets/widget/expo-target.config.js b/targets/widget/expo-target.config.js
index 4d60f7e..cfaf4d2 100644
--- a/targets/widget/expo-target.config.js
+++ b/targets/widget/expo-target.config.js
@@ -2,5 +2,9 @@
module.exports = config => ({
type: "widget",
icon: 'https://github.com/expo.png',
- entitlements: { /* Add entitlements */ },
+ entitlements: {
+ "com.apple.security.application-groups": [
+ "group.abunchofknowitalls.remotewol-upsnap"
+ ]
+ },
});
\ No newline at end of file
diff --git a/targets/widget/generated.entitlements b/targets/widget/generated.entitlements
new file mode 100644
index 0000000..c9bb277
--- /dev/null
+++ b/targets/widget/generated.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.abunchofknowitalls.remotewol-upsnap
+
+
+
\ No newline at end of file
diff --git a/targets/widget/index.swift b/targets/widget/index.swift
index 79bdffc..8b772cd 100644
--- a/targets/widget/index.swift
+++ b/targets/widget/index.swift
@@ -6,7 +6,6 @@ struct exportWidgets: WidgetBundle {
var body: some Widget {
// Export widgets here
widget()
- widgetControl()
- WidgetLiveActivity()
+ // widgetControl()
}
}
diff --git a/targets/widget/remotewol-ios.icon/Assets/Image.png b/targets/widget/remotewol-ios.icon/Assets/Image.png
new file mode 100644
index 0000000..391371e
Binary files /dev/null and b/targets/widget/remotewol-ios.icon/Assets/Image.png differ
diff --git a/targets/widget/remotewol-ios.icon/Assets/display 2.png b/targets/widget/remotewol-ios.icon/Assets/display 2.png
new file mode 100644
index 0000000..8783a71
Binary files /dev/null and b/targets/widget/remotewol-ios.icon/Assets/display 2.png differ
diff --git a/targets/widget/remotewol-ios.icon/Assets/display 3.png b/targets/widget/remotewol-ios.icon/Assets/display 3.png
new file mode 100644
index 0000000..210fbd3
Binary files /dev/null and b/targets/widget/remotewol-ios.icon/Assets/display 3.png differ
diff --git a/targets/widget/remotewol-ios.icon/Assets/display.png b/targets/widget/remotewol-ios.icon/Assets/display.png
new file mode 100644
index 0000000..5c2a731
Binary files /dev/null and b/targets/widget/remotewol-ios.icon/Assets/display.png differ
diff --git a/targets/widget/remotewol-ios.icon/icon.json b/targets/widget/remotewol-ios.icon/icon.json
new file mode 100644
index 0000000..ebdf087
--- /dev/null
+++ b/targets/widget/remotewol-ios.icon/icon.json
@@ -0,0 +1,66 @@
+{
+ "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"
+ }
+}
\ No newline at end of file
diff --git a/targets/widget/widgets.swift b/targets/widget/widgets.swift
index 534c1df..11e486a 100644
--- a/targets/widget/widgets.swift
+++ b/targets/widget/widgets.swift
@@ -1,81 +1,202 @@
import WidgetKit
import SwiftUI
-struct Provider: AppIntentTimelineProvider {
- func placeholder(in context: Context) -> SimpleEntry {
- SimpleEntry(date: Date(), configuration: ConfigurationAppIntent())
- }
+// MARK: - Timeline Entry
- func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry {
- SimpleEntry(date: Date(), configuration: configuration)
- }
-
- func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline {
- var entries: [SimpleEntry] = []
-
- // Generate a timeline consisting of five entries an hour apart, starting from the current date.
- let currentDate = Date()
- for hourOffset in 0 ..< 5 {
- let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
- let entry = SimpleEntry(date: entryDate, configuration: configuration)
- entries.append(entry)
- }
-
- return Timeline(entries: entries, policy: .atEnd)
- }
-
-// func relevances() async -> WidgetRelevances {
-// // Generate a list containing the contexts this widget is relevant in.
-// }
-}
-
-struct SimpleEntry: TimelineEntry {
+struct DeviceEntry: TimelineEntry {
let date: Date
let configuration: ConfigurationAppIntent
+ let device: DeviceInfo?
}
-struct widgetEntryView : View {
+// 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", 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 {
+ 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 {
- VStack {
- Text("Time:")
- Text(entry.date, style: .time)
-
- Text("Favorite Emoji:")
- Text(entry.configuration.favoriteEmoji)
+ 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)
+ ActionButton(action: .sleep, deviceId: device.id)
+ }
+ HStack(spacing: 6) {
+ ActionButton(action: .restart, deviceId: device.id)
+ ActionButton(action: .shutdown, deviceId: device.id)
+ }
+ }
+ } else {
+ // Medium/Large widget: horizontal layout
+ HStack(spacing: 8) {
+ ActionButton(action: .wake, deviceId: device.id)
+ ActionButton(action: .sleep, deviceId: device.id)
+ ActionButton(action: .restart, deviceId: device.id)
+ ActionButton(action: .shutdown, deviceId: device.id)
+ }
+ }
+ }
+ .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)
}
}
}
-struct widget: Widget {
+// MARK: - Action Button
+
+struct ActionButton: View {
+ let action: DeviceAction
+ let deviceId: String
+
+ 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 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: action.url(for: deviceId)) {
+ VStack(spacing: 2) {
+ Image(systemName: icon)
+ .font(.system(size: 16, weight: .medium))
+ .foregroundColor(color)
+ }
+ .frame(maxWidth: .infinity, minHeight: 36)
+ .background(color.opacity(0.15))
+ .cornerRadius(8)
+ }
+ }
+}
+
+// MARK: - Widget Definition
+
+struct DeviceControlWidget: Widget {
let kind: String = "widget"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
- widgetEntryView(entry: entry)
+ 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])
}
}
-extension ConfigurationAppIntent {
- fileprivate static var smiley: ConfigurationAppIntent {
- let intent = ConfigurationAppIntent()
- intent.favoriteEmoji = "π"
- return intent
- }
-
- fileprivate static var starEyes: ConfigurationAppIntent {
- let intent = ConfigurationAppIntent()
- intent.favoriteEmoji = "π€©"
- return intent
- }
-}
+// Keep legacy name for backwards compatibility with existing widget installations
+typealias widget = DeviceControlWidget
+
+// MARK: - Preview
#Preview(as: .systemSmall) {
- widget()
+ DeviceControlWidget()
} timeline: {
- SimpleEntry(date: .now, configuration: .smiley)
- SimpleEntry(date: .now, configuration: .starEyes)
+ DeviceEntry(
+ date: .now,
+ configuration: ConfigurationAppIntent(),
+ device: DeviceInfo(id: "preview", name: "Gaming PC", mac: "AA:BB:CC:DD:EE:FF", ip: "192.168.1.100", 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", status: "offline")
+ )
}