feat: complete widget support

This commit is contained in:
2026-01-07 23:51:54 -05:00
Unverified
parent 0967b113be
commit 6ecb85a7d0
36 changed files with 851 additions and 313 deletions

View File

@@ -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"
}
}

View File

@@ -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,7 +275,21 @@ export default function DeviceListScreen() {
</TouchableOpacity>
);
const renderDevice = ({ item }: { item: Device }) => (
const renderDevice = ({ item }: { item: Device }) => {
const isOnline = item.status?.toLowerCase() === 'online';
const isOffline = item.status?.toLowerCase() === 'offline';
const hasActions = isOnline || isOffline;
return (
<View
style={[
styles.deviceCard,
{
backgroundColor: cardBg,
shadowColor: isDark ? 'rgba(0,0,0,0.6)' : '#000',
},
]}
>
<Host>
<ContextMenu activationMethod="longPress">
<ContextMenu.Items>
@@ -285,16 +302,6 @@ export default function DeviceListScreen() {
</SwiftUIButton>
</ContextMenu.Items>
<ContextMenu.Trigger>
<View
style={[
styles.deviceCard,
{
backgroundColor: cardBg,
shadowColor: isDark ? 'rgba(0,0,0,0.6)' : '#000',
},
]}
>
<View>
<View style={styles.deviceHeader}>
<View style={styles.deviceInfo}>
<Text style={[styles.deviceName, { color: textColor }]}>
@@ -330,9 +337,13 @@ export default function DeviceListScreen() {
]}
/>
</View>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
{hasActions && (
<View style={styles.deviceActions}>
{item.status?.toLowerCase() === 'offline' && (
{isOffline && (
<ActionIcon
name="Wake"
symbolName="bolt.circle.fill"
@@ -341,7 +352,7 @@ export default function DeviceListScreen() {
onPress={() => handleWake(item)}
/>
)}
{item.status?.toLowerCase() === 'online' && (
{isOnline && (
<>
<ActionIcon
name="Sleep"
@@ -367,12 +378,10 @@ export default function DeviceListScreen() {
</>
)}
</View>
)}
</View>
</View>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
);
};
if (isLoading) {
return (
@@ -389,9 +398,11 @@ export default function DeviceListScreen() {
renderItem={renderDevice}
keyExtractor={item => item.id}
contentContainerStyle={styles.list}
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
ListFooterComponent={<View style={{ height: 20 }} />}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={[styles.emptyText, { color: subTextColor }]}>

View File

@@ -1,17 +1,17 @@
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 isDark = useColorScheme() === 'dark';
const activityColor = isDark ? '#0A84FF' : '#007AFF';
return (
<TouchableOpacity
onPress={() => router.push("/scan-devices")}
onPress={() => router.push('/scan-devices')}
style={{ paddingHorizontal: 8 }}
>
<Ionicons name="add-circle-outline" size={24} color={activityColor} />
@@ -22,24 +22,24 @@ function DevicesHeader() {
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;
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" }}>
<Text style={{ color: titleColor, fontSize: 17, fontWeight: '600' }}>
{title}
</Text>
);
}
export default function RootLayout() {
const isDark = useColorScheme() === "dark";
const bgColor = isDark ? "#0b0b0d" : "#fff";
const titleColor = isDark ? "#fff" : "#000";
const 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 isIndex = last === '(tabs)' || last === undefined;
return (
<AuthProvider>
@@ -60,7 +60,7 @@ export default function RootLayout() {
headerTintColor: titleColor,
} as any
}
/>{" "}
/>{' '}
<Stack.Screen name="login" options={{ headerShown: false }} />
<Stack.Screen
name="scan-devices"
@@ -68,9 +68,14 @@ export default function RootLayout() {
headerShown: true,
headerTitle: () => <TabsTitle title="Scan Devices" />,
headerStyle: { backgroundColor: bgColor },
headerBackButtonDisplayMode: "minimal",
headerBackButtonDisplayMode: 'minimal',
}}
/>
{/* Deep link action handler - no header, immediately redirects */}
<Stack.Screen
name="action/[action]/[deviceId]"
options={{ headerShown: false, animation: 'none' }}
/>
</Stack>
</AuthProvider>
);

View File

@@ -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 <Redirect href={"/"} />;
}
async function executeAction(
action: DeviceAction,
deviceId: string
): Promise<void> {
// 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<DeviceAction, string> = {
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}.`,
});
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

After

Width:  |  Height:  |  Size: 780 KiB

View File

@@ -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);
}
}

View File

@@ -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?
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 703 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 963 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 703 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -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))
}
}

View File

@@ -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
}

View File

@@ -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"
]
},
});

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.abunchofknowitalls.remotewol-upsnap</string>
</array>
</dict>
</plist>

View File

@@ -6,7 +6,6 @@ struct exportWidgets: WidgetBundle {
var body: some Widget {
// Export widgets here
widget()
widgetControl()
WidgetLiveActivity()
// widgetControl()
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -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"
}
}

View File

@@ -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<SimpleEntry> {
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<ConfigurationAppIntent> {
// // 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<DeviceEntry> {
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)
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)
}
Text("Favorite Emoji:")
Text(entry.configuration.favoriteEmoji)
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
}
// Keep legacy name for backwards compatibility with existing widget installations
typealias widget = DeviceControlWidget
fileprivate static var starEyes: ConfigurationAppIntent {
let intent = ConfigurationAppIntent()
intent.favoriteEmoji = "🤩"
return intent
}
}
// 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")
)
}