@protobase/ui 0.57.3 → 0.57.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/dist/app/app.js +25 -19
  2. package/dist/app/live-update/app-live-updater.js +31 -0
  3. package/dist/app/live-update/live-update-gate.js +39 -0
  4. package/dist/app/live-update/live-updater.js +74 -0
  5. package/dist/app/live-update/messages.js +14 -0
  6. package/dist/app/live-update/native-fingerprint.js +8 -0
  7. package/dist/app/live-update/release-answer.js +22 -0
  8. package/dist/app/live-update/release-check.js +31 -0
  9. package/dist/app/live-update/swap-decision.js +20 -0
  10. package/dist/app/native-auth-fetch.d.ts +2 -0
  11. package/dist/package.js +1 -1
  12. package/dist/shell/assets/app-CxObXtKG.js +9 -0
  13. package/dist/shell/assets/{index-D9RYusK2.js → index-ChLDTRug.js} +2 -2
  14. package/dist/shell/assets/{records-map-BNTWktmc.js → records-map-B8E3NRrM.js} +2 -2
  15. package/dist/shell/assets/{shared-protobase-ui-I4aWTPvf.js → shared-protobase-ui-4uRv7Kid.js} +2 -2
  16. package/dist/shell/index.html +3 -3
  17. package/dist/shell/protobase.shell.json +3 -3
  18. package/dist/shell/protobase.sw.js +2 -2
  19. package/dist/shell-source-maps/assets/app-CxObXtKG.js.map +1 -0
  20. package/dist/shell-source-maps/assets/{index-D9RYusK2.js.map → index-ChLDTRug.js.map} +1 -1
  21. package/dist/shell-source-maps/assets/{records-map-BNTWktmc.js.map → records-map-B8E3NRrM.js.map} +1 -1
  22. package/dist/shell-source-maps/assets/{shared-protobase-ui-I4aWTPvf.js.map → shared-protobase-ui-4uRv7Kid.js.map} +1 -1
  23. package/package.json +4 -4
  24. package/dist/shell/assets/app-XPhKCfUU.js +0 -9
  25. package/dist/shell-source-maps/assets/app-XPhKCfUU.js.map +0 -1
package/dist/app/app.js CHANGED
@@ -61,6 +61,7 @@ import { NewVersionToast } from "./new-version/new-version-toast.js";
61
61
  import { RuntimeUpdateButton } from "./runtime/runtime-update.js";
62
62
  import { StaffBanner } from "./staff-banner.js";
63
63
  import { useNavRecent } from "./use-nav-recent.js";
64
+ import { LiveUpdateGate } from "./live-update/live-update-gate.js";
64
65
  import { useCallback, useEffect, useMemo, useState } from "react";
65
66
  import { viewOfList } from "@protobase/schema/internal";
66
67
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
@@ -321,27 +322,32 @@ const PublicFormGate = ({ baseUrl, client, ui, children }) => {
321
322
  */
322
323
  const App = ({ auth, workspace, basePath, initialUrl, pluginLoader, locale, nativeBridge, ...rest }) => {
323
324
  setFallbackLocale(locale ?? "en");
324
- return /* @__PURE__ */ jsx(ThemeProvider, { children: /* @__PURE__ */ jsx(AuthProvider, {
325
- session: auth,
326
- ...rest.baseUrl && { origin: new URL(rest.baseUrl, window.location.href).origin },
327
- ...nativeBridge && { nativeBridge },
328
- children: /* @__PURE__ */ jsx(Router, {
329
- basePath,
330
- initialUrl,
331
- children: /* @__PURE__ */ jsx(PluginSourceProvider, {
332
- ...pluginLoader && { loader: pluginLoader },
333
- children: /* @__PURE__ */ jsx(AuthLocale, { children: /* @__PURE__ */ jsx(PublicFormGate, {
334
- baseUrl: rest.baseUrl,
335
- client: rest.client,
336
- ui: rest.ui,
337
- children: /* @__PURE__ */ jsx(AuthGate, {
338
- workspace,
339
- children: /* @__PURE__ */ jsx(Authenticated, {
325
+ const apiOrigin = rest.baseUrl && new URL(rest.baseUrl, window.location.href).origin;
326
+ return /* @__PURE__ */ jsx(ThemeProvider, { children: /* @__PURE__ */ jsx(LiveUpdateGate, {
327
+ nativeBridge,
328
+ apiOrigin,
329
+ children: /* @__PURE__ */ jsx(AuthProvider, {
330
+ session: auth,
331
+ ...apiOrigin && { origin: apiOrigin },
332
+ ...nativeBridge && { nativeBridge },
333
+ children: /* @__PURE__ */ jsx(Router, {
334
+ basePath,
335
+ initialUrl,
336
+ children: /* @__PURE__ */ jsx(PluginSourceProvider, {
337
+ ...pluginLoader && { loader: pluginLoader },
338
+ children: /* @__PURE__ */ jsx(AuthLocale, { children: /* @__PURE__ */ jsx(PublicFormGate, {
339
+ baseUrl: rest.baseUrl,
340
+ client: rest.client,
341
+ ui: rest.ui,
342
+ children: /* @__PURE__ */ jsx(AuthGate, {
340
343
  workspace,
341
- ...rest
344
+ children: /* @__PURE__ */ jsx(Authenticated, {
345
+ workspace,
346
+ ...rest
347
+ })
342
348
  })
343
- })
344
- }) })
349
+ }) })
350
+ })
345
351
  })
346
352
  })
347
353
  }) });
@@ -0,0 +1,31 @@
1
+ import { protobaseVersion } from "../../plugins/plugin-api.js";
2
+ import { createLiveUpdater } from "./live-updater.js";
3
+ import { nativeFingerprintOf } from "./native-fingerprint.js";
4
+ import { checkRelease } from "./release-check.js";
5
+ import { mobileReleasesPath } from "@protobase/schema/internal";
6
+ //#region src/app/live-update/app-live-updater.ts
7
+ /**
8
+ * The live updater of the app the page runs in: `undefined` in a browser, and in a binary without the live updater,
9
+ * such as the iOS app, or one built without `mobile.publicKey`. It asks the server only with the binary's fingerprint
10
+ * and the API origin the page names; without them it only tells the plugin the files started.
11
+ */
12
+ const appLiveUpdater = ({ bridge, apiOrigin, userAgent, fetch, onHold }) => {
13
+ if (!bridge?.Exception || !bridge.isPluginAvailable("LiveUpdate")) return;
14
+ const fingerprint = nativeFingerprintOf(userAgent);
15
+ const check = async () => fingerprint && apiOrigin ? checkRelease(fetch, {
16
+ apiOrigin,
17
+ fingerprint,
18
+ shellVersion: protobaseVersion
19
+ }) : void 0;
20
+ return createLiveUpdater({
21
+ bridge: {
22
+ nativePromise: bridge.nativePromise.bind(bridge),
23
+ Exception: bridge.Exception
24
+ },
25
+ check,
26
+ releasesUrl: `${apiOrigin}${mobileReleasesPath}`,
27
+ onHold
28
+ });
29
+ };
30
+ //#endregion
31
+ export { appLiveUpdater };
@@ -0,0 +1,39 @@
1
+ import { Notice } from "../meta-gate.js";
2
+ import { appLiveUpdater } from "./app-live-updater.js";
3
+ import { text } from "./messages.js";
4
+ import { useEffect, useMemo, useState } from "react";
5
+ import { jsx } from "react/jsx-runtime";
6
+ //#region src/app/live-update/live-update-gate.tsx
7
+ /**
8
+ * The live updater in the iOS or Android app, around everything the shell draws. Once the shell has drawn, it tells
9
+ * the plugin the files started, so a release that fails before then goes back to the app's own; then it checks for a
10
+ * release, at launch and each time the app comes back to the foreground, in the background. Only a required release
11
+ * at launch holds the app, with a notice, until it reloads into it or goes on without it.
12
+ */
13
+ const LiveUpdateGate = ({ nativeBridge, apiOrigin, children }) => {
14
+ const [holding, setHolding] = useState(false);
15
+ const updater = useMemo(() => appLiveUpdater({
16
+ bridge: nativeBridge,
17
+ apiOrigin,
18
+ userAgent: navigator.userAgent,
19
+ fetch: window.fetch.bind(window),
20
+ onHold: setHolding
21
+ }), [nativeBridge, apiOrigin]);
22
+ useEffect(() => {
23
+ if (!updater) return;
24
+ updater.ready();
25
+ updater.check(true);
26
+ const onVisible = () => {
27
+ if (document.visibilityState === "visible") updater.check(false);
28
+ };
29
+ document.addEventListener("visibilitychange", onVisible);
30
+ return () => document.removeEventListener("visibilitychange", onVisible);
31
+ }, [updater]);
32
+ if (holding) return /* @__PURE__ */ jsx(Notice, {
33
+ title: text("holdTitle"),
34
+ children: text("holdLine")
35
+ });
36
+ return children;
37
+ };
38
+ //#endregion
39
+ export { LiveUpdateGate };
@@ -0,0 +1,74 @@
1
+ import { swapDecision } from "./swap-decision.js";
2
+ //#region src/app/live-update/live-updater.ts
3
+ /** How long a required update may hold the app at launch before it goes on with the files it runs. */
4
+ const requiredUpdateTimeoutMs = 6e4;
5
+ const bundleIdOf = (result) => {
6
+ const id = typeof result === "object" && result !== null ? result.bundleId : void 0;
7
+ return typeof id === "string" ? id : null;
8
+ };
9
+ /**
10
+ * The app's side of the live updater, through Protobase's guarded LiveUpdate plugin, which checks every release
11
+ * itself. `ready()` tells the plugin the files the app runs started; `check(launch)` asks the server and takes a new
12
+ * release for the next cold start, one check at a time. A release the guard refuses, or a call that fails, leaves the
13
+ * app on the files it runs.
14
+ */
15
+ const createLiveUpdater = ({ bridge, check, releasesUrl, onHold }) => {
16
+ const call = (method, options = {}) => bridge.nativePromise("LiveUpdate", method, options);
17
+ const refused = (error) => error instanceof bridge.Exception;
18
+ const take = async (bundleId, download) => {
19
+ if (download) await call("downloadBundle", {
20
+ bundleId,
21
+ url: `${releasesUrl}/${bundleId}`,
22
+ artifactType: "manifest"
23
+ });
24
+ await call("setNextBundle", { bundleId });
25
+ };
26
+ const takeHolding = async (bundleId, download) => {
27
+ let holding = true;
28
+ const release = () => {
29
+ if (!holding) return;
30
+ holding = false;
31
+ onHold(false);
32
+ };
33
+ onHold(true);
34
+ const timer = setTimeout(release, requiredUpdateTimeoutMs);
35
+ try {
36
+ await take(bundleId, download);
37
+ if (holding) await call("reload");
38
+ } finally {
39
+ clearTimeout(timer);
40
+ release();
41
+ }
42
+ };
43
+ const run = async (launch) => {
44
+ const [answer, current, next] = await Promise.all([
45
+ check(),
46
+ call("getCurrentBundle"),
47
+ call("getNextBundle")
48
+ ]);
49
+ const swap = swapDecision({
50
+ answer,
51
+ current: bundleIdOf(current),
52
+ next: bundleIdOf(next),
53
+ launch
54
+ });
55
+ if (swap.kind === "none") return;
56
+ try {
57
+ await (swap.reload ? takeHolding(swap.bundleId, swap.download) : take(swap.bundleId, swap.download));
58
+ } catch (error) {
59
+ if (!refused(error)) throw error;
60
+ }
61
+ };
62
+ let running;
63
+ return {
64
+ ready: () => call("ready"),
65
+ check: (launch) => {
66
+ running ??= run(launch).finally(() => {
67
+ running = void 0;
68
+ });
69
+ return running;
70
+ }
71
+ };
72
+ };
73
+ //#endregion
74
+ export { createLiveUpdater, requiredUpdateTimeoutMs };
@@ -0,0 +1,14 @@
1
+ import { defineMessages } from "../../locale/messages.js";
2
+ //#region src/app/live-update/messages.ts
3
+ const text = defineMessages({
4
+ en: {
5
+ holdTitle: "Updating the app",
6
+ holdLine: "The new version is downloading. The app opens it in a moment."
7
+ },
8
+ nl: {
9
+ holdTitle: "De app wordt bijgewerkt",
10
+ holdLine: "De nieuwe versie wordt gedownload. De app opent die zo."
11
+ }
12
+ });
13
+ //#endregion
14
+ export { text };
@@ -0,0 +1,8 @@
1
+ //#region src/app/live-update/native-fingerprint.ts
2
+ /**
3
+ * The native fingerprint the iOS or Android binary names in its user agent, `ProtobaseNative/<fingerprint>`, which
4
+ * `protobase mobile` writes beside the app's files: `undefined` without one.
5
+ */
6
+ const nativeFingerprintOf = (userAgent) => /(?:^|\s)ProtobaseNative\/([0-9a-f]{16})(?:\s|$)/.exec(userAgent)?.[1];
7
+ //#endregion
8
+ export { nativeFingerprintOf };
@@ -0,0 +1,22 @@
1
+ //#region src/app/live-update/release-answer.ts
2
+ const bundleIdPattern = /^[A-Za-z0-9_-]{1,64}$/;
3
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4
+ /**
5
+ * The server's answer to `GET /api/mobile/release` when it is one: `bundleId` a safe id or null, `url` a string or null
6
+ * and `required` a boolean. Any other body is `undefined`, which changes nothing; fields a later server adds are
7
+ * ignored.
8
+ */
9
+ const releaseAnswerOf = (body) => {
10
+ if (!isRecord(body)) return;
11
+ const { bundleId, url, required } = body;
12
+ if (bundleId !== null && (typeof bundleId !== "string" || !bundleIdPattern.test(bundleId))) return;
13
+ if (url !== null && typeof url !== "string") return;
14
+ if (typeof required !== "boolean") return;
15
+ return {
16
+ bundleId,
17
+ url,
18
+ required
19
+ };
20
+ };
21
+ //#endregion
22
+ export { releaseAnswerOf };
@@ -0,0 +1,31 @@
1
+ import { releaseAnswerOf } from "./release-answer.js";
2
+ import { mobileReleasePath, mobileReleaseQuery } from "@protobase/schema/internal";
3
+ //#region src/app/live-update/release-check.ts
4
+ /** How long the app waits for the server's answer before it goes on with the files it runs. */
5
+ const releaseCheckTimeoutMs = 15e3;
6
+ const noAnswer = (error) => error instanceof TypeError || error instanceof DOMException && error.name === "TimeoutError";
7
+ /**
8
+ * Asks the app's server which release this binary should run. `undefined` for anything but a 200 holding the answer:
9
+ * a 503 while the server starts, a 500, no network, the timeout or another body, after which the app keeps the files
10
+ * it runs. The route is public, so no credentials go with it.
11
+ */
12
+ const checkRelease = async (fetch, { apiOrigin, fingerprint, shellVersion }) => {
13
+ const url = `${apiOrigin}${mobileReleasePath}?${mobileReleaseQuery({
14
+ fingerprint,
15
+ shellVersion
16
+ })}`;
17
+ try {
18
+ const response = await fetch(url, {
19
+ credentials: "omit",
20
+ cache: "no-store",
21
+ signal: AbortSignal.timeout(releaseCheckTimeoutMs)
22
+ });
23
+ if (response.status !== 200) return;
24
+ return releaseAnswerOf(await response.json());
25
+ } catch (error) {
26
+ if (noAnswer(error) || error instanceof SyntaxError) return;
27
+ throw error;
28
+ }
29
+ };
30
+ //#endregion
31
+ export { checkRelease, releaseCheckTimeoutMs };
@@ -0,0 +1,20 @@
1
+ //#region src/app/live-update/swap-decision.ts
2
+ /**
3
+ * The live updater's rules. A release the app does not run is downloaded and set as the next bundle, which it runs at
4
+ * its next cold start; at launch, a required one is also reloaded into at once. A release it runs, no release, or no
5
+ * answer changes nothing: the answer is not signed, so it never takes the app back to its own files, which only a
6
+ * release that fails to start does.
7
+ */
8
+ const swapDecision = ({ answer, current, next, launch }) => {
9
+ if (!answer?.bundleId || answer.bundleId === current) return { kind: "none" };
10
+ const reload = launch && answer.required;
11
+ if (answer.bundleId === next && !reload) return { kind: "none" };
12
+ return {
13
+ kind: "take",
14
+ bundleId: answer.bundleId,
15
+ download: answer.bundleId !== next,
16
+ reload
17
+ };
18
+ };
19
+ //#endregion
20
+ export { swapDecision };
@@ -5,5 +5,7 @@ export type NativeBridge = {
5
5
  getPlatform(): string;
6
6
  isPluginAvailable(name: string): boolean;
7
7
  nativePromise(plugin: string, method: string, options: object): Promise<unknown>;
8
+ /** What a plugin's call rejects with. */
9
+ Exception?: abstract new (...args: never[]) => Error;
8
10
  };
9
11
  //#endregion
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.57.3";
2
+ var version = "0.57.4";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };