feat: app intents, siri & widgets + fix for auth (#5)
Reviewed-on: #5 Co-authored-by: Joshua Higgins <joshuafhiggins@gmail.com>
This commit was merged in pull request #5.
This commit is contained in:
1 parent
838a25b990
commit
7327ae9c68
45 files changed
+1390
-1205
No files matched your search
+248
-38
@@ -1,6 +1,9 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { AppState } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import api from '../services/api';
|
||||
import api, { isAuthCollection, isRevokedAuthError } from '../services/api';
|
||||
import { getJwtExpiryMs } from '../services/jwtExpiry';
|
||||
import { syncDevicesToWidget } from '../services/widgetSync';
|
||||
import { AuthResponse, User } from '../types';
|
||||
|
||||
interface AuthContextType {
|
||||
@@ -16,6 +19,22 @@ interface AuthContextType {
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
const AUTH_COLLECTION_KEY = 'auth_collection';
|
||||
const REFRESH_BEFORE_EXPIRY_MS = 60_000;
|
||||
const MAX_REFRESH_DELAY_MS = 2_147_483_647;
|
||||
const REFRESH_RETRY_MS = 30_000;
|
||||
const REFRESH_BACKOFF_MS = 15_000;
|
||||
|
||||
function msUntilRefresh(token: string): number | null {
|
||||
const expiresAt = getJwtExpiryMs(token);
|
||||
if (expiresAt == null) return null;
|
||||
return expiresAt - Date.now() - REFRESH_BEFORE_EXPIRY_MS;
|
||||
}
|
||||
|
||||
function collectionFromUser(user: User | null): 'users' | '_superusers' | null {
|
||||
return isAuthCollection(user?.collectionName) ? user.collectionName : null;
|
||||
}
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [serverAddress, setServerAddress] = useState<string | null>(null);
|
||||
@@ -23,44 +42,192 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [canCreate, setCanCreate] = useState<boolean | null>(null);
|
||||
|
||||
const clearSession = useCallback(async () => {
|
||||
await AsyncStorage.multiRemove(['auth_token', 'auth_user', 'auth_can_create']);
|
||||
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const refreshingRef = useRef(false);
|
||||
const sessionWriteRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const refreshSessionRef = useRef<() => Promise<void>>(async () => {});
|
||||
const armRefreshTimerRef = useRef<(token: string, overdueDelay?: number) => void>(() => {});
|
||||
|
||||
const enqueueSessionWrite = useCallback((task: () => Promise<void>) => {
|
||||
const run = sessionWriteRef.current.then(task, task);
|
||||
sessionWriteRef.current = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return run;
|
||||
}, []);
|
||||
|
||||
const clearRefreshTimer = useCallback(() => {
|
||||
if (refreshTimerRef.current != null) {
|
||||
clearTimeout(refreshTimerRef.current);
|
||||
refreshTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearSession = useCallback(async () => {
|
||||
clearRefreshTimer();
|
||||
api.clearToken();
|
||||
api.clearCanCreate();
|
||||
api.clearAuthCollection();
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setCanCreate(null);
|
||||
|
||||
await enqueueSessionWrite(async () => {
|
||||
await AsyncStorage.multiRemove([
|
||||
'auth_token',
|
||||
'auth_user',
|
||||
'auth_can_create',
|
||||
AUTH_COLLECTION_KEY,
|
||||
]);
|
||||
});
|
||||
}, [clearRefreshTimer, enqueueSessionWrite]);
|
||||
|
||||
const armRefreshTimer = useCallback((nextToken: string, overdueDelay = 0) => {
|
||||
clearRefreshTimer();
|
||||
const untilRefresh = msUntilRefresh(nextToken);
|
||||
if (untilRefresh == null) return;
|
||||
|
||||
const delay = untilRefresh <= 0 ? overdueDelay : Math.min(untilRefresh, MAX_REFRESH_DELAY_MS);
|
||||
const rescheduleOnly = untilRefresh > MAX_REFRESH_DELAY_MS;
|
||||
|
||||
refreshTimerRef.current = setTimeout(() => {
|
||||
refreshTimerRef.current = null;
|
||||
const latest = api.getToken();
|
||||
if (!latest) return;
|
||||
if (rescheduleOnly) {
|
||||
armRefreshTimerRef.current(latest);
|
||||
return;
|
||||
}
|
||||
void refreshSessionRef.current();
|
||||
}, delay);
|
||||
}, [clearRefreshTimer]);
|
||||
|
||||
const refreshSession = useCallback(async () => {
|
||||
if (refreshingRef.current || !api.getToken()) return;
|
||||
|
||||
refreshingRef.current = true;
|
||||
try {
|
||||
await api.refreshAuth();
|
||||
} catch {
|
||||
// The failure listener either keeps the session and retries, or signs out.
|
||||
} finally {
|
||||
refreshingRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRefreshFailure = useCallback(async (error: unknown) => {
|
||||
if (isRevokedAuthError(error)) {
|
||||
await clearSession();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!api.getToken()) return;
|
||||
|
||||
clearRefreshTimer();
|
||||
refreshTimerRef.current = setTimeout(() => {
|
||||
refreshTimerRef.current = null;
|
||||
void refreshSessionRef.current();
|
||||
}, REFRESH_RETRY_MS);
|
||||
}, [clearRefreshTimer, clearSession]);
|
||||
|
||||
const persistRefreshedAuth = useCallback(async (auth: AuthResponse) => {
|
||||
await enqueueSessionWrite(async () => {
|
||||
if (api.getToken() !== auth.token) return;
|
||||
|
||||
const collection = api.getAuthCollection();
|
||||
const pairs: [string, string][] = [
|
||||
['auth_token', auth.token],
|
||||
['auth_user', JSON.stringify(auth.record)],
|
||||
];
|
||||
if (collection) {
|
||||
pairs.push([AUTH_COLLECTION_KEY, collection]);
|
||||
}
|
||||
if (collection === '_superusers') {
|
||||
pairs.push(['auth_can_create', 'true']);
|
||||
}
|
||||
|
||||
await AsyncStorage.multiSet(pairs);
|
||||
if (api.getToken() !== auth.token) return;
|
||||
|
||||
setToken(auth.token);
|
||||
setUser(auth.record);
|
||||
if (collection === '_superusers') {
|
||||
setCanCreate(true);
|
||||
}
|
||||
armRefreshTimerRef.current(auth.token, REFRESH_BACKOFF_MS);
|
||||
});
|
||||
}, [enqueueSessionWrite]);
|
||||
|
||||
const watchToken = useCallback((nextToken: string) => {
|
||||
const untilRefresh = msUntilRefresh(nextToken);
|
||||
if (untilRefresh != null && untilRefresh <= 0) {
|
||||
void refreshSessionRef.current();
|
||||
return;
|
||||
}
|
||||
armRefreshTimerRef.current(nextToken);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshSessionRef.current = refreshSession;
|
||||
armRefreshTimerRef.current = armRefreshTimer;
|
||||
}, [armRefreshTimer, refreshSession]);
|
||||
|
||||
useEffect(() => {
|
||||
api.setUnauthorizedHandler(clearSession);
|
||||
api.setAuthUpdateListener(persistRefreshedAuth);
|
||||
api.setAuthFailureListener(handleRefreshFailure);
|
||||
|
||||
return () => {
|
||||
api.setUnauthorizedHandler(null);
|
||||
api.setAuthUpdateListener(null);
|
||||
api.setAuthFailureListener(null);
|
||||
clearRefreshTimer();
|
||||
};
|
||||
}, [clearSession]);
|
||||
}, [clearRefreshTimer, clearSession, handleRefreshFailure, persistRefreshedAuth]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void AsyncStorage.multiGet([
|
||||
'auth_token',
|
||||
'auth_user',
|
||||
'auth_server_address',
|
||||
'auth_can_create',
|
||||
]).then(
|
||||
([[, storedToken], [, storedUser], [, storedServerAddress], [, storedCanCreate]]) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const [
|
||||
[, storedToken],
|
||||
[, storedUser],
|
||||
[, storedServerAddress],
|
||||
[, storedCanCreate],
|
||||
[, storedCollection],
|
||||
] = await AsyncStorage.multiGet([
|
||||
'auth_token',
|
||||
'auth_user',
|
||||
'auth_server_address',
|
||||
'auth_can_create',
|
||||
AUTH_COLLECTION_KEY,
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
if (storedCanCreate) {
|
||||
setCanCreate(storedCanCreate === 'true');
|
||||
let restoredUser: User | null = null;
|
||||
if (storedToken && storedUser) {
|
||||
restoredUser = JSON.parse(storedUser) as User;
|
||||
api.setToken(storedToken);
|
||||
setToken(storedToken);
|
||||
setUser(restoredUser);
|
||||
}
|
||||
|
||||
if (storedToken && storedUser) {
|
||||
setToken(storedToken);
|
||||
api.setToken(storedToken);
|
||||
setUser(JSON.parse(storedUser));
|
||||
const collection = isAuthCollection(storedCollection)
|
||||
? storedCollection
|
||||
: collectionFromUser(restoredUser);
|
||||
if (collection) {
|
||||
api.setAuthCollection(collection);
|
||||
}
|
||||
|
||||
if (collection === '_superusers') {
|
||||
api.setCanCreate(true);
|
||||
setCanCreate(true);
|
||||
} else if (storedCanCreate) {
|
||||
const restoredCanCreate = storedCanCreate === 'true';
|
||||
api.setCanCreate(restoredCanCreate);
|
||||
setCanCreate(restoredCanCreate);
|
||||
}
|
||||
|
||||
if (storedServerAddress) {
|
||||
@@ -68,41 +235,84 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
api.setAddress(storedServerAddress + '/api');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
},
|
||||
error => {
|
||||
const untilRefresh = storedToken ? msUntilRefresh(storedToken) : null;
|
||||
const refreshIsDue = !!storedToken && !!restoredUser && untilRefresh != null && untilRefresh <= 0;
|
||||
|
||||
if (refreshIsDue) {
|
||||
try {
|
||||
// Hold the loading gate so an expired token does not flash the device list.
|
||||
await api.refreshAuth();
|
||||
} catch {
|
||||
// Revoked tokens sign out; transient failures keep the session and retry.
|
||||
}
|
||||
} else if (storedToken && restoredUser) {
|
||||
watchToken(storedToken);
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
console.error('Failed to load auth', error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [watchToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener('change', nextState => {
|
||||
if (nextState !== 'active') return;
|
||||
|
||||
const currentToken = api.getToken();
|
||||
if (!currentToken) return;
|
||||
|
||||
const untilRefresh = msUntilRefresh(currentToken);
|
||||
if (untilRefresh == null) return;
|
||||
if (untilRefresh <= 0) {
|
||||
void refreshSessionRef.current();
|
||||
return;
|
||||
}
|
||||
armRefreshTimerRef.current(currentToken);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const login = async (serverAddress: string, identity: string, password: string) => {
|
||||
try {
|
||||
const response: AuthResponse = await api.authenticate(serverAddress, identity, password);
|
||||
|
||||
await AsyncStorage.setItem('auth_token', response.token);
|
||||
await AsyncStorage.setItem('auth_user', JSON.stringify(response.record));
|
||||
await AsyncStorage.setItem('auth_server_address', serverAddress);
|
||||
await AsyncStorage.setItem('auth_can_create', api.getCanCreate() ? 'true' : 'false');
|
||||
|
||||
setToken(response.token);
|
||||
setUser(response.record);
|
||||
setServerAddress(serverAddress);
|
||||
setCanCreate(api.getCanCreate() || false);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
const login = async (nextServerAddress: string, identity: string, password: string) => {
|
||||
const response: AuthResponse = await api.authenticate(nextServerAddress, identity, password);
|
||||
const collection = api.getAuthCollection();
|
||||
|
||||
await enqueueSessionWrite(async () => {
|
||||
const pairs: [string, string][] = [
|
||||
['auth_token', response.token],
|
||||
['auth_user', JSON.stringify(response.record)],
|
||||
['auth_server_address', nextServerAddress],
|
||||
['auth_can_create', api.getCanCreate() ? 'true' : 'false'],
|
||||
];
|
||||
if (collection) {
|
||||
pairs.push([AUTH_COLLECTION_KEY, collection]);
|
||||
}
|
||||
await AsyncStorage.multiSet(pairs);
|
||||
});
|
||||
|
||||
setToken(response.token);
|
||||
setUser(response.record);
|
||||
setServerAddress(nextServerAddress);
|
||||
setCanCreate(api.getCanCreate() || false);
|
||||
watchToken(response.token);
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await clearSession();
|
||||
syncDevicesToWidget([]);
|
||||
} catch (error) {
|
||||
console.error('Failed to logout', error);
|
||||
}
|
||||
|
||||
+251
-97
@@ -5,12 +5,47 @@ import {
|
||||
PermissionResponse,
|
||||
} from '../types';
|
||||
|
||||
export type AuthCollection = 'users' | '_superusers';
|
||||
|
||||
export type ApiError = Error & {
|
||||
status?: number;
|
||||
isAuthError?: boolean;
|
||||
};
|
||||
|
||||
export type AuthUpdateListener = (auth: AuthResponse) => void | Promise<void>;
|
||||
export type AuthFailureListener = (error: unknown) => void | Promise<void>;
|
||||
|
||||
const AUTH_ERROR_MESSAGE = /authorization token|invalid token|unauthorized|not authenticated/i;
|
||||
|
||||
export function isAuthCollection(value: unknown): value is AuthCollection {
|
||||
return value === 'users' || value === '_superusers';
|
||||
}
|
||||
|
||||
export function isRevokedAuthError(error: unknown): boolean {
|
||||
if (typeof error !== 'object' || error === null) return false;
|
||||
const status = (error as { status?: unknown }).status;
|
||||
return status === 401 || status === 403 || status === 404;
|
||||
}
|
||||
|
||||
function isSessionChanged(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
(error as { sessionChanged?: boolean }).sessionChanged === true
|
||||
);
|
||||
}
|
||||
|
||||
class UpSnapAPI {
|
||||
private token: string | null = null;
|
||||
private address: string | null = null;
|
||||
private canCreate: boolean | null = null;
|
||||
private authCollection: AuthCollection | null = null;
|
||||
private authEpoch = 0;
|
||||
private unauthorizedHandler: (() => Promise<void>) | null = null;
|
||||
private authUpdateListener: AuthUpdateListener | null = null;
|
||||
private authFailureListener: AuthFailureListener | null = null;
|
||||
private isHandlingUnauthorized = false;
|
||||
private refreshInFlight: Promise<AuthResponse> | null = null;
|
||||
|
||||
setToken(token: string) {
|
||||
this.token = token;
|
||||
@@ -22,6 +57,7 @@ class UpSnapAPI {
|
||||
|
||||
clearToken() {
|
||||
this.token = null;
|
||||
this.authEpoch += 1;
|
||||
}
|
||||
|
||||
setAddress(address: string) {
|
||||
@@ -48,10 +84,30 @@ class UpSnapAPI {
|
||||
this.canCreate = null;
|
||||
}
|
||||
|
||||
setAuthCollection(collection: AuthCollection | null) {
|
||||
this.authCollection = collection;
|
||||
}
|
||||
|
||||
getAuthCollection(): AuthCollection | null {
|
||||
return this.authCollection;
|
||||
}
|
||||
|
||||
clearAuthCollection() {
|
||||
this.authCollection = null;
|
||||
}
|
||||
|
||||
setUnauthorizedHandler(handler: (() => Promise<void>) | null) {
|
||||
this.unauthorizedHandler = handler;
|
||||
}
|
||||
|
||||
setAuthUpdateListener(listener: AuthUpdateListener | null) {
|
||||
this.authUpdateListener = listener;
|
||||
}
|
||||
|
||||
setAuthFailureListener(listener: AuthFailureListener | null) {
|
||||
this.authFailureListener = listener;
|
||||
}
|
||||
|
||||
private async handleUnauthorized() {
|
||||
if (!this.unauthorizedHandler || this.isHandlingUnauthorized) {
|
||||
return;
|
||||
@@ -66,7 +122,7 @@ class UpSnapAPI {
|
||||
}
|
||||
}
|
||||
|
||||
private async throwApiError(response: Response, fallbackMessage: string): Promise<never> {
|
||||
private async errorFromResponse(response: Response, fallbackMessage: string): Promise<ApiError> {
|
||||
let message = fallbackMessage;
|
||||
|
||||
try {
|
||||
@@ -76,23 +132,26 @@ class UpSnapAPI {
|
||||
// Ignore JSON parsing errors and use the fallback message.
|
||||
}
|
||||
|
||||
const isAuthError =
|
||||
const apiError = new Error(message) as ApiError;
|
||||
apiError.status = response.status;
|
||||
apiError.isAuthError =
|
||||
response.status === 401 ||
|
||||
response.status === 403 ||
|
||||
/authorization token|invalid token|unauthorized|not authenticated/i.test(message);
|
||||
AUTH_ERROR_MESSAGE.test(message);
|
||||
return apiError;
|
||||
}
|
||||
|
||||
if (isAuthError) {
|
||||
private async throwApiError(
|
||||
response: Response,
|
||||
fallbackMessage: string,
|
||||
skipUnauthorizedHandler = false,
|
||||
): Promise<never> {
|
||||
const error = await this.errorFromResponse(response, fallbackMessage);
|
||||
|
||||
if (error.isAuthError && !skipUnauthorizedHandler) {
|
||||
await this.handleUnauthorized();
|
||||
}
|
||||
|
||||
const error = new Error(message) as Error & {
|
||||
status?: number;
|
||||
isAuthError?: boolean;
|
||||
};
|
||||
|
||||
error.status = response.status;
|
||||
error.isAuthError = isAuthError;
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -108,11 +167,166 @@ class UpSnapAPI {
|
||||
return headers;
|
||||
}
|
||||
|
||||
private sessionChanged(): ApiError {
|
||||
const error = new Error('Auth session changed') as ApiError & { sessionChanged: true };
|
||||
error.sessionChanged = true;
|
||||
error.isAuthError = true;
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* One refresh is shared by the expiry timer and every request that receives a 401.
|
||||
* PocketBase returns a new auth token and record for the same collection.
|
||||
*/
|
||||
async refreshAuth(): Promise<AuthResponse> {
|
||||
if (!this.refreshInFlight) {
|
||||
this.refreshInFlight = this.performRefresh().finally(() => {
|
||||
this.refreshInFlight = null;
|
||||
});
|
||||
}
|
||||
|
||||
return this.refreshInFlight;
|
||||
}
|
||||
|
||||
private async performRefresh(): Promise<AuthResponse> {
|
||||
const epoch = this.authEpoch;
|
||||
const tokenAtStart = this.token;
|
||||
|
||||
try {
|
||||
if (!tokenAtStart || !this.address) {
|
||||
throw Object.assign(new Error('Not authenticated'), {
|
||||
status: 401,
|
||||
isAuthError: true,
|
||||
}) as ApiError;
|
||||
}
|
||||
|
||||
const collections: AuthCollection[] = this.authCollection
|
||||
? [this.authCollection]
|
||||
: ['users', '_superusers'];
|
||||
let lastError: ApiError | null = null;
|
||||
|
||||
for (let index = 0; index < collections.length; index += 1) {
|
||||
const collection = collections[index];
|
||||
const response = await fetch(
|
||||
`${this.address}/collections/${collection}/auth-refresh`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
},
|
||||
);
|
||||
|
||||
if (this.authEpoch !== epoch || this.token !== tokenAtStart) {
|
||||
throw this.sessionChanged();
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as AuthResponse;
|
||||
if (this.authEpoch !== epoch || this.token !== tokenAtStart) {
|
||||
throw this.sessionChanged();
|
||||
}
|
||||
if (!data?.token) {
|
||||
throw Object.assign(new Error('Auth refresh failed'), {
|
||||
status: 401,
|
||||
isAuthError: true,
|
||||
}) as ApiError;
|
||||
}
|
||||
|
||||
this.authCollection = isAuthCollection(data.record?.collectionName)
|
||||
? data.record.collectionName
|
||||
: collection;
|
||||
this.token = data.token;
|
||||
if (this.authCollection === '_superusers') {
|
||||
this.canCreate = true;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.authUpdateListener?.(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to persist refreshed auth', error);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
lastError = await this.errorFromResponse(response, 'Auth refresh failed');
|
||||
const tryNextCollection =
|
||||
!this.authCollection && index < collections.length - 1 && response.status === 403;
|
||||
if (!tryNextCollection) {
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
|
||||
throw (
|
||||
lastError ??
|
||||
(Object.assign(new Error('Auth refresh failed'), {
|
||||
status: 401,
|
||||
isAuthError: true,
|
||||
}) as ApiError)
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isSessionChanged(error)) {
|
||||
try {
|
||||
await this.authFailureListener?.(error);
|
||||
} catch (listenerError) {
|
||||
console.error('Failed to handle auth refresh failure', listenerError);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On 401, refresh once and retry the original request.
|
||||
* A second 401, or a refresh the server rejects, ends the session.
|
||||
*/
|
||||
private async request(url: string, fallbackMessage: string, init?: RequestInit): Promise<Response> {
|
||||
const send = () =>
|
||||
fetch(url, {
|
||||
...init,
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
const response = await send();
|
||||
if (response.ok) {
|
||||
return response;
|
||||
}
|
||||
|
||||
if (response.status === 401 && this.token) {
|
||||
try {
|
||||
await this.refreshAuth();
|
||||
} catch (error) {
|
||||
if (isSessionChanged(error)) {
|
||||
throw error;
|
||||
}
|
||||
if (!isRevokedAuthError(error)) {
|
||||
throw error;
|
||||
}
|
||||
await this.handleUnauthorized();
|
||||
return this.throwApiError(response, fallbackMessage, true);
|
||||
}
|
||||
|
||||
const retried = await send();
|
||||
if (!retried.ok) {
|
||||
if (retried.status === 401) {
|
||||
await this.handleUnauthorized();
|
||||
return this.throwApiError(retried, fallbackMessage, true);
|
||||
}
|
||||
return this.throwApiError(retried, fallbackMessage);
|
||||
}
|
||||
return retried;
|
||||
}
|
||||
|
||||
return this.throwApiError(response, fallbackMessage);
|
||||
}
|
||||
|
||||
async authenticate(
|
||||
serverAddress: string,
|
||||
identity: string,
|
||||
password: string
|
||||
): Promise<AuthResponse> {
|
||||
this.authEpoch += 1;
|
||||
this.token = null;
|
||||
this.authCollection = null;
|
||||
this.address = serverAddress + '/api';
|
||||
|
||||
const response = await fetch(
|
||||
@@ -125,7 +339,7 @@ class UpSnapAPI {
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const response = await fetch(
|
||||
const superuserResponse = await fetch(
|
||||
`${this.address}/collections/_superusers/auth-with-password`,
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -134,18 +348,20 @@ class UpSnapAPI {
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
await this.throwApiError(response, 'Authentication failed');
|
||||
if (!superuserResponse.ok) {
|
||||
await this.throwApiError(superuserResponse, 'Authentication failed');
|
||||
}
|
||||
|
||||
const data: AuthResponse = await response.json();
|
||||
const data: AuthResponse = await superuserResponse.json();
|
||||
this.token = data.token;
|
||||
this.authCollection = '_superusers';
|
||||
this.canCreate = true;
|
||||
return data;
|
||||
}
|
||||
|
||||
const data: AuthResponse = await response.json();
|
||||
this.token = data.token;
|
||||
this.authCollection = 'users';
|
||||
|
||||
const userID = data.record.id;
|
||||
const userPermissionResponse = await fetch(
|
||||
@@ -165,142 +381,80 @@ class UpSnapAPI {
|
||||
}
|
||||
|
||||
async getDevices(page = 1, perPage = 100): Promise<Device[]> {
|
||||
const response = await fetch(
|
||||
const response = await this.request(
|
||||
`${this.address}/collections/devices/records?page=${page}&perPage=${perPage}`,
|
||||
{
|
||||
headers: this.getHeaders(),
|
||||
}
|
||||
'Failed to fetch devices',
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
await this.throwApiError(response, 'Failed to fetch devices');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.items;
|
||||
}
|
||||
|
||||
async getDevice(id: string): Promise<Device> {
|
||||
const response = await fetch(
|
||||
const response = await this.request(
|
||||
`${this.address}/collections/devices/records/${id}`,
|
||||
{
|
||||
headers: this.getHeaders(),
|
||||
}
|
||||
'Failed to fetch device',
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
await this.throwApiError(response, 'Failed to fetch device');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async createDevice(device: Partial<Device>): Promise<Device> {
|
||||
const response = await fetch(
|
||||
const response = await this.request(
|
||||
`${this.address}/collections/devices/records`,
|
||||
'Failed to create device',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(device),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
await this.throwApiError(response, 'Failed to create device');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async updateDevice(id: string, device: Partial<Device>): Promise<Device> {
|
||||
const response = await fetch(
|
||||
const response = await this.request(
|
||||
`${this.address}/collections/devices/records/${id}`,
|
||||
'Failed to update device',
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(device),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
await this.throwApiError(response, 'Failed to update device');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async deleteDevice(id: string): Promise<void> {
|
||||
const response = await fetch(
|
||||
await this.request(
|
||||
`${this.address}/collections/devices/records/${id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: this.getHeaders(),
|
||||
}
|
||||
'Failed to delete device',
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
await this.throwApiError(response, 'Failed to delete device');
|
||||
}
|
||||
}
|
||||
|
||||
async wakeDevice(id: string): Promise<void> {
|
||||
const response = await fetch(`${this.address}/upsnap/wake/${id}`, {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await this.throwApiError(response, 'Failed to wake device');
|
||||
}
|
||||
await this.request(`${this.address}/upsnap/wake/${id}`, 'Failed to wake device');
|
||||
}
|
||||
|
||||
async wakeGroup(id: string): Promise<void> {
|
||||
const response = await fetch(`${this.address}/upsnap/wakegroup/${id}`, {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await this.throwApiError(response, 'Failed to wake group');
|
||||
}
|
||||
await this.request(`${this.address}/upsnap/wakegroup/${id}`, 'Failed to wake group');
|
||||
}
|
||||
|
||||
async sleepDevice(id: string): Promise<void> {
|
||||
const response = await fetch(`${this.address}/upsnap/sleep/${id}`, {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await this.throwApiError(response, 'Failed to sleep device');
|
||||
}
|
||||
await this.request(`${this.address}/upsnap/sleep/${id}`, 'Failed to sleep device');
|
||||
}
|
||||
|
||||
async rebootDevice(id: string): Promise<void> {
|
||||
const response = await fetch(`${this.address}/upsnap/reboot/${id}`, {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await this.throwApiError(response, 'Failed to reboot device');
|
||||
}
|
||||
await this.request(`${this.address}/upsnap/reboot/${id}`, 'Failed to reboot device');
|
||||
}
|
||||
|
||||
async shutdownDevice(id: string): Promise<void> {
|
||||
const response = await fetch(`${this.address}/upsnap/shutdown/${id}`, {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await this.throwApiError(response, 'Failed to shutdown device');
|
||||
}
|
||||
await this.request(`${this.address}/upsnap/shutdown/${id}`, 'Failed to shutdown device');
|
||||
}
|
||||
|
||||
async scanNetwork(): Promise<NetworkScanResult[]> {
|
||||
const response = await fetch(`${this.address}/upsnap/scan`, {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await this.throwApiError(response, 'Failed to scan network');
|
||||
}
|
||||
const response = await this.request(`${this.address}/upsnap/scan`, 'Failed to scan network');
|
||||
|
||||
const data = await response.json();
|
||||
if (data.devices && Array.isArray(data.devices)) {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
|
||||
function decodeBase64Url(segment: string): string {
|
||||
const normalized = segment.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
|
||||
const bytes: number[] = [];
|
||||
|
||||
for (let index = 0; index < padded.length; index += 4) {
|
||||
const a = BASE64_ALPHABET.indexOf(padded[index]);
|
||||
const b = BASE64_ALPHABET.indexOf(padded[index + 1]);
|
||||
const c = padded[index + 2] === '=' ? 0 : BASE64_ALPHABET.indexOf(padded[index + 2]);
|
||||
const d = padded[index + 3] === '=' ? 0 : BASE64_ALPHABET.indexOf(padded[index + 3]);
|
||||
if (a < 0 || b < 0 || c < 0 || d < 0) {
|
||||
throw new Error('Invalid base64url');
|
||||
}
|
||||
|
||||
const chunk = (a << 18) | (b << 12) | (c << 6) | d;
|
||||
bytes.push((chunk >> 16) & 255);
|
||||
if (padded[index + 2] !== '=') bytes.push((chunk >> 8) & 255);
|
||||
if (padded[index + 3] !== '=') bytes.push(chunk & 255);
|
||||
}
|
||||
|
||||
return String.fromCharCode(...bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the JWT `exp` claim in milliseconds.
|
||||
* Scheduling only; the signature is not verified.
|
||||
*/
|
||||
export function getJwtExpiryMs(token: string): number | null {
|
||||
const segment = token.split('.')[1];
|
||||
if (!segment) return null;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(decodeBase64Url(segment)) as { exp?: unknown };
|
||||
if (typeof payload.exp !== 'number' || !Number.isFinite(payload.exp)) return null;
|
||||
return payload.exp * 1000;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+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