first commit

This commit is contained in:
iceBear67
2026-08-09 04:00:13 +00:00
commit b3b6bf3f70
46 changed files with 7146 additions and 0 deletions
+98
View File
@@ -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 });
},
};
+30
View File
@@ -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));
}
+121
View File
@@ -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;
}