@marktiderman/genesis-native 0.1.0 → 0.3.0

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 (87) hide show
  1. package/README.md +354 -10
  2. package/dist/block-C5-noMhB.d.ts +223 -0
  3. package/dist/blocks-a1YQ1Elf.d.ts +1540 -0
  4. package/dist/chunk-65P7RU4V.js +49 -0
  5. package/dist/chunk-65P7RU4V.js.map +1 -0
  6. package/dist/chunk-6BT4PTGH.js +37 -0
  7. package/dist/chunk-6BT4PTGH.js.map +1 -0
  8. package/dist/chunk-75YP72G6.js +374 -0
  9. package/dist/chunk-75YP72G6.js.map +1 -0
  10. package/dist/chunk-NGYNYJPL.js +90 -0
  11. package/dist/chunk-NGYNYJPL.js.map +1 -0
  12. package/dist/chunk-VKTIHNAV.js +95 -0
  13. package/dist/chunk-VKTIHNAV.js.map +1 -0
  14. package/dist/{chunk-MMTPWUWI.js → chunk-VTJDIKFC.js} +9 -6
  15. package/dist/chunk-VTJDIKFC.js.map +1 -0
  16. package/dist/chunk-XE7LC7K6.js +29 -0
  17. package/dist/chunk-XE7LC7K6.js.map +1 -0
  18. package/dist/dev-warnings.d.ts +34 -0
  19. package/dist/dev-warnings.js +15 -0
  20. package/dist/dev-warnings.js.map +1 -0
  21. package/dist/devtools.d.ts +144 -0
  22. package/dist/devtools.js +390 -0
  23. package/dist/devtools.js.map +1 -0
  24. package/dist/index.d.ts +12 -368
  25. package/dist/index.js +88 -191
  26. package/dist/index.js.map +1 -1
  27. package/dist/observability.d.ts +170 -0
  28. package/dist/observability.js +123 -0
  29. package/dist/observability.js.map +1 -0
  30. package/dist/persist.d.ts +1 -1
  31. package/dist/persist.js +1 -1
  32. package/dist/push.d.ts +169 -0
  33. package/dist/push.js +996 -0
  34. package/dist/push.js.map +1 -0
  35. package/dist/{query-DhH3lLFa.d.ts → query-DUmVAfsF.d.ts} +11 -6
  36. package/dist/updates.d.ts +8 -1
  37. package/dist/updates.js +44 -69
  38. package/dist/updates.js.map +1 -1
  39. package/package.json +222 -5
  40. package/src/app.tsx +491 -0
  41. package/src/auth.tsx +176 -0
  42. package/src/blocks.tsx +144 -0
  43. package/src/boot.tsx +188 -0
  44. package/src/dev-warnings.ts +43 -0
  45. package/src/devtools/block.tsx +488 -0
  46. package/src/devtools/gestures.ts +107 -0
  47. package/src/devtools/index.ts +22 -0
  48. package/src/devtools/info.ts +45 -0
  49. package/src/devtools/slots.ts +21 -0
  50. package/src/errors.tsx +195 -0
  51. package/src/held-link.ts +90 -0
  52. package/src/hydration-gate.ts +88 -0
  53. package/src/index.ts +83 -0
  54. package/src/links.ts +140 -0
  55. package/src/nativewind.d.ts +41 -0
  56. package/src/observability/block.tsx +300 -0
  57. package/src/observability/events.ts +89 -0
  58. package/src/observability/index.ts +44 -0
  59. package/src/observability/scrub.ts +166 -0
  60. package/src/observability/sentry.ts +222 -0
  61. package/src/observability/sink.ts +147 -0
  62. package/src/persist.ts +83 -0
  63. package/src/push/backend.ts +129 -0
  64. package/src/push/block.tsx +545 -0
  65. package/src/push/index.ts +102 -0
  66. package/src/push/modules.ts +111 -0
  67. package/src/push/permission.ts +191 -0
  68. package/src/push/preferences.ts +136 -0
  69. package/src/push/quiet-hours.ts +71 -0
  70. package/src/push/registration.ts +189 -0
  71. package/src/push/routing.ts +27 -0
  72. package/src/push/screens.tsx +46 -0
  73. package/src/push/settings.tsx +378 -0
  74. package/src/query.ts +100 -0
  75. package/src/updates/block.tsx +442 -0
  76. package/src/updates/channels.ts +131 -0
  77. package/src/updates/checker.ts +190 -0
  78. package/src/updates/index.ts +87 -0
  79. package/src/updates/modules.ts +64 -0
  80. package/src/updates/policy.ts +314 -0
  81. package/src/updates/screens.tsx +159 -0
  82. package/src/updates/signature.ts +80 -0
  83. package/src/updates/store.ts +33 -0
  84. package/src/updates/version.ts +39 -0
  85. package/src/updates/whats-new.ts +122 -0
  86. package/dist/block-Dh5ny06W.d.ts +0 -535
  87. package/dist/chunk-MMTPWUWI.js.map +0 -1
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The default renderers, built from `@marktiderman/genesis-ui-native`. Each
3
+ * is the default of one slot (`forceUpgrade`, `maintenance`, `updateBanner`,
4
+ * `whatsNew`) and is exported on its own.
5
+ */
6
+ import { View } from "react-native";
7
+ import { SafeAreaView } from "react-native-safe-area-context";
8
+ import {
9
+ NativeBanner,
10
+ NativeButton,
11
+ NativeEmptyState,
12
+ NativeGlassSurface,
13
+ NativeSheet,
14
+ NativeSheetScrollView,
15
+ NativeText,
16
+ useLiquidGlass,
17
+ } from "@marktiderman/genesis-ui-native/components";
18
+ import type { ReleaseNote } from "./policy";
19
+
20
+ export interface ForceUpgradeProps {
21
+ minimumVersion: string;
22
+ installedVersion: string;
23
+ /** The policy's store link for this platform (a minimum without one forces nothing). */
24
+ storeUrl: string;
25
+ /** Opens `storeUrl`. */
26
+ openStore: () => void;
27
+ /** Ask the policy again (it may have been lowered). */
28
+ retry: () => void;
29
+ checking: boolean;
30
+ }
31
+
32
+ /** Blocking: this version is below the minimum. "Update" opens the store. */
33
+ export function ForceUpgradeScreen({ minimumVersion, installedVersion, openStore, retry, checking }: ForceUpgradeProps) {
34
+ return (
35
+ <View testID="genesis-force-upgrade" className="flex-1 justify-center bg-background">
36
+ <NativeEmptyState
37
+ variant="first-run"
38
+ title="Update required"
39
+ body={`This version (${installedVersion}) is no longer supported. Update to ${minimumVersion} or later to keep going.`}
40
+ primaryAction={
41
+ <NativeButton onPress={openStore} testID="genesis-force-upgrade-store">
42
+ Update
43
+ </NativeButton>
44
+ }
45
+ secondaryAction={
46
+ <NativeButton variant="ghost" onPress={retry} loading={checking} testID="genesis-force-upgrade-retry">
47
+ Check again
48
+ </NativeButton>
49
+ }
50
+ testID="genesis-force-upgrade-state"
51
+ />
52
+ </View>
53
+ );
54
+ }
55
+
56
+ export interface MaintenanceProps {
57
+ /** The policy's message, or `null` for the default copy. */
58
+ message: string | null;
59
+ retry: () => void;
60
+ retrying: boolean;
61
+ }
62
+
63
+ /** Blocking: the policy says maintenance is on. "Try again" asks again. */
64
+ export function MaintenanceScreen({ message, retry, retrying }: MaintenanceProps) {
65
+ return (
66
+ <View testID="genesis-maintenance" className="flex-1 justify-center bg-background">
67
+ <NativeEmptyState
68
+ variant="first-run"
69
+ title="Down for maintenance"
70
+ body={message ?? "We're doing some work on the app. Try again in a few minutes."}
71
+ primaryAction={
72
+ <NativeButton onPress={retry} loading={retrying} testID="genesis-maintenance-retry">
73
+ Try again
74
+ </NativeButton>
75
+ }
76
+ testID="genesis-maintenance-state"
77
+ />
78
+ </View>
79
+ );
80
+ }
81
+
82
+ export interface UpdateBannerProps {
83
+ /** Reload into the downloaded update (refused while `canReload` is false). */
84
+ reload: () => void;
85
+ /** Hide the banner; the update still applies on the next cold start. */
86
+ dismiss: () => void;
87
+ /** False while something is mid-edit (`useBlockReload`) or your `canReload` says no. */
88
+ canReload: boolean;
89
+ /** The last tap was refused because something is mid-edit. */
90
+ blocked: boolean;
91
+ reloading: boolean;
92
+ }
93
+
94
+ /**
95
+ * Non-blocking: floats over the top of the screens while an update is
96
+ * downloaded. Liquid Glass on iOS 26 when the app passed expo-glass-effect
97
+ * (`modules.glass`); the solid secondary surface otherwise.
98
+ */
99
+ export function UpdateBanner({ reload, dismiss, canReload, blocked, reloading }: UpdateBannerProps) {
100
+ // On glass the message drops its own fill, or it would cover the glass.
101
+ const onGlass = useLiquidGlass() !== null;
102
+ return (
103
+ <SafeAreaView edges={["top"]} pointerEvents="box-none" style={{ position: "absolute", top: 0, left: 0, right: 0 }}>
104
+ <View className="mx-3 mt-2">
105
+ <NativeGlassSurface testID="genesis-update-banner" fallbackClassName="overflow-hidden rounded-lg border border-border bg-secondary">
106
+ <NativeBanner
107
+ variant="info"
108
+ title="An update is ready"
109
+ description={blocked || !canReload ? "Finish what you're doing, then restart to use it." : "Restart to use the latest version."}
110
+ className={onGlass ? "border-b-0 border-transparent bg-transparent" : "border-b-0"}
111
+ testID="genesis-update-banner-message"
112
+ />
113
+ <View className="flex-row justify-end gap-2 px-4 pb-3">
114
+ <NativeButton variant="ghost" size="sm" onPress={dismiss} testID="genesis-update-banner-later">
115
+ Later
116
+ </NativeButton>
117
+ <NativeButton size="sm" onPress={reload} disabled={!canReload} loading={reloading} testID="genesis-update-banner-restart">
118
+ Restart
119
+ </NativeButton>
120
+ </View>
121
+ </NativeGlassSurface>
122
+ </View>
123
+ </SafeAreaView>
124
+ );
125
+ }
126
+
127
+ export interface WhatsNewProps {
128
+ /** Newest first. */
129
+ notes: readonly ReleaseNote[];
130
+ /** The installed version. */
131
+ version: string | null;
132
+ /** Close and remember this version. */
133
+ dismiss: () => void;
134
+ }
135
+
136
+ /** The release notes for this version, once, in a sheet. */
137
+ export function WhatsNewSheet({ notes, dismiss }: WhatsNewProps) {
138
+ return (
139
+ <NativeSheet open onOpenChange={(open) => (open ? undefined : dismiss())} testID="genesis-whats-new">
140
+ {/* The notes scroll once they pass the sheet's height. */}
141
+ <NativeSheetScrollView contentContainerClassName="gap-4 p-6">
142
+ <NativeText preset="h3" accessibilityRole="header">
143
+ What's new
144
+ </NativeText>
145
+ {notes.map((note) => (
146
+ <View key={note.version} className="gap-1" testID={`genesis-whats-new-${note.version}`}>
147
+ <NativeText preset="label">{note.title ?? `Version ${note.version}`}</NativeText>
148
+ <NativeText preset="body-sm" className="text-muted-foreground">
149
+ {note.body}
150
+ </NativeText>
151
+ </View>
152
+ ))}
153
+ <NativeButton onPress={dismiss} testID="genesis-whats-new-done">
154
+ Got it
155
+ </NativeButton>
156
+ </NativeSheetScrollView>
157
+ </NativeSheet>
158
+ );
159
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The version signature: which binary and which update this is, in one
3
+ * line for Settings "About" and in a few lines for a support report.
4
+ *
5
+ * 1.4.0 (37) · preview · 3f2a9c1b
6
+ * 1.4.0 (37) · production · embedded
7
+ * 1.4.0 (37) · updates off
8
+ *
9
+ * A value the modules can't give is printed as `unknown`, never left out
10
+ * (an empty string counts as not given: expo-updates gives `""` for the
11
+ * runtime version in a development build); one that doesn't apply, with
12
+ * updates off, as `none`.
13
+ */
14
+ import type { ApplicationLike, UpdatesModuleLike } from "./modules";
15
+
16
+ export interface VersionSignature {
17
+ version: string | null;
18
+ build: string | null;
19
+ platform: string;
20
+ /** The build's channel (expo-updates). */
21
+ channel: string | null;
22
+ /** A channel picked on this device (a test build switched channels); `null` when it follows the build. */
23
+ requestedChannel: string | null;
24
+ runtimeVersion: string | null;
25
+ updateId: string | null;
26
+ /** `update`: a downloaded update runs. `embedded`: the binary's own code. `off`: updates are off (development, Expo Go). */
27
+ launch: "update" | "embedded" | "off";
28
+ /** expo-updates fell back to the embedded code after a failed launch. */
29
+ emergency: boolean;
30
+ /** One line, for an About row. */
31
+ short: string;
32
+ /** Several lines, for a support report. */
33
+ text: string;
34
+ }
35
+
36
+ export interface VersionSignatureInput {
37
+ application?: ApplicationLike;
38
+ updates?: UpdatesModuleLike;
39
+ /** `Platform.OS`. */
40
+ platform: string;
41
+ requestedChannel?: string | null;
42
+ }
43
+
44
+ const UNKNOWN = "unknown";
45
+
46
+ /** A module's value, or null when it gave none (undefined, null or an empty string). */
47
+ const given = (value: string | null | undefined): string | null => (typeof value === "string" && value.trim() !== "" ? value : null);
48
+
49
+ export function versionSignature({ application, updates, platform, requestedChannel = null }: VersionSignatureInput): VersionSignature {
50
+ const version = given(application?.nativeApplicationVersion);
51
+ const build = given(application?.nativeBuildVersion);
52
+ const on = Boolean(updates?.isEnabled);
53
+ const channel = on ? given(updates?.channel) : null;
54
+ const updateId = on ? given(updates?.updateId) : null;
55
+ const runtimeVersion = given(updates?.runtimeVersion);
56
+ const launch: VersionSignature["launch"] = !on ? "off" : updates?.isEmbeddedLaunch ? "embedded" : "update";
57
+ const emergency = on && updates?.isEmergencyLaunch === true;
58
+ const shownChannel = requestedChannel ?? channel;
59
+
60
+ const head = `${version ?? UNKNOWN} (${build ?? UNKNOWN})`;
61
+ const parts = [head];
62
+ if (launch === "off") {
63
+ parts.push("updates off");
64
+ } else {
65
+ parts.push(shownChannel ?? "no channel");
66
+ parts.push(launch === "embedded" ? "embedded" : (updateId?.slice(0, 8) ?? UNKNOWN));
67
+ }
68
+ if (emergency) parts.push("emergency launch");
69
+ const short = parts.join(" · ");
70
+
71
+ const lines = [
72
+ `Version ${head}`,
73
+ `Platform ${platform}`,
74
+ `Channel ${channel ?? (on ? UNKNOWN : "none")}${requestedChannel && requestedChannel !== channel ? ` (switched to ${requestedChannel})` : ""}`,
75
+ `Runtime ${runtimeVersion ?? (on ? UNKNOWN : "none")}`,
76
+ `Update ${launch === "off" ? "updates off" : launch === "embedded" ? "embedded" : (updateId ?? UNKNOWN)}`,
77
+ ];
78
+ if (emergency) lines.push(`Emergency launch: ${updates?.emergencyLaunchReason ?? UNKNOWN}`);
79
+ return { version, build, platform, channel, requestedChannel, runtimeVersion, updateId, launch, emergency, short, text: lines.join("\n") };
80
+ }
@@ -0,0 +1,33 @@
1
+ /** A tiny external store for `useSyncExternalStore`: immutable state, one notify per change. */
2
+ export interface Store<T> {
3
+ getState(): T;
4
+ setState(next: Partial<T>): void;
5
+ subscribe(listener: () => void): () => void;
6
+ }
7
+
8
+ export function createStore<T extends object>(initial: T): Store<T> {
9
+ let state = initial;
10
+ const listeners = new Set<() => void>();
11
+ return {
12
+ getState: () => state,
13
+ setState(next) {
14
+ const merged = { ...state, ...next };
15
+ if ((Object.keys(merged) as (keyof T)[]).every((key) => Object.is(merged[key], state[key]))) return;
16
+ state = merged;
17
+ for (const listener of [...listeners]) listener();
18
+ },
19
+ subscribe(listener) {
20
+ listeners.add(listener);
21
+ return () => {
22
+ listeners.delete(listener);
23
+ };
24
+ },
25
+ };
26
+ }
27
+
28
+ /** Resolves after `ms`, or never when `ms` is not finite. */
29
+ export function delay(ms: number): Promise<void> {
30
+ return new Promise((resolve) => {
31
+ if (Number.isFinite(ms)) setTimeout(resolve, Math.max(0, ms));
32
+ });
33
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Version numbers: the store version an app reports (`1.4.0`, `1.10`) and
3
+ * the minimum a policy sets. Only plain dotted numbers count (one to four
4
+ * parts, as App Store Connect and Play accept). Anything else (`1.2-beta`,
5
+ * `latest`, an empty string, a number too large to compare exactly) does not
6
+ * parse, and a comparison with it has no answer (`null`), so a malformed
7
+ * minimum can never block anyone.
8
+ */
9
+
10
+ const VERSION = /^\d+(?:\.\d+){0,3}$/;
11
+
12
+ /** `"1.10"` → `[1, 10, 0, 0]`; `null` when it isn't a plain dotted version. */
13
+ export function parseVersion(value: unknown): number[] | null {
14
+ if (typeof value !== "string") return null;
15
+ const text = value.trim();
16
+ if (!VERSION.test(text)) return null;
17
+ const parts = text.split(".").map(Number);
18
+ if (parts.some((part) => !Number.isSafeInteger(part))) return null;
19
+ while (parts.length < 4) parts.push(0);
20
+ return parts;
21
+ }
22
+
23
+ /** -1, 0 or 1 as `a` is older than, the same as or newer than `b`; `null` when either doesn't parse. */
24
+ export function compareVersions(a: unknown, b: unknown): -1 | 0 | 1 | null {
25
+ const left = parseVersion(a);
26
+ const right = parseVersion(b);
27
+ if (!left || !right) return null;
28
+ for (let i = 0; i < 4; i++) {
29
+ const l = left[i] ?? 0;
30
+ const r = right[i] ?? 0;
31
+ if (l !== r) return l < r ? -1 : 1;
32
+ }
33
+ return 0;
34
+ }
35
+
36
+ /** True only when both parse and `installed` is older than `minimum`. Fails open. */
37
+ export function isBelowMinimum(installed: unknown, minimum: unknown): boolean {
38
+ return compareVersions(installed, minimum) === -1;
39
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * What's new: release notes shown once per version, remembered in storage.
3
+ *
4
+ * - First launch after an install (nothing stored): nothing shows; the
5
+ * version is remembered, so a new install never opens on a sheet.
6
+ * - A newer version than the one remembered: the notes for every version
7
+ * after it, up to this one, newest first. Closing them remembers this
8
+ * version. With no notes for it, the version is remembered once the policy
9
+ * has answered from the network (or there is no policy source), so notes
10
+ * that arrive late still show.
11
+ * - Storage that can't be read shows nothing (rather than every launch).
12
+ */
13
+ import { createStore, type Store } from "./store";
14
+ import { compareVersions } from "./version";
15
+ import { toError, type UpdatesOnError, type UpdatesStorageLike } from "./modules";
16
+ import type { ReleaseNote } from "./policy";
17
+
18
+ export interface WhatsNewState {
19
+ status: "loading" | "hidden" | "showing";
20
+ notes: readonly ReleaseNote[];
21
+ }
22
+
23
+ export interface WhatsNewOptions {
24
+ storage?: UpdatesStorageLike;
25
+ /** The installed version notes are matched against. `null`: never shows. */
26
+ version: string | null;
27
+ /** Default `"genesis:updates:seen-version"`. */
28
+ storageKey?: string;
29
+ onError?: UpdatesOnError;
30
+ }
31
+
32
+ export interface WhatsNewStore extends Pick<Store<WhatsNewState>, "getState" | "subscribe"> {
33
+ /** Read the remembered version. Never rejects; runs once. */
34
+ load(): Promise<void>;
35
+ /** Recompute with the notes known now; `final` once no more notes can arrive. */
36
+ update(notes: readonly ReleaseNote[], final: boolean): void;
37
+ /** Close the notes and remember this version. */
38
+ dismiss(): void;
39
+ }
40
+
41
+ /** The notes after `seen`, up to `version`, newest first. */
42
+ export function notesSince(notes: readonly ReleaseNote[], seen: string, version: string): ReleaseNote[] {
43
+ return notes
44
+ .filter((note) => {
45
+ if (note.version === version) return seen !== version;
46
+ const afterSeen = compareVersions(note.version, seen);
47
+ const upToNow = compareVersions(note.version, version);
48
+ return afterSeen === 1 && (upToNow === -1 || upToNow === 0);
49
+ })
50
+ .sort((a, b) => compareVersions(b.version, a.version) ?? 0);
51
+ }
52
+
53
+ export function createWhatsNewStore(options: WhatsNewOptions): WhatsNewStore {
54
+ const { storage, version, storageKey = "genesis:updates:seen-version", onError } = options;
55
+ const store = createStore<WhatsNewState>({ status: "loading", notes: [] });
56
+ // undefined: not read yet; null: nothing stored (a fresh install); false: unreadable.
57
+ let seen: string | null | false | undefined;
58
+ let loading: Promise<void> | null = null;
59
+ let pending: { notes: readonly ReleaseNote[]; final: boolean } | null = null;
60
+
61
+ const remember = () => {
62
+ if (!storage || !version) return;
63
+ seen = version;
64
+ Promise.resolve()
65
+ .then(() => storage.setItem(storageKey, version))
66
+ .catch((error) => {
67
+ try {
68
+ onError?.(toError(error), "storage");
69
+ } catch {
70
+ // ignore a failing reporter
71
+ }
72
+ });
73
+ };
74
+
75
+ const compute = (notes: readonly ReleaseNote[], final: boolean) => {
76
+ if (seen === undefined) {
77
+ pending = { notes, final };
78
+ return;
79
+ }
80
+ if (!version || seen === false) return store.setState({ status: "hidden", notes: [] });
81
+ if (seen === null) {
82
+ remember();
83
+ return store.setState({ status: "hidden", notes: [] });
84
+ }
85
+ const due = notesSince(notes, seen, version);
86
+ if (due.length > 0) return store.setState({ status: "showing", notes: due });
87
+ if (final && seen !== version) remember();
88
+ store.setState({ status: "hidden", notes: [] });
89
+ };
90
+
91
+ return {
92
+ getState: store.getState,
93
+ subscribe: store.subscribe,
94
+ load() {
95
+ loading ??= (async () => {
96
+ if (!storage || !version) {
97
+ seen = false;
98
+ } else {
99
+ try {
100
+ seen = await storage.getItem(storageKey);
101
+ } catch (error) {
102
+ seen = false;
103
+ try {
104
+ onError?.(toError(error), "storage");
105
+ } catch {
106
+ // ignore a failing reporter
107
+ }
108
+ }
109
+ }
110
+ const next = pending ?? { notes: [], final: false };
111
+ pending = null;
112
+ compute(next.notes, next.final);
113
+ })();
114
+ return loading;
115
+ },
116
+ update: compute,
117
+ dismiss() {
118
+ remember();
119
+ store.setState({ status: "hidden", notes: [] });
120
+ },
121
+ };
122
+ }