[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:
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* Single-origin transport contract.
|
||||||
|
*
|
||||||
|
* The client talks to one instance origin and four paths under it. These are
|
||||||
|
* pure-string assertions: they pin what the client will send, not that a live
|
||||||
|
* Maestro accepts the upgrade — that is the E2E harness's job.
|
||||||
|
*/
|
||||||
|
import { configureApi, get } from '../src/services/apiClient';
|
||||||
|
import { alarmSocketUrl, normalizeInstanceUrl, servicePath } from '../src/services/instanceUrl';
|
||||||
|
import { listSubscriptions, removeSubscription } from '../src/services/alarmApi';
|
||||||
|
import { probe } from '../src/services/connection';
|
||||||
|
|
||||||
|
// The probe module reads settings from storage; the URL helpers above do not,
|
||||||
|
// which is why they live in a storage-free module.
|
||||||
|
jest.mock('@react-native-async-storage/async-storage', () => ({
|
||||||
|
getItem: jest.fn(async () => null),
|
||||||
|
setItem: jest.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('normalizeInstanceUrl', () => {
|
||||||
|
it('accepts a bare host and adds http', () => {
|
||||||
|
expect(normalizeInstanceUrl('localhost:8080')).toBe('http://localhost:8080');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps https and strips trailing slashes', () => {
|
||||||
|
expect(normalizeInstanceUrl(' https://dcntrlized.example/ ')).toBe('https://dcntrlized.example');
|
||||||
|
expect(normalizeInstanceUrl('http://10.0.2.2:8080///')).toBe('http://10.0.2.2:8080');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses non-http schemes, whitespace and empty values', () => {
|
||||||
|
expect(normalizeInstanceUrl('')).toBeNull();
|
||||||
|
expect(normalizeInstanceUrl(' ')).toBeNull();
|
||||||
|
expect(normalizeInstanceUrl('ftp://host')).toBeNull();
|
||||||
|
expect(normalizeInstanceUrl('http://')).toBeNull();
|
||||||
|
expect(normalizeInstanceUrl('http://host with space')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('servicePath', () => {
|
||||||
|
it('prefixes actions and auth with the service name', () => {
|
||||||
|
expect(servicePath('/action/createAlert')).toBe('/api/alarm/action/createAlert');
|
||||||
|
expect(servicePath('/auth/login')).toBe('/api/alarm/auth/login');
|
||||||
|
expect(servicePath('action/getAlert')).toBe('/api/alarm/action/getAlert');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('alarmSocketUrl', () => {
|
||||||
|
it('carries the instance prefix and the frozen query', () => {
|
||||||
|
expect(
|
||||||
|
alarmSocketUrl({ baseUrl: 'https://instance.example' }, 'a1 b', 'tok/en'),
|
||||||
|
).toBe('wss://instance.example/api/alarm/ws/alarm?alertId=a1%20b&token=tok%2Fen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps ws for a plain-http dev instance', () => {
|
||||||
|
expect(alarmSocketUrl({ baseUrl: 'http://127.0.0.1:8080' }, 'a', 't')).toBe(
|
||||||
|
'ws://127.0.0.1:8080/api/alarm/ws/alarm?alertId=a&token=t',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is null when no instance is configured', () => {
|
||||||
|
expect(alarmSocketUrl({ baseUrl: '' }, 'a', 't')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('probe', () => {
|
||||||
|
it('reports offline with no base URL when nothing is configured', async () => {
|
||||||
|
await expect(probe({ instanceUrl: '' })).resolves.toEqual({ baseUrl: '', online: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HTTP paths and bodies', () => {
|
||||||
|
const g = globalThis as unknown as { fetch: typeof fetch };
|
||||||
|
const realFetch = g.fetch;
|
||||||
|
const seen: { url: string; body: string | undefined }[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
seen.length = 0;
|
||||||
|
configureApi({
|
||||||
|
baseUrl: 'https://instance.example',
|
||||||
|
token: 'tok',
|
||||||
|
refreshToken: null,
|
||||||
|
onUnauthorized: () => {},
|
||||||
|
onSessionUpdate: () => {},
|
||||||
|
});
|
||||||
|
g.fetch = (async (url: string, init?: { body?: string }) => {
|
||||||
|
seen.push({ url, body: init?.body });
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ items: [] }),
|
||||||
|
text: async () => '',
|
||||||
|
};
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
g.fetch = realFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends actions through /api/alarm/action with the payload envelope', async () => {
|
||||||
|
await get('/action/getAlert?alertId=a1');
|
||||||
|
expect(seen[0].url).toBe('https://instance.example/api/alarm/action/getAlert?alertId=a1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends revocation with the wire subscription id inside the payload envelope', async () => {
|
||||||
|
// The service parses `subscriptionId` as a uuid; before the type matched the
|
||||||
|
// wire, the client sent `undefined` here and the revoke failed with a 400.
|
||||||
|
await removeSubscription({ alertId: 'a1', subscriptionId: 's1' });
|
||||||
|
expect(seen[0].url).toBe(
|
||||||
|
'https://instance.example/api/alarm/action/removeSubscription',
|
||||||
|
);
|
||||||
|
expect(JSON.parse(String(seen[0].body))).toEqual({
|
||||||
|
payload: { alertId: 'a1', subscriptionId: 's1' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unwraps the listSubscriptions `{items}` envelope', async () => {
|
||||||
|
g.fetch = (async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 's1',
|
||||||
|
deviceLabel: 'Android phone',
|
||||||
|
platform: 'android',
|
||||||
|
enabled: true,
|
||||||
|
created: '2026-10-18T21:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
text: async () => '',
|
||||||
|
})) as unknown as typeof fetch;
|
||||||
|
await expect(listSubscriptions('a1')).resolves.toEqual([
|
||||||
|
{
|
||||||
|
id: 's1',
|
||||||
|
deviceLabel: 'Android phone',
|
||||||
|
platform: 'android',
|
||||||
|
enabled: true,
|
||||||
|
created: '2026-10-18T21:00:00Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+7
-7
@@ -4,17 +4,17 @@
|
|||||||
# Expects metro already running on 127.0.0.1:8089 (start it with `bun start`
|
# Expects metro already running on 127.0.0.1:8089 (start it with `bun start`
|
||||||
# in another terminal). Reverses the adb tunnels so the device reaches the
|
# in another terminal). Reverses the adb tunnels so the device reaches the
|
||||||
# dev machine via 127.0.0.1, then builds, installs and launches the app:
|
# dev machine via 127.0.0.1, then builds, installs and launches the app:
|
||||||
# - 8080 Maestro (dev default)
|
# - 8080 the instance/Maestro origin (dev default; every service path lives
|
||||||
# - 8088 Alarm direct mode (bypasses Maestro) — the service's host port
|
# under it — the client never opens the service's own 8088)
|
||||||
# - 8089 the Metro packager (this client's port)
|
# - 8089 the Metro packager (this client's port)
|
||||||
# Each reverse maps device 127.0.0.1:<port> -> host 127.0.0.1:<port>, so Metro
|
# Each reverse maps device 127.0.0.1:<port> -> host 127.0.0.1:<port>.
|
||||||
# and the service must keep distinct numbers; 8088 belongs to the service.
|
|
||||||
#
|
#
|
||||||
# If your phone is on the same LAN instead, turn "Use adb reverse" off in
|
# The instance address is the one thing the app configures (Settings). Leave it
|
||||||
# Settings and it will fall back to the LAN host baked into src/services/connection.ts.
|
# at the default http://127.0.0.1:8080 for a USB phone with these reverses, or
|
||||||
|
# point it at a LAN/WAN origin; there is no direct-service mode to toggle.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
for port in 8080 8088 8089; do
|
for port in 8080 8089; do
|
||||||
adb reverse tcp:"$port" tcp:"$port" >/dev/null 2>&1 || true
|
adb reverse tcp:"$port" tcp:"$port" >/dev/null 2>&1 || true
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
|||||||
import { KeyboardAvoidingView, Platform, View } from 'react-native';
|
import { KeyboardAvoidingView, Platform, View } from 'react-native';
|
||||||
import { useAuth } from '../state/AuthContext';
|
import { useAuth } from '../state/AuthContext';
|
||||||
import { useConnection } from '../state/ConnectionContext';
|
import { useConnection } from '../state/ConnectionContext';
|
||||||
|
import { navigationRef } from '../navigationRef';
|
||||||
import {
|
import {
|
||||||
AppText,
|
AppText,
|
||||||
Button,
|
Button,
|
||||||
@@ -17,7 +18,7 @@ type Mode = 'signin' | 'register';
|
|||||||
|
|
||||||
export default function LoginScreen() {
|
export default function LoginScreen() {
|
||||||
const { login, register } = useAuth();
|
const { login, register } = useAuth();
|
||||||
const { online, resolved } = useConnection();
|
const { online, resolved, settings } = useConnection();
|
||||||
|
|
||||||
const [mode, setMode] = useState<Mode>('signin');
|
const [mode, setMode] = useState<Mode>('signin');
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
@@ -134,11 +135,25 @@ export default function LoginScreen() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* A release build starts with no instance configured, and sign-in
|
||||||
|
is impossible until one is set — Settings also owns that field,
|
||||||
|
so the login screen must be able to reach it. Without this the
|
||||||
|
app is unconfigurable on a fresh install (Settings was only
|
||||||
|
reachable from Home, which needs a session). */}
|
||||||
|
<Button
|
||||||
|
label={settings.instanceUrl ? `Server · ${settings.instanceUrl}` : 'Choose your server'}
|
||||||
|
variant="ghost"
|
||||||
|
height={44}
|
||||||
|
onPress={() => {
|
||||||
|
if (navigationRef.isReady()) navigationRef.navigate('Settings');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{!online ? (
|
{!online ? (
|
||||||
<AppText style={{ ...typeCaption, color: textSecondary, textAlign: 'center' }}>
|
<AppText style={{ ...typeCaption, color: textSecondary, textAlign: 'center' }}>
|
||||||
{resolved
|
{resolved
|
||||||
? 'The alarm service did not answer. Check Settings → Connection.'
|
? 'Your instance did not answer. Check Settings → Connection.'
|
||||||
: 'Looking for the alarm service…'}
|
: 'Looking for your instance…'}
|
||||||
</AppText>
|
</AppText>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* Alarm WebSocket client.
|
* Alarm WebSocket client.
|
||||||
*
|
*
|
||||||
* URL (frozen contract, direct mode only — Maestro has no service WS
|
* URL (single origin, frozen contract):
|
||||||
* passthrough): `ws://<host>:<port>/ws/alarm?alertId=<id>&token=<subscribeToken>`
|
* `ws(s)://{instance}/api/alarm/ws/alarm?alertId=<id>&token=<subscribeToken>`
|
||||||
*
|
*
|
||||||
* Frames:
|
* Frames:
|
||||||
* server → client {"type":"alarm","alertId","eventId","action":"trigger"|"disarm",
|
* server → client {"type":"alarm","alertId","eventId","action":"trigger"|"disarm",
|
||||||
* "message","serverTime","autoStopSeconds"}
|
* "message","serverTime"}
|
||||||
* client → server {"type":"ack","eventId"}
|
* client → server {"type":"ack","eventId"}
|
||||||
* client → server {"type":"ping"}
|
* client → server {"type":"ping"}
|
||||||
*
|
*
|
||||||
|
* Delivery is durable, not just live: on every successful upgrade the service
|
||||||
|
* replays that caller's unacked trigger frames down the socket (so the native
|
||||||
|
* foreground service rings with JS dead), and a replayed frame is handled
|
||||||
|
* exactly like a live one — the ack frame above is what retires it in the ledger.
|
||||||
|
* `alarmStatus`'s 409 check-in covers the cases a replay cannot reach (socket
|
||||||
|
* down, app reopened cold).
|
||||||
|
*
|
||||||
* The reconnect policy and the frame parser are pure and exported separately so
|
* The reconnect policy and the frame parser are pure and exported separately so
|
||||||
* they can be tested headlessly (no React Native, no real socket) — see
|
* they can be tested headlessly (no React Native, no real socket) — see
|
||||||
* `__tests__/alarmSocket.test.ts`.
|
* `__tests__/alarmSocket.test.ts`.
|
||||||
@@ -112,7 +119,6 @@ export interface AlarmFrame {
|
|||||||
action: 'trigger' | 'disarm';
|
action: 'trigger' | 'disarm';
|
||||||
message?: string;
|
message?: string;
|
||||||
serverTime?: string;
|
serverTime?: string;
|
||||||
autoStopSeconds?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AckFrame {
|
export interface AckFrame {
|
||||||
@@ -151,8 +157,6 @@ export function parseFrame(raw: string): IncomingFrame {
|
|||||||
action: obj.action,
|
action: obj.action,
|
||||||
message: typeof obj.message === 'string' ? obj.message : undefined,
|
message: typeof obj.message === 'string' ? obj.message : undefined,
|
||||||
serverTime: typeof obj.serverTime === 'string' ? obj.serverTime : undefined,
|
serverTime: typeof obj.serverTime === 'string' ? obj.serverTime : undefined,
|
||||||
autoStopSeconds:
|
|
||||||
typeof obj.autoStopSeconds === 'number' ? obj.autoStopSeconds : undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (type === 'ack') {
|
if (type === 'ack') {
|
||||||
|
|||||||
+51
-43
@@ -1,16 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* HTTP client for the Alarm service — house pattern from
|
* HTTP client for the Alarm service — single origin.
|
||||||
* `calendar-client/src/services/apiClient.ts`, retargeted:
|
|
||||||
*
|
*
|
||||||
* - maestro mode addresses `POST {maestro}/api/actions/{serviceRef}/{action}`
|
* Every request goes to the instance the user configured, as a path under it:
|
||||||
* with the `{payload: …}` envelope (Maestro's action surface is HTTP-only),
|
*
|
||||||
* - direct mode addresses the service's own listener and uses the `{payload: …}`
|
* `/action/<name>` → `{instance}/api/alarm/action/<name>` (`{payload: …}`)
|
||||||
* envelope for `/action/*` while `/auth/*` take raw JSON bodies
|
* `/auth/<path>` → `{instance}/api/alarm/auth/<path>` (raw JSON body)
|
||||||
* (`raw = true`),
|
*
|
||||||
* - every call carries `X-Access-Token` when a session exists,
|
* There is no `direct` mode and no `serviceRef` lookup: the service is reached
|
||||||
* - a 401 (or an invalid/expired-token body) triggers ONE single-flight
|
* only through the instance, which is the deployment the platform supports.
|
||||||
* `/auth/refresh` and a single retry; a failed refresh clears the session.
|
*
|
||||||
|
* Every `/action/*` call carries `X-Access-Token` when a session exists; `/auth/*`
|
||||||
|
* calls pass `raw = true` because the service's auth handlers decode the raw
|
||||||
|
* body (root AGENTS.md body contract). A 401 (or an invalid/expired-token body)
|
||||||
|
* triggers ONE single-flight `/auth/refresh` and a single retry; a failed
|
||||||
|
* refresh clears the session.
|
||||||
*/
|
*/
|
||||||
|
import { servicePath } from './instanceUrl';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status: number | null;
|
status: number | null;
|
||||||
/**
|
/**
|
||||||
@@ -27,10 +33,25 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A 409 that is *not* a failure: the check-in reports an alarm that fired while
|
||||||
|
* this client was offline, with the frame in the same body. It is modelled as a
|
||||||
|
* distinct error type so `alarmStatus` can turn it into a success-shaped result
|
||||||
|
* before any generic error path sees it — callers must never render it as a
|
||||||
|
* toast, and the generic handler must not swallow it.
|
||||||
|
*/
|
||||||
|
export class OfflineAlarmError extends ApiError {
|
||||||
|
/** The whole parsed body, so the caller can find the frame without re-fetching. */
|
||||||
|
body: unknown;
|
||||||
|
constructor(status: number, message: string, code: string | null, body: unknown) {
|
||||||
|
super(status, message, code);
|
||||||
|
this.name = 'OfflineAlarmError';
|
||||||
|
this.body = body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiConfig {
|
export interface ApiConfig {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
mode: 'maestro' | 'direct';
|
|
||||||
serviceRef: string | null;
|
|
||||||
token: string | null;
|
token: string | null;
|
||||||
refreshToken: string | null;
|
refreshToken: string | null;
|
||||||
onUnauthorized: () => void;
|
onUnauthorized: () => void;
|
||||||
@@ -40,8 +61,6 @@ export interface ApiConfig {
|
|||||||
|
|
||||||
const config: ApiConfig = {
|
const config: ApiConfig = {
|
||||||
baseUrl: '',
|
baseUrl: '',
|
||||||
mode: 'direct',
|
|
||||||
serviceRef: null,
|
|
||||||
token: null,
|
token: null,
|
||||||
refreshToken: null,
|
refreshToken: null,
|
||||||
onUnauthorized: () => {},
|
onUnauthorized: () => {},
|
||||||
@@ -56,25 +75,8 @@ export function apiConfig(): Readonly<ApiConfig> {
|
|||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitQuery(path: string): [string, string] {
|
|
||||||
const i = path.indexOf('?');
|
|
||||||
return i === -1 ? [path, ''] : [path.slice(0, i), path.slice(i + 1)];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildUrl(path: string): string {
|
function buildUrl(path: string): string {
|
||||||
if (config.mode === 'maestro') {
|
return `${config.baseUrl}${servicePath(path)}`;
|
||||||
if (!config.serviceRef) {
|
|
||||||
throw new ApiError(null, 'Service not found through Maestro');
|
|
||||||
}
|
|
||||||
const [pathOnly, query] = splitQuery(path);
|
|
||||||
const segments = pathOnly.split('/').filter(Boolean);
|
|
||||||
const actionName = segments[segments.length - 1];
|
|
||||||
if (!actionName) throw new ApiError(null, `Invalid path: ${path}`);
|
|
||||||
let url = `${config.baseUrl}/api/actions/${encodeURIComponent(config.serviceRef)}/${actionName}`;
|
|
||||||
if (query) url += `?${query}`;
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
return `${config.baseUrl}${path}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// One in-flight refresh shared by every 401: a burst of requests that all hit
|
// One in-flight refresh shared by every 401: a burst of requests that all hit
|
||||||
@@ -88,12 +90,9 @@ function refreshSession(): Promise<boolean> {
|
|||||||
try {
|
try {
|
||||||
if (!config.refreshToken) return false;
|
if (!config.refreshToken) return false;
|
||||||
const url = buildUrl('/auth/refresh');
|
const url = buildUrl('/auth/refresh');
|
||||||
// Raw {refreshToken} in direct mode; {payload} action form in Maestro
|
// `/auth/*` takes the raw body (root AGENTS.md body contract); the
|
||||||
// mode — the service proxies both (raw flag contract, root AGENTS.md).
|
// instance forwards it unchanged.
|
||||||
const body =
|
const body = JSON.stringify({ refreshToken: config.refreshToken });
|
||||||
config.mode === 'maestro'
|
|
||||||
? JSON.stringify({ payload: { refreshToken: config.refreshToken } })
|
|
||||||
: JSON.stringify({ refreshToken: config.refreshToken });
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -130,13 +129,12 @@ async function perform<T>(
|
|||||||
throw new ApiError(null, 'Server not configured yet');
|
throw new ApiError(null, 'Server not configured yet');
|
||||||
}
|
}
|
||||||
|
|
||||||
// In Maestro mode every call goes through /api/actions/{service}/{action},
|
// `/action/*` expects the `{ payload: … }` envelope; `/auth/*` expects the
|
||||||
// which always expects the { payload: ... } wrapper. The raw flag only
|
// raw body. The instance forwards the body unchanged, so this is the service
|
||||||
// applies when talking to a service directly.
|
// contract end to end.
|
||||||
const maestro = config.mode === 'maestro';
|
|
||||||
let body: string | undefined;
|
let body: string | undefined;
|
||||||
if (payload !== undefined) {
|
if (payload !== undefined) {
|
||||||
body = maestro || !raw ? JSON.stringify({ payload }) : JSON.stringify(payload);
|
body = raw ? JSON.stringify(payload) : JSON.stringify({ payload });
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = buildUrl(path);
|
const url = buildUrl(path);
|
||||||
@@ -169,6 +167,7 @@ async function perform<T>(
|
|||||||
|
|
||||||
let message = `HTTP ${res.status}`;
|
let message = `HTTP ${res.status}`;
|
||||||
let code: string | null = null;
|
let code: string | null = null;
|
||||||
|
let parsedBody: unknown = null;
|
||||||
try {
|
try {
|
||||||
const t = await res.text();
|
const t = await res.text();
|
||||||
if (t) {
|
if (t) {
|
||||||
@@ -179,6 +178,7 @@ async function perform<T>(
|
|||||||
// that need to distinguish cases the message cannot.
|
// that need to distinguish cases the message cannot.
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(t) as { error?: { code?: unknown; message?: unknown } };
|
const parsed = JSON.parse(t) as { error?: { code?: unknown; message?: unknown } };
|
||||||
|
parsedBody = parsed;
|
||||||
const err = parsed?.error;
|
const err = parsed?.error;
|
||||||
if (err && typeof err === 'object') {
|
if (err && typeof err === 'object') {
|
||||||
if (typeof err.code === 'string') code = err.code;
|
if (typeof err.code === 'string') code = err.code;
|
||||||
@@ -189,6 +189,14 @@ async function perform<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
// Durable delivery: `alarmStatus` may answer 409 with this code to say an
|
||||||
|
// alarm fired while the client was offline. It is an alarm signal, not an
|
||||||
|
// error — surface it as its own type *before* the session/error handling
|
||||||
|
// below so no generic path can turn it into a failure.
|
||||||
|
if (res.status === 409 && code === 'ERR_ALARM_TRIGGERED_OFFLINE') {
|
||||||
|
throw new OfflineAlarmError(res.status, message, code, parsedBody);
|
||||||
|
}
|
||||||
|
|
||||||
// A 401, or any body reporting an invalid/expired token, means the session
|
// A 401, or any body reporting an invalid/expired token, means the session
|
||||||
// needs a refresh. This only applies when we actually hold a token — a 401
|
// needs a refresh. This only applies when we actually hold a token — a 401
|
||||||
// on /auth/login just means wrong credentials and must surface the
|
// on /auth/login just means wrong credentials and must surface the
|
||||||
|
|||||||
+65
-217
@@ -1,256 +1,104 @@
|
|||||||
/**
|
/**
|
||||||
* Connection resolution for the Alarm client.
|
* Connection resolution for the Alarm client — **single origin**.
|
||||||
*
|
*
|
||||||
* Mirrors `calendar-client/src/services/connection.ts` (the house pattern):
|
* Product ruling: *"every packet goes through Maestro, and thus, only a single
|
||||||
* two modes — `maestro` (the orchestrator's `/api/containers` list is probed and
|
* port needs to ever be open … Direct service access should never happen."*
|
||||||
* the service is addressed through `serviceRef`) and `direct` (the service's own
|
* This app is not special: it is another service under one instance address.
|
||||||
* `/_manifest` probe). Ports are unique per client/service so several can run at
|
|
||||||
* once. The Alarm service owns dev host port **8088**; this client's Metro runs
|
|
||||||
* on **8089** (lead ruling, 2026-10-18). They must differ: both are reached over
|
|
||||||
* `adb reverse`, which maps device `127.0.0.1:<port>` to the *dev machine's*
|
|
||||||
* `127.0.0.1:<port>`, so a shared number would make the packager and the service
|
|
||||||
* compete for one host socket. Per-client Metro ports: keepnote=8082,
|
|
||||||
* hub=8084, calendar=8086, alarm=8089.
|
|
||||||
*
|
*
|
||||||
* IMPORTANT (lead ruling, 2026-10-18): Maestro has NO service WebSocket
|
* So there is exactly one thing to resolve — the instance origin — and four
|
||||||
* passthrough — its only WS is `/api/ws` for its own container-status stream and
|
* paths under it:
|
||||||
* its action surface is HTTP-only. So live delivery over the socket is a
|
*
|
||||||
* direct-mode capability; in maestro mode the client polls `alarmStatus`.
|
* HTTP `{instance}/api/alarm/action/<action>` (actions, `{payload}` body)
|
||||||
|
* HTTP `{instance}/api/alarm/auth/<path>` (raw JSON body)
|
||||||
|
* WS `ws(s)://{instance}/api/alarm/ws/alarm?alertId=…&token=…`
|
||||||
|
*
|
||||||
|
* The old two-mode design (`maestro` via `/api/containers` + `serviceRef`, and
|
||||||
|
* `direct` via the service's own `/_manifest` and `:8088`) is deleted. Both
|
||||||
|
* existed to reach a service on its own port: the direct listener is no longer
|
||||||
|
* a supported deployment, and the proxied path is now the only path. The
|
||||||
|
* instance address is a *hosting* concept, not a per-service hostname — an
|
||||||
|
* instance exposes one port and every service is a path under it.
|
||||||
|
*
|
||||||
|
* The WebSocket is the delivery path. There is no polling mode that claims to
|
||||||
|
* deliver an alarm; a status poll may exist for display reconciliation only.
|
||||||
*/
|
*/
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { normalizeInstanceUrl } from './instanceUrl';
|
||||||
|
|
||||||
export type ConnectionMode = 'maestro' | 'direct';
|
export { alarmSocketUrl, normalizeInstanceUrl, servicePath } from './instanceUrl';
|
||||||
|
|
||||||
export interface ConnectionSettings {
|
export { SERVICE_NAME } from './instanceUrl';
|
||||||
mode: ConnectionMode;
|
|
||||||
host: string;
|
|
||||||
port: string;
|
|
||||||
useAdbReverse: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SERVICE_NAME = 'alarm';
|
|
||||||
|
|
||||||
export const isRelease = !__DEV__;
|
export const isRelease = !__DEV__;
|
||||||
|
|
||||||
/** Dev machine LAN address (fallback when adb reverse is off). */
|
/**
|
||||||
const LAN_HOST = '192.168.1.232';
|
* Dev default: Maestro on the dev machine. On a USB phone this is reached
|
||||||
|
* through `adb reverse tcp:8080 tcp:8080` (see `scripts/android.sh`); it is a
|
||||||
|
* development convenience, not a second connection mode.
|
||||||
|
*/
|
||||||
|
export const DEV_INSTANCE_URL = 'http://127.0.0.1:8080';
|
||||||
|
|
||||||
export const DEFAULTS: Record<ConnectionMode, { host: string; port: string }> = {
|
export interface ConnectionSettings {
|
||||||
maestro: { host: LAN_HOST, port: '8080' },
|
/** The instance origin, e.g. `https://dcntrlized.example` or `http://127.0.0.1:8080`. */
|
||||||
direct: { host: LAN_HOST, port: '8088' },
|
instanceUrl: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: ConnectionSettings = {
|
export const DEFAULT_SETTINGS: ConnectionSettings = {
|
||||||
mode: 'maestro',
|
instanceUrl: isRelease ? '' : DEV_INSTANCE_URL,
|
||||||
host: '',
|
|
||||||
port: '',
|
|
||||||
useAdbReverse: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const SETTINGS_KEY = 'settings.connection';
|
const SETTINGS_KEY = 'settings.connection.v2';
|
||||||
const LAST_HOST_KEY = 'settings.lastHost';
|
|
||||||
|
|
||||||
export function defaultPort(mode: ConnectionMode): string {
|
|
||||||
return DEFAULTS[mode].port;
|
|
||||||
}
|
|
||||||
|
|
||||||
function releaseBaseUrl(): string {
|
|
||||||
return `https://${SERVICE_NAME}.dcntrlized.org`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadSettings(): Promise<ConnectionSettings> {
|
export async function loadSettings(): Promise<ConnectionSettings> {
|
||||||
if (isRelease) {
|
|
||||||
return { ...DEFAULT_SETTINGS, mode: 'direct', useAdbReverse: false };
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const raw = await AsyncStorage.getItem(SETTINGS_KEY);
|
const raw = await AsyncStorage.getItem(SETTINGS_KEY);
|
||||||
if (!raw) return { ...DEFAULT_SETTINGS };
|
if (!raw) return { ...DEFAULT_SETTINGS };
|
||||||
return { ...DEFAULT_SETTINGS, ...(JSON.parse(raw) as Partial<ConnectionSettings>) };
|
const parsed = JSON.parse(raw) as Partial<ConnectionSettings>;
|
||||||
|
const normalized = typeof parsed.instanceUrl === 'string'
|
||||||
|
? normalizeInstanceUrl(parsed.instanceUrl)
|
||||||
|
: null;
|
||||||
|
return normalized ? { instanceUrl: normalized } : { ...DEFAULT_SETTINGS };
|
||||||
} catch {
|
} catch {
|
||||||
return { ...DEFAULT_SETTINGS };
|
return { ...DEFAULT_SETTINGS };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveSettings(settings: ConnectionSettings): Promise<void> {
|
export async function saveSettings(settings: ConnectionSettings): Promise<void> {
|
||||||
if (isRelease) return;
|
|
||||||
try {
|
try {
|
||||||
await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function candidateHosts(settings: ConnectionSettings, lastHost: string | null): string[] {
|
|
||||||
const explicit = settings.host.trim();
|
|
||||||
if (explicit) return [explicit];
|
|
||||||
const hosts: string[] = [];
|
|
||||||
if (lastHost) hosts.push(lastHost);
|
|
||||||
hosts.push(settings.useAdbReverse ? '127.0.0.1' : DEFAULTS[settings.mode].host);
|
|
||||||
if (settings.useAdbReverse) hosts.push(LAN_HOST);
|
|
||||||
return [...new Set(hosts)];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reachable(baseUrl: string, timeoutMs: number): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
||||||
const res = await fetch(`${baseUrl}/_manifest`, { signal: ctrl.signal });
|
|
||||||
clearTimeout(timer);
|
|
||||||
return res.ok;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MaestroContainer {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
service_name?: string;
|
|
||||||
docker_name?: string;
|
|
||||||
status?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findServiceRef(list: MaestroContainer[]): string | null {
|
|
||||||
const needle = SERVICE_NAME.toLowerCase();
|
|
||||||
const candidates = list.filter(c => {
|
|
||||||
const svc = (c.service_name || '').toLowerCase();
|
|
||||||
const docker = (c.docker_name || '').toLowerCase();
|
|
||||||
return svc === needle || docker === needle;
|
|
||||||
});
|
|
||||||
const match = candidates.find(c => c.status === 'running') || candidates[0];
|
|
||||||
if (!match) return null;
|
|
||||||
return match.service_name || match.docker_name || match.name || match.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Maestro does not serve /_manifest; its reachability check and the service
|
|
||||||
// container lookup both come from the /api/containers endpoint.
|
|
||||||
async function probeMaestro(
|
|
||||||
baseUrl: string,
|
|
||||||
timeoutMs: number,
|
|
||||||
): Promise<{ reachable: boolean; serviceRef: string | null }> {
|
|
||||||
try {
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
||||||
const res = await fetch(`${baseUrl}/api/containers`, { signal: ctrl.signal });
|
|
||||||
clearTimeout(timer);
|
|
||||||
if (!res.ok) return { reachable: false, serviceRef: null };
|
|
||||||
const list = (await res.json()) as MaestroContainer[];
|
|
||||||
return { reachable: true, serviceRef: findServiceRef(list) };
|
|
||||||
} catch (e) {
|
|
||||||
console.log('probeMaestro fetch failed', baseUrl, (e as Error).name, (e as Error).message);
|
|
||||||
return { reachable: false, serviceRef: null };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResolvedConnection {
|
export interface ResolvedConnection {
|
||||||
|
/** Normalized origin, no trailing slash. Empty when not configured. */
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
host: string;
|
/** Did the instance answer its public profile? */
|
||||||
port: string;
|
online: boolean;
|
||||||
adbReverse: string | null;
|
|
||||||
mode: ConnectionMode;
|
|
||||||
serviceRef: string | null;
|
|
||||||
/** True when a live WebSocket is possible (direct mode only — see header). */
|
|
||||||
socketSupported: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function probe(
|
|
||||||
settings: ConnectionSettings,
|
|
||||||
timeoutMs = 2500,
|
|
||||||
): Promise<ResolvedConnection> {
|
|
||||||
if (isRelease) {
|
|
||||||
const host = `${SERVICE_NAME}.dcntrlized.org`;
|
|
||||||
return {
|
|
||||||
baseUrl: releaseBaseUrl(),
|
|
||||||
host,
|
|
||||||
port: '443',
|
|
||||||
adbReverse: null,
|
|
||||||
mode: 'direct',
|
|
||||||
serviceRef: null,
|
|
||||||
socketSupported: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const port = settings.port.trim() || defaultPort(settings.mode);
|
|
||||||
let lastHost: string | null = null;
|
|
||||||
try {
|
|
||||||
lastHost = await AsyncStorage.getItem(LAST_HOST_KEY);
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
const hosts = candidateHosts(settings, lastHost);
|
|
||||||
for (const host of hosts) {
|
|
||||||
const baseUrl = `http://${host}:${port}`;
|
|
||||||
if (settings.mode === 'maestro') {
|
|
||||||
const { reachable: okReach, serviceRef } = await probeMaestro(baseUrl, timeoutMs);
|
|
||||||
if (okReach) {
|
|
||||||
try {
|
|
||||||
await AsyncStorage.setItem(LAST_HOST_KEY, host);
|
|
||||||
} catch {}
|
|
||||||
return {
|
|
||||||
baseUrl,
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
adbReverse: adbReverseCommand(settings, port, host),
|
|
||||||
mode: settings.mode,
|
|
||||||
serviceRef,
|
|
||||||
socketSupported: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else if (await reachable(baseUrl, timeoutMs)) {
|
|
||||||
try {
|
|
||||||
await AsyncStorage.setItem(LAST_HOST_KEY, host);
|
|
||||||
} catch {}
|
|
||||||
return {
|
|
||||||
baseUrl,
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
adbReverse: adbReverseCommand(settings, port, host),
|
|
||||||
mode: settings.mode,
|
|
||||||
serviceRef: null,
|
|
||||||
socketSupported: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = hosts[0];
|
|
||||||
return {
|
|
||||||
baseUrl: `http://${host}:${port}`,
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
adbReverse: adbReverseCommand(settings, port, host),
|
|
||||||
mode: settings.mode,
|
|
||||||
serviceRef: null,
|
|
||||||
socketSupported: settings.mode === 'direct',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function adbReverseCommand(
|
|
||||||
settings: ConnectionSettings,
|
|
||||||
port?: string,
|
|
||||||
host?: string,
|
|
||||||
): string | null {
|
|
||||||
if (isRelease) return null;
|
|
||||||
const p = port || settings.port.trim() || defaultPort(settings.mode);
|
|
||||||
const h =
|
|
||||||
host ||
|
|
||||||
settings.host.trim() ||
|
|
||||||
(settings.useAdbReverse ? '127.0.0.1' : DEFAULTS[settings.mode].host);
|
|
||||||
if (h !== '127.0.0.1') return null;
|
|
||||||
return `adb reverse tcp:${p} tcp:${p}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The exact WebSocket URL the client opens for an alert. Direct mode only:
|
* Resolves settings into a base URL and checks that the instance is reachable.
|
||||||
* `ws://<host>:<port>/ws/alarm?alertId=<id>&token=<subscribeToken>`
|
|
||||||
* (release: `wss://alarm.dcntrlized.org/ws/alarm?...`).
|
|
||||||
*
|
*
|
||||||
* Returned verbatim here so it is testable headlessly and so the service team
|
* Reachability is `GET {instance}/api/instance` — the one deliberately public
|
||||||
* can diff the client's expectation against the service.
|
* read surface on an instance. Whether the alarm service is installed is not
|
||||||
|
* probed separately: a stopped service is a service error, not a connection
|
||||||
|
* error, and the alarm screens surface it.
|
||||||
*/
|
*/
|
||||||
export function alarmSocketUrl(
|
export async function probe(
|
||||||
resolved: Pick<ResolvedConnection, 'baseUrl' | 'mode'>,
|
settings: ConnectionSettings,
|
||||||
alertId: string,
|
timeoutMs = 4000,
|
||||||
subscribeToken: string,
|
): Promise<ResolvedConnection> {
|
||||||
): string | null {
|
const baseUrl = normalizeInstanceUrl(settings.instanceUrl) ?? '';
|
||||||
if (resolved.mode !== 'direct') return null;
|
if (!baseUrl) return { baseUrl: '', online: false };
|
||||||
const ws = resolved.baseUrl.replace(/^http/i, 'ws');
|
try {
|
||||||
const q = `alertId=${encodeURIComponent(alertId)}&token=${encodeURIComponent(subscribeToken)}`;
|
const ctrl = new AbortController();
|
||||||
return `${ws}/ws/alarm?${q}`;
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||||
|
const res = await fetch(`${baseUrl}/api/instance`, { signal: ctrl.signal });
|
||||||
|
clearTimeout(timer);
|
||||||
|
return { baseUrl, online: res.ok };
|
||||||
|
} catch (e) {
|
||||||
|
console.log('instance probe failed', baseUrl, (e as Error).name, (e as Error).message);
|
||||||
|
return { baseUrl, online: false };
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Pure single-origin URL helpers.
|
||||||
|
*
|
||||||
|
* Kept free of React Native / AsyncStorage imports so the transport contract can
|
||||||
|
* be asserted headlessly (`__tests__/connection.test.ts`) and so the HTTP client
|
||||||
|
* can build paths without pulling storage into a unit test.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SERVICE_NAME = 'alarm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes what the user typed into an origin.
|
||||||
|
*
|
||||||
|
* Accepts `localhost:8080`, `http://host:8080/`, `https://host`, and returns
|
||||||
|
* `http(s)://host[:port]` without a trailing slash. Returns `null` when the
|
||||||
|
* value cannot be an origin — the UI treats that as "not configured" instead of
|
||||||
|
* sending requests to a nonsense URL.
|
||||||
|
*/
|
||||||
|
export function normalizeInstanceUrl(raw: string): string | null {
|
||||||
|
const value = raw.trim();
|
||||||
|
if (!value) return null;
|
||||||
|
// Refuse other schemes rather than guessing (a typo must not become a fetch).
|
||||||
|
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value) && !/^https?:\/\//i.test(value)) return null;
|
||||||
|
if (/\s/.test(value)) return null;
|
||||||
|
const withScheme = /^https?:\/\//i.test(value) ? value : `http://${value}`;
|
||||||
|
// Require a host: `http://` alone is not an instance.
|
||||||
|
const rest = withScheme.replace(/^https?:\/\//i, '').replace(/\/+$/, '');
|
||||||
|
if (!rest || rest.startsWith('/')) return null;
|
||||||
|
const scheme = withScheme.slice(0, withScheme.indexOf('://') + 3).toLowerCase();
|
||||||
|
return `${scheme}${rest}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `'/action/createAlert'` → `'/api/alarm/action/createAlert'`; same for `/auth/*`. */
|
||||||
|
export function servicePath(path: string): string {
|
||||||
|
const clean = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
return `/api/${SERVICE_NAME}${clean}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exact WebSocket URL the client opens for an alert:
|
||||||
|
* `ws(s)://{instance}/api/alarm/ws/alarm?alertId=<id>&token=<subscribeToken>`.
|
||||||
|
*
|
||||||
|
* That the upgrade actually succeeds through a live Maestro is proven by the
|
||||||
|
* E2E harness, not here. Returns `null` when no instance is configured.
|
||||||
|
*/
|
||||||
|
export function alarmSocketUrl(
|
||||||
|
resolved: { baseUrl: string },
|
||||||
|
alertId: string,
|
||||||
|
subscribeToken: string,
|
||||||
|
): string | null {
|
||||||
|
if (!resolved.baseUrl) return null;
|
||||||
|
const ws = resolved.baseUrl.replace(/^http/i, 'ws');
|
||||||
|
const q = `alertId=${encodeURIComponent(alertId)}&token=${encodeURIComponent(subscribeToken)}`;
|
||||||
|
return `${ws}/api/${SERVICE_NAME}/ws/alarm?${q}`;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import React, {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import { configureApi } from '../services/apiClient';
|
import { configureApi } from '../services/apiClient';
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_SETTINGS,
|
||||||
isRelease,
|
isRelease,
|
||||||
loadSettings,
|
loadSettings,
|
||||||
probe,
|
probe,
|
||||||
@@ -31,10 +32,7 @@ const ConnectionContext = createContext<ConnectionContextValue | undefined>(unde
|
|||||||
|
|
||||||
export function ConnectionProvider({ children }: { children: React.ReactNode }) {
|
export function ConnectionProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [settings, setSettings] = useState<ConnectionSettings>({
|
const [settings, setSettings] = useState<ConnectionSettings>({
|
||||||
mode: 'maestro',
|
instanceUrl: DEFAULT_SETTINGS.instanceUrl,
|
||||||
host: '',
|
|
||||||
port: '',
|
|
||||||
useAdbReverse: true,
|
|
||||||
});
|
});
|
||||||
const [resolved, setResolved] = useState<ResolvedConnection | null>(null);
|
const [resolved, setResolved] = useState<ResolvedConnection | null>(null);
|
||||||
const [probing, setProbing] = useState(true);
|
const [probing, setProbing] = useState(true);
|
||||||
@@ -44,7 +42,7 @@ export function ConnectionProvider({ children }: { children: React.ReactNode })
|
|||||||
setProbing(true);
|
setProbing(true);
|
||||||
const r = await probe(s);
|
const r = await probe(s);
|
||||||
setResolved(r);
|
setResolved(r);
|
||||||
configureApi({ baseUrl: r.baseUrl, mode: r.mode, serviceRef: r.serviceRef });
|
configureApi({ baseUrl: r.baseUrl });
|
||||||
setProbing(false);
|
setProbing(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -85,7 +83,7 @@ export function ConnectionProvider({ children }: { children: React.ReactNode })
|
|||||||
probing,
|
probing,
|
||||||
reprobe,
|
reprobe,
|
||||||
isRelease,
|
isRelease,
|
||||||
online: !!resolved && !!resolved.baseUrl,
|
online: !!resolved?.online,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <ConnectionContext.Provider value={value}>{children}</ConnectionContext.Provider>;
|
return <ConnectionContext.Provider value={value}>{children}</ConnectionContext.Provider>;
|
||||||
|
|||||||
+44
-14
@@ -7,7 +7,7 @@ import {
|
|||||||
type SocketState,
|
type SocketState,
|
||||||
} from '../services/alarmSocket';
|
} from '../services/alarmSocket';
|
||||||
import { alarmSocketUrl, type ResolvedConnection } from '../services/connection';
|
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';
|
export type LiveDelivery = 'socket' | 'polling' | 'unavailable';
|
||||||
|
|
||||||
@@ -22,6 +22,10 @@ export interface UseAlarmSocketOptions {
|
|||||||
onTrigger: (frame: AlarmFrame) => void;
|
onTrigger: (frame: AlarmFrame) => void;
|
||||||
onDisarm: (frame: AlarmFrame) => void;
|
onDisarm: (frame: AlarmFrame) => void;
|
||||||
enabled?: boolean;
|
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 {
|
export interface UseAlarmSocketResult {
|
||||||
@@ -36,9 +40,11 @@ export interface UseAlarmSocketResult {
|
|||||||
/**
|
/**
|
||||||
* Owns the live-alarm transport for one subscribed alert.
|
* Owns the live-alarm transport for one subscribed alert.
|
||||||
*
|
*
|
||||||
* Direct mode gets the WebSocket (the only mode Maestro can pass through);
|
* The WebSocket is the delivery path: triggers arrive only over
|
||||||
* maestro mode is polling-only and reports `delivery: 'unavailable'` so the
|
* `ws(s)://{instance}/api/alarm/ws/alarm`. `delivery: 'unavailable'` means no
|
||||||
* Settings/health screen can say so out loud instead of pretending.
|
* 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({
|
export function useAlarmSocket({
|
||||||
resolved,
|
resolved,
|
||||||
@@ -49,6 +55,8 @@ export function useAlarmSocket({
|
|||||||
onTrigger,
|
onTrigger,
|
||||||
onDisarm,
|
onDisarm,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
|
deviceId,
|
||||||
|
onGone,
|
||||||
}: UseAlarmSocketOptions): UseAlarmSocketResult {
|
}: UseAlarmSocketOptions): UseAlarmSocketResult {
|
||||||
const [socketState, setSocketState] = useState<SocketState>('idle');
|
const [socketState, setSocketState] = useState<SocketState>('idle');
|
||||||
const [attempts, setAttempts] = useState(0);
|
const [attempts, setAttempts] = useState(0);
|
||||||
@@ -66,17 +74,36 @@ export function useAlarmSocket({
|
|||||||
[resolved, alertId, subscribeToken],
|
[resolved, alertId, subscribeToken],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Capability, not liveness: the URL exists. Liveness is `socketState`. */
|
||||||
const delivery: LiveDelivery = url ? 'socket' : 'unavailable';
|
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 () => {
|
const refresh = useCallback(async () => {
|
||||||
if (!alertId) return;
|
if (!alertId) return;
|
||||||
try {
|
try {
|
||||||
setLastStatus(await fetchAlarmStatus(alertId));
|
applyStatus(await fetchAlarmStatus(alertId, deviceId ?? undefined));
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
if (isAlertGone(e)) onGoneRef.current?.();
|
||||||
// A failed status read is not fatal — the socket is the primary path and
|
// A failed status read is not fatal — the socket is the primary path and
|
||||||
// the next poll retries.
|
// the next poll retries.
|
||||||
}
|
}
|
||||||
}, [alertId]);
|
}, [alertId, applyStatus, deviceId]);
|
||||||
|
|
||||||
// AppState awareness: while backgrounded the JS socket is unreliable, so the
|
// 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
|
// 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]);
|
}, [enabled, url, alertId, subscribeToken]);
|
||||||
|
|
||||||
// Polling is always on while armed: it is the fallback when the socket is not
|
// Status reconciliation. It never *delivers* a live trigger (the socket is the
|
||||||
// available (maestro mode) and the safety net when it is momentarily down.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!enabled || !alertId) return;
|
if (!enabled || !alertId) return;
|
||||||
const interval = appState === 'active' ? foregroundPollMs : backgroundPollMs;
|
const interval = appState === 'active' ? foregroundPollMs : backgroundPollMs;
|
||||||
@@ -121,10 +150,11 @@ export function useAlarmSocket({
|
|||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
try {
|
try {
|
||||||
const status = await fetchAlarmStatus(alertId);
|
const status = await fetchAlarmStatus(alertId, deviceId ?? undefined);
|
||||||
if (!cancelled) setLastStatus(status);
|
if (!cancelled) applyStatus(status);
|
||||||
} catch {
|
} catch (e) {
|
||||||
// ignore — polling must never surface a spinner
|
if (isAlertGone(e)) onGoneRef.current?.();
|
||||||
|
// ignore otherwise — polling must never surface a spinner
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
tick();
|
tick();
|
||||||
@@ -133,7 +163,7 @@ export function useAlarmSocket({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearInterval(handle);
|
clearInterval(handle);
|
||||||
};
|
};
|
||||||
}, [enabled, alertId, appState, foregroundPollMs, backgroundPollMs]);
|
}, [enabled, alertId, appState, foregroundPollMs, backgroundPollMs, applyStatus, deviceId]);
|
||||||
|
|
||||||
return { socketState, delivery, attempts, lastStatus, refresh };
|
return { socketState, delivery, attempts, lastStatus, refresh };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user