This commit is contained in:
2026-01-04 00:38:04 -05:00
Unverified
parent ec2425f2b7
commit d3dbd1e33a
20 changed files with 1622 additions and 1869 deletions

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Joshua Higgins
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

112
README.md
View File

@@ -17,24 +17,23 @@ A React Native Expo app that connects to an UpSnap server and provides mobile ac
- Node.js installed - Node.js installed
- Expo CLI installed (`npm install -g expo-cli`) - Expo CLI installed (`npm install -g expo-cli`)
- An UpSnap server instance running (e.g., https://wol.f6knight.duckdns.org/) - An UpSnap server instance running
### Installation ### Installation
1. Install dependencies: 1. Install dependencies:
```bash ```bash
npm install npm i
``` ```
2. Start the development server: 2. Start the development server:
```bash ```bash
npm start npx expo run
``` ```
3. Run on your preferred platform: 3. Run on your preferred platform:
- iOS: Press `i` in the terminal or run `npm run ios` - iOS: Press `i` in the terminal or run `npx expo run:ios`
- Android: Press `a` in the terminal or run `npm run android` - Android: Press `a` in the terminal or run `npx expo run:android`
- Web: Press `w` in the terminal or run `npm run web`
## Usage ## Usage
@@ -42,25 +41,15 @@ npm start
1. Open the app 1. Open the app
2. Enter your UpSnap server credentials: 2. Enter your UpSnap server credentials:
- Server Address
- Username or Email - Username or Email
- Password - Password
- Check "Login as Admin" if you're an admin user
3. Tap "Login" 3. Tap "Login"
### Device Management ### Device Management
#### Adding a Device #### Adding a Device
1. Tap the "+ Add" button in the Devices screen
2. Fill in the required fields:
- Device Name (e.g., "My PC")
- MAC Address (format: XX:XX:XX:XX:XX:XX)
- IP Address
- Optional: Netmask, Broadcast Address, SecureOn Password, Port
3. Tap "Add Device"
#### Scanning for Devices
1. Tap the "Scan" button in the Devices screen 1. Tap the "Scan" button in the Devices screen
2. Wait for the network scan to complete 2. Wait for the network scan to complete
3. Tap on a discovered device to add it to your list 3. Tap on a discovered device to add it to your list
@@ -68,7 +57,6 @@ npm start
#### Managing Devices #### Managing Devices
- **View Devices**: Scroll through the list on the main screen - **View Devices**: Scroll through the list on the main screen
- **Device Details**: Tap on a device card to view and edit details
- **Wake Device**: Tap the green "Wake" button on a device card - **Wake Device**: Tap the green "Wake" button on a device card
- **Sleep Device**: Tap the orange "Sleep" button - **Sleep Device**: Tap the orange "Sleep" button
- **Reboot Device**: Tap the blue "Reboot" button - **Reboot Device**: Tap the blue "Reboot" button
@@ -80,93 +68,8 @@ npm start
- 🔴 Red dot: Device is offline - 🔴 Red dot: Device is offline
- 🟠 Orange dot: Status unknown - 🟠 Orange dot: Status unknown
## Screens
### Login Screen
- Username/email and password fields
- Admin login option
- Persistent authentication using AsyncStorage
### Devices Screen
- List of all devices with status indicators
- Quick action buttons for each device
- Pull-to-refresh for updating status
- Add device and scan network buttons
### Device Details Screen
- View all device information
- Edit device details
- Delete device option
### Add Device Screen
- Manual device entry form
- All required and optional fields
- Input validation
### Scan Devices Screen
- Network scanning functionality
- List of discovered devices
- Quick add to device list
### Settings Screen
- User information display
- Server configuration
- Logout option
- Clear data option
## API Integration
The app connects to an UpSnap server via its REST API:
- **Authentication**: `/api/collections/users/auth-with-password` or `/api/collections/_superusers/auth-with-password`
- **Devices**: `/api/collections/devices/records`
- **Wake**: `/api/upsnap/wake/:id`
- **Sleep**: `/api/collections/upsnap/sleep/:id`
- **Reboot**: `/api/upsnap/reboot/:id`
- **Shutdown**: `/api/upsnap/shutdown/:id`
- **Scan**: `/api/upsnap/scan`
## Project Structure
```
src/
├── components/ # Reusable components
├── context/ # React context providers (AuthContext)
├── navigation/ # Navigation setup
├── screens/ # All app screens
│ ├── LoginScreen.tsx
│ ├── DeviceListScreen.tsx
│ ├── DeviceDetailsScreen.tsx
│ ├── AddDeviceScreen.tsx
│ ├── ScanDevicesScreen.tsx
│ └── SettingsScreen.tsx
├── services/ # API service
│ └── api.ts
└── types/ # TypeScript type definitions
└── index.ts
```
## Tech Stack
- **React Native**: Cross-platform mobile framework
- **Expo**: Development platform
- **TypeScript**: Type safety
- **React Navigation**: Navigation library
- **AsyncStorage**: Local data persistence
## Server Configuration
The app is pre-configured to connect to:
- Server URL: `https://wol.f6knight.duckdns.org`
- API Base: `/api`
To change the server URL, modify the `API_BASE_URL` constant in `src/services/api.ts`.
## Security Notes ## Security Notes
- Authentication tokens are stored securely in AsyncStorage
- All API calls require authentication
- Destructive actions (sleep, reboot, shutdown) require confirmation
- Never expose your UpSnap server to the open web without proper security measures - Never expose your UpSnap server to the open web without proper security measures
## Troubleshooting ## Troubleshooting
@@ -174,7 +77,6 @@ To change the server URL, modify the `API_BASE_URL` constant in `src/services/ap
### Login Issues ### Login Issues
- Verify your username/email and password are correct - Verify your username/email and password are correct
- Check if you need to log in as admin
- Ensure your UpSnap server is accessible - Ensure your UpSnap server is accessible
### Network Scan Issues ### Network Scan Issues
@@ -192,7 +94,7 @@ To change the server URL, modify the `API_BASE_URL` constant in `src/services/ap
## License ## License
This project is a mobile companion to UpSnap, which is licensed under the MIT License. This project is licensed under the MIT License.
## Credits ## Credits

View File

@@ -1,6 +1,6 @@
{ {
"expo": { "expo": {
"name": "remote-wol", "name": "Remote WoL",
"slug": "remote-wol", "slug": "remote-wol",
"version": "1.0.0", "version": "1.0.0",
"orientation": "portrait", "orientation": "portrait",
@@ -10,7 +10,8 @@
"newArchEnabled": true, "newArchEnabled": true,
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
"bundleIdentifier": "com.anonymous.remote-wol" "icon": "./assets/remotewol-ios.icon",
"bundleIdentifier": "com.abunchofknowitalls.remotewol-upsnap"
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {

View File

@@ -1,11 +1,14 @@
import React from "react";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { View, TouchableOpacity, StyleSheet } from "react-native"; import { Icon, Label, NativeTabs } from "expo-router/unstable-native-tabs";
import { NativeTabs, Icon, Label } from "expo-router/unstable-native-tabs"; import React from "react";
import { StyleSheet, TouchableOpacity, View } from "react-native";
import { useColorScheme } from "../../hooks/use-color-scheme";
export function DevicesHeader() { export function DevicesHeader() {
const router = useRouter(); const router = useRouter();
const isDark = useColorScheme() === "dark";
const activityColor = isDark ? "#0A84FF" : "#007AFF";
return ( return (
<View style={styles.headerRight}> <View style={styles.headerRight}>
@@ -13,7 +16,7 @@ export function DevicesHeader() {
onPress={() => router.push("/scan-devices")} onPress={() => router.push("/scan-devices")}
style={styles.headerButton} style={styles.headerButton}
> >
<Ionicons name="add-circle-outline" size={24} color="#007AFF" /> <Ionicons name="add-circle-outline" size={24} color={activityColor} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
); );
@@ -23,12 +26,12 @@ export default function TabsLayout() {
return ( return (
<NativeTabs> <NativeTabs>
<NativeTabs.Trigger name="index"> <NativeTabs.Trigger name="index">
<Icon sf="desktopcomputer"/> <Icon sf="desktopcomputer" />
<Label>Devices</Label> <Label>Devices</Label>
</NativeTabs.Trigger> </NativeTabs.Trigger>
<NativeTabs.Trigger name="settings"> <NativeTabs.Trigger name="settings">
<Icon sf="gear"/> <Icon sf="gear" />
<Label>Settings</Label> <Label>Settings</Label>
</NativeTabs.Trigger> </NativeTabs.Trigger>
</NativeTabs> </NativeTabs>
); );

View File

@@ -1,66 +1,111 @@
import React, { useState, useEffect } from "react"; import { ContextMenu, Host, Button as SwiftUIButton } from "@expo/ui/swift-ui";
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
RefreshControl,
Alert,
ActivityIndicator,
} from "react-native";
import { ContextMenu, Button as SwiftUIButton, Host } from "@expo/ui/swift-ui";
import { useRouter, useLocalSearchParams, Stack } from "expo-router";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { SymbolView } from "expo-symbols";
import React, { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
Alert,
AppState,
FlatList,
RefreshControl,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { useColorScheme } from "../../hooks/use-color-scheme";
import api from "../../src/services/api"; import api from "../../src/services/api";
import { Device } from "../../src/types"; import { Device } from "../../src/types";
import * as Burnt from "burnt";
function DevicesHeader() {
const router = useRouter();
return (
<View>
<TouchableOpacity onPress={() => router.push("/scan-devices")}>
<Ionicons name="add-circle-outline" size={24} color="#007AFF" />
</TouchableOpacity>
</View>
);
}
export default function DeviceListScreen() { export default function DeviceListScreen() {
const router = useRouter(); const colorScheme = useColorScheme() ?? "light";
const isDark = colorScheme === "dark";
const bgColor = isDark ? "#0b0b0d" : "#f5f5f5";
const cardBg = isDark ? "#1c1c1e" : "#fff";
const textColor = isDark ? "#ffffff" : "#333333";
const subTextColor = isDark ? "#c6c6c8" : "#666666";
const activityColor = isDark ? "#0A84FF" : "#007AFF";
const [devices, setDevices] = useState<Device[]>([]); const [devices, setDevices] = useState<Device[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
useEffect(() => { const fetchDevices = useCallback(async (showLoading = false) => {
loadDevices();
}, []);
const loadDevices = async () => {
try { try {
setIsLoading(true); if (showLoading) setIsLoading(true);
const data = await api.getDevices(); const data = await api.getDevices();
setDevices(data); setDevices(data);
} catch (error: any) { } catch (error: any) {
Alert.alert("Error", error.message || "Failed to load devices"); // For background/periodic refreshes, avoid interruptive alerts
if (showLoading) {
Alert.alert("Error", error.message || "Failed to load devices");
}
} finally { } finally {
setIsLoading(false); if (showLoading) setIsLoading(false);
} }
}; }, []);
useEffect(() => {
fetchDevices(true);
}, [fetchDevices]);
useEffect(() => {
let intervalId: number | null = null;
const startPolling = () => {
if (intervalId !== null) return;
intervalId = setInterval(() => {
fetchDevices(false);
}, 10000) as unknown as number;
};
const stopPolling = () => {
if (intervalId !== null) {
clearInterval(intervalId);
intervalId = null;
}
};
// Start polling while app is active; pause when backgrounded
startPolling();
const onAppStateChange = (nextAppState: string) => {
if (nextAppState === "active") {
startPolling();
} else {
stopPolling();
}
};
const subscription = AppState.addEventListener("change", onAppStateChange);
return () => {
stopPolling();
subscription.remove();
};
}, [fetchDevices]);
const onRefresh = async () => { const onRefresh = async () => {
setRefreshing(true); setRefreshing(true);
await loadDevices(); await fetchDevices(false);
setRefreshing(false); setRefreshing(false);
}; };
const handleWake = async (device: Device) => { const handleWake = async (device: Device) => {
try { try {
await api.wakeDevice(device.id); await api.wakeDevice(device.id);
Alert.alert("Success", `Wake signal sent to ${device.name}`); Burnt.toast({
title: "Success",
preset: "done",
message: `Waking ${device.name} up.`,
});
} catch (error: any) { } catch (error: any) {
Alert.alert("Error", error.message || "Failed to wake device"); Burnt.toast({
title: "Error",
preset: "error",
message: error.message || `Failed to wake up ${device.name}.`,
});
} }
}; };
@@ -73,9 +118,17 @@ export default function DeviceListScreen() {
onPress: async () => { onPress: async () => {
try { try {
await api.sleepDevice(device.id); await api.sleepDevice(device.id);
Alert.alert("Success", `Sleep signal sent to ${device.name}`); Burnt.toast({
title: "Success",
preset: "done",
message: `Sending ${device.name} to sleep.`,
});
} catch (error: any) { } catch (error: any) {
Alert.alert("Error", error.message || "Failed to sleep device"); Burnt.toast({
title: "Error",
preset: "error",
message: error.message || `Failed to send ${device.name} to sleep.`,
});
} }
}, },
}, },
@@ -91,9 +144,17 @@ export default function DeviceListScreen() {
onPress: async () => { onPress: async () => {
try { try {
await api.rebootDevice(device.id); await api.rebootDevice(device.id);
Alert.alert("Success", `Reboot signal sent to ${device.name}`); Burnt.toast({
title: "Success",
preset: "done",
message: `Rebooting ${device.name}.`,
});
} catch (error: any) { } catch (error: any) {
Alert.alert("Error", error.message || "Failed to reboot device"); Burnt.toast({
title: "Error",
preset: "error",
message: error.message || `Failed to reboot ${device.name}`,
});
} }
}, },
}, },
@@ -112,12 +173,17 @@ export default function DeviceListScreen() {
onPress: async () => { onPress: async () => {
try { try {
await api.shutdownDevice(device.id); await api.shutdownDevice(device.id);
Alert.alert("Success", `Shutdown signal sent to ${device.name}`); Burnt.toast({
title: "Success",
preset: "done",
message: `Shutting down ${device.name}.`,
});
} catch (error: any) { } catch (error: any) {
Alert.alert( Burnt.toast({
"Error", title: "Error",
error.message || "Failed to shutdown device" preset: "error",
); message: error.message || `Failed to shut down ${device.name}.`,
});
} }
}, },
}, },
@@ -127,16 +193,31 @@ export default function DeviceListScreen() {
const handleDelete = (device: Device) => { const handleDelete = (device: Device) => {
Alert.alert("Delete Device", `Delete "${device.name}"?`, [ Alert.alert("Delete Device", `Delete "${device.name}"?`, [
{
text: "Cancel",
style: "cancel",
onPress: () => {
// Close alert
},
},
{ {
text: "Delete", text: "Delete",
style: "destructive", style: "destructive",
onPress: async () => { onPress: async () => {
try { try {
await api.deleteDevice(device.id); await api.deleteDevice(device.id);
Alert.alert("Success", "Device deleted successfully"); Burnt.toast({
loadDevices(); title: "Success",
preset: "done",
message: `Deleted ${device.name} successfully.`,
});
fetchDevices(false);
} catch (error: any) { } catch (error: any) {
Alert.alert("Error", error.message || "Failed to delete device"); Burnt.toast({
title: "Error",
preset: "error",
message: error.message || `Failed to delete ${device.name}.`,
});
} }
}, },
}, },
@@ -154,6 +235,42 @@ export default function DeviceListScreen() {
} }
}; };
const ActionIcon = ({
name,
symbolName,
color,
onPress,
fallbackName,
}: {
name: string;
symbolName: string;
color: string;
onPress: () => void;
fallbackName?: string;
}) => (
<TouchableOpacity
style={styles.actionIconContainer}
onPress={onPress}
accessibilityLabel={name}
activeOpacity={0.75}
>
<SymbolView
name={symbolName as any}
size={20}
tintColor={color}
type="monochrome"
fallback={
<Ionicons
name={(fallbackName || "ellipse") as any}
size={20}
color={color}
/>
}
style={styles.actionIcon}
/>
</TouchableOpacity>
);
const renderDevice = ({ item }: { item: Device }) => ( const renderDevice = ({ item }: { item: Device }) => (
<Host> <Host>
<ContextMenu activationMethod="longPress"> <ContextMenu activationMethod="longPress">
@@ -167,51 +284,83 @@ export default function DeviceListScreen() {
</SwiftUIButton> </SwiftUIButton>
</ContextMenu.Items> </ContextMenu.Items>
<ContextMenu.Trigger> <ContextMenu.Trigger>
<View style={styles.deviceCard}> <View
<TouchableOpacity style={[
onPress={() => router.push(`/devices/${item.id}`)} styles.deviceCard,
activeOpacity={1} {
> backgroundColor: cardBg,
shadowColor: isDark ? "rgba(0,0,0,0.6)" : "#000",
},
]}
>
<View>
<View style={styles.deviceHeader}> <View style={styles.deviceHeader}>
<View style={styles.deviceInfo}> <View style={styles.deviceInfo}>
<Text style={styles.deviceName}>{item.name}</Text> <Text style={[styles.deviceName, { color: textColor }]}>
<Text style={styles.deviceIP}>{item.ip}</Text> {item.name}
</Text>
<Text style={[styles.deviceIP, { color: subTextColor }]}>
{item.mac}
</Text>
</View> </View>
<View <SymbolView
name="circle.fill"
size={18}
tintColor={getStatusColor(item.status)}
animationSpec={{
effect: { type: "pulse" },
repeating: true,
speed: 1,
}}
fallback={
<View
style={[
styles.statusDot,
{ backgroundColor: getStatusColor(item.status) },
]}
/>
}
style={[ style={[
styles.statusDot, styles.statusSymbol,
{ backgroundColor: getStatusColor(item.status) }, {
shadowColor: getStatusColor(item.status),
shadowOpacity: isDark ? 0.9 : 0.6,
},
]} ]}
/> />
</View> </View>
<View style={styles.deviceActions}> <View style={styles.deviceActions}>
<TouchableOpacity <ActionIcon
style={[styles.actionButton, styles.wakeButton]} name="Wake"
symbolName="bolt.fill"
fallbackName="flash"
color="#4CAF50"
onPress={() => handleWake(item)} onPress={() => handleWake(item)}
> />
<Text style={styles.actionButtonText}>Wake</Text> <ActionIcon
</TouchableOpacity> name="Sleep"
<TouchableOpacity symbolName="moon.fill"
style={[styles.actionButton, styles.sleepButton]} fallbackName="moon"
color="#FF9800"
onPress={() => handleSleep(item)} onPress={() => handleSleep(item)}
> />
<Text style={styles.actionButtonText}>Sleep</Text> <ActionIcon
</TouchableOpacity> name="Reboot"
<TouchableOpacity symbolName="arrow.clockwise.circle.fill"
style={[styles.actionButton, styles.rebootButton]} fallbackName="refresh"
color="#2196F3"
onPress={() => handleReboot(item)} onPress={() => handleReboot(item)}
> />
<Text style={styles.actionButtonText}>Reboot</Text> <ActionIcon
</TouchableOpacity> name="Shutdown"
<TouchableOpacity symbolName="power.circle.fill"
style={[styles.actionButton, styles.shutdownButton]} fallbackName="power"
color="#f44336"
onPress={() => handleShutdown(item)} onPress={() => handleShutdown(item)}
> />
<Text style={styles.actionButtonText}>Shutdown</Text>
</TouchableOpacity>
</View> </View>
</TouchableOpacity> </View>
</View> </View>
</ContextMenu.Trigger> </ContextMenu.Trigger>
</ContextMenu> </ContextMenu>
@@ -221,19 +370,13 @@ export default function DeviceListScreen() {
if (isLoading) { if (isLoading) {
return ( return (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#007AFF" /> <ActivityIndicator size="large" color={activityColor} />
</View> </View>
); );
} }
return ( return (
<View style={styles.container}> <View style={[styles.container, { backgroundColor: bgColor }]}>
<Stack.Screen
options={{
title: "Devices",
headerRight: () => <DevicesHeader />,
}}
/>
<FlatList <FlatList
data={devices} data={devices}
renderItem={renderDevice} renderItem={renderDevice}
@@ -244,7 +387,9 @@ export default function DeviceListScreen() {
} }
ListEmptyComponent={ ListEmptyComponent={
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No devices found</Text> <Text style={[styles.emptyText, { color: subTextColor }]}>
No devices found
</Text>
</View> </View>
} }
/> />
@@ -271,12 +416,13 @@ const styles = StyleSheet.create({
}, },
list: { list: {
padding: 15, padding: 15,
gap: 15,
}, },
deviceCard: { deviceCard: {
backgroundColor: "#fff", backgroundColor: "#fff",
borderRadius: 12, borderRadius: 12,
padding: 15, padding: 15,
marginBottom: 15, paddingRight: 25,
shadowColor: "#000", shadowColor: "#000",
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1, shadowOpacity: 0.1,
@@ -309,14 +455,27 @@ const styles = StyleSheet.create({
}, },
deviceActions: { deviceActions: {
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between",
gap: 8,
},
actionButton: {
flex: 1,
paddingVertical: 10,
borderRadius: 8,
alignItems: "center", alignItems: "center",
justifyContent: "flex-start",
marginTop: 8,
paddingBottom: 6,
},
actionIconContainer: {
padding: 6,
marginRight: 12,
justifyContent: "center",
alignItems: "center",
minWidth: 36,
minHeight: 36,
},
actionIcon: {
width: 22,
height: 22,
},
actionButtonText: {
color: "#fff",
fontWeight: "600",
fontSize: 12,
}, },
wakeButton: { wakeButton: {
backgroundColor: "#4CAF50", backgroundColor: "#4CAF50",
@@ -330,10 +489,14 @@ const styles = StyleSheet.create({
shutdownButton: { shutdownButton: {
backgroundColor: "#f44336", backgroundColor: "#f44336",
}, },
actionButtonText: { statusSymbol: {
color: "#fff", width: 18,
fontWeight: "600", height: 18,
fontSize: 12, borderRadius: 9,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.6,
shadowRadius: 8,
elevation: 6,
}, },
emptyContainer: { emptyContainer: {
alignItems: "center", alignItems: "center",

View File

@@ -1,231 +1,251 @@
import React from 'react'; import AsyncStorage from "@react-native-async-storage/async-storage";
import React from "react";
import { import {
View, Alert,
Text, ScrollView,
StyleSheet, StyleSheet,
ScrollView, Text,
TouchableOpacity, TouchableOpacity,
Alert, View,
} from 'react-native'; } from "react-native";
import { useAuth } from '../../src/context/AuthContext'; import { useColorScheme } from "../../hooks/use-color-scheme";
import AsyncStorage from '@react-native-async-storage/async-storage'; import { useAuth } from "../../src/context/AuthContext";
import { useRouter } from "expo-router";
export default function SettingsScreen() { export default function SettingsScreen() {
const { user, logout } = useAuth(); const { user, serverAddress, logout } = useAuth();
const router = useRouter();
const colorScheme = useColorScheme() ?? "light";
const isDark = colorScheme === "dark";
const bgColor = isDark ? "#0b0b0d" : "#f5f5f5";
const sectionBg = isDark ? "#1c1c1e" : "#fff";
const sectionTitleBg = isDark ? "#111111" : "#f9f9f9";
const textColor = isDark ? "#fff" : "#333";
const subTextColor = isDark ? "#c6c6c8" : "#666";
const primary = isDark ? "#0A84FF" : "#007AFF";
const destructiveColor = "#f44336";
const handleLogout = () => { const handleLogout = () => {
Alert.alert( Alert.alert("Logout", "Are you sure you want to logout?", [
'Logout', { text: "Cancel", style: "cancel" },
'Are you sure you want to logout?', {
[ text: "Logout",
{ text: 'Cancel', style: 'cancel' }, style: "destructive",
{ onPress: async () => {
text: 'Logout', router.replace("/login");
style: 'destructive', await logout();
onPress: async () => { },
await logout(); },
}, ]);
}, };
]
);
};
const handleClearData = () => { const SettingItem = ({
Alert.alert( label,
'Clear Data', value,
'This will clear all stored data. Are you sure?', onPress,
[ }: {
{ text: 'Cancel', style: 'cancel' }, label: string;
{ value?: string;
text: 'Clear', onPress?: () => void;
style: 'destructive', }) => (
onPress: async () => { <TouchableOpacity
try { style={[
await AsyncStorage.clear(); styles.settingItem,
Alert.alert('Success', 'All data cleared'); { borderBottomColor: isDark ? "rgba(255,255,255,0.03)" : "#f0f0f0" },
} catch (error) { ]}
Alert.alert('Error', 'Failed to clear data'); onPress={onPress}
} disabled={!onPress}
}, activeOpacity={onPress ? 0.7 : 1}
}, >
] <Text style={[styles.settingLabel, { color: textColor }]}>{label}</Text>
); <View style={styles.settingValueContainer}>
}; <Text
style={[styles.settingValue, { color: subTextColor }]}
numberOfLines={1}
>
{value}
</Text>
</View>
</TouchableOpacity>
);
const SettingItem = ({ const ActionButton = ({
label, title,
value, onPress,
onPress destructive = false,
}: { }: {
label: string; title: string;
value?: string; onPress: () => void;
onPress?: () => void; destructive?: boolean;
}) => ( }) => (
<TouchableOpacity <TouchableOpacity
style={styles.settingItem} style={[
onPress={onPress} styles.actionButton,
disabled={!onPress} { backgroundColor: destructive ? destructiveColor : primary },
activeOpacity={onPress ? 0.7 : 1} ]}
> onPress={onPress}
<Text style={styles.settingLabel}>{label}</Text> >
<View style={styles.settingValueContainer}> <Text style={styles.actionButtonText}>{title}</Text>
<Text style={styles.settingValue} numberOfLines={1}> </TouchableOpacity>
{value || 'N/A'} );
</Text>
</View>
</TouchableOpacity>
);
const ActionButton = ({ return (
title, <ScrollView style={[styles.container, { backgroundColor: bgColor }]}>
onPress, <View style={styles.content}>
destructive = false, <View
}: { style={[
title: string; styles.section,
onPress: () => void; {
destructive?: boolean; backgroundColor: sectionBg,
}) => ( shadowColor: isDark ? "rgba(255,255,255,0.02)" : "#000",
<TouchableOpacity },
style={[styles.actionButton, destructive && styles.actionButtonDestructive]} ]}
onPress={onPress} >
> <Text
<Text style={[
style={[ styles.sectionTitle,
styles.actionButtonText, { color: subTextColor, backgroundColor: sectionTitleBg },
destructive && styles.actionButtonTextDestructive, ]}
]} >
> User Information
{title} </Text>
</Text> <SettingItem label="Username" value={user?.username || "Admin"} />
</TouchableOpacity> <SettingItem label="Email" value={user?.email || "N/A"} />
); <SettingItem label="Server URL" value={serverAddress || "N/A"} />
</View>
return ( <View
<ScrollView style={styles.container}> style={[
<View style={styles.content}> styles.section,
<View style={styles.section}> {
<Text style={styles.sectionTitle}>User Information</Text> backgroundColor: sectionBg,
<SettingItem label="Username" value={user?.username || 'N/A'} /> shadowColor: isDark ? "rgba(255,255,255,0.02)" : "#000",
<SettingItem label="Email" value={user?.email || 'N/A'} /> },
<SettingItem ]}
label="User ID" >
value={user?.id || 'N/A'} <Text
/> style={[
</View> styles.sectionTitle,
{ color: subTextColor, backgroundColor: sectionTitleBg },
]}
>
App Info
</Text>
<SettingItem label="Version" value="1.0.0" />
<SettingItem label="Build" value="Expo" />
</View>
<View style={styles.section}> <View
<Text style={styles.sectionTitle}>Server</Text> style={[
<SettingItem styles.section,
label="Server URL" {
value="https://wol.f6knight.duckdns.org" backgroundColor: sectionBg,
/> shadowColor: isDark ? "rgba(255,255,255,0.02)" : "#000",
<SettingItem },
label="API Base" ]}
value="/api" >
/> <Text
</View> style={[
styles.sectionTitle,
{ color: subTextColor, backgroundColor: sectionTitleBg },
]}
>
Actions
</Text>
<ActionButton title="Logout" onPress={handleLogout} destructive />
</View>
<View style={styles.section}> <View style={styles.footer}>
<Text style={styles.sectionTitle}>App Info</Text> <Text style={[styles.footerText, { color: subTextColor }]}>
<SettingItem label="Version" value="1.0.0" /> UpSnap Mobile App
<SettingItem label="Build" value="Expo" /> </Text>
</View> <Text style={[styles.footerText, { color: subTextColor }]}>
Connect to your UpSnap server
<View style={styles.section}> </Text>
<Text style={styles.sectionTitle}>Actions</Text> </View>
<ActionButton title="Logout" onPress={handleLogout} destructive /> </View>
<ActionButton title="Clear All Data" onPress={handleClearData} destructive /> </ScrollView>
</View> );
<View style={styles.footer}>
<Text style={styles.footerText}>
UpSnap Mobile App
</Text>
<Text style={styles.footerText}>
Connect to your UpSnap server
</Text>
</View>
</View>
</ScrollView>
);
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: '#f5f5f5', backgroundColor: "#f5f5f5",
}, },
content: { content: {
padding: 20, padding: 20,
}, },
section: { section: {
backgroundColor: '#fff', backgroundColor: "#fff",
borderRadius: 12, borderRadius: 12,
marginBottom: 20, marginBottom: 20,
overflow: 'hidden', overflow: "hidden",
shadowColor: '#000', shadowColor: "#000",
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1, shadowOpacity: 0.1,
shadowRadius: 4, shadowRadius: 4,
elevation: 3, elevation: 3,
}, },
sectionTitle: { sectionTitle: {
fontSize: 14, fontSize: 14,
fontWeight: 'bold', fontWeight: "bold",
color: '#666', color: "#666",
paddingHorizontal: 15, paddingHorizontal: 15,
paddingTop: 15, paddingTop: 15,
paddingBottom: 10, paddingBottom: 10,
backgroundColor: '#f9f9f9', backgroundColor: "#f9f9f9",
}, borderTopLeftRadius: 12,
settingItem: { borderTopRightRadius: 12,
flexDirection: 'row', },
justifyContent: 'space-between', settingItem: {
alignItems: 'center', flexDirection: "row",
paddingHorizontal: 15, justifyContent: "space-between",
paddingVertical: 15, alignItems: "center",
borderBottomWidth: 1, paddingHorizontal: 15,
borderBottomColor: '#f0f0f0', paddingVertical: 15,
}, borderBottomWidth: 1,
settingLabel: { borderBottomColor: "#f0f0f0",
fontSize: 16, },
color: '#333', settingLabel: {
flex: 1, fontSize: 16,
}, color: "#333",
settingValueContainer: { flex: 1,
flex: 1, },
alignItems: 'flex-end', settingValueContainer: {
}, flex: 1,
settingValue: { alignItems: "flex-end",
fontSize: 14, },
color: '#666', settingValue: {
maxWidth: 200, fontSize: 14,
}, color: "#666",
actionButton: { maxWidth: 200,
backgroundColor: '#007AFF', },
margin: 15, actionButton: {
padding: 15, backgroundColor: "#007AFF",
borderRadius: 8, margin: 15,
alignItems: 'center', padding: 15,
}, borderRadius: 8,
actionButtonDestructive: { alignItems: "center",
backgroundColor: '#f44336', },
}, actionButtonDestructive: {
actionButtonText: { backgroundColor: "#f44336",
color: '#fff', },
fontSize: 16, actionButtonText: {
fontWeight: 'bold', color: "#fff",
}, fontSize: 16,
actionButtonTextDestructive: { fontWeight: "bold",
color: '#fff', },
}, actionButtonTextDestructive: {
footer: { color: "#fff",
alignItems: 'center', },
paddingVertical: 30, footer: {
}, alignItems: "center",
footerText: { paddingVertical: 30,
fontSize: 14, },
color: '#999', footerText: {
marginBottom: 5, fontSize: 14,
}, color: "#999",
marginBottom: 5,
},
}); });

View File

@@ -1,41 +1,77 @@
import { StatusBar } from "expo-status-bar"; import { Ionicons } from "@expo/vector-icons";
import { AuthProvider, useAuth } from "../src/context/AuthContext"; import { Stack, useRouter, useSegments } from "expo-router";
import { useEffect } from "react"; import { TouchableOpacity, Text } from "react-native";
import { Stack, useRouter } from "expo-router"; import { useColorScheme } from "../hooks/use-color-scheme";
import { AuthProvider } from "../src/context/AuthContext";
function RootStack() { function DevicesHeader() {
const router = useRouter(); const router = useRouter();
const { isAuthenticated, isLoading } = useAuth(); const isDark = useColorScheme() === "dark";
const activityColor = isDark ? "#0A84FF" : "#007AFF";
useEffect(() => {
if (!isLoading) {
if (isAuthenticated) {
router.replace('/(tabs)');
} else {
router.replace('/login');
}
}
}, [isAuthenticated, isLoading, router]);
return ( return (
<Stack> <TouchableOpacity
{isAuthenticated ? ( onPress={() => router.push("/scan-devices")}
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> style={{ paddingHorizontal: 8 }}
) : ( >
<Stack.Screen name="login" options={{ headerShown: false }} /> <Ionicons name="add-circle-outline" size={24} color={activityColor} />
)} </TouchableOpacity>
<Stack.Screen name="add-device" options={{ headerShown: false }} /> );
<Stack.Screen name="scan-devices" options={{ headerShown: false }} /> }
<Stack.Screen name="devices/[id]" />
</Stack> 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() { 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 ( return (
<AuthProvider> <AuthProvider>
<RootStack /> <Stack>
<StatusBar style="auto" /> {/* 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,
headerTitle: () => <TabsTitle title="Scan Devices" />,
headerStyle: { backgroundColor: bgColor },
headerBackButtonDisplayMode: "minimal",
}}
/>
</Stack>
</AuthProvider> </AuthProvider>
); );
} }

View File

@@ -1,236 +0,0 @@
import React, { useState } from 'react';
import {
View,
Text,
ScrollView,
StyleSheet,
TextInput,
TouchableOpacity,
Alert,
ActivityIndicator,
} from 'react-native';
import { useRouter } from 'expo-router';
import api from '../src/services/api';
export default function AddDeviceScreen() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [formData, setFormData] = useState({
name: '',
mac: '',
ip: '',
netmask: '255.255.255.0',
broadcast: '',
secureOnPassword: '',
port: '9',
});
const handleSave = async () => {
if (!formData.name || !formData.mac || !formData.ip) {
Alert.alert('Error', 'Please fill in all required fields');
return;
}
setIsLoading(true);
try {
await api.createDevice({
name: formData.name,
mac: formData.mac,
ip: formData.ip,
netmask: formData.netmask,
broadcast: formData.broadcast,
secureOnPassword: formData.secureOnPassword,
port: parseInt(formData.port) || 9,
groups: [],
status: 'offline',
});
Alert.alert('Success', 'Device added successfully');
router.back();
} catch (error: any) {
Alert.alert('Error', error.message || 'Failed to add device');
} finally {
setIsLoading(false);
}
};
return (
<ScrollView style={styles.container}>
<View style={styles.content}>
<Text style={styles.header}>Add New Device</Text>
<View style={styles.form}>
<View style={styles.inputGroup}>
<Text style={styles.label}>Device Name *</Text>
<TextInput
style={styles.input}
value={formData.name}
onChangeText={(text) => setFormData({ ...formData, name: text })}
placeholder="My PC"
autoCapitalize="words"
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>MAC Address *</Text>
<TextInput
style={styles.input}
value={formData.mac}
onChangeText={(text) => setFormData({ ...formData, mac: text })}
placeholder="00:11:22:33:44:55"
autoCapitalize="characters"
/>
<Text style={styles.hint}>
Format: XX:XX:XX:XX:XX:XX
</Text>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>IP Address *</Text>
<TextInput
style={styles.input}
value={formData.ip}
onChangeText={(text) => setFormData({ ...formData, ip: text })}
placeholder="192.168.1.100"
keyboardType="numeric"
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Netmask</Text>
<TextInput
style={styles.input}
value={formData.netmask}
onChangeText={(text) => setFormData({ ...formData, netmask: text })}
placeholder="255.255.255.0"
keyboardType="numeric"
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Broadcast Address</Text>
<TextInput
style={styles.input}
value={formData.broadcast}
onChangeText={(text) => setFormData({ ...formData, broadcast: text })}
placeholder="192.168.1.255"
keyboardType="numeric"
/>
<Text style={styles.hint}>
Optional: Auto-calculated if left blank
</Text>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>SecureOn Password</Text>
<TextInput
style={styles.input}
value={formData.secureOnPassword}
onChangeText={(text) => setFormData({ ...formData, secureOnPassword: text })}
placeholder="Optional password"
secureTextEntry
/>
<Text style={styles.hint}>
Optional: For SecureOn enabled NICs
</Text>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Port</Text>
<TextInput
style={styles.input}
value={formData.port}
onChangeText={(text) => setFormData({ ...formData, port: text })}
placeholder="9"
keyboardType="numeric"
/>
<Text style={styles.hint}>
Default: 9 (Standard Wake-on-LAN port)
</Text>
</View>
<View style={styles.buttonGroup}>
<TouchableOpacity
style={[styles.button, styles.cancelButton]}
onPress={() => router.back()}
disabled={isLoading}
>
<Text style={styles.buttonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.saveButton]}
onPress={handleSave}
disabled={isLoading}
>
<Text style={styles.buttonText}>
{isLoading ? 'Adding...' : 'Add Device'}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
content: {
padding: 20,
},
header: {
fontSize: 24,
fontWeight: 'bold',
color: '#333',
marginBottom: 20,
textAlign: 'center',
},
form: {
gap: 20,
},
inputGroup: {
gap: 8,
},
label: {
fontSize: 14,
fontWeight: '600',
color: '#333',
},
input: {
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 12,
fontSize: 16,
},
hint: {
fontSize: 12,
color: '#666',
},
buttonGroup: {
flexDirection: 'row',
gap: 10,
marginTop: 10,
},
button: {
flex: 1,
paddingVertical: 15,
borderRadius: 8,
alignItems: 'center',
},
cancelButton: {
backgroundColor: '#999',
},
saveButton: {
backgroundColor: '#4CAF50',
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: 'bold',
},
});

View File

@@ -1,350 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
ScrollView,
StyleSheet,
TextInput,
TouchableOpacity,
Alert,
ActivityIndicator,
} from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import api from '../../src/services/api';
import { Device } from '../../src/types';
export default function DeviceDetailsScreen() {
const router = useRouter();
const { id: deviceId } = useLocalSearchParams<{ id: string }>();
const [device, setDevice] = useState<Device | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [formData, setFormData] = useState({
name: '',
mac: '',
ip: '',
netmask: '',
broadcast: '',
secureOnPassword: '',
port: '',
});
useEffect(() => {
loadDevice();
}, [deviceId]);
const loadDevice = async () => {
try {
setIsLoading(true);
const data = await api.getDevice(deviceId);
setDevice(data);
setFormData({
name: data.name,
mac: data.mac,
ip: data.ip,
netmask: data.netmask || '',
broadcast: data.broadcast || '',
secureOnPassword: data.secureOnPassword || '',
port: String(data.port),
});
} catch (error: any) {
Alert.alert('Error', error.message || 'Failed to load device');
router.back();
} finally {
setIsLoading(false);
}
};
const handleSave = async () => {
if (!formData.name || !formData.mac || !formData.ip) {
Alert.alert('Error', 'Please fill in all required fields');
return;
}
setIsSaving(true);
try {
await api.updateDevice(deviceId, {
name: formData.name,
mac: formData.mac,
ip: formData.ip,
netmask: formData.netmask,
broadcast: formData.broadcast,
secureOnPassword: formData.secureOnPassword,
port: parseInt(formData.port) || 9,
});
setIsEditing(false);
await loadDevice();
Alert.alert('Success', 'Device updated successfully');
} catch (error: any) {
Alert.alert('Error', error.message || 'Failed to update device');
} finally {
setIsSaving(false);
}
};
const handleDelete = () => {
Alert.alert(
'Delete Device',
'Are you sure you want to delete this device?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
try {
await api.deleteDevice(deviceId);
Alert.alert('Success', 'Device deleted successfully');
router.back();
} catch (error: any) {
Alert.alert('Error', error.message || 'Failed to delete device');
}
},
},
]
);
};
if (isLoading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#007AFF" />
</View>
);
}
return (
<ScrollView style={styles.container}>
<View style={styles.content}>
{isEditing ? (
<View style={styles.form}>
<Text style={styles.label}>Device Name *</Text>
<TextInput
style={styles.input}
value={formData.name}
onChangeText={(text) => setFormData({ ...formData, name: text })}
placeholder="Device name"
/>
<Text style={styles.label}>MAC Address *</Text>
<TextInput
style={styles.input}
value={formData.mac}
onChangeText={(text) => setFormData({ ...formData, mac: text })}
placeholder="00:11:22:33:44:55"
autoCapitalize="characters"
/>
<Text style={styles.label}>IP Address *</Text>
<TextInput
style={styles.input}
value={formData.ip}
onChangeText={(text) => setFormData({ ...formData, ip: text })}
placeholder="192.168.1.100"
keyboardType="numeric"
/>
<Text style={styles.label}>Netmask</Text>
<TextInput
style={styles.input}
value={formData.netmask}
onChangeText={(text) => setFormData({ ...formData, netmask: text })}
placeholder="255.255.255.0"
keyboardType="numeric"
/>
<Text style={styles.label}>Broadcast Address</Text>
<TextInput
style={styles.input}
value={formData.broadcast}
onChangeText={(text) => setFormData({ ...formData, broadcast: text })}
placeholder="192.168.1.255"
keyboardType="numeric"
/>
<Text style={styles.label}>SecureOn Password</Text>
<TextInput
style={styles.input}
value={formData.secureOnPassword}
onChangeText={(text) => setFormData({ ...formData, secureOnPassword: text })}
placeholder="Optional password"
/>
<Text style={styles.label}>Port</Text>
<TextInput
style={styles.input}
value={formData.port}
onChangeText={(text) => setFormData({ ...formData, port: text })}
placeholder="9"
keyboardType="numeric"
/>
<View style={styles.buttonGroup}>
<TouchableOpacity
style={[styles.button, styles.saveButton]}
onPress={handleSave}
disabled={isSaving}
>
<Text style={styles.buttonText}>
{isSaving ? 'Saving...' : 'Save'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.cancelButton]}
onPress={() => {
setIsEditing(false);
setFormData({
name: device!.name,
mac: device!.mac,
ip: device!.ip,
netmask: device!.netmask || '',
broadcast: device!.broadcast || '',
secureOnPassword: device!.secureOnPassword || '',
port: String(device!.port),
});
}}
>
<Text style={styles.buttonText}>Cancel</Text>
</TouchableOpacity>
</View>
</View>
) : (
<View style={styles.details}>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Name</Text>
<Text style={styles.detailValue}>{device?.name}</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>MAC Address</Text>
<Text style={styles.detailValue}>{device?.mac}</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>IP Address</Text>
<Text style={styles.detailValue}>{device?.ip}</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Netmask</Text>
<Text style={styles.detailValue}>{device?.netmask || 'N/A'}</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Broadcast</Text>
<Text style={styles.detailValue}>{device?.broadcast || 'N/A'}</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Port</Text>
<Text style={styles.detailValue}>{device?.port}</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Status</Text>
<Text style={styles.detailValue}>{device?.status}</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Groups</Text>
<Text style={styles.detailValue}>
{device?.groups?.join(', ') || 'None'}
</Text>
</View>
<View style={styles.buttonGroup}>
<TouchableOpacity
style={[styles.button, styles.editButton]}
onPress={() => setIsEditing(true)}
>
<Text style={styles.buttonText}>Edit</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.deleteButton]}
onPress={handleDelete}
>
<Text style={styles.buttonText}>Delete</Text>
</TouchableOpacity>
</View>
</View>
)}
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
content: {
padding: 20,
},
form: {
gap: 15,
},
details: {
gap: 20,
},
label: {
fontSize: 14,
fontWeight: '600',
color: '#333',
marginBottom: 5,
},
input: {
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 12,
fontSize: 16,
},
detailRow: {
backgroundColor: '#fff',
borderRadius: 8,
padding: 15,
borderWidth: 1,
borderColor: '#e0e0e0',
},
detailLabel: {
fontSize: 14,
color: '#666',
marginBottom: 4,
},
detailValue: {
fontSize: 16,
color: '#333',
fontWeight: '500',
},
buttonGroup: {
flexDirection: 'row',
gap: 10,
marginTop: 20,
},
button: {
flex: 1,
paddingVertical: 15,
borderRadius: 8,
alignItems: 'center',
},
saveButton: {
backgroundColor: '#4CAF50',
},
cancelButton: {
backgroundColor: '#999',
},
editButton: {
backgroundColor: '#007AFF',
},
deleteButton: {
backgroundColor: '#f44336',
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: 'bold',
},
});

21
app/index.tsx Normal file
View File

@@ -0,0 +1,21 @@
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();
useEffect(() => {
if (!isLoading) {
if (isAuthenticated) {
router.replace("/(tabs)");
} else {
router.replace("/login");
}
}
}, [isAuthenticated, isLoading, router]);
return <StatusBar style="auto" />;
}

View File

@@ -1,174 +1,212 @@
import React, { useState } from 'react'; import { useRouter } from "expo-router";
import React, { useState } from "react";
import { import {
View, Alert,
Text, KeyboardAvoidingView,
TextInput, Platform,
TouchableOpacity, ScrollView,
StyleSheet, StyleSheet,
Alert, Text,
KeyboardAvoidingView, TextInput,
Platform, TouchableOpacity,
ScrollView, View,
} from 'react-native'; } from "react-native";
import { useAuth } from '../src/context/AuthContext'; import { useColorScheme } from "../hooks/use-color-scheme";
import { useRouter } from 'expo-router'; import { useAuth } from "../src/context/AuthContext";
export default function LoginScreen() { export default function LoginScreen() {
const router = useRouter(); const router = useRouter();
const [identity, setIdentity] = useState(''); const colorScheme = useColorScheme() ?? "light";
const [password, setPassword] = useState(''); const isDark = colorScheme === "dark";
const [isSuperuser, setIsSuperuser] = useState(false); const bgColor = isDark ? "#0b0b0d" : "#f5f5f5";
const [isLoading, setIsLoading] = useState(false); const textColor = isDark ? "#fff" : "#333";
const { login } = useAuth(); const subText = isDark ? "#c6c6c8" : "#666";
const inputBg = isDark ? "rgba(255,255,255,0.04)" : "#fff";
const borderColor = isDark ? "rgba(255,255,255,0.06)" : "#ddd";
const primary = isDark ? "#0A84FF" : "#007AFF";
const handleLogin = async () => { const { serverAddress: storedServerAddress, login } = useAuth();
if (!identity || !password) { const [serverAddress, setServerAddress] = useState(storedServerAddress || "");
Alert.alert('Error', 'Please enter both identity and password'); const [identity, setIdentity] = useState("");
return; const [password, setPassword] = useState("");
} const [isLoading, setIsLoading] = useState(false);
setIsLoading(true); const handleLogin = async () => {
try { if (!serverAddress || !identity || !password) {
await login(identity, password, isSuperuser); Alert.alert("Error", "Please enter server address, identity, and password");
router.replace('/'); return;
} catch (error: any) { }
Alert.alert('Login Failed', error.message || 'An error occurred');
} finally {
setIsLoading(false);
}
};
return ( setIsLoading(true);
<KeyboardAvoidingView try {
style={styles.container} await login(serverAddress, identity, password);
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} router.replace("/");
> } catch (error: any) {
<ScrollView contentContainerStyle={styles.scrollContent}> Alert.alert("Login Failed", error.message || "An error occurred");
<View style={styles.header}> } finally {
<Text style={styles.title}>UpSnap</Text> setIsLoading(false);
<Text style={styles.subtitle}>Wake on LAN Mobile</Text> }
</View> };
<View style={styles.form}> return (
<TextInput <KeyboardAvoidingView
style={styles.input} style={[styles.container, { backgroundColor: bgColor }]}
placeholder="Username or Email" behavior={Platform.OS === "ios" ? "padding" : "height"}
value={identity} >
onChangeText={setIdentity} <ScrollView contentContainerStyle={styles.scrollContent}>
autoCapitalize="none" <View style={styles.header}>
autoCorrect={false} <Text style={[styles.title, { color: textColor }]}>Remote WoL</Text>
/> <Text style={[styles.subtitle, { color: subText }]}>
Mobile Frontend for UpSnap
</Text>
</View>
<TextInput <View style={styles.form}>
style={styles.input} <TextInput
placeholder="Password" style={[
value={password} styles.input,
onChangeText={setPassword} { backgroundColor: inputBg, borderColor, color: textColor },
secureTextEntry ]}
autoCapitalize="none" placeholder="Server Address"
/> placeholderTextColor={subText}
value={serverAddress}
onChangeText={setServerAddress}
autoCapitalize="none"
autoCorrect={false}
selectionColor={primary}
keyboardAppearance={isDark ? "dark" : "light"}
/>
<TouchableOpacity <TextInput
style={styles.checkboxContainer} style={[
onPress={() => setIsSuperuser(!isSuperuser)} styles.input,
activeOpacity={0.7} { backgroundColor: inputBg, borderColor, color: textColor },
> ]}
<View style={[styles.checkbox, isSuperuser && styles.checkboxChecked]}> placeholder="Username or Email"
{isSuperuser && <Text style={styles.checkmark}></Text>} placeholderTextColor={subText}
</View> value={identity}
<Text style={styles.checkboxLabel}>Login as Admin</Text> onChangeText={setIdentity}
</TouchableOpacity> autoCapitalize="none"
autoCorrect={false}
selectionColor={primary}
keyboardAppearance={isDark ? "dark" : "light"}
/>
<TouchableOpacity <TextInput
style={[styles.button, isLoading && styles.buttonDisabled]} style={[
onPress={handleLogin} styles.input,
disabled={isLoading} { backgroundColor: inputBg, borderColor, color: textColor },
> ]}
<Text style={styles.buttonText}> placeholder="Password"
{isLoading ? 'Logging in...' : 'Login'} placeholderTextColor={subText}
</Text> value={password}
</TouchableOpacity> onChangeText={setPassword}
</View> secureTextEntry
</ScrollView> autoCapitalize="none"
</KeyboardAvoidingView> selectionColor={primary}
); keyboardAppearance={isDark ? "dark" : "light"}
/>
<TouchableOpacity
style={[
styles.button,
isLoading && styles.buttonDisabled,
{
backgroundColor: isLoading
? isDark
? "#333"
: "#ccc"
: primary,
},
]}
onPress={handleLogin}
disabled={isLoading}
>
<Text style={[styles.buttonText, { color: "#fff" }]}>
{isLoading ? "Logging in..." : "Login"}
</Text>
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: '#f5f5f5', backgroundColor: "#f5f5f5",
}, },
scrollContent: { scrollContent: {
flexGrow: 1, flexGrow: 1,
justifyContent: 'center', justifyContent: "center",
padding: 20, padding: 20,
}, },
header: { header: {
alignItems: 'center', alignItems: "center",
marginBottom: 40, marginBottom: 40,
}, },
title: { title: {
fontSize: 32, fontSize: 32,
fontWeight: 'bold', fontWeight: "bold",
color: '#333', color: "#333",
marginBottom: 8, marginBottom: 8,
}, },
subtitle: { subtitle: {
fontSize: 16, fontSize: 16,
color: '#666', color: "#666",
}, },
form: { form: {
width: '100%', width: "100%",
}, },
input: { input: {
backgroundColor: '#fff', backgroundColor: "#fff",
borderWidth: 1, borderWidth: 1,
borderColor: '#ddd', borderColor: "#ddd",
borderRadius: 8, borderRadius: 8,
padding: 15, padding: 15,
fontSize: 16, fontSize: 16,
marginBottom: 15, marginBottom: 15,
}, },
checkboxContainer: { checkboxContainer: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
marginBottom: 20, marginBottom: 20,
}, },
checkbox: { checkbox: {
width: 24, width: 24,
height: 24, height: 24,
borderWidth: 2, borderWidth: 2,
borderColor: '#007AFF', borderColor: "#007AFF",
borderRadius: 4, borderRadius: 4,
marginRight: 10, marginRight: 10,
justifyContent: 'center', justifyContent: "center",
alignItems: 'center', alignItems: "center",
}, },
checkboxChecked: { checkboxChecked: {
backgroundColor: '#007AFF', backgroundColor: "#007AFF",
}, },
checkmark: { checkmark: {
color: '#fff', color: "#fff",
fontSize: 16, fontSize: 16,
fontWeight: 'bold', fontWeight: "bold",
}, },
checkboxLabel: { checkboxLabel: {
fontSize: 16, fontSize: 16,
color: '#333', color: "#333",
}, },
button: { button: {
backgroundColor: '#007AFF', backgroundColor: "#007AFF",
borderRadius: 8, borderRadius: 8,
padding: 15, padding: 15,
alignItems: 'center', alignItems: "center",
}, },
buttonDisabled: { buttonDisabled: {
backgroundColor: '#ccc', backgroundColor: "#ccc",
}, },
buttonText: { buttonText: {
color: '#fff', color: "#fff",
fontSize: 16, fontSize: 16,
fontWeight: 'bold', fontWeight: "bold",
}, },
}); });

View File

@@ -1,433 +1,356 @@
import React, { useState } from 'react'; import { useRouter } from "expo-router";
import React, { useState } from "react";
import { import {
View, ActivityIndicator,
Text, Alert,
FlatList, FlatList,
TouchableOpacity, StyleSheet,
StyleSheet, Text,
Alert, TouchableOpacity,
ActivityIndicator, View,
Modal, } from "react-native";
TextInput, import { useColorScheme } from "../hooks/use-color-scheme";
ScrollView, import api from "../src/services/api";
} from 'react-native'; import { NetworkScanResult } from "../src/types";
import { useRouter } from 'expo-router'; import { SymbolView } from "expo-symbols";
import api from '../src/services/api'; import * as Burnt from "burnt";
import { NetworkScanResult } from '../src/types';
export default function ScanDevicesScreen() { export default function ScanDevicesScreen() {
const router = useRouter(); const router = useRouter();
const [scanning, setScanning] = useState(false); const colorScheme = useColorScheme() ?? "light";
const [devices, setDevices] = useState<NetworkScanResult[]>([]); const isDark = colorScheme === "dark";
const [selectedDevice, setSelectedDevice] = useState<NetworkScanResult | null>(null); const bgColor = isDark ? "#0b0b0d" : "#f5f5f5";
const [showAddModal, setShowAddModal] = useState(false); const cardBg = isDark ? "rgba(255,255,255,0.04)" : "rgba(255, 255, 255, 0.8)";
const primary = isDark ? "#0A84FF" : "#007AFF";
const primaryPressed = isDark ? "#004BB5" : "#0051CC";
const textColor = isDark ? "#fff" : "#333";
const subText = isDark ? "#c6c6c8" : "#666";
const [formData, setFormData] = useState({ const [scanning, setScanning] = useState(false);
name: '', const [devices, setDevices] = useState<NetworkScanResult[]>([]);
mac: '',
ip: '',
netmask: '255.255.255.0',
broadcast: '',
secureOnPassword: '',
port: '9',
});
const handleScan = async () => { const handleScan = async () => {
setScanning(true); setScanning(true);
try { try {
const results = await api.scanNetwork(); const results = await api.scanNetwork();
setDevices(results); setDevices(results);
} catch (error: any) { } catch (error: any) {
Alert.alert('Error', error.message || 'Failed to scan network'); Alert.alert("Error", error.message || "Failed to scan network");
} finally { } finally {
setScanning(false); setScanning(false);
} }
}; };
const handleAddFromScan = (device: NetworkScanResult) => { const handleAddFromScan = async (device: NetworkScanResult) => {
const deviceName = device.name || device.hostname || `Device ${device.ip || device.ip_address}`; const deviceName =
const deviceIP = device.ip || device.ip_address || ''; device.name ||
const deviceMAC = device.mac || device.mac_address || ''; device.hostname ||
const deviceVendor = device.mac_vendor || ''; `Device ${device.ip || device.ip_address}`;
const deviceIP = device.ip || device.ip_address || "";
const deviceMAC = device.mac || device.mac_address || "";
setFormData({ try {
name: deviceName, await api.createDevice({
mac: deviceMAC, name: deviceName,
ip: deviceIP, mac: deviceMAC,
netmask: '255.255.255.0', ip: deviceIP,
broadcast: '', netmask: "255.255.255.0",
secureOnPassword: '', broadcast: "",
port: '9', secureOnPassword: "",
}); port: 9,
setSelectedDevice(device); groups: [],
setShowAddModal(true); status: "offline",
}; });
Burnt.toast({
title: "Success",
preset: "done",
message: `Added ${deviceName} successfully`,
});
router.back();
} catch (error: any) {
Burnt.toast({
title: "Error",
preset: "error",
message: error.message || "Failed to add device",
});
}
};
const handleSaveDevice = async () => { const renderDevice = ({ item }: { item: NetworkScanResult }) => {
if (!formData.name || !formData.mac || !formData.ip) { const displayName = item.name || item.hostname || "Unknown Device";
Alert.alert('Error', 'Please fill in all required fields'); const displayIP = item.ip || item.ip_address || "";
return; const displayMAC = item.mac || item.mac_address || "";
} const displayVendor = item.mac_vendor || "";
try { return (
await api.createDevice({ <View
name: formData.name, style={[
mac: formData.mac, styles.deviceCard,
ip: formData.ip, {
netmask: formData.netmask, backgroundColor: cardBg,
broadcast: formData.broadcast, shadowColor: isDark ? "rgba(255,255,255,0.02)" : "#000",
secureOnPassword: formData.secureOnPassword, },
port: parseInt(formData.port) || 9, ]}
groups: [], >
status: 'offline', <View style={styles.deviceInfo}>
}); <Text
Alert.alert('Success', 'Device added successfully'); style={[styles.deviceName, { color: textColor }]}
setShowAddModal(false); numberOfLines={1}
router.back(); >
} catch (error: any) { {displayName}
Alert.alert('Error', error.message || 'Failed to add device'); </Text>
} <Text style={[styles.deviceDetail, { color: subText }]}>
}; {displayIP}
</Text>
<Text style={[styles.deviceDetail, { color: subText }]}>
{displayMAC}
</Text>
{displayVendor && displayVendor !== "Unknown" && (
<Text style={[styles.vendorText, { color: subText }]}>
{displayVendor}
</Text>
)}
</View>
<TouchableOpacity onPress={() => handleAddFromScan(item)}>
<SymbolView
name="plus.circle.fill"
size={20}
type="hierarchical"
style={styles.addButton}
/>
</TouchableOpacity>
</View>
);
};
const renderDevice = ({ item }: { item: NetworkScanResult }) => { return (
const displayName = item.name || item.hostname || 'Unknown Device'; <View style={[styles.container, { backgroundColor: bgColor }]}>
const displayIP = item.ip || item.ip_address || ''; <View style={styles.header}>
const displayMAC = item.mac || item.mac_address || ''; <Text style={[styles.headerText, { color: textColor }]}>
const displayVendor = item.mac_vendor || ''; Add Devices
</Text>
<Text style={[styles.headerSubtext, { color: subText }]}>
Discover devices on your local network
</Text>
</View>
return ( <TouchableOpacity
<TouchableOpacity style={[
style={styles.deviceCard} styles.scanButton,
onPress={() => handleAddFromScan(item)} {
> backgroundColor: scanning ? primaryPressed : primary,
<View style={styles.deviceInfo}> },
<Text style={styles.deviceName} numberOfLines={1}>{displayName}</Text> ]}
<Text style={styles.deviceDetail}>{displayIP}</Text> onPress={handleScan}
<Text style={styles.deviceDetail}>{displayMAC}</Text> disabled={scanning}
{displayVendor && displayVendor !== 'Unknown' && ( >
<Text style={styles.vendorText}>{displayVendor}</Text> {scanning ? (
)} <ActivityIndicator color="#fff" />
</View> ) : (
<View style={styles.addButton}> <Text style={[styles.scanButtonText, { color: "#fff" }]}>
<Text style={styles.addButtonText}>+</Text> Scan Network
</View> </Text>
</TouchableOpacity> )}
); </TouchableOpacity>
};
return ( <Text style={[styles.infoText, { color: subText }]}>
<View style={styles.container}> Note: This requires the server to have nmap installed and may take
<View style={styles.header}> several minutes.
<Text style={styles.headerText}>Network Scan</Text> </Text>
<Text style={styles.headerSubtext}>
Discover devices on your local network
</Text>
</View>
<TouchableOpacity {devices.length > 0 && (
style={[styles.scanButton, scanning && styles.scanButtonDisabled]} <View style={styles.resultsContainer}>
onPress={handleScan} <Text style={[styles.resultsHeader, { color: textColor }]}>
disabled={scanning} Discovered Devices ({devices.length})
> </Text>
{scanning ? ( <FlatList
<ActivityIndicator color="#fff" /> data={devices}
) : ( renderItem={renderDevice}
<Text style={styles.scanButtonText}>Scan Network</Text> keyExtractor={(item, index) =>
)} `${item.ip || item.ip_address || index}-${index}`
</TouchableOpacity> }
contentContainerStyle={styles.list}
/>
</View>
)}
<Text style={styles.infoText}> {devices.length === 0 && !scanning && (
Note: This requires the server to have nmap installed and may take several minutes. <View style={styles.emptyContainer}>
</Text> <Text style={styles.emptyText}>
Tap "Scan Network" to discover devices
{devices.length > 0 && ( </Text>
<View style={styles.resultsContainer}> </View>
<Text style={styles.resultsHeader}>Discovered Devices ({devices.length})</Text> )}
<FlatList </View>
data={devices} );
renderItem={renderDevice}
keyExtractor={(item, index) => `${item.ip || item.ip_address || index}-${index}`}
contentContainerStyle={styles.list}
/>
</View>
)}
{devices.length === 0 && !scanning && (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>Tap "Scan Network" to discover devices</Text>
</View>
)}
<Modal
visible={showAddModal}
animationType="slide"
transparent
onRequestClose={() => setShowAddModal(false)}
>
<View>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Add Device</Text>
<TouchableOpacity onPress={() => setShowAddModal(false)}>
<Text style={styles.closeButton}></Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.modalBody}>
<View style={styles.formGroup}>
<Text style={styles.label}>Device Name *</Text>
<TextInput
style={styles.input}
value={formData.name}
onChangeText={(text) => setFormData({ ...formData, name: text })}
placeholder="Device name"
/>
{selectedDevice?.mac_vendor && (
<Text style={styles.hint}>
Vendor: {selectedDevice.mac_vendor}
</Text>
)}
</View>
<View style={styles.formGroup}>
<Text style={styles.label}>MAC Address *</Text>
<TextInput
style={styles.input}
value={formData.mac}
onChangeText={(text) => setFormData({ ...formData, mac: text })}
placeholder="00:11:22:33:44:55"
autoCapitalize="characters"
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.label}>IP Address *</Text>
<TextInput
style={styles.input}
value={formData.ip}
onChangeText={(text) => setFormData({ ...formData, ip: text })}
placeholder="192.168.1.100"
keyboardType="numeric"
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.label}>Netmask</Text>
<TextInput
style={styles.input}
value={formData.netmask}
onChangeText={(text) => setFormData({ ...formData, netmask: text })}
placeholder="255.255.255.0"
keyboardType="numeric"
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.label}>Broadcast Address</Text>
<TextInput
style={styles.input}
value={formData.broadcast}
onChangeText={(text) => setFormData({ ...formData, broadcast: text })}
placeholder="192.168.1.255"
keyboardType="numeric"
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.label}>Port</Text>
<TextInput
style={styles.input}
value={formData.port}
onChangeText={(text) => setFormData({ ...formData, port: text })}
placeholder="9"
keyboardType="numeric"
/>
</View>
<TouchableOpacity
style={styles.saveButton}
onPress={handleSaveDevice}
>
<Text style={styles.saveButtonText}>Add Device</Text>
</TouchableOpacity>
</ScrollView>
</View>
</View>
</Modal>
</View>
);
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: '#f5f5f5', backgroundColor: "#f5f5f5",
padding: 20, padding: 20,
}, },
header: { header: {
marginBottom: 20, marginBottom: 20,
}, },
headerText: { headerText: {
fontSize: 24, fontSize: 24,
fontWeight: 'bold', fontWeight: "bold",
color: '#333', color: "#333",
marginBottom: 5, marginBottom: 5,
}, },
headerSubtext: { headerSubtext: {
fontSize: 14, fontSize: 14,
color: '#666', color: "#666",
}, },
scanButton: { scanButton: {
backgroundColor: '#007AFF', backgroundColor: "#007AFF",
borderRadius: 12, borderRadius: 12,
padding: 16, padding: 16,
alignItems: 'center', alignItems: "center",
marginBottom: 15, marginBottom: 15,
}, },
scanButtonDisabled: { scanButtonText: {
backgroundColor: '#ccc', color: "#fff",
}, fontSize: 16,
scanButtonText: { fontWeight: "bold",
color: '#fff', },
fontSize: 16, infoText: {
fontWeight: 'bold', fontSize: 12,
}, color: "#999",
infoText: { textAlign: "center",
fontSize: 12, marginBottom: 20,
color: '#999', },
textAlign: 'center', resultsContainer: {
marginBottom: 20, flex: 1,
}, },
resultsContainer: { resultsHeader: {
flex: 1, fontSize: 18,
}, fontWeight: "bold",
resultsHeader: { color: "#333",
fontSize: 18, marginBottom: 15,
fontWeight: 'bold', },
color: '#333', list: {
marginBottom: 15, gap: 10,
}, },
list: { deviceCard: {
gap: 10, backgroundColor: "rgba(255, 255, 255, 0.8)",
}, borderRadius: 16,
deviceCard: { padding: 15,
backgroundColor: 'rgba(255, 255, 255, 0.8)', flexDirection: "row",
borderRadius: 16, justifyContent: "space-between",
padding: 15, alignItems: "center",
flexDirection: 'row', shadowColor: "#000",
justifyContent: 'space-between', shadowOffset: { width: 0, height: 2 },
alignItems: 'center', shadowOpacity: 0.1,
shadowColor: '#000', shadowRadius: 8,
shadowOffset: { width: 0, height: 2 }, elevation: 3,
shadowOpacity: 0.1, },
shadowRadius: 8, deviceInfo: {
elevation: 3, flex: 1,
}, },
deviceInfo: { deviceName: {
flex: 1, fontSize: 16,
}, fontWeight: "bold",
deviceName: { color: "#333",
fontSize: 16, marginBottom: 4,
fontWeight: 'bold', },
color: '#333', deviceDetail: {
marginBottom: 4, fontSize: 14,
}, color: "#666",
deviceDetail: { marginBottom: 2,
fontSize: 14, },
color: '#666', vendorText: {
marginBottom: 2, fontSize: 12,
}, color: "#999",
vendorText: { fontStyle: "italic",
fontSize: 12, },
color: '#999', addButton: {
fontStyle: 'italic', width: 44,
}, height: 44,
addButton: { borderRadius: 22,
width: 44, justifyContent: "center",
height: 44, alignItems: "center",
backgroundColor: 'rgba(76, 175, 80, 0.9)', },
borderRadius: 22, addButtonText: {
justifyContent: 'center', color: "#fff",
alignItems: 'center', fontSize: 24,
}, fontWeight: "bold",
addButtonText: { },
color: '#fff', emptyContainer: {
fontSize: 24, flex: 1,
fontWeight: 'bold', justifyContent: "center",
}, alignItems: "center",
emptyContainer: { },
flex: 1, emptyText: {
justifyContent: 'center', fontSize: 16,
alignItems: 'center', color: "#999",
}, textAlign: "center",
emptyText: { },
fontSize: 16, modalBlur: {
color: '#999', flex: 1,
textAlign: 'center', justifyContent: "flex-end",
}, },
modalBlur: { modalContent: {
flex: 1, backgroundColor: "rgba(255, 255, 255, 0.95)",
justifyContent: 'flex-end', borderTopLeftRadius: 24,
}, borderTopRightRadius: 24,
modalContent: { maxHeight: "90%",
backgroundColor: 'rgba(255, 255, 255, 0.95)', },
borderTopLeftRadius: 24, modalHeader: {
borderTopRightRadius: 24, flexDirection: "row",
maxHeight: '90%', justifyContent: "space-between",
}, alignItems: "center",
modalHeader: { padding: 20,
flexDirection: 'row', borderBottomWidth: 1,
justifyContent: 'space-between', borderBottomColor: "rgba(0, 0, 0, 0.1)",
alignItems: 'center', },
padding: 20, modalTitle: {
borderBottomWidth: 1, fontSize: 20,
borderBottomColor: 'rgba(0, 0, 0, 0.1)', fontWeight: "bold",
}, color: "#333",
modalTitle: { },
fontSize: 20, closeButton: {
fontWeight: 'bold', fontSize: 24,
color: '#333', color: "#999",
}, padding: 8,
closeButton: { },
fontSize: 24, modalBody: {
color: '#999', padding: 20,
padding: 8, },
}, formGroup: {
modalBody: { marginBottom: 16,
padding: 20, },
}, label: {
formGroup: { fontSize: 14,
marginBottom: 16, fontWeight: "600",
}, color: "#333",
label: { marginBottom: 8,
fontSize: 14, },
fontWeight: '600', input: {
color: '#333', backgroundColor: "rgba(255, 255, 255, 0.8)",
marginBottom: 8, borderWidth: 1,
}, borderColor: "rgba(0, 0, 0, 0.1)",
input: { borderRadius: 12,
backgroundColor: 'rgba(255, 255, 255, 0.8)', padding: 14,
borderWidth: 1, fontSize: 16,
borderColor: 'rgba(0, 0, 0, 0.1)', },
borderRadius: 12, hint: {
padding: 14, fontSize: 12,
fontSize: 16, color: "#666",
}, marginTop: 4,
hint: { },
fontSize: 12, saveButton: {
color: '#666', backgroundColor: "#4CAF50",
marginTop: 4, borderRadius: 12,
}, padding: 16,
saveButton: { alignItems: "center",
backgroundColor: '#4CAF50', marginTop: 10,
borderRadius: 12, },
padding: 16, saveButtonText: {
alignItems: 'center', color: "#fff",
marginTop: 10, fontSize: 16,
}, fontWeight: "bold",
saveButtonText: { },
color: '#fff',
fontSize: 16,
fontWeight: 'bold',
},
}); });

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 265 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -0,0 +1,37 @@
{
"fill" : {
"automatic-gradient" : "extended-srgb:0.00000,0.53333,1.00000,1.00000"
},
"groups" : [
{
"layers" : [
{
"glass" : true,
"image-name" : "gopher.svg",
"name" : "gopher",
"position" : {
"scale" : 0.45,
"translation-in-points" : [
-6.017952794793246,
-0.4600234584925147
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

35
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3", "@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
"burnt": "^0.13.0",
"expo": "~54.0.30", "expo": "~54.0.30",
"expo-constants": "~18.0.12", "expo-constants": "~18.0.12",
"expo-font": "~14.0.10", "expo-font": "~14.0.10",
@@ -4677,6 +4678,40 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/burnt": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/burnt/-/burnt-0.13.0.tgz",
"integrity": "sha512-LjlQa7CLkGWUdz08YUIaGCJ8BLXib31/ztKqowgwqd7UH283A/kmdCj+1PYAQwDQEMPNmvSUfFHrjXbcwZibFQ==",
"license": "MIT",
"dependencies": {
"sf-symbols-typescript": "^1.0.0",
"sonner": "^2.0.1"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/burnt/node_modules/sf-symbols-typescript": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/sf-symbols-typescript/-/sf-symbols-typescript-1.0.0.tgz",
"integrity": "sha512-DkS7q3nN68dEMb4E18HFPDAvyrjDZK9YAQQF2QxeFu9gp2xRDXFMF8qLJ1EmQ/qeEGQmop4lmMM1WtYJTIcCMw==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/burnt/node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",

View File

@@ -17,6 +17,7 @@
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3", "@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
"burnt": "^0.13.0",
"expo": "~54.0.30", "expo": "~54.0.30",
"expo-constants": "~18.0.12", "expo-constants": "~18.0.12",
"expo-font": "~14.0.10", "expo-font": "~14.0.10",

View File

@@ -5,10 +5,11 @@ import { AuthResponse, User } from '../types';
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
serverAddress: string | null;
token: string | null; token: string | null;
isAuthenticated: boolean; isAuthenticated: boolean;
isLoading: boolean; isLoading: boolean;
login: (identity: string, password: string, isSuperuser?: boolean) => Promise<void>; login: (serverAddress: string, identity: string, password: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
} }
@@ -16,6 +17,7 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [serverAddress, setServerAddress] = useState<string | null>(null);
const [token, setToken] = useState<string | null>(null); const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -27,12 +29,18 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
try { try {
const storedToken = await AsyncStorage.getItem('auth_token'); const storedToken = await AsyncStorage.getItem('auth_token');
const storedUser = await AsyncStorage.getItem('auth_user'); const storedUser = await AsyncStorage.getItem('auth_user');
const storedServerAddress = await AsyncStorage.getItem('auth_server_address');
if (storedToken && storedUser) { if (storedToken && storedUser) {
setToken(storedToken); setToken(storedToken);
api.setToken(storedToken); api.setToken(storedToken);
setUser(JSON.parse(storedUser)); setUser(JSON.parse(storedUser));
} }
if (storedServerAddress) {
setServerAddress(storedServerAddress);
api.setAddress(storedServerAddress + '/api');
}
} catch (error) { } catch (error) {
console.error('Failed to load auth', error); console.error('Failed to load auth', error);
} finally { } finally {
@@ -40,15 +48,17 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
} }
}; };
const login = async (identity: string, password: string, isSuperuser = false) => { const login = async (serverAddress: string, identity: string, password: string) => {
try { try {
const response: AuthResponse = await api.authenticate(identity, password, isSuperuser); const response: AuthResponse = await api.authenticate(serverAddress, identity, password);
await AsyncStorage.setItem('auth_token', response.token); await AsyncStorage.setItem('auth_token', response.token);
await AsyncStorage.setItem('auth_user', JSON.stringify(response.record)); await AsyncStorage.setItem('auth_user', JSON.stringify(response.record));
await AsyncStorage.setItem('auth_server_address', serverAddress);
setToken(response.token); setToken(response.token);
setUser(response.record); setUser(response.record);
setServerAddress(serverAddress);
} catch (error) { } catch (error) {
throw error; throw error;
} }
@@ -60,8 +70,10 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
await AsyncStorage.removeItem('auth_user'); await AsyncStorage.removeItem('auth_user');
api.clearToken(); api.clearToken();
api.clearAddress();
setToken(null); setToken(null);
setUser(null); setUser(null);
setServerAddress(null);
} catch (error) { } catch (error) {
console.error('Failed to logout', error); console.error('Failed to logout', error);
} }
@@ -70,6 +82,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
return ( return (
<AuthContext.Provider <AuthContext.Provider
value={{ value={{
serverAddress,
user, user,
token, token,
isAuthenticated: !!token && !!user, isAuthenticated: !!token && !!user,

View File

@@ -1,245 +1,256 @@
import { Device, AuthResponse, NetworkScanResult } from '../types'; import { Device, AuthResponse, NetworkScanResult } from "../types";
const API_BASE_URL = 'https://wol.f6knight.duckdns.org/api';
class UpSnapAPI { class UpSnapAPI {
private token: string | null = null; private token: string | null = null;
private address: string | null = null;
setToken(token: string) { setToken(token: string) {
this.token = token; this.token = token;
} }
getToken(): string | null { getToken(): string | null {
return this.token; return this.token;
} }
clearToken() { clearToken() {
this.token = null; this.token = null;
} }
private getHeaders(): HeadersInit { setAddress(address: string) {
const headers: HeadersInit = { this.address = address;
'Content-Type': 'application/json', }
};
if (this.token) { getAddress(): string | null {
headers['Authorization'] = `Bearer ${this.token}`; return this.address;
} }
return headers; clearAddress() {
} this.address = null;
}
async authenticate(identity: string, password: string, isSuperuser = false): Promise<AuthResponse> { private getHeaders(): HeadersInit {
const endpoint = isSuperuser const headers: HeadersInit = {
? `${API_BASE_URL}/collections/_superusers/auth-with-password` "Content-Type": "application/json",
: `${API_BASE_URL}/collections/users/auth-with-password`; };
const response = await fetch(endpoint, { if (this.token) {
method: 'POST', headers["Authorization"] = `Bearer ${this.token}`;
headers: this.getHeaders(), }
body: JSON.stringify({ identity, password }),
});
if (!response.ok) { return headers;
const error = await response.json(); }
throw new Error(error.message || 'Authentication failed');
}
const data: AuthResponse = await response.json(); async authenticate(
this.token = data.token; serverAddress: string,
return data; identity: string,
} password: string
): Promise<AuthResponse> {
this.address = serverAddress + "/api";
async getDevices(page = 1, perPage = 30): Promise<Device[]> { const response = await fetch(
const response = await fetch( `${this.address}/collections/users/auth-with-password`,
`${API_BASE_URL}/collections/devices/records?page=${page}&perPage=${perPage}`, {
{ method: "POST",
headers: this.getHeaders(), headers: this.getHeaders(),
} body: JSON.stringify({ identity, password }),
); }
);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch devices'); const response = await fetch(
} `${this.address}/collections/_superusers/auth-with-password`,
{
method: "POST",
headers: this.getHeaders(),
body: JSON.stringify({ identity, password }),
}
);
const data = await response.json(); if (!response.ok) {
return data.items; const error = await response.json();
} throw new Error(error.message || "Authentication failed");
}
async getDevice(id: string): Promise<Device> { const data: AuthResponse = await response.json();
const response = await fetch( this.token = data.token;
`${API_BASE_URL}/collections/devices/records/${id}`, return data;
{ }
headers: this.getHeaders(),
}
);
if (!response.ok) { const data: AuthResponse = await response.json();
throw new Error('Failed to fetch device'); this.token = data.token;
} return data;
}
return response.json(); async getDevices(page = 1, perPage = 100): Promise<Device[]> {
} const response = await fetch(
`${this.address}/collections/devices/records?page=${page}&perPage=${perPage}`,
{
headers: this.getHeaders(),
}
);
async createDevice(device: Partial<Device>): Promise<Device> { if (!response.ok) {
const response = await fetch( throw new Error("Failed to fetch devices");
`${API_BASE_URL}/collections/devices/records`, }
{
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(device),
}
);
if (!response.ok) { const data = await response.json();
const error = await response.json(); return data.items;
throw new Error(error.message || 'Failed to create device'); }
}
return response.json(); async getDevice(id: string): Promise<Device> {
} const response = await fetch(
`${this.address}/collections/devices/records/${id}`,
{
headers: this.getHeaders(),
}
);
async updateDevice(id: string, device: Partial<Device>): Promise<Device> { if (!response.ok) {
const response = await fetch( throw new Error("Failed to fetch device");
`${API_BASE_URL}/collections/devices/records/${id}`, }
{
method: 'PATCH',
headers: this.getHeaders(),
body: JSON.stringify(device),
}
);
if (!response.ok) { return response.json();
const error = await response.json(); }
throw new Error(error.message || 'Failed to update device');
}
return response.json(); async createDevice(device: Partial<Device>): Promise<Device> {
} const response = await fetch(
`${this.address}/collections/devices/records`,
{
method: "POST",
headers: this.getHeaders(),
body: JSON.stringify(device),
}
);
async deleteDevice(id: string): Promise<void> { if (!response.ok) {
const response = await fetch( const error = await response.json();
`${API_BASE_URL}/collections/devices/records/${id}`, throw new Error(error.message || "Failed to create device");
{ }
method: 'DELETE',
headers: this.getHeaders(),
}
);
if (!response.ok) { return response.json();
throw new Error('Failed to delete device'); }
}
}
async wakeDevice(id: string): Promise<void> { async updateDevice(id: string, device: Partial<Device>): Promise<Device> {
const response = await fetch( const response = await fetch(
`${API_BASE_URL}/upsnap/wake/${id}`, `${this.address}/collections/devices/records/${id}`,
{ {
headers: this.getHeaders(), method: "PATCH",
} headers: this.getHeaders(),
); body: JSON.stringify(device),
}
);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to wake device'); const error = await response.json();
} throw new Error(error.message || "Failed to update device");
} }
async wakeGroup(id: string): Promise<void> { return response.json();
const response = await fetch( }
`${API_BASE_URL}/upsnap/wakegroup/${id}`,
{
headers: this.getHeaders(),
}
);
if (!response.ok) { async deleteDevice(id: string): Promise<void> {
throw new Error('Failed to wake group'); const response = await fetch(
} `${this.address}/collections/devices/records/${id}`,
} {
method: "DELETE",
headers: this.getHeaders(),
}
);
async sleepDevice(id: string): Promise<void> { if (!response.ok) {
const response = await fetch( throw new Error("Failed to delete device");
`${API_BASE_URL}/upsnap/sleep/${id}`, }
{ }
headers: this.getHeaders(),
}
);
if (!response.ok) { async wakeDevice(id: string): Promise<void> {
throw new Error('Failed to sleep device'); const response = await fetch(`${this.address}/upsnap/wake/${id}`, {
} headers: this.getHeaders(),
} });
async rebootDevice(id: string): Promise<void> { if (!response.ok) {
const response = await fetch( throw new Error("Failed to wake device");
`${API_BASE_URL}/upsnap/reboot/${id}`, }
{ }
headers: this.getHeaders(),
}
);
if (!response.ok) { async wakeGroup(id: string): Promise<void> {
throw new Error('Failed to reboot device'); const response = await fetch(`${this.address}/upsnap/wakegroup/${id}`, {
} headers: this.getHeaders(),
} });
async shutdownDevice(id: string): Promise<void> { if (!response.ok) {
const response = await fetch( throw new Error("Failed to wake group");
`${API_BASE_URL}/upsnap/shutdown/${id}`, }
{ }
headers: this.getHeaders(),
}
);
if (!response.ok) { async sleepDevice(id: string): Promise<void> {
throw new Error('Failed to shutdown device'); const response = await fetch(`${this.address}/upsnap/sleep/${id}`, {
} headers: this.getHeaders(),
} });
async scanNetwork(): Promise<NetworkScanResult[]> { if (!response.ok) {
const response = await fetch( throw new Error("Failed to sleep device");
`${API_BASE_URL}/upsnap/scan`, }
{ }
headers: this.getHeaders(),
}
);
if (!response.ok) { async rebootDevice(id: string): Promise<void> {
throw new Error('Failed to scan network'); const response = await fetch(`${this.address}/upsnap/reboot/${id}`, {
} headers: this.getHeaders(),
});
const data = await response.json(); if (!response.ok) {
console.log('Raw scan data:', data); throw new Error("Failed to reboot device");
}
}
if (data.devices && Array.isArray(data.devices)) { async shutdownDevice(id: string): Promise<void> {
return data.devices.map((item: any) => ({ const response = await fetch(`${this.address}/upsnap/shutdown/${id}`, {
name: item.name || item.hostname || 'Unknown', headers: this.getHeaders(),
ip: item.ip || item.ip_address || '', });
mac: item.mac || item.mac_address || '',
mac_vendor: item.mac_vendor || 'Unknown',
}));
}
if (Array.isArray(data)) { if (!response.ok) {
return data.map((item: any) => ({ throw new Error("Failed to shutdown device");
name: item.name || item.hostname || 'Unknown', }
ip: item.ip || item.ip_address || '', }
mac: item.mac || item.mac_address || '',
mac_vendor: item.mac_vendor || 'Unknown',
}));
}
if (data.items && Array.isArray(data.items)) { async scanNetwork(): Promise<NetworkScanResult[]> {
return data.items.map((item: any) => ({ const response = await fetch(`${this.address}/upsnap/scan`, {
name: item.name || item.hostname || 'Unknown', headers: this.getHeaders(),
ip: item.ip || item.ip_address || '', });
mac: item.mac || item.mac_address || '',
mac_vendor: item.mac_vendor || 'Unknown',
}));
}
return []; if (!response.ok) {
} throw new Error("Failed to scan network");
}
const data = await response.json();
if (data.devices && Array.isArray(data.devices)) {
return data.devices.map((item: any) => ({
name: item.name || item.hostname || "Unknown",
ip: item.ip || item.ip_address || "",
mac: item.mac || item.mac_address || "",
mac_vendor: item.mac_vendor || "Unknown",
}));
}
if (Array.isArray(data)) {
return data.map((item: any) => ({
name: item.name || item.hostname || "Unknown",
ip: item.ip || item.ip_address || "",
mac: item.mac || item.mac_address || "",
mac_vendor: item.mac_vendor || "Unknown",
}));
}
if (data.items && Array.isArray(data.items)) {
return data.items.map((item: any) => ({
name: item.name || item.hostname || "Unknown",
ip: item.ip || item.ip_address || "",
mac: item.mac || item.mac_address || "",
mac_vendor: item.mac_vendor || "Unknown",
}));
}
return [];
}
} }
export default new UpSnapAPI(); export default new UpSnapAPI();

View File

@@ -1,50 +1,50 @@
export interface Device { export interface Device {
id: string; id: string;
collectionId: string; collectionId: string;
collectionName: string; collectionName: string;
name: string; name: string;
mac: string; mac: string;
ip: string; ip: string;
netmask: string; netmask: string;
broadcast: string; broadcast: string;
secureOnPassword: string; secureOnPassword: string;
port: number; port: number;
groups: string[]; groups: string[];
status: string; status: string;
created: string; created: string;
updated: string; updated: string;
} }
export interface AuthResponse { export interface AuthResponse {
token: string; token: string;
record: User; record: User;
} }
export interface User { export interface User {
id: string; id: string;
collectionId: string; collectionId: string;
collectionName: string; collectionName: string;
username: string; username: string;
verified: boolean; verified: boolean;
emailVisibility: boolean; emailVisibility: boolean;
email: string; email: string;
created: string; created: string;
updated: string; updated: string;
name: string; name: string;
avatar: number; avatar: number;
} }
export interface DeviceGroup { export interface DeviceGroup {
id: string; id: string;
name: string; name: string;
} }
export interface NetworkScanResult { export interface NetworkScanResult {
name?: string; name?: string;
hostname?: string; hostname?: string;
ip?: string; ip?: string;
ip_address?: string; ip_address?: string;
mac?: string; mac?: string;
mac_address?: string; mac_address?: string;
mac_vendor?: string; mac_vendor?: string;
} }