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