Merge branch 't3/upgrade-expo-sdk-58' into t3/pocketbase-token-refresh

Keep automatic token refresh and clear the device widget on logout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
joshuafhigginsandCursor committed 2026-10-22 18:43:25 -04:00 Verified
commit df24ec4a6a
43 files changed
+852 -1070

No files matched your search

+2
View File
@@ -3,6 +3,7 @@ import { AppState } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import api, { isAuthCollection, isRevokedAuthError } from '../services/api';
import { getJwtExpiryMs } from '../services/jwtExpiry';
import { syncDevicesToWidget } from '../services/widgetSync';
import { AuthResponse, User } from '../types';
interface AuthContextType {
@@ -311,6 +312,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
View File
@@ -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);
}
+181
View File
@@ -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;