Compare commits

6 Commits

26 changed files with 1577 additions and 796 deletions
+28 -1
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Jumpstart",
"slug": "remote-wol",
"version": "1.0.0",
"version": "1.0.2",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "remotewol-upsnap",
@@ -14,8 +14,12 @@
"appleTeamId": "8S7C654DQ4",
"entitlements": {
"com.apple.security.application-groups": [
"group.abunchofknowitalls.remotewol-upsnap",
"group.abunchofknowitalls.remotewol-upsnap"
]
},
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
},
"android": {
@@ -54,6 +58,29 @@
"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"
}
}
}
}
+2 -3
View File
@@ -1,10 +1,9 @@
import { NativeTabs } from 'expo-router/unstable-native-tabs';
import React from 'react';
export default function TabsLayout() {
export default function TabLayout() {
return (
<NativeTabs>
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger name="devices">
<NativeTabs.Trigger.Icon sf="desktopcomputer" md="home" />
<NativeTabs.Trigger.Label>Devices</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
+43
View File
@@ -0,0 +1,43 @@
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,8 +1,4 @@
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';
@@ -18,10 +14,11 @@ 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 { 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';
const isAuthError = (error: unknown) =>
typeof error === 'object' &&
@@ -42,6 +39,8 @@ 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);
@@ -65,10 +64,14 @@ export default function DeviceListScreen() {
}, []);
useEffect(() => {
if (!isAuthenticated) return;
fetchDevices(true);
}, [fetchDevices]);
}, [fetchDevices, isAuthenticated]);
useEffect(() => {
if (!isAuthenticated) return;
let intervalId: number | null = null;
const startPolling = () => {
@@ -102,7 +105,7 @@ export default function DeviceListScreen() {
stopPolling();
subscription.remove();
};
}, [fetchDevices]);
}, [fetchDevices, isAuthenticated]);
const onRefresh = async () => {
setRefreshing(true);
@@ -259,17 +262,20 @@ 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}
>
@@ -293,29 +299,28 @@ 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 (
<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>
<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.deviceHeader}>
<View style={styles.deviceInfo}>
<Text style={[styles.deviceName, { color: textColor }]}>
@@ -351,49 +356,45 @@ export default function DeviceListScreen() {
]}
/>
</View>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
{hasActions && (
<View style={styles.deviceActions}>
{isOffline && (
<ActionIcon
name="Wake"
symbolName="bolt.circle.fill"
fallbackName="flash"
color="#4CAF50"
onPress={() => handleWake(item)}
/>
)}
{isOnline && (
<>
<View style={styles.deviceActions}>
<ActionIcon
name="Wake"
enabled={isOffline}
symbolName="bolt.circle.fill"
fallbackName="flash"
color={isOffline ? '#4CAF50' : subTextColor}
onPress={() => handleWake(item)}
/>
<ActionIcon
name="Sleep"
enabled={isOnline && item.sol_enabled}
symbolName="moon.circle.fill"
fallbackName="moon"
color="#FF9800"
color={isOnline && item.sol_enabled ? '#FF9800' : subTextColor}
onPress={() => handleSleep(item)}
/>
<ActionIcon
name="Reboot"
enabled={isOnline && item.shutdown_cmd !== "" }
symbolName="arrow.clockwise.circle.fill"
fallbackName="refresh"
color="#2196F3"
color={isOnline && item.shutdown_cmd !== "" ? '#2196F3' : subTextColor}
onPress={() => handleReboot(item)}
/>
<ActionIcon
name="Shutdown"
enabled={isOnline && item.shutdown_cmd !== "" }
symbolName="power.circle.fill"
fallbackName="power"
color="#f44336"
color={isOnline && item.shutdown_cmd !== "" ? '#f44336' : subTextColor}
onPress={() => handleShutdown(item)}
/>
</>
)}
</View>
)}
</View>
</View>
</View>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
);
};
@@ -406,26 +407,28 @@ export default function DeviceListScreen() {
}
return (
<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>
<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>
}
/>
);
}
+18
View File
@@ -0,0 +1,18 @@
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 },
}}
/>
);
}
@@ -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 }]}>
<ScrollView style={[styles.container, { backgroundColor: bgColor }]} contentInsetAdjustmentBehavior='automatic'>
<View style={styles.content}>
<View
style={[
@@ -109,8 +109,7 @@ export default function SettingsScreen() {
>
Information
</Text>
<SettingItem label="Username" value={user?.username || 'Admin'} />
<SettingItem label="Email" value={user?.email || 'N/A'} />
<SettingItem label={user?.email ? 'Email' : 'Username'} value={user?.email || user?.username} />
<SettingItem label="Server URL" value={serverAddress || 'N/A'} />
<SettingItem label="App Version" value={`${Constants.expoConfig?.version}`} />
</View>
@@ -137,7 +136,7 @@ export default function SettingsScreen() {
<View style={styles.footer}>
<Text style={[styles.footerText, { color: subTextColor }]}>
UpSnap Mobile App
Jumpstart
</Text>
<Text style={[styles.footerText, { color: subTextColor }]}>
Connect to your UpSnap server
+14 -66
View File
@@ -1,108 +1,56 @@
import { Ionicons } from '@expo/vector-icons';
import { Stack, useRouter, useSegments } from 'expo-router';
import { useEffect } from 'react';
import { Text, TouchableOpacity } from 'react-native';
import React, { useEffect } from 'react';
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 = segments[0] === 'login';
const isLoginRoute = firstSegment === 'login';
if (!isAuthenticated && !isLoginRoute) {
router.replace('/login');
} else if (isAuthenticated && isLoginRoute) {
router.replace('/devices');
}
if (isAuthenticated && isLoginRoute) {
router.replace('/(tabs)');
}
}, [isAuthenticated, isLoading, router, segments]);
}, [firstSegment, isAuthenticated, isLoading, router]);
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>
{/* 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 screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="login" />
<Stack.Screen name="(tabs)" />
<Stack.Screen
name="scan-devices"
options={{
headerShown: true,
headerTitle: () => <TabsTitle title="Scan Devices" />,
title: 'Scan for Devices',
headerStyle: { backgroundColor: bgColor },
headerTintColor: titleColor,
headerBackButtonDisplayMode: 'minimal',
}}
/>
{/* Deep link action handler - no header, immediately redirects */}
<Stack.Screen
name="action/[action]/[deviceId]"
options={{ headerShown: false, animation: 'none' }}
options={{ animation: 'none' }}
/>
</Stack>
</AuthProvider>
+2 -2
View File
@@ -20,11 +20,11 @@ export default function ActionHandler() {
executeAction(action as DeviceAction, deviceId);
}
// Reset navigation to tabs - clears entire stack so no back button
// Reset navigation to devices - clears entire stack so no back button
navigation.dispatch(
CommonActions.reset({
index: 0,
routes: [{ name: '(tabs)' }],
routes: [{ name: 'devices' }],
})
);
}, [action, deviceId, navigation]);
+5 -13
View File
@@ -1,21 +1,13 @@
import { useRouter } from "expo-router";
import { Redirect } 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();
useEffect(() => {
if (!isLoading) {
if (isAuthenticated) {
router.replace("/(tabs)");
} else {
router.replace("/login");
}
}
}, [isAuthenticated, isLoading, router]);
if (isLoading) {
return <StatusBar style="auto" />;
}
return <StatusBar style="auto" />;
return <Redirect href={isAuthenticated ? "/devices" : "/login"} />;
}
+2 -1
View File
@@ -155,7 +155,8 @@ export default function ScanDevicesScreen() {
<Text style={[styles.infoText, { color: subText }]}>
Note: This requires the server to have nmap installed and may take
several minutes.
several minutes. To allow for shutdown, reboot, and sleep actions,
please configure their respective commands in your UpSnap server.
</Text>
{devices.length > 0 && (
+21
View File
@@ -0,0 +1,21 @@
{
"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.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 799 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+1312 -597
View File
File diff suppressed because it is too large Load Diff
+15 -14
View File
@@ -12,35 +12,36 @@
},
"dependencies": {
"@bacons/apple-targets": "^3.0.6",
"@expo/ui": "55.0.1",
"@expo/ui": "~55.0.15",
"@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.5",
"expo": "^55.0.23",
"expo-constants": "~55.0.7",
"expo-dev-client": "~55.0.33",
"expo-font": "~55.0.4",
"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",
"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",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-native": "0.83.2",
"react-native": "0.83.6",
"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.2"
"react-native-worklets": "0.7.4"
},
"devDependencies": {
"@types/react": "~19.2.10",
+4
View File
@@ -13,6 +13,8 @@ interface WidgetDevice {
name: string;
mac: string;
ip: string;
canSleep: boolean;
canShutdown: boolean;
status: string;
}
@@ -32,6 +34,8 @@ 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',
}));
+4 -2
View File
@@ -7,8 +7,10 @@ export interface Device {
ip: string;
netmask: string;
broadcast: string;
secureOnPassword: string;
port: number;
password: string;
shutdown_cmd: string;
sol_enabled: boolean;
ports: number[];
groups: string[];
status: string;
created: string;
+2
View File
@@ -32,6 +32,8 @@ struct DeviceInfo: Codable, Hashable, Identifiable {
let name: String
let mac: String
let ip: String
let canSleep: Bool
let canShutdown: Bool
let status: String
}
+20 -14
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", status: "unknown")
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")
)
}
@@ -76,21 +76,21 @@ struct DeviceWidgetEntryView: View {
// Small widget: 2x2 grid
VStack(spacing: 6) {
HStack(spacing: 6) {
ActionButton(action: .wake, deviceId: device.id)
ActionButton(action: .sleep, deviceId: device.id)
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)
ActionButton(action: .shutdown, deviceId: device.id)
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)
ActionButton(action: .sleep, deviceId: device.id)
ActionButton(action: .restart, deviceId: device.id)
ActionButton(action: .shutdown, deviceId: device.id)
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)
}
}
}
@@ -118,6 +118,7 @@ struct DeviceWidgetEntryView: View {
struct ActionButton: View {
let action: DeviceAction
let deviceId: String
let enabled: Bool
var icon: String {
switch action {
@@ -136,6 +137,10 @@ struct ActionButton: View {
case .shutdown: return .red
}
}
var disabled: Color {
return .gray
}
var label: String {
switch action {
@@ -147,15 +152,16 @@ struct ActionButton: View {
}
var body: some View {
Link(destination: action.url(for: deviceId)) {
Link(destination: enabled ? action.url(for: deviceId) : URL("")!) {
VStack(spacing: 2) {
Image(systemName: icon)
.font(.system(size: 16, weight: .medium))
.foregroundColor(color)
.foregroundColor(enabled ? color : disabled)
}
.frame(maxWidth: .infinity, minHeight: 36)
.background(color.opacity(0.15))
.background((enabled ? color : disabled).opacity(0.15))
.cornerRadius(8)
.disabled(!enabled)
}
}
}
@@ -187,7 +193,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", status: "online")
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")
)
}
@@ -197,6 +203,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", status: "offline")
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")
)
}