Compare commits

1 Commits

26 changed files with 779 additions and 1560 deletions
+1 -28
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Jumpstart",
"slug": "remote-wol",
"version": "1.0.2",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "remotewol-upsnap",
@@ -14,12 +14,8 @@
"appleTeamId": "8S7C654DQ4",
"entitlements": {
"com.apple.security.application-groups": [
"group.abunchofknowitalls.remotewol-upsnap",
"group.abunchofknowitalls.remotewol-upsnap"
]
},
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
},
"android": {
@@ -58,29 +54,6 @@
"experiments": {
"typedRoutes": true,
"reactCompiler": true
},
"extra": {
"router": {},
"eas": {
"build": {
"experimental": {
"ios": {
"appExtensions": [
{
"bundleIdentifier": "com.abunchofknowitalls.remotewol-upsnap.widget",
"targetName": "widget",
"entitlements": {
"com.apple.security.application-groups": [
"group.abunchofknowitalls.remotewol-upsnap"
]
}
}
]
}
}
},
"projectId": "b2c99399-8470-4a78-9253-492056fc1c35"
}
}
}
}
+3 -2
View File
@@ -1,9 +1,10 @@
import { NativeTabs } from 'expo-router/unstable-native-tabs';
import React from 'react';
export default function TabLayout() {
export default function TabsLayout() {
return (
<NativeTabs>
<NativeTabs.Trigger name="devices">
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Icon sf="desktopcomputer" md="home" />
<NativeTabs.Trigger.Label>Devices</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
-43
View File
@@ -1,43 +0,0 @@
import { Stack, useRouter } from 'expo-router';
import { useColorScheme } from '../../../hooks/use-color-scheme';
import { TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '@/src/context/AuthContext';
function Header() {
const router = useRouter();
const isDark = useColorScheme() === 'dark';
const activityColor = isDark ? '#0A84FF' : '#007AFF';
const { canCreate } = useAuth();
return (
<TouchableOpacity
onPress={() => router.push('/scan-devices')}
disabled={!canCreate}
style={{ paddingHorizontal: 8 }}
>
<Ionicons
name="add-circle-outline"
size={24}
color={canCreate ? activityColor : 'gray'}
/>
</TouchableOpacity>
);
}
export default function Layout() {
const isDark = useColorScheme() === 'dark';
const titleColor = isDark ? '#fff' : '#000';
return (
<Stack
screenOptions={{
title: 'Devices',
headerLargeTitle: true,
headerRight: () => <Header />,
headerTintColor: titleColor,
headerLargeTitleStyle: { color: titleColor },
}}
/>
);
}
@@ -1,4 +1,8 @@
import { Button, ContextMenu, Host } from '@expo/ui/swift-ui';
import {
Button,
ContextMenu,
Host,
} from '@expo/ui/swift-ui';
import { Ionicons } from '@expo/vector-icons';
import * as Burnt from 'burnt';
import { SymbolView } from 'expo-symbols';
@@ -14,11 +18,10 @@ import {
TouchableOpacity,
View,
} 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 { useAuth } from '@/src/context/AuthContext';
import { useColorScheme } from '../../hooks/use-color-scheme';
import api from '../../src/services/api';
import { syncDevicesToWidget } from '../../src/services/widgetSync';
import { Device } from '../../src/types';
const isAuthError = (error: unknown) =>
typeof error === 'object' &&
@@ -39,8 +42,6 @@ export default function DeviceListScreen() {
const [isLoading, setIsLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const { isAuthenticated } = useAuth();
const fetchDevices = useCallback(async (showLoading = false) => {
try {
if (showLoading) setIsLoading(true);
@@ -64,14 +65,10 @@ export default function DeviceListScreen() {
}, []);
useEffect(() => {
if (!isAuthenticated) return;
fetchDevices(true);
}, [fetchDevices, isAuthenticated]);
}, [fetchDevices]);
useEffect(() => {
if (!isAuthenticated) return;
let intervalId: number | null = null;
const startPolling = () => {
@@ -105,7 +102,7 @@ export default function DeviceListScreen() {
stopPolling();
subscription.remove();
};
}, [fetchDevices, isAuthenticated]);
}, [fetchDevices]);
const onRefresh = async () => {
setRefreshing(true);
@@ -262,20 +259,17 @@ export default function DeviceListScreen() {
symbolName,
color,
onPress,
enabled,
fallbackName,
}: {
name: string;
symbolName: string;
color: string;
onPress: () => void;
enabled: boolean;
fallbackName?: string;
}) => (
<TouchableOpacity
style={styles.actionIconContainer}
onPress={onPress}
disabled={!enabled}
accessibilityLabel={name}
activeOpacity={0.75}
>
@@ -299,28 +293,29 @@ export default function DeviceListScreen() {
const renderDevice = ({ item }: { item: Device }) => {
const isOnline = item.status?.toLowerCase() === 'online';
const isOffline = item.status?.toLowerCase() === 'offline';
const hasActions = isOnline || isOffline;
return (
<Host>
<ContextMenu>
<ContextMenu.Items>
<Button
systemImage="trash"
role="destructive"
label="Delete Device"
onPress={() => handleDelete(item)}
/>
</ContextMenu.Items>
<ContextMenu.Trigger>
<View
style={[
styles.deviceCard,
{
backgroundColor: cardBg,
shadowColor: isDark ? 'rgba(0,0,0,0.6)' : '#000',
},
]}
>
<View
style={[
styles.deviceCard,
{
backgroundColor: cardBg,
shadowColor: isDark ? 'rgba(0,0,0,0.6)' : '#000',
},
]}
>
<Host>
<ContextMenu>
<ContextMenu.Items>
<Button
systemImage="trash"
role="destructive"
label='Delete Device'
onPress={() => handleDelete(item)}
/>
</ContextMenu.Items>
<ContextMenu.Trigger>
<View style={styles.deviceHeader}>
<View style={styles.deviceInfo}>
<Text style={[styles.deviceName, { color: textColor }]}>
@@ -356,45 +351,49 @@ export default function DeviceListScreen() {
]}
/>
</View>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
<View style={styles.deviceActions}>
<ActionIcon
name="Wake"
enabled={isOffline}
symbolName="bolt.circle.fill"
fallbackName="flash"
color={isOffline ? '#4CAF50' : subTextColor}
onPress={() => handleWake(item)}
/>
{hasActions && (
<View style={styles.deviceActions}>
{isOffline && (
<ActionIcon
name="Wake"
symbolName="bolt.circle.fill"
fallbackName="flash"
color="#4CAF50"
onPress={() => handleWake(item)}
/>
)}
{isOnline && (
<>
<ActionIcon
name="Sleep"
enabled={isOnline && item.sol_enabled}
symbolName="moon.circle.fill"
fallbackName="moon"
color={isOnline && item.sol_enabled ? '#FF9800' : subTextColor}
color="#FF9800"
onPress={() => handleSleep(item)}
/>
<ActionIcon
name="Reboot"
enabled={isOnline && item.shutdown_cmd !== "" }
symbolName="arrow.clockwise.circle.fill"
fallbackName="refresh"
color={isOnline && item.shutdown_cmd !== "" ? '#2196F3' : subTextColor}
color="#2196F3"
onPress={() => handleReboot(item)}
/>
<ActionIcon
name="Shutdown"
enabled={isOnline && item.shutdown_cmd !== "" }
symbolName="power.circle.fill"
fallbackName="power"
color={isOnline && item.shutdown_cmd !== "" ? '#f44336' : subTextColor}
color="#f44336"
onPress={() => handleShutdown(item)}
/>
</View>
</View>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
</>
)}
</View>
)}
</View>
);
};
@@ -407,28 +406,26 @@ export default function DeviceListScreen() {
}
return (
<FlatList
style={[styles.container, { backgroundColor: bgColor }]}
data={devices}
renderItem={renderDevice}
keyExtractor={item => item.id}
contentContainerStyle={styles.list}
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={isDark ? subTextColor : undefined}
/>
}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={[styles.emptyText, { color: subTextColor }]}>
No devices found
</Text>
</View>
}
/>
<View style={[styles.container, { backgroundColor: bgColor }]}>
<FlatList
data={devices}
renderItem={renderDevice}
keyExtractor={item => item.id}
contentContainerStyle={styles.list}
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
ListFooterComponent={<View style={{ height: 20 }} />}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={[styles.emptyText, { color: subTextColor }]}>
No devices found
</Text>
</View>
}
/>
</View>
);
}
@@ -7,8 +7,8 @@ import {
TouchableOpacity,
View,
} from 'react-native';
import { useColorScheme } from '../../../hooks/use-color-scheme';
import { useAuth } from '../../../src/context/AuthContext';
import { useColorScheme } from '../../hooks/use-color-scheme';
import { useAuth } from '../../src/context/AuthContext';
import { useRouter } from 'expo-router';
import Constants from 'expo-constants';
@@ -90,7 +90,7 @@ export default function SettingsScreen() {
);
return (
<ScrollView style={[styles.container, { backgroundColor: bgColor }]} contentInsetAdjustmentBehavior='automatic'>
<ScrollView style={[styles.container, { backgroundColor: bgColor }]}>
<View style={styles.content}>
<View
style={[
@@ -109,7 +109,8 @@ export default function SettingsScreen() {
>
Information
</Text>
<SettingItem label={user?.email ? 'Email' : 'Username'} value={user?.email || user?.username} />
<SettingItem label="Username" value={user?.username || 'Admin'} />
<SettingItem label="Email" value={user?.email || 'N/A'} />
<SettingItem label="Server URL" value={serverAddress || 'N/A'} />
<SettingItem label="App Version" value={`${Constants.expoConfig?.version}`} />
</View>
@@ -136,7 +137,7 @@ export default function SettingsScreen() {
<View style={styles.footer}>
<Text style={[styles.footerText, { color: subTextColor }]}>
Jumpstart
UpSnap Mobile App
</Text>
<Text style={[styles.footerText, { color: subTextColor }]}>
Connect to your UpSnap server
-18
View File
@@ -1,18 +0,0 @@
import { Stack } from 'expo-router';
import { useColorScheme } from '../../../hooks/use-color-scheme';
export default function Layout() {
const isDark = useColorScheme() === 'dark';
const titleColor = isDark ? '#fff' : '#000';
return (
<Stack
screenOptions={{
title: 'Settings',
headerLargeTitle: true,
headerTintColor: titleColor,
headerLargeTitleStyle: { color: titleColor },
}}
/>
);
}
+66 -14
View File
@@ -1,56 +1,108 @@
import { Ionicons } from '@expo/vector-icons';
import { Stack, useRouter, useSegments } from 'expo-router';
import React, { useEffect } from 'react';
import { useEffect } from 'react';
import { Text, TouchableOpacity } from 'react-native';
import { useColorScheme } from '../hooks/use-color-scheme';
import { AuthProvider, useAuth } from '../src/context/AuthContext';
function AuthRedirect() {
const { isAuthenticated, isLoading } = useAuth();
const router = useRouter();
const segments = useSegments();
const { isAuthenticated, isLoading } = useAuth();
const firstSegment = segments[0];
useEffect(() => {
if (isLoading) {
return;
}
const isLoginRoute = firstSegment === 'login';
const isLoginRoute = segments[0] === 'login';
if (!isAuthenticated && !isLoginRoute) {
router.replace('/login');
} else if (isAuthenticated && isLoginRoute) {
router.replace('/devices');
}
}, [firstSegment, isAuthenticated, isLoading, router]);
if (isAuthenticated && isLoginRoute) {
router.replace('/(tabs)');
}
}, [isAuthenticated, isLoading, router, segments]);
return null;
}
function DevicesHeader() {
const router = useRouter();
const isDark = useColorScheme() === 'dark';
const activityColor = isDark ? '#0A84FF' : '#007AFF';
const { canCreate } = useAuth();
return (
<TouchableOpacity
onPress={() => router.push('/scan-devices')}
disabled={!canCreate}
style={{ paddingHorizontal: 8 }}
>
<Ionicons name="add-circle-outline" size={24} color={canCreate ? activityColor : 'gray'} />
</TouchableOpacity>
);
}
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 (
<Text style={{ color: titleColor, fontSize: 17, fontWeight: '600' }}>
{title}
</Text>
);
}
export default function RootLayout() {
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;
return (
<AuthProvider>
<AuthRedirect />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="login" />
<Stack.Screen name="(tabs)" />
<Stack>
{/* Root index that performs auth redirect (app/index.tsx) */}
<Stack.Screen name="index" options={{ headerShown: false }} />
{/* Tabs parent - render tabs with dynamic header */}
<Stack.Screen
name="(tabs)"
options={
{
headerTitle: () => <TabsTitle title="Devices" />,
headerRight: isIndex ? () => <DevicesHeader /> : undefined,
headerRightContainerStyle: isIndex
? undefined
: { width: 0, paddingRight: 0 },
headerStyle: { backgroundColor: bgColor },
headerTintColor: titleColor,
} as any
}
/>{' '}
<Stack.Screen name="login" options={{ headerShown: false }} />
<Stack.Screen
name="scan-devices"
options={{
headerShown: true,
title: 'Scan for Devices',
headerTitle: () => <TabsTitle title="Scan Devices" />,
headerStyle: { backgroundColor: bgColor },
headerTintColor: titleColor,
headerBackButtonDisplayMode: 'minimal',
}}
/>
{/* Deep link action handler - no header, immediately redirects */}
<Stack.Screen
name="action/[action]/[deviceId]"
options={{ animation: 'none' }}
options={{ headerShown: false, animation: 'none' }}
/>
</Stack>
</AuthProvider>
+2 -2
View File
@@ -20,11 +20,11 @@ export default function ActionHandler() {
executeAction(action as DeviceAction, deviceId);
}
// Reset navigation to devices - clears entire stack so no back button
// Reset navigation to tabs - clears entire stack so no back button
navigation.dispatch(
CommonActions.reset({
index: 0,
routes: [{ name: 'devices' }],
routes: [{ name: '(tabs)' }],
})
);
}, [action, deviceId, navigation]);
+13 -5
View File
@@ -1,13 +1,21 @@
import { Redirect } from "expo-router";
import { useRouter } from "expo-router";
import { StatusBar } from "expo-status-bar";
import React, { useEffect } from "react";
import { useAuth } from "../src/context/AuthContext";
export default function IndexRedirect() {
const router = useRouter();
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return <StatusBar style="auto" />;
}
useEffect(() => {
if (!isLoading) {
if (isAuthenticated) {
router.replace("/(tabs)");
} else {
router.replace("/login");
}
}
}, [isAuthenticated, isLoading, router]);
return <Redirect href={isAuthenticated ? "/devices" : "/login"} />;
return <StatusBar style="auto" />;
}
+1 -2
View File
@@ -155,8 +155,7 @@ export default function ScanDevicesScreen() {
<Text style={[styles.infoText, { color: subText }]}>
Note: This requires the server to have nmap installed and may take
several minutes. To allow for shutdown, reboot, and sleep actions,
please configure their respective commands in your UpSnap server.
several minutes.
</Text>
{devices.length > 0 && (
-21
View File
@@ -1,21 +0,0 @@
{
"cli": {
"version": ">= 18.3.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 799 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

+580 -1295
View File
File diff suppressed because it is too large Load Diff
+14 -15
View File
@@ -12,36 +12,35 @@
},
"dependencies": {
"@bacons/apple-targets": "^3.0.6",
"@expo/ui": "~55.0.15",
"@expo/ui": "55.0.1",
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"burnt": "^0.13.0",
"expo": "^55.0.23",
"expo": "^55.0.5",
"expo-constants": "~55.0.7",
"expo-dev-client": "~55.0.33",
"expo-font": "~55.0.4",
"expo-glass-effect": "~55.0.11",
"expo-haptics": "~55.0.14",
"expo-image": "~55.0.10",
"expo-linking": "~55.0.15",
"expo-router": "~55.0.14",
"expo-splash-screen": "~55.0.20",
"expo-status-bar": "~55.0.6",
"expo-symbols": "~55.0.8",
"expo-system-ui": "~55.0.17",
"expo-web-browser": "~55.0.15",
"expo-glass-effect": "~55.0.7",
"expo-haptics": "~55.0.8",
"expo-image": "~55.0.6",
"expo-linking": "~55.0.7",
"expo-router": "~55.0.4",
"expo-splash-screen": "~55.0.10",
"expo-status-bar": "~55.0.4",
"expo-symbols": "~55.0.5",
"expo-system-ui": "~55.0.9",
"expo-web-browser": "~55.0.9",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-native": "0.83.6",
"react-native": "0.83.2",
"react-native-gesture-handler": "~2.30.0",
"react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.23.0",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.7.4"
"react-native-worklets": "0.7.2"
},
"devDependencies": {
"@types/react": "~19.2.10",
-4
View File
@@ -13,8 +13,6 @@ interface WidgetDevice {
name: string;
mac: string;
ip: string;
canSleep: boolean;
canShutdown: boolean;
status: string;
}
@@ -34,8 +32,6 @@ export function syncDevicesToWidget(devices: Device[]): void {
name: device.name,
mac: device.mac,
ip: device.ip,
canSleep: device.sol_enabled,
canShutdown: device.shutdown_cmd !== "",
status: device.status || 'unknown',
}));
+2 -4
View File
@@ -7,10 +7,8 @@ export interface Device {
ip: string;
netmask: string;
broadcast: string;
password: string;
shutdown_cmd: string;
sol_enabled: boolean;
ports: number[];
secureOnPassword: string;
port: number;
groups: string[];
status: string;
created: string;
-2
View File
@@ -32,8 +32,6 @@ struct DeviceInfo: Codable, Hashable, Identifiable {
let name: String
let mac: String
let ip: String
let canSleep: Bool
let canShutdown: Bool
let status: String
}
+14 -20
View File
@@ -16,7 +16,7 @@ struct Provider: AppIntentTimelineProvider {
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")
device: DeviceInfo(id: "placeholder", name: "My Computer", mac: "AA:BB:CC:DD:EE:FF", ip: "192.168.1.100", status: "unknown")
)
}
@@ -76,21 +76,21 @@ struct DeviceWidgetEntryView: View {
// 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)
ActionButton(action: .wake, deviceId: device.id)
ActionButton(action: .sleep, deviceId: device.id)
}
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)
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, 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)
ActionButton(action: .wake, deviceId: device.id)
ActionButton(action: .sleep, deviceId: device.id)
ActionButton(action: .restart, deviceId: device.id)
ActionButton(action: .shutdown, deviceId: device.id)
}
}
}
@@ -118,7 +118,6 @@ struct DeviceWidgetEntryView: View {
struct ActionButton: View {
let action: DeviceAction
let deviceId: String
let enabled: Bool
var icon: String {
switch action {
@@ -137,10 +136,6 @@ struct ActionButton: View {
case .shutdown: return .red
}
}
var disabled: Color {
return .gray
}
var label: String {
switch action {
@@ -152,16 +147,15 @@ struct ActionButton: View {
}
var body: some View {
Link(destination: enabled ? action.url(for: deviceId) : URL("")!) {
Link(destination: action.url(for: deviceId)) {
VStack(spacing: 2) {
Image(systemName: icon)
.font(.system(size: 16, weight: .medium))
.foregroundColor(enabled ? color : disabled)
.foregroundColor(color)
}
.frame(maxWidth: .infinity, minHeight: 36)
.background((enabled ? color : disabled).opacity(0.15))
.background(color.opacity(0.15))
.cornerRadius(8)
.disabled(!enabled)
}
}
}
@@ -193,7 +187,7 @@ typealias widget = DeviceControlWidget
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")
device: DeviceInfo(id: "preview", name: "Gaming PC", mac: "AA:BB:CC:DD:EE:FF", ip: "192.168.1.100", status: "online")
)
}
@@ -203,6 +197,6 @@ typealias widget = DeviceControlWidget
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")
device: DeviceInfo(id: "preview", name: "Home Server", mac: "11:22:33:44:55:66", ip: "192.168.1.50", status: "offline")
)
}