[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`
|
||||
# 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:
|
||||
# - 8080 Maestro (dev default)
|
||||
# - 8088 Alarm direct mode (bypasses Maestro) — the service's host port
|
||||
# - 8080 the instance/Maestro origin (dev default; every service path lives
|
||||
# under it — the client never opens the service's own 8088)
|
||||
# - 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
|
||||
# and the service must keep distinct numbers; 8088 belongs to the service.
|
||||
# Each reverse maps device 127.0.0.1:<port> -> host 127.0.0.1:<port>.
|
||||
#
|
||||
# If your phone is on the same LAN instead, turn "Use adb reverse" off in
|
||||
# Settings and it will fall back to the LAN host baked into src/services/connection.ts.
|
||||
# The instance address is the one thing the app configures (Settings). Leave it
|
||||
# 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
|
||||
|
||||
for port in 8080 8088 8089; do
|
||||
for port in 8080 8089; do
|
||||
adb reverse tcp:"$port" tcp:"$port" >/dev/null 2>&1 || true
|
||||
done
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||
import { KeyboardAvoidingView, Platform, View } from 'react-native';
|
||||
import { useAuth } from '../state/AuthContext';
|
||||
import { useConnection } from '../state/ConnectionContext';
|
||||
import { navigationRef } from '../navigationRef';
|
||||
import {
|
||||
AppText,
|
||||
Button,
|
||||
@@ -17,7 +18,7 @@ type Mode = 'signin' | 'register';
|
||||
|
||||
export default function LoginScreen() {
|
||||
const { login, register } = useAuth();
|
||||
const { online, resolved } = useConnection();
|
||||
const { online, resolved, settings } = useConnection();
|
||||
|
||||
const [mode, setMode] = useState<Mode>('signin');
|
||||
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 ? (
|
||||
<AppText style={{ ...typeCaption, color: textSecondary, textAlign: 'center' }}>
|
||||
{resolved
|
||||
? 'The alarm service did not answer. Check Settings → Connection.'
|
||||
: 'Looking for the alarm service…'}
|
||||
? 'Your instance did not answer. Check Settings → Connection.'
|
||||
: 'Looking for your instance…'}
|
||||
</AppText>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
/**
|
||||
* Alarm WebSocket client.
|
||||
*
|
||||
* URL (frozen contract, direct mode only — Maestro has no service WS
|
||||
* passthrough): `ws://<host>:<port>/ws/alarm?alertId=<id>&token=<subscribeToken>`
|
||||
* URL (single origin, frozen contract):
|
||||
* `ws(s)://{instance}/api/alarm/ws/alarm?alertId=<id>&token=<subscribeToken>`
|
||||
*
|
||||
* Frames:
|
||||
* server → client {"type":"alarm","alertId","eventId","action":"trigger"|"disarm",
|
||||
* "message","serverTime","autoStopSeconds"}
|
||||
* "message","serverTime"}
|
||||
* client → server {"type":"ack","eventId"}
|
||||
* 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
|
||||
* they can be tested headlessly (no React Native, no real socket) — see
|
||||
* `__tests__/alarmSocket.test.ts`.
|
||||
@@ -112,7 +119,6 @@ export interface AlarmFrame {
|
||||
action: 'trigger' | 'disarm';
|
||||
message?: string;
|
||||
serverTime?: string;
|
||||
autoStopSeconds?: number;
|
||||
}
|
||||
|
||||
export interface AckFrame {
|
||||
@@ -151,8 +157,6 @@ export function parseFrame(raw: string): IncomingFrame {
|
||||
action: obj.action,
|
||||
message: typeof obj.message === 'string' ? obj.message : undefined,
|
||||
serverTime: typeof obj.serverTime === 'string' ? obj.serverTime : undefined,
|
||||
autoStopSeconds:
|
||||
typeof obj.autoStopSeconds === 'number' ? obj.autoStopSeconds : undefined,
|
||||
};
|
||||
}
|
||||
if (type === 'ack') {
|
||||
|
||||
+51
-43
@@ -1,16 +1,22 @@
|
||||
/**
|
||||
* HTTP client for the Alarm service — house pattern from
|
||||
* `calendar-client/src/services/apiClient.ts`, retargeted:
|
||||
* HTTP client for the Alarm service — single origin.
|
||||
*
|
||||
* - maestro mode addresses `POST {maestro}/api/actions/{serviceRef}/{action}`
|
||||
* with the `{payload: …}` envelope (Maestro's action surface is HTTP-only),
|
||||
* - direct mode addresses the service's own listener and uses the `{payload: …}`
|
||||
* envelope for `/action/*` while `/auth/*` take raw JSON bodies
|
||||
* (`raw = true`),
|
||||
* - every call carries `X-Access-Token` when a session exists,
|
||||
* - a 401 (or an invalid/expired-token body) triggers ONE single-flight
|
||||
* `/auth/refresh` and a single retry; a failed refresh clears the session.
|
||||
* Every request goes to the instance the user configured, as a path under it:
|
||||
*
|
||||
* `/action/<name>` → `{instance}/api/alarm/action/<name>` (`{payload: …}`)
|
||||
* `/auth/<path>` → `{instance}/api/alarm/auth/<path>` (raw JSON body)
|
||||
*
|
||||
* There is no `direct` mode and no `serviceRef` lookup: the service is reached
|
||||
* only through the instance, which is the deployment the platform supports.
|
||||
*
|
||||
* 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 {
|
||||
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 {
|
||||
baseUrl: string;
|
||||
mode: 'maestro' | 'direct';
|
||||
serviceRef: string | null;
|
||||
token: string | null;
|
||||
refreshToken: string | null;
|
||||
onUnauthorized: () => void;
|
||||
@@ -40,8 +61,6 @@ export interface ApiConfig {
|
||||
|
||||
const config: ApiConfig = {
|
||||
baseUrl: '',
|
||||
mode: 'direct',
|
||||
serviceRef: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
onUnauthorized: () => {},
|
||||
@@ -56,25 +75,8 @@ export function apiConfig(): Readonly<ApiConfig> {
|
||||
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 {
|
||||
if (config.mode === 'maestro') {
|
||||
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}`;
|
||||
return `${config.baseUrl}${servicePath(path)}`;
|
||||
}
|
||||
|
||||
// One in-flight refresh shared by every 401: a burst of requests that all hit
|
||||
@@ -88,12 +90,9 @@ function refreshSession(): Promise<boolean> {
|
||||
try {
|
||||
if (!config.refreshToken) return false;
|
||||
const url = buildUrl('/auth/refresh');
|
||||
// Raw {refreshToken} in direct mode; {payload} action form in Maestro
|
||||
// mode — the service proxies both (raw flag contract, root AGENTS.md).
|
||||
const body =
|
||||
config.mode === 'maestro'
|
||||
? JSON.stringify({ payload: { refreshToken: config.refreshToken } })
|
||||
: JSON.stringify({ refreshToken: config.refreshToken });
|
||||
// `/auth/*` takes the raw body (root AGENTS.md body contract); the
|
||||
// instance forwards it unchanged.
|
||||
const body = JSON.stringify({ refreshToken: config.refreshToken });
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -130,13 +129,12 @@ async function perform<T>(
|
||||
throw new ApiError(null, 'Server not configured yet');
|
||||
}
|
||||
|
||||
// In Maestro mode every call goes through /api/actions/{service}/{action},
|
||||
// which always expects the { payload: ... } wrapper. The raw flag only
|
||||
// applies when talking to a service directly.
|
||||
const maestro = config.mode === 'maestro';
|
||||
// `/action/*` expects the `{ payload: … }` envelope; `/auth/*` expects the
|
||||
// raw body. The instance forwards the body unchanged, so this is the service
|
||||
// contract end to end.
|
||||
let body: string | 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);
|
||||
@@ -169,6 +167,7 @@ async function perform<T>(
|
||||
|
||||
let message = `HTTP ${res.status}`;
|
||||
let code: string | null = null;
|
||||
let parsedBody: unknown = null;
|
||||
try {
|
||||
const t = await res.text();
|
||||
if (t) {
|
||||
@@ -179,6 +178,7 @@ async function perform<T>(
|
||||
// that need to distinguish cases the message cannot.
|
||||
try {
|
||||
const parsed = JSON.parse(t) as { error?: { code?: unknown; message?: unknown } };
|
||||
parsedBody = parsed;
|
||||
const err = parsed?.error;
|
||||
if (err && typeof err === 'object') {
|
||||
if (typeof err.code === 'string') code = err.code;
|
||||
@@ -189,6 +189,14 @@ async function perform<T>(
|
||||
}
|
||||
}
|
||||
} 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
|
||||
// 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
|
||||
|
||||
+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):
|
||||
* two modes — `maestro` (the orchestrator's `/api/containers` list is probed and
|
||||
* the service is addressed through `serviceRef`) and `direct` (the service's own
|
||||
* `/_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.
|
||||
* Product ruling: *"every packet goes through Maestro, and thus, only a single
|
||||
* port needs to ever be open … Direct service access should never happen."*
|
||||
* This app is not special: it is another service under one instance address.
|
||||
*
|
||||
* IMPORTANT (lead ruling, 2026-10-18): Maestro has NO service WebSocket
|
||||
* passthrough — its only WS is `/api/ws` for its own container-status stream and
|
||||
* its action surface is HTTP-only. So live delivery over the socket is a
|
||||
* direct-mode capability; in maestro mode the client polls `alarmStatus`.
|
||||
* So there is exactly one thing to resolve — the instance origin — and four
|
||||
* paths under it:
|
||||
*
|
||||
* 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 { normalizeInstanceUrl } from './instanceUrl';
|
||||
|
||||
export type ConnectionMode = 'maestro' | 'direct';
|
||||
export { alarmSocketUrl, normalizeInstanceUrl, servicePath } from './instanceUrl';
|
||||
|
||||
export interface ConnectionSettings {
|
||||
mode: ConnectionMode;
|
||||
host: string;
|
||||
port: string;
|
||||
useAdbReverse: boolean;
|
||||
}
|
||||
|
||||
export const SERVICE_NAME = 'alarm';
|
||||
export { SERVICE_NAME } from './instanceUrl';
|
||||
|
||||
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 }> = {
|
||||
maestro: { host: LAN_HOST, port: '8080' },
|
||||
direct: { host: LAN_HOST, port: '8088' },
|
||||
};
|
||||
export interface ConnectionSettings {
|
||||
/** The instance origin, e.g. `https://dcntrlized.example` or `http://127.0.0.1:8080`. */
|
||||
instanceUrl: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: ConnectionSettings = {
|
||||
mode: 'maestro',
|
||||
host: '',
|
||||
port: '',
|
||||
useAdbReverse: true,
|
||||
instanceUrl: isRelease ? '' : DEV_INSTANCE_URL,
|
||||
};
|
||||
|
||||
const SETTINGS_KEY = 'settings.connection';
|
||||
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`;
|
||||
}
|
||||
const SETTINGS_KEY = 'settings.connection.v2';
|
||||
|
||||
export async function loadSettings(): Promise<ConnectionSettings> {
|
||||
if (isRelease) {
|
||||
return { ...DEFAULT_SETTINGS, mode: 'direct', useAdbReverse: false };
|
||||
}
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(SETTINGS_KEY);
|
||||
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 {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSettings(settings: ConnectionSettings): Promise<void> {
|
||||
if (isRelease) return;
|
||||
try {
|
||||
await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
} 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 {
|
||||
/** Normalized origin, no trailing slash. Empty when not configured. */
|
||||
baseUrl: string;
|
||||
host: string;
|
||||
port: string;
|
||||
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}`;
|
||||
/** Did the instance answer its public profile? */
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact WebSocket URL the client opens for an alert. Direct mode only:
|
||||
* `ws://<host>:<port>/ws/alarm?alertId=<id>&token=<subscribeToken>`
|
||||
* (release: `wss://alarm.dcntrlized.org/ws/alarm?...`).
|
||||
* Resolves settings into a base URL and checks that the instance is reachable.
|
||||
*
|
||||
* Returned verbatim here so it is testable headlessly and so the service team
|
||||
* can diff the client's expectation against the service.
|
||||
* Reachability is `GET {instance}/api/instance` — the one deliberately public
|
||||
* 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(
|
||||
resolved: Pick<ResolvedConnection, 'baseUrl' | 'mode'>,
|
||||
alertId: string,
|
||||
subscribeToken: string,
|
||||
): string | null {
|
||||
if (resolved.mode !== 'direct') return null;
|
||||
const ws = resolved.baseUrl.replace(/^http/i, 'ws');
|
||||
const q = `alertId=${encodeURIComponent(alertId)}&token=${encodeURIComponent(subscribeToken)}`;
|
||||
return `${ws}/ws/alarm?${q}`;
|
||||
export async function probe(
|
||||
settings: ConnectionSettings,
|
||||
timeoutMs = 4000,
|
||||
): Promise<ResolvedConnection> {
|
||||
const baseUrl = normalizeInstanceUrl(settings.instanceUrl) ?? '';
|
||||
if (!baseUrl) return { baseUrl: '', online: false };
|
||||
try {
|
||||
const ctrl = new AbortController();
|
||||
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';
|
||||
import { configureApi } from '../services/apiClient';
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
isRelease,
|
||||
loadSettings,
|
||||
probe,
|
||||
@@ -31,10 +32,7 @@ const ConnectionContext = createContext<ConnectionContextValue | undefined>(unde
|
||||
|
||||
export function ConnectionProvider({ children }: { children: React.ReactNode }) {
|
||||
const [settings, setSettings] = useState<ConnectionSettings>({
|
||||
mode: 'maestro',
|
||||
host: '',
|
||||
port: '',
|
||||
useAdbReverse: true,
|
||||
instanceUrl: DEFAULT_SETTINGS.instanceUrl,
|
||||
});
|
||||
const [resolved, setResolved] = useState<ResolvedConnection | null>(null);
|
||||
const [probing, setProbing] = useState(true);
|
||||
@@ -44,7 +42,7 @@ export function ConnectionProvider({ children }: { children: React.ReactNode })
|
||||
setProbing(true);
|
||||
const r = await probe(s);
|
||||
setResolved(r);
|
||||
configureApi({ baseUrl: r.baseUrl, mode: r.mode, serviceRef: r.serviceRef });
|
||||
configureApi({ baseUrl: r.baseUrl });
|
||||
setProbing(false);
|
||||
}, []);
|
||||
|
||||
@@ -85,7 +83,7 @@ export function ConnectionProvider({ children }: { children: React.ReactNode })
|
||||
probing,
|
||||
reprobe,
|
||||
isRelease,
|
||||
online: !!resolved && !!resolved.baseUrl,
|
||||
online: !!resolved?.online,
|
||||
};
|
||||
|
||||
return <ConnectionContext.Provider value={value}>{children}</ConnectionContext.Provider>;
|
||||
|
||||
+44
-14
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user