feat: add App Intents and device control widget
This commit is contained in:
43 files changed
+852
-1070
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import api from '../services/api';
|
||||
import { syncDevicesToWidget } from '../services/widgetSync';
|
||||
import { AuthResponse, User } from '../types';
|
||||
|
||||
interface AuthContextType {
|
||||
@@ -103,6 +104,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
const logout = async () => {
|
||||
try {
|
||||
await clearSession();
|
||||
syncDevicesToWidget([]);
|
||||
} catch (error) {
|
||||
console.error('Failed to logout', error);
|
||||
}
|
||||
|
||||
+86
-35
@@ -1,64 +1,115 @@
|
||||
import { ExtensionStorage } from '@bacons/apple-targets';
|
||||
import { setEntityCatalogAsync, isAvailable as appIntentsAvailable } from 'expo-app-intents';
|
||||
import type { AppIntentEntity } from 'expo-app-intents';
|
||||
import { Platform } from 'react-native';
|
||||
import { Device } from '../types';
|
||||
import DeviceControlWidget, { type WidgetDevice } from '../widgets/DeviceControl';
|
||||
|
||||
// Create a storage object with the App Group
|
||||
const storage =
|
||||
Platform.OS === 'ios'
|
||||
? new ExtensionStorage('group.abunchofknowitalls.remotewol-upsnap')
|
||||
: null;
|
||||
const DEVICE_KIND = 'device';
|
||||
|
||||
interface WidgetDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
mac: string;
|
||||
ip: string;
|
||||
canSleep: boolean;
|
||||
canShutdown: boolean;
|
||||
status: string;
|
||||
let pendingCatalog: AppIntentEntity[] | null = null;
|
||||
let catalogWrite: Promise<void> | null = null;
|
||||
|
||||
function toWidgetDevice(device: Device): WidgetDevice | null {
|
||||
const id = device.id.trim();
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name: device.name.trim() || device.mac.trim() || 'Device',
|
||||
mac: device.mac,
|
||||
ip: device.ip,
|
||||
canSleep: device.sol_enabled,
|
||||
canShutdown: device.shutdown_cmd !== '',
|
||||
status: device.status || 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
function toCatalogEntity(device: WidgetDevice): AppIntentEntity {
|
||||
const synonyms = [device.mac, device.ip].filter(value => {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 && trimmed !== device.name;
|
||||
});
|
||||
|
||||
return {
|
||||
id: device.id,
|
||||
title: device.name,
|
||||
subtitle: device.mac.trim() || undefined,
|
||||
synonyms,
|
||||
};
|
||||
}
|
||||
|
||||
function publishDeviceCatalog(entities: AppIntentEntity[]): void {
|
||||
if (!appIntentsAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingCatalog = entities;
|
||||
if (catalogWrite) {
|
||||
return;
|
||||
}
|
||||
|
||||
catalogWrite = drainDeviceCatalog();
|
||||
}
|
||||
|
||||
async function drainDeviceCatalog(): Promise<void> {
|
||||
while (pendingCatalog) {
|
||||
const next = pendingCatalog;
|
||||
pendingCatalog = null;
|
||||
try {
|
||||
await setEntityCatalogAsync(DEVICE_KIND, next);
|
||||
} catch (error) {
|
||||
console.warn('Could not publish the device catalog.', error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
catalogWrite = null;
|
||||
if (pendingCatalog) {
|
||||
catalogWrite = drainDeviceCatalog();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Syncs device data to the iOS widget and Siri device catalog.
|
||||
* Called after the device list loads.
|
||||
*/
|
||||
export function syncDevicesToWidget(devices: Device[]): void {
|
||||
if (!storage) {
|
||||
if (Platform.OS !== 'ios') {
|
||||
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,
|
||||
canSleep: device.sol_enabled,
|
||||
canShutdown: device.shutdown_cmd !== "",
|
||||
status: device.status || 'unknown',
|
||||
}));
|
||||
const widgetDevices = devices
|
||||
.map(toWidgetDevice)
|
||||
.filter((device): device is WidgetDevice => device !== null);
|
||||
|
||||
const devicesJson = JSON.stringify(widgetDevices);
|
||||
storage.set('devices', devicesJson);
|
||||
|
||||
// Refresh widgets to pick up the new data
|
||||
ExtensionStorage.reloadWidget();
|
||||
DeviceControlWidget.setConfigurationParameterEnum(
|
||||
'device',
|
||||
widgetDevices.map(device => ({
|
||||
name: device.name,
|
||||
value: device.id,
|
||||
subtitle: device.mac.trim() || undefined,
|
||||
}))
|
||||
);
|
||||
DeviceControlWidget.updateSnapshot({ devices: widgetDevices });
|
||||
publishDeviceCatalog(widgetDevices.map(toCatalogEntity));
|
||||
} catch (error) {
|
||||
console.error('Failed to sync devices to widget:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes all widget timelines to pick up new data.
|
||||
* Refreshes widget timelines so they pick up the latest snapshot.
|
||||
*/
|
||||
export function refreshWidgets(): void {
|
||||
if (!storage) {
|
||||
if (Platform.OS !== 'ios') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ExtensionStorage.reloadWidget();
|
||||
DeviceControlWidget.reload();
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh widgets:', error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { HStack, Image, Link, Spacer, Text, VStack } from '@expo/ui/swift-ui';
|
||||
import {
|
||||
background,
|
||||
containerBackground,
|
||||
font,
|
||||
foregroundStyle,
|
||||
frame,
|
||||
lineLimit,
|
||||
padding,
|
||||
shapes,
|
||||
} from '@expo/ui/swift-ui/modifiers';
|
||||
import { createWidget, type WidgetEnvironment } from 'expo-widgets';
|
||||
import type { SFSymbol } from 'sf-symbols-typescript';
|
||||
|
||||
export type WidgetDevice = {
|
||||
id: string;
|
||||
name: string;
|
||||
mac: string;
|
||||
ip: string;
|
||||
canSleep: boolean;
|
||||
canShutdown: boolean;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type DeviceControlProps = {
|
||||
devices: WidgetDevice[];
|
||||
};
|
||||
|
||||
type DeviceControlConfiguration = {
|
||||
device?: string | null;
|
||||
};
|
||||
|
||||
type DeviceAction = {
|
||||
id: 'wake' | 'sleep' | 'restart' | 'shutdown';
|
||||
symbol: SFSymbol;
|
||||
color: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
const DeviceControlWidgetView = (
|
||||
props: DeviceControlProps,
|
||||
environment: WidgetEnvironment<DeviceControlConfiguration>
|
||||
) => {
|
||||
'widget';
|
||||
const rootModifiers = [
|
||||
containerBackground({ type: 'hierarchical', style: 'quaternary' }, 'widget'),
|
||||
padding({ top: 4, bottom: 4 }),
|
||||
];
|
||||
const devices = Array.isArray(props.devices) ? props.devices : [];
|
||||
const selectedId = environment.configuration?.device;
|
||||
const device = devices.find(candidate => candidate.id === selectedId);
|
||||
|
||||
if (!device || !selectedId || selectedId === 'none') {
|
||||
return (
|
||||
<VStack
|
||||
spacing={8}
|
||||
modifiers={[...rootModifiers, frame({ maxWidth: 10000, maxHeight: 10000 })]}
|
||||
>
|
||||
<Image systemName="desktopcomputer" size={32} color="#8E8E93" />
|
||||
<Text
|
||||
modifiers={[
|
||||
font({ size: 14, weight: 'medium' }),
|
||||
foregroundStyle({ type: 'hierarchical', style: 'secondary' }),
|
||||
]}
|
||||
>
|
||||
Select a Device
|
||||
</Text>
|
||||
<Text
|
||||
modifiers={[
|
||||
font({ size: 11 }),
|
||||
foregroundStyle({ type: 'hierarchical', style: 'tertiary' }),
|
||||
]}
|
||||
>
|
||||
Long press to configure
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const isOnline = device.status === 'online';
|
||||
const isOffline = device.status === 'offline';
|
||||
const actions: DeviceAction[] = [
|
||||
{ id: 'wake', symbol: 'bolt.fill', color: '#4CAF50', enabled: isOffline },
|
||||
{
|
||||
id: 'sleep',
|
||||
symbol: 'moon.fill',
|
||||
color: '#FF9800',
|
||||
enabled: isOnline && Boolean(device.canSleep),
|
||||
},
|
||||
{
|
||||
id: 'restart',
|
||||
symbol: 'arrow.clockwise',
|
||||
color: '#2196F3',
|
||||
enabled: isOnline && Boolean(device.canShutdown),
|
||||
},
|
||||
{
|
||||
id: 'shutdown',
|
||||
symbol: 'power',
|
||||
color: '#F44336',
|
||||
enabled: isOnline && Boolean(device.canShutdown),
|
||||
},
|
||||
];
|
||||
|
||||
const buttons = actions.map(action => {
|
||||
const tint = action.enabled ? action.color : '#8E8E93';
|
||||
const button = (
|
||||
<VStack
|
||||
alignment="center"
|
||||
modifiers={[
|
||||
frame({ maxWidth: 10000, minHeight: 36 }),
|
||||
background(`${tint}26`, shapes.roundedRectangle({ cornerRadius: 8 })),
|
||||
]}
|
||||
>
|
||||
<Image systemName={action.symbol} size={16} color={tint} />
|
||||
</VStack>
|
||||
);
|
||||
|
||||
if (!action.enabled) {
|
||||
return <VStack key={action.id}>{button}</VStack>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={action.id}
|
||||
destination={`remotewol-upsnap://action/${action.id}/${encodeURIComponent(device.id)}`}
|
||||
>
|
||||
{button}
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
const header = (
|
||||
<VStack alignment="leading" spacing={2}>
|
||||
<Text modifiers={[font({ size: 15, weight: 'semibold' }), lineLimit(1)]}>{device.name}</Text>
|
||||
<Text
|
||||
modifiers={[
|
||||
font({ size: 11 }),
|
||||
foregroundStyle({ type: 'hierarchical', style: 'secondary' }),
|
||||
lineLimit(1),
|
||||
]}
|
||||
>
|
||||
{device.mac}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
if (environment.widgetFamily === 'systemSmall') {
|
||||
return (
|
||||
<VStack alignment="leading" spacing={8} modifiers={rootModifiers}>
|
||||
{header}
|
||||
<Spacer minLength={4} />
|
||||
<VStack spacing={6}>
|
||||
<HStack spacing={6}>
|
||||
{buttons[0]}
|
||||
{buttons[1]}
|
||||
</HStack>
|
||||
<HStack spacing={6}>
|
||||
{buttons[2]}
|
||||
{buttons[3]}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack alignment="leading" spacing={8} modifiers={rootModifiers}>
|
||||
{header}
|
||||
<Spacer minLength={4} />
|
||||
<HStack spacing={8}>{buttons}</HStack>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
const DeviceControlWidget = createWidget<DeviceControlProps, DeviceControlConfiguration>(
|
||||
'DeviceControl',
|
||||
DeviceControlWidgetView,
|
||||
{ devices: [] }
|
||||
);
|
||||
|
||||
export default DeviceControlWidget;
|
||||
Reference in new issue
Block a user