Apps reach every service through Maestro on one port; direct service addressing is no longer a supported deployment, and Maestro has no per-service hostname or host-port route. Replace the maestro/direct modes and the serviceRef lookup with one configured origin and the fixed paths /api/alarm/{action,auth,ws}, defaulting dev builds to 127.0.0.1:8080 through adb reverse. The old mode-specific body wrapping and the direct-mode socket branch go with it.
170 lines
6.0 KiB
TypeScript
170 lines
6.0 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { AppState, type AppStateStatus } from 'react-native';
|
|
import {
|
|
AlarmSocket,
|
|
DEFAULT_RECONNECT_POLICY,
|
|
type AlarmFrame,
|
|
type SocketState,
|
|
} from '../services/alarmSocket';
|
|
import { alarmSocketUrl, type ResolvedConnection } from '../services/connection';
|
|
import { alarmStatus as fetchAlarmStatus, isAlertGone, type AlarmStatus } from '../services/alarmApi';
|
|
|
|
export type LiveDelivery = 'socket' | 'polling' | 'unavailable';
|
|
|
|
export interface UseAlarmSocketOptions {
|
|
resolved: ResolvedConnection | null;
|
|
alertId: string | null;
|
|
subscribeToken: string | null;
|
|
/** Poll cadence while the app is foregrounded (spec: 5s). */
|
|
foregroundPollMs?: number;
|
|
/** Poll cadence while backgrounded (spec: 30s). */
|
|
backgroundPollMs?: number;
|
|
onTrigger: (frame: AlarmFrame) => void;
|
|
onDisarm: (frame: AlarmFrame) => void;
|
|
enabled?: boolean;
|
|
/** This device's ledger id (the service hashes it to address the debt). */
|
|
deviceId?: string | null;
|
|
/** The service answered 404: the alert is destroyed, there is nothing to ring. */
|
|
onGone?: () => void;
|
|
}
|
|
|
|
export interface UseAlarmSocketResult {
|
|
socketState: SocketState;
|
|
delivery: LiveDelivery;
|
|
attempts: number;
|
|
lastStatus: AlarmStatus | null;
|
|
/** Force an immediate status read (used by pull-to-refresh and Settings). */
|
|
refresh: () => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Owns the live-alarm transport for one subscribed alert.
|
|
*
|
|
* The WebSocket is the delivery path: triggers arrive only over
|
|
* `ws(s)://{instance}/api/alarm/ws/alarm`. `delivery: 'unavailable'` means no
|
|
* instance is configured, and the UI must say this device is not listening
|
|
* rather than implying coverage. The status poll below is a display
|
|
* reconciliation only — it never becomes a delivery claim.
|
|
*/
|
|
export function useAlarmSocket({
|
|
resolved,
|
|
alertId,
|
|
subscribeToken,
|
|
foregroundPollMs = 5000,
|
|
backgroundPollMs = 30000,
|
|
onTrigger,
|
|
onDisarm,
|
|
enabled = true,
|
|
deviceId,
|
|
onGone,
|
|
}: UseAlarmSocketOptions): UseAlarmSocketResult {
|
|
const [socketState, setSocketState] = useState<SocketState>('idle');
|
|
const [attempts, setAttempts] = useState(0);
|
|
const [lastStatus, setLastStatus] = useState<AlarmStatus | null>(null);
|
|
const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState);
|
|
|
|
const socketRef = useRef<AlarmSocket | null>(null);
|
|
const triggerRef = useRef(onTrigger);
|
|
const disarmRef = useRef(onDisarm);
|
|
triggerRef.current = onTrigger;
|
|
disarmRef.current = onDisarm;
|
|
|
|
const url = useMemo(
|
|
() => (resolved && alertId && subscribeToken ? alarmSocketUrl(resolved, alertId, subscribeToken) : null),
|
|
[resolved, alertId, subscribeToken],
|
|
);
|
|
|
|
/** Capability, not liveness: the URL exists. Liveness is `socketState`. */
|
|
const delivery: LiveDelivery = url ? 'socket' : 'unavailable';
|
|
|
|
/**
|
|
* A check-in can carry the durable "fired while you were offline" record
|
|
* (409 `ERR_ALARM_TRIGGERED_OFFLINE`, mapped to a success-shaped status by
|
|
* `alarmStatus`). Ack it over the socket when one is open, then let the
|
|
* caller's status effect raise the takeover; a closed socket is fine because
|
|
* the service clears the pending entry on the check-in response itself.
|
|
*/
|
|
const onGoneRef = useRef<UseAlarmSocketOptions['onGone']>(undefined);
|
|
onGoneRef.current = onGone;
|
|
|
|
const applyStatus = useCallback((status: AlarmStatus) => {
|
|
setLastStatus(status);
|
|
if (status.offlineTrigger && status.eventId) {
|
|
socketRef.current?.ack(status.eventId);
|
|
}
|
|
}, []);
|
|
|
|
const refresh = useCallback(async () => {
|
|
if (!alertId) return;
|
|
try {
|
|
applyStatus(await fetchAlarmStatus(alertId, deviceId ?? undefined));
|
|
} catch (e) {
|
|
if (isAlertGone(e)) onGoneRef.current?.();
|
|
// A failed status read is not fatal — the socket is the primary path and
|
|
// the next poll retries.
|
|
}
|
|
}, [alertId, applyStatus, deviceId]);
|
|
|
|
// AppState awareness: while backgrounded the JS socket is unreliable, so the
|
|
// poll cadence relaxes to the background value and the socket is left to the
|
|
// native foreground service (which holds its own connection).
|
|
useEffect(() => {
|
|
const sub = AppState.addEventListener('change', next => setAppState(next));
|
|
return () => sub.remove();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!enabled || !url || !alertId || !subscribeToken) {
|
|
setSocketState('idle');
|
|
setAttempts(0);
|
|
return;
|
|
}
|
|
const socket = new AlarmSocket({
|
|
url,
|
|
policy: DEFAULT_RECONNECT_POLICY,
|
|
onStateChange: (state, nextAttempts) => {
|
|
setSocketState(state);
|
|
setAttempts(nextAttempts);
|
|
},
|
|
onAlarm: frame => {
|
|
if (frame.action === 'trigger') triggerRef.current(frame);
|
|
else disarmRef.current(frame);
|
|
},
|
|
});
|
|
socketRef.current = socket;
|
|
socket.connect();
|
|
return () => {
|
|
socket.close();
|
|
socketRef.current = null;
|
|
};
|
|
}, [enabled, url, alertId, subscribeToken]);
|
|
|
|
// Status reconciliation. It never *delivers* a live trigger (the socket is the
|
|
// alarm path), but it is also the check-in that carries the durable record of
|
|
// an alarm fired while this device was offline — so a missed trigger comes
|
|
// back as a late takeover instead of disappearing.
|
|
useEffect(() => {
|
|
if (!enabled || !alertId) return;
|
|
const interval = appState === 'active' ? foregroundPollMs : backgroundPollMs;
|
|
let cancelled = false;
|
|
const tick = async () => {
|
|
if (cancelled) return;
|
|
try {
|
|
const status = await fetchAlarmStatus(alertId, deviceId ?? undefined);
|
|
if (!cancelled) applyStatus(status);
|
|
} catch (e) {
|
|
if (isAlertGone(e)) onGoneRef.current?.();
|
|
// ignore otherwise — polling must never surface a spinner
|
|
}
|
|
};
|
|
tick();
|
|
const handle = setInterval(tick, interval);
|
|
return () => {
|
|
cancelled = true;
|
|
clearInterval(handle);
|
|
};
|
|
}, [enabled, alertId, appState, foregroundPollMs, backgroundPollMs, applyStatus, deviceId]);
|
|
|
|
return { socketState, delivery, attempts, lastStatus, refresh };
|
|
}
|