first commit
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import type {
|
||||
ApprovalMode,
|
||||
ApprovalSettings,
|
||||
DeviceInfo,
|
||||
GateInfo,
|
||||
Snapshot,
|
||||
} from "@/protocol";
|
||||
import type {
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
} from "@simplewebauthn/browser";
|
||||
import { startAuthentication, startRegistration } from "@simplewebauthn/browser";
|
||||
|
||||
/**
|
||||
* The daemon rejects any POST without this header. A cross-origin page cannot set it without
|
||||
* a CORS preflight that we never answer, so it is a second barrier behind the SameSite cookie.
|
||||
*/
|
||||
const POST_HEADERS = {
|
||||
"content-type": "application/json",
|
||||
"x-glance-csrf": "1",
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, { credentials: "same-origin", ...init });
|
||||
const text = await res.text();
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
(body as { error?: string } | null)?.error ?? `${res.status} ${res.statusText}`;
|
||||
throw new ApiError(message, res.status);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
function post<T>(path: string, body?: unknown): Promise<T> {
|
||||
return request<T>(path, {
|
||||
method: "POST",
|
||||
headers: POST_HEADERS,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
export const api = {
|
||||
gate: () => request<GateInfo>("/api/gate"),
|
||||
snapshot: () => request<Snapshot>("/api/snapshot"),
|
||||
devices: () => request<{ devices: DeviceInfo[]; current: string }>("/api/devices"),
|
||||
logout: () => post<{ ok: true }>("/api/auth/logout"),
|
||||
|
||||
resolveApproval: (id: string, decision: "allow" | "deny") =>
|
||||
post<{ ok: true }>("/api/approvals/resolve", { id, decision }),
|
||||
|
||||
setApproval: (patch: {
|
||||
mode?: ApprovalMode;
|
||||
requireWatcher?: boolean;
|
||||
onTimeout?: "allow" | "deny";
|
||||
}) => post<ApprovalSettings>("/api/approval", patch),
|
||||
|
||||
/**
|
||||
* Sign in with an already-enrolled passkey. The device proves itself with a biometric or
|
||||
* PIN; we never see or store anything the phone could not re-derive.
|
||||
*/
|
||||
async signIn(): Promise<string | undefined> {
|
||||
const optionsJSON = await post<PublicKeyCredentialRequestOptionsJSON>(
|
||||
"/api/auth/login/options",
|
||||
);
|
||||
const response = await startAuthentication({ optionsJSON });
|
||||
const out = await post<{ ok: true; label?: string }>("/api/auth/login/verify", { response });
|
||||
return out.label;
|
||||
},
|
||||
|
||||
/**
|
||||
* Enrol this device using a one-time code from `glance enroll`. The code is checked twice —
|
||||
* once to get options, once to accept the attestation — and only consumed on success.
|
||||
*/
|
||||
async enroll(code: string, label: string): Promise<void> {
|
||||
const optionsJSON = await post<PublicKeyCredentialCreationOptionsJSON>(
|
||||
"/api/auth/register/options",
|
||||
{ code },
|
||||
);
|
||||
const response = await startRegistration({ optionsJSON });
|
||||
await post<{ ok: true }>("/api/auth/register/verify", { code, label, response });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Time formatting for a screen you look at for three seconds. */
|
||||
|
||||
export function relTime(ts: number, now: number): string {
|
||||
const delta = Math.max(0, now - ts);
|
||||
const s = Math.round(delta / 1000);
|
||||
if (s < 5) return "now";
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.round(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.round(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.round(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
export function clockTime(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export function duration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`;
|
||||
const m = Math.floor(ms / 60_000);
|
||||
const s = Math.round((ms % 60_000) / 1000);
|
||||
return `${m}m ${s}s`;
|
||||
}
|
||||
|
||||
/** Seconds left, floored at zero, for an approval countdown. */
|
||||
export function secondsLeft(expiresAt: number, now: number): number {
|
||||
return Math.max(0, Math.ceil((expiresAt - now) / 1000));
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Snapshot } from "@/protocol";
|
||||
|
||||
export type Connection = "connecting" | "live" | "offline";
|
||||
|
||||
/** Backoff caps out quickly: a phone coming out of sleep should reconnect, not sulk. */
|
||||
const RETRY_MS = [500, 1000, 2000, 4000, 8000, 15_000];
|
||||
|
||||
/**
|
||||
* Subscribes to the daemon's event stream and keeps the latest full snapshot.
|
||||
*
|
||||
* The server sends whole snapshots rather than deltas, so a phone that slept through twenty
|
||||
* events still lands on the truth with no reconciliation logic here.
|
||||
*/
|
||||
export function useGlance(enabled: boolean) {
|
||||
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
|
||||
const [connection, setConnection] = useState<Connection>("connecting");
|
||||
const attempt = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setSnapshot(null);
|
||||
setConnection("connecting");
|
||||
return;
|
||||
}
|
||||
|
||||
let stopped = false;
|
||||
let source: EventSource | null = null;
|
||||
let timer: number | undefined;
|
||||
|
||||
const schedule = () => {
|
||||
if (stopped) return;
|
||||
const wait = RETRY_MS[Math.min(attempt.current, RETRY_MS.length - 1)];
|
||||
attempt.current += 1;
|
||||
timer = window.setTimeout(open, wait);
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
if (stopped) return;
|
||||
// Fetch once alongside the stream so the first paint does not wait on the SSE handshake.
|
||||
api.snapshot().then(
|
||||
(snap) => {
|
||||
if (!stopped) setSnapshot(snap);
|
||||
},
|
||||
() => {
|
||||
/* the stream will report the real problem */
|
||||
},
|
||||
);
|
||||
|
||||
source = new EventSource("/events", { withCredentials: true });
|
||||
|
||||
source.addEventListener("open", () => {
|
||||
if (stopped) return;
|
||||
attempt.current = 0;
|
||||
setConnection("live");
|
||||
});
|
||||
|
||||
source.addEventListener("snapshot", (event) => {
|
||||
if (stopped) return;
|
||||
try {
|
||||
setSnapshot(JSON.parse((event as MessageEvent<string>).data) as Snapshot);
|
||||
setConnection("live");
|
||||
} catch {
|
||||
/* ignore a malformed frame rather than tearing down the stream */
|
||||
}
|
||||
});
|
||||
|
||||
// The daemon says goodbye on shutdown; reconnecting will pick it up when it returns.
|
||||
source.addEventListener("bye", () => {
|
||||
source?.close();
|
||||
setConnection("offline");
|
||||
schedule();
|
||||
});
|
||||
|
||||
source.addEventListener("error", () => {
|
||||
source?.close();
|
||||
source = null;
|
||||
if (stopped) return;
|
||||
setConnection("offline");
|
||||
schedule();
|
||||
});
|
||||
};
|
||||
|
||||
open();
|
||||
|
||||
// iOS suspends the stream in the background; nudge it the moment the app is looked at.
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
if (source && source.readyState === EventSource.OPEN) {
|
||||
api.snapshot().then(setSnapshot, () => {});
|
||||
return;
|
||||
}
|
||||
source?.close();
|
||||
source = null;
|
||||
attempt.current = 0;
|
||||
if (timer) window.clearTimeout(timer);
|
||||
open();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
if (timer) window.clearTimeout(timer);
|
||||
source?.close();
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return { snapshot, connection };
|
||||
}
|
||||
|
||||
/** A ticking clock, for countdowns and "3s ago" labels. */
|
||||
export function useNow(intervalMs = 1000): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setNow(Date.now()), intervalMs);
|
||||
return () => window.clearInterval(id);
|
||||
}, [intervalMs]);
|
||||
return now;
|
||||
}
|
||||
Reference in New Issue
Block a user