[feat](client): address one instance origin instead of connection modes

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.
This commit is contained in:
2026-10-19 02:30:51 +01:00
parent 2df34ab938
commit a29d9b775e
9 changed files with 398 additions and 296 deletions
+44 -14
View File
@@ -7,7 +7,7 @@ import {
type SocketState,
} from '../services/alarmSocket';
import { alarmSocketUrl, type ResolvedConnection } from '../services/connection';
import { alarmStatus as fetchAlarmStatus, type AlarmStatus } from '../services/alarmApi';
import { alarmStatus as fetchAlarmStatus, isAlertGone, type AlarmStatus } from '../services/alarmApi';
export type LiveDelivery = 'socket' | 'polling' | 'unavailable';
@@ -22,6 +22,10 @@ export interface UseAlarmSocketOptions {
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 {
@@ -36,9 +40,11 @@ export interface UseAlarmSocketResult {
/**
* Owns the live-alarm transport for one subscribed alert.
*
* Direct mode gets the WebSocket (the only mode Maestro can pass through);
* maestro mode is polling-only and reports `delivery: 'unavailable'` so the
* Settings/health screen can say so out loud instead of pretending.
* 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,
@@ -49,6 +55,8 @@ export function useAlarmSocket({
onTrigger,
onDisarm,
enabled = true,
deviceId,
onGone,
}: UseAlarmSocketOptions): UseAlarmSocketResult {
const [socketState, setSocketState] = useState<SocketState>('idle');
const [attempts, setAttempts] = useState(0);
@@ -66,17 +74,36 @@ export function useAlarmSocket({
[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 {
setLastStatus(await fetchAlarmStatus(alertId));
} catch {
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]);
}, [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
@@ -112,8 +139,10 @@ export function useAlarmSocket({
};
}, [enabled, url, alertId, subscribeToken]);
// Polling is always on while armed: it is the fallback when the socket is not
// available (maestro mode) and the safety net when it is momentarily down.
// 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;
@@ -121,10 +150,11 @@ export function useAlarmSocket({
const tick = async () => {
if (cancelled) return;
try {
const status = await fetchAlarmStatus(alertId);
if (!cancelled) setLastStatus(status);
} catch {
// ignore — polling must never surface a spinner
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();
@@ -133,7 +163,7 @@ export function useAlarmSocket({
cancelled = true;
clearInterval(handle);
};
}, [enabled, alertId, appState, foregroundPollMs, backgroundPollMs]);
}, [enabled, alertId, appState, foregroundPollMs, backgroundPollMs, applyStatus, deviceId]);
return { socketState, delivery, attempts, lastStatus, refresh };
}