Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 794bab5 on 2026-09-16. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
import { isNative } from "@/lib/nativescan";
|
||||
|
||||
/* The phone's half of notifications, as the Account screen sees it.
|
||||
*
|
||||
* The native side is config-gated: the Android project only compiles its push code when a Firebase
|
||||
* config file is present, so a shell built without one answers nothing here. Everything below
|
||||
* therefore reports "unavailable" rather than failing — a browser, an older build of the app and a
|
||||
* self-hosted bundle with no Firebase all take the same honest path, and the screen says where
|
||||
* notifications actually arrive instead of pretending.
|
||||
*
|
||||
* The bridge is one intercepted navigation, the same shape as the welcome screen's `__server`: the
|
||||
* page asks by navigating to https://localhost/__push, the shell requests POST_NOTIFICATIONS
|
||||
* (Android 13+) and hands the registration token back by calling window.__tcPush(token). Nothing is
|
||||
* ever asked at launch — only when somebody turns a switch on, and the launch-time refresh below
|
||||
* asks the phone for nothing it has not already been given.
|
||||
*/
|
||||
|
||||
export type PushAsk =
|
||||
| { ok: true; token: string }
|
||||
| { ok: false; reason: "unavailable" | "refused" };
|
||||
|
||||
/** The last token this device registered, so signing out can hand it back and so a launch knows
|
||||
* whether this person ever turned notifications on. Per browser origin, and never sent anywhere
|
||||
* but `push.register` and `push.forget`. */
|
||||
const TOKEN_KEY = "tc.push.token";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/** Called by the shell with a registration token, or with an empty token and a reason when one
|
||||
* is not coming. The second argument is optional, so an older shell still works. */
|
||||
__tcPush?: (token: string, reason?: string) => void;
|
||||
}
|
||||
}
|
||||
|
||||
/* Which shell this page is running in, as far as notifications are concerned.
|
||||
*
|
||||
* `isNative()` alone was not enough, and the difference is a page somebody cannot get back from.
|
||||
* The /__push interception below is new in version 1.4 of the app; the shell already on people's
|
||||
* phones catches only /__server and hands everything else to the WebView, so on that build the ask
|
||||
* genuinely LOADED https://localhost/__push — off the site, onto the bundled origin, where no such
|
||||
* asset exists and the shell's own error handling leaves a blank page with no way back. The web app
|
||||
* reaches those installs the moment it deploys, long before a store update rolls out.
|
||||
*
|
||||
* So the shell says what it can do, and the page believes nothing else. The marker is a token the
|
||||
* staff shell appends to the WebView's user agent (capacitor.config.ts, `appendUserAgent`), which
|
||||
* is set before the first page loads, survives every navigation, and is absent from every older
|
||||
* build and from every browser. A shell that does not claim it is treated exactly like a browser:
|
||||
* nothing is navigated, nothing is asked, and Account says so in one line.
|
||||
*/
|
||||
export type PushShell = "none" | "old" | "ready";
|
||||
|
||||
/** The marker the staff shell appends to its user agent. The number is the bridge's own version,
|
||||
* not the app's — it is bumped when what the shell understands changes. */
|
||||
const SHELL_UA = /ThreadCountStaffShell\/(\d+)/;
|
||||
|
||||
export function pushShell(): PushShell {
|
||||
if (typeof window === "undefined") return "none";
|
||||
if (!isNative()) return "none";
|
||||
const m = SHELL_UA.exec(navigator.userAgent || "");
|
||||
return m && Number(m[1]) >= 1 ? "ready" : "old";
|
||||
}
|
||||
|
||||
/** Is there a shell here that understands the notification bridge? False in every browser, and
|
||||
* false in a shell built before it existed. */
|
||||
export function pushBridgeAvailable(): boolean {
|
||||
return pushShell() === "ready";
|
||||
}
|
||||
|
||||
export function rememberedToken(): string | null {
|
||||
try { return window.localStorage.getItem(TOKEN_KEY); } catch { return null; }
|
||||
}
|
||||
|
||||
function remember(token: string | null) {
|
||||
try {
|
||||
if (token) window.localStorage.setItem(TOKEN_KEY, token);
|
||||
else window.localStorage.removeItem(TOKEN_KEY);
|
||||
} catch { /* a WebView in private mode throws; the token is a convenience, not the record */ }
|
||||
}
|
||||
|
||||
/* One receiver, not one per ask.
|
||||
*
|
||||
* The shell calls window.__tcPush whenever it has an answer: after a permission dialog, after a
|
||||
* silent refresh at launch, and never at a moment the page chose. An ask that installed its own
|
||||
* handler and deleted it afterwards would tear down a refresh's handler as it finished, and the
|
||||
* next answer would land on nothing at all. So the property is installed once and never removed,
|
||||
* and whoever is waiting queues behind it. An answer settles everybody waiting rather than being
|
||||
* matched to one asker: the shell sends one token per phone, and there is nothing to tell two
|
||||
* answers apart if an ask and a refresh ever overlap. */
|
||||
type Answer = (token: string, reason: string) => void;
|
||||
let waiting: Answer[] = [];
|
||||
|
||||
function receive() {
|
||||
if (typeof window === "undefined" || window.__tcPush) return;
|
||||
window.__tcPush = (raw: string, reason?: string) => {
|
||||
const token = String(raw || "").trim();
|
||||
if (token) remember(token);
|
||||
const queued = waiting;
|
||||
waiting = [];
|
||||
for (const answer of queued) answer(token, String(reason || ""));
|
||||
};
|
||||
}
|
||||
|
||||
/** Wait for the shell's next answer, or give up. Running out reads as a refusal: from the screen's
|
||||
* point of view a dialog nobody answered and a dialog somebody dismissed are the same thing. */
|
||||
function nextAnswer(timeoutMs: number): Promise<PushAsk> {
|
||||
return new Promise<PushAsk>((resolve) => {
|
||||
let done = false;
|
||||
const finish = (r: PushAsk) => { if (!done) { done = true; resolve(r); } };
|
||||
waiting.push((token, reason) => {
|
||||
if (token) finish({ ok: true, token });
|
||||
else finish({ ok: false, reason: reason === "unavailable" ? "unavailable" : "refused" });
|
||||
});
|
||||
window.setTimeout(() => finish({ ok: false, reason: "refused" }), timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
/** Ask the phone for permission and a registration token. Resolves `unavailable` in a browser or
|
||||
* in a shell built without notification support, and `refused` when the person said no or the ask
|
||||
* went unanswered. It never throws and never rejects: the switch beside it has to be able to say
|
||||
* something either way. */
|
||||
export function askForPush(timeoutMs = 20000): Promise<PushAsk> {
|
||||
if (typeof window === "undefined" || !pushBridgeAvailable()) return Promise.resolve({ ok: false, reason: "unavailable" });
|
||||
receive();
|
||||
// A permission dialog is as slow as the person reading it, so the wait is generous.
|
||||
const answer = nextAnswer(timeoutMs);
|
||||
window.location.href = "https://localhost/__push?ask=1";
|
||||
return answer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-register this phone on launch, for somebody who turned notifications on earlier.
|
||||
*
|
||||
* Registration tokens rotate — a restore onto a new phone, a reinstall, Android's own rotation —
|
||||
* and a stale one is not an error anybody sees: notifications simply stop, silently, which is the
|
||||
* worst way for this feature to fail. So the app re-reads the token when it opens and registers
|
||||
* whatever comes back, which also keeps the row's last-seen date current.
|
||||
*
|
||||
* ⛔ It asks for nothing. With no remembered token this returns at once rather than prompting: a
|
||||
* permission dialog at launch is exactly what the rules forbid, and somebody who has never turned a
|
||||
* switch on has not agreed to anything. The shell's refresh path is equally silent — if the
|
||||
* permission is not already granted it answers "no token" rather than asking for one.
|
||||
*
|
||||
* `register` is normally `(token) => mutate("push.register", { token, platform: "android" })`.
|
||||
*/
|
||||
export async function refreshPush(
|
||||
register: (token: string) => unknown | Promise<unknown>,
|
||||
timeoutMs = 10000,
|
||||
): Promise<void> {
|
||||
if (typeof window === "undefined" || !pushBridgeAvailable()) return;
|
||||
if (!rememberedToken()) return;
|
||||
receive();
|
||||
const answer = nextAnswer(timeoutMs);
|
||||
window.location.href = "https://localhost/__push?refresh=1";
|
||||
const r = await answer;
|
||||
if (!r.ok) return;
|
||||
try { await register(r.token); } catch { /* no signal; the next launch tries again */ }
|
||||
}
|
||||
|
||||
/** Tell the shell to stop, and forget the token here. Called on the way out of the app, and it
|
||||
* never blocks signing out: somebody on a ward with no signal still has to be able to leave, and
|
||||
* an orphaned token is reclaimed three other ways — the next registration re-points it, FCM says
|
||||
* it is gone, and a password change clears the lot. Returns the token to hand to `push.forget`. */
|
||||
export function forgetPush(): string | null {
|
||||
const token = rememberedToken();
|
||||
remember(null);
|
||||
if (typeof window !== "undefined" && pushBridgeAvailable()) {
|
||||
try { window.location.href = "https://localhost/__push?forget=1"; } catch { /* nothing to undo */ }
|
||||
}
|
||||
return token;
|
||||
}
|
||||
Reference in New Issue
Block a user