@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.
- package/README.md +354 -10
- package/dist/block-C5-noMhB.d.ts +223 -0
- package/dist/blocks-a1YQ1Elf.d.ts +1540 -0
- package/dist/chunk-65P7RU4V.js +49 -0
- package/dist/chunk-65P7RU4V.js.map +1 -0
- package/dist/chunk-6BT4PTGH.js +37 -0
- package/dist/chunk-6BT4PTGH.js.map +1 -0
- package/dist/chunk-75YP72G6.js +374 -0
- package/dist/chunk-75YP72G6.js.map +1 -0
- package/dist/chunk-NGYNYJPL.js +90 -0
- package/dist/chunk-NGYNYJPL.js.map +1 -0
- package/dist/chunk-VKTIHNAV.js +95 -0
- package/dist/chunk-VKTIHNAV.js.map +1 -0
- package/dist/{chunk-MMTPWUWI.js → chunk-VTJDIKFC.js} +9 -6
- package/dist/chunk-VTJDIKFC.js.map +1 -0
- package/dist/chunk-XE7LC7K6.js +29 -0
- package/dist/chunk-XE7LC7K6.js.map +1 -0
- package/dist/dev-warnings.d.ts +34 -0
- package/dist/dev-warnings.js +15 -0
- package/dist/dev-warnings.js.map +1 -0
- package/dist/devtools.d.ts +144 -0
- package/dist/devtools.js +390 -0
- package/dist/devtools.js.map +1 -0
- package/dist/index.d.ts +12 -368
- package/dist/index.js +88 -191
- package/dist/index.js.map +1 -1
- package/dist/observability.d.ts +170 -0
- package/dist/observability.js +123 -0
- package/dist/observability.js.map +1 -0
- package/dist/persist.d.ts +1 -1
- package/dist/persist.js +1 -1
- package/dist/push.d.ts +169 -0
- package/dist/push.js +996 -0
- package/dist/push.js.map +1 -0
- package/dist/{query-DhH3lLFa.d.ts → query-DUmVAfsF.d.ts} +11 -6
- package/dist/updates.d.ts +8 -1
- package/dist/updates.js +44 -69
- package/dist/updates.js.map +1 -1
- package/package.json +222 -5
- package/src/app.tsx +491 -0
- package/src/auth.tsx +176 -0
- package/src/blocks.tsx +144 -0
- package/src/boot.tsx +188 -0
- package/src/dev-warnings.ts +43 -0
- package/src/devtools/block.tsx +488 -0
- package/src/devtools/gestures.ts +107 -0
- package/src/devtools/index.ts +22 -0
- package/src/devtools/info.ts +45 -0
- package/src/devtools/slots.ts +21 -0
- package/src/errors.tsx +195 -0
- package/src/held-link.ts +90 -0
- package/src/hydration-gate.ts +88 -0
- package/src/index.ts +83 -0
- package/src/links.ts +140 -0
- package/src/nativewind.d.ts +41 -0
- package/src/observability/block.tsx +300 -0
- package/src/observability/events.ts +89 -0
- package/src/observability/index.ts +44 -0
- package/src/observability/scrub.ts +166 -0
- package/src/observability/sentry.ts +222 -0
- package/src/observability/sink.ts +147 -0
- package/src/persist.ts +83 -0
- package/src/push/backend.ts +129 -0
- package/src/push/block.tsx +545 -0
- package/src/push/index.ts +102 -0
- package/src/push/modules.ts +111 -0
- package/src/push/permission.ts +191 -0
- package/src/push/preferences.ts +136 -0
- package/src/push/quiet-hours.ts +71 -0
- package/src/push/registration.ts +189 -0
- package/src/push/routing.ts +27 -0
- package/src/push/screens.tsx +46 -0
- package/src/push/settings.tsx +378 -0
- package/src/query.ts +100 -0
- package/src/updates/block.tsx +442 -0
- package/src/updates/channels.ts +131 -0
- package/src/updates/checker.ts +190 -0
- package/src/updates/index.ts +87 -0
- package/src/updates/modules.ts +64 -0
- package/src/updates/policy.ts +314 -0
- package/src/updates/screens.tsx +159 -0
- package/src/updates/signature.ts +80 -0
- package/src/updates/store.ts +33 -0
- package/src/updates/version.ts +39 -0
- package/src/updates/whats-new.ts +122 -0
- package/dist/block-Dh5ny06W.d.ts +0 -535
- package/dist/chunk-MMTPWUWI.js.map +0 -1
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The update banner's engine: ask expo-updates for a newer update, download
|
|
3
|
+
* it, and reload only when the person taps and nothing is mid-edit.
|
|
4
|
+
*
|
|
5
|
+
* - It never reloads on its own. A downloaded update also applies on the next
|
|
6
|
+
* cold start, so dismissing the banner loses nothing.
|
|
7
|
+
* - The reload guard says no while any `useBlockReload(true)` is mounted (a
|
|
8
|
+
* form with unsaved changes) or your `canReload()` returns false (or
|
|
9
|
+
* throws). The banner then asks the person to finish first.
|
|
10
|
+
* - expo-updates only serves updates built for this binary's runtime
|
|
11
|
+
* version; an update whose manifest names another runtime is ignored too.
|
|
12
|
+
*/
|
|
13
|
+
import { createStore, type Store } from "./store";
|
|
14
|
+
import { manifestField, toError, type UpdatesModuleLike, type UpdatesOnError } from "./modules";
|
|
15
|
+
|
|
16
|
+
export interface ReloadGuard {
|
|
17
|
+
/** Hold reloads off until the returned function is called. */
|
|
18
|
+
block(): () => void;
|
|
19
|
+
/** True when nothing blocks and your `canReload` agrees. */
|
|
20
|
+
canReload(): boolean;
|
|
21
|
+
/** Fires when a block starts or ends. */
|
|
22
|
+
subscribe(listener: () => void): () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createReloadGuard(canReload?: () => boolean): ReloadGuard {
|
|
26
|
+
let blockers = 0;
|
|
27
|
+
const listeners = new Set<() => void>();
|
|
28
|
+
const notify = () => {
|
|
29
|
+
for (const listener of [...listeners]) listener();
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
block() {
|
|
33
|
+
blockers++;
|
|
34
|
+
notify();
|
|
35
|
+
let released = false;
|
|
36
|
+
return () => {
|
|
37
|
+
if (released) return;
|
|
38
|
+
released = true;
|
|
39
|
+
blockers--;
|
|
40
|
+
notify();
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
canReload() {
|
|
44
|
+
if (blockers > 0) return false;
|
|
45
|
+
try {
|
|
46
|
+
return canReload ? canReload() === true : true;
|
|
47
|
+
} catch {
|
|
48
|
+
return false; // When in doubt, don't throw away someone's work.
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
subscribe(listener) {
|
|
52
|
+
listeners.add(listener);
|
|
53
|
+
return () => {
|
|
54
|
+
listeners.delete(listener);
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type UpdateCheckStatus = "disabled" | "idle" | "checking" | "downloading" | "ready" | "reloading" | "error";
|
|
61
|
+
|
|
62
|
+
export interface UpdateCheckState {
|
|
63
|
+
status: UpdateCheckStatus;
|
|
64
|
+
/** The downloaded update's id, when the manifest has one. */
|
|
65
|
+
updateId: string | null;
|
|
66
|
+
/** The person closed the banner; the update still applies on the next cold start. */
|
|
67
|
+
dismissed: boolean;
|
|
68
|
+
/** The last tap was refused by the reload guard; cleared when the guard opens. */
|
|
69
|
+
blocked: boolean;
|
|
70
|
+
error: Error | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface UpdateCheckerOptions {
|
|
74
|
+
updates?: UpdatesModuleLike;
|
|
75
|
+
guard: ReloadGuard;
|
|
76
|
+
/** Passed to `reloadAsync` (a branded reload screen). */
|
|
77
|
+
reloadScreenOptions?: object;
|
|
78
|
+
now?: () => number;
|
|
79
|
+
onError?: UpdatesOnError;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface UpdateChecker extends Pick<Store<UpdateCheckState>, "getState" | "subscribe"> {
|
|
83
|
+
/** Check and download. Never rejects; does nothing while one is in flight or an update is ready. */
|
|
84
|
+
check(): Promise<void>;
|
|
85
|
+
/** `check()` unless the last one started less than `minIntervalMs` ago. */
|
|
86
|
+
checkIfDue(minIntervalMs: number): Promise<void>;
|
|
87
|
+
/** Reload into the downloaded update. Resolves false when there is none or the guard refuses. */
|
|
88
|
+
reload(): Promise<boolean>;
|
|
89
|
+
dismiss(): void;
|
|
90
|
+
/** Stop following the guard. */
|
|
91
|
+
dispose(): void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function createUpdateChecker(options: UpdateCheckerOptions): UpdateChecker {
|
|
95
|
+
const { updates, guard, reloadScreenOptions, now = Date.now, onError } = options;
|
|
96
|
+
const enabled = Boolean(updates?.isEnabled);
|
|
97
|
+
const store = createStore<UpdateCheckState>({
|
|
98
|
+
status: enabled ? "idle" : "disabled",
|
|
99
|
+
updateId: null,
|
|
100
|
+
dismissed: false,
|
|
101
|
+
blocked: false,
|
|
102
|
+
error: null,
|
|
103
|
+
});
|
|
104
|
+
let inflight: Promise<void> | null = null;
|
|
105
|
+
let lastCheck: number | null = null;
|
|
106
|
+
const report = (error: Error, from: "check" | "reload") => {
|
|
107
|
+
try {
|
|
108
|
+
onError?.(error, from);
|
|
109
|
+
} catch {
|
|
110
|
+
// ignore a failing reporter
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const stopGuard = guard.subscribe(() => {
|
|
114
|
+
if (store.getState().blocked && guard.canReload()) store.setState({ blocked: false });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const check = (): Promise<void> => {
|
|
118
|
+
if (!updates || !enabled) return Promise.resolve();
|
|
119
|
+
const { status } = store.getState();
|
|
120
|
+
if (inflight || status === "ready" || status === "reloading") return inflight ?? Promise.resolve();
|
|
121
|
+
lastCheck = now();
|
|
122
|
+
// eslint-disable-next-line prefer-const -- read in `finally`, after the assignment below
|
|
123
|
+
let run!: Promise<void>;
|
|
124
|
+
run = (async () => {
|
|
125
|
+
// Never settle synchronously (a module that throws before its first await).
|
|
126
|
+
await null;
|
|
127
|
+
try {
|
|
128
|
+
store.setState({ status: "checking", error: null });
|
|
129
|
+
const result = await updates.checkForUpdateAsync();
|
|
130
|
+
if (!result.isAvailable && !result.isRollBackToEmbedded) {
|
|
131
|
+
store.setState({ status: "idle" });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const runtime = manifestField(result.manifest, "runtimeVersion");
|
|
135
|
+
if (runtime && updates.runtimeVersion && runtime !== updates.runtimeVersion) {
|
|
136
|
+
store.setState({ status: "idle" });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
store.setState({ status: "downloading" });
|
|
140
|
+
const fetched = await updates.fetchUpdateAsync();
|
|
141
|
+
const updateId = manifestField(fetched.manifest, "id") ?? manifestField(result.manifest, "id");
|
|
142
|
+
if ((fetched.isNew || fetched.isRollBackToEmbedded) && !(updateId && updateId === updates.updateId)) {
|
|
143
|
+
store.setState({ status: "ready", updateId, dismissed: false });
|
|
144
|
+
} else {
|
|
145
|
+
store.setState({ status: "idle" });
|
|
146
|
+
}
|
|
147
|
+
} catch (raw) {
|
|
148
|
+
const error = toError(raw);
|
|
149
|
+
report(error, "check");
|
|
150
|
+
store.setState({ status: "error", error });
|
|
151
|
+
} finally {
|
|
152
|
+
if (inflight === run) inflight = null;
|
|
153
|
+
}
|
|
154
|
+
})();
|
|
155
|
+
inflight = run;
|
|
156
|
+
return run;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
getState: store.getState,
|
|
161
|
+
subscribe: store.subscribe,
|
|
162
|
+
check,
|
|
163
|
+
checkIfDue(minIntervalMs) {
|
|
164
|
+
const at = now();
|
|
165
|
+
if (lastCheck !== null && at >= lastCheck && at - lastCheck < minIntervalMs) return Promise.resolve();
|
|
166
|
+
return check();
|
|
167
|
+
},
|
|
168
|
+
async reload() {
|
|
169
|
+
if (!updates || store.getState().status !== "ready") return false;
|
|
170
|
+
if (!guard.canReload()) {
|
|
171
|
+
store.setState({ blocked: true });
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
store.setState({ status: "reloading", blocked: false });
|
|
175
|
+
try {
|
|
176
|
+
await updates.reloadAsync(reloadScreenOptions ? { reloadScreenOptions } : undefined);
|
|
177
|
+
return true;
|
|
178
|
+
} catch (raw) {
|
|
179
|
+
const error = toError(raw);
|
|
180
|
+
report(error, "reload");
|
|
181
|
+
store.setState({ status: "ready", error });
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
dismiss() {
|
|
186
|
+
store.setState({ dismissed: true, blocked: false });
|
|
187
|
+
},
|
|
188
|
+
dispose: stopGuard,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@marktiderman/genesis-native/updates`: force upgrade, maintenance, the
|
|
3
|
+
* update banner, what's new, and the version signature for internal builds.
|
|
4
|
+
*
|
|
5
|
+
* Rung 1: `<GenesisNativeApp updates={createUpdates(…)}>`. Rung 2: its
|
|
6
|
+
* `forceUpgrade`, `maintenance`, `updateBanner` and `whatsNew` slots. Rung 3:
|
|
7
|
+
* the screens (`ForceUpgradeScreen`, …) and stores (`createPolicyStore`, …).
|
|
8
|
+
* Rung 4: the hooks (`useForceUpgrade`, …) under `UpdatesProvider`.
|
|
9
|
+
*
|
|
10
|
+
* Optional peers: `expo-updates` (banner, channels) and `expo-application`
|
|
11
|
+
* (the installed version), passed in rather than imported.
|
|
12
|
+
*
|
|
13
|
+
* @stability Beta
|
|
14
|
+
*/
|
|
15
|
+
export {
|
|
16
|
+
createUpdates,
|
|
17
|
+
UPDATES_BLOCK_ID,
|
|
18
|
+
UpdatesProvider,
|
|
19
|
+
UpdatesRoot,
|
|
20
|
+
useUpdates,
|
|
21
|
+
useBlockReload,
|
|
22
|
+
useUpdatePolicy,
|
|
23
|
+
useUpdateVerdict,
|
|
24
|
+
useForceUpgrade,
|
|
25
|
+
useMaintenance,
|
|
26
|
+
useUpdateCheck,
|
|
27
|
+
useCanReload,
|
|
28
|
+
useUpdateBanner,
|
|
29
|
+
useWhatsNew,
|
|
30
|
+
useVersionSignature,
|
|
31
|
+
type UpdatesBlock,
|
|
32
|
+
type UpdatesOptions,
|
|
33
|
+
type UpdatesRootProps,
|
|
34
|
+
type UpdatesSlots,
|
|
35
|
+
} from "./block";
|
|
36
|
+
export {
|
|
37
|
+
ForceUpgradeScreen,
|
|
38
|
+
MaintenanceScreen,
|
|
39
|
+
UpdateBanner,
|
|
40
|
+
WhatsNewSheet,
|
|
41
|
+
type ForceUpgradeProps,
|
|
42
|
+
type MaintenanceProps,
|
|
43
|
+
type UpdateBannerProps,
|
|
44
|
+
type WhatsNewProps,
|
|
45
|
+
} from "./screens";
|
|
46
|
+
export {
|
|
47
|
+
createPolicyStore,
|
|
48
|
+
evaluatePolicy,
|
|
49
|
+
sanitizePolicy,
|
|
50
|
+
staticPolicySource,
|
|
51
|
+
supabasePolicySource,
|
|
52
|
+
CLOCK_SKEW_MS,
|
|
53
|
+
WEEK_MS,
|
|
54
|
+
type EvaluatePolicyOptions,
|
|
55
|
+
type PolicyClientLike,
|
|
56
|
+
type PolicyOrigin,
|
|
57
|
+
type PolicyState,
|
|
58
|
+
type PolicyStore,
|
|
59
|
+
type PolicyStoreOptions,
|
|
60
|
+
type ReleaseNote,
|
|
61
|
+
type SupabasePolicySourceOptions,
|
|
62
|
+
type UpdatePlatform,
|
|
63
|
+
type UpdatePolicy,
|
|
64
|
+
type UpdatePolicySource,
|
|
65
|
+
type UpdateVerdict,
|
|
66
|
+
} from "./policy";
|
|
67
|
+
export {
|
|
68
|
+
createUpdateChecker,
|
|
69
|
+
createReloadGuard,
|
|
70
|
+
type ReloadGuard,
|
|
71
|
+
type UpdateChecker,
|
|
72
|
+
type UpdateCheckerOptions,
|
|
73
|
+
type UpdateCheckState,
|
|
74
|
+
type UpdateCheckStatus,
|
|
75
|
+
} from "./checker";
|
|
76
|
+
export { createWhatsNewStore, notesSince, type WhatsNewOptions, type WhatsNewState, type WhatsNewStore } from "./whats-new";
|
|
77
|
+
export { versionSignature, type VersionSignature, type VersionSignatureInput } from "./signature";
|
|
78
|
+
export { createChannelSwitcher, type ChannelSwitcher, type ChannelSwitcherOptions, type ChannelSwitchResult } from "./channels";
|
|
79
|
+
export { compareVersions, isBelowMinimum, parseVersion } from "./version";
|
|
80
|
+
export type {
|
|
81
|
+
ApplicationLike,
|
|
82
|
+
UpdatesAppStateLike,
|
|
83
|
+
UpdatesErrorSource,
|
|
84
|
+
UpdatesModuleLike,
|
|
85
|
+
UpdatesOnError,
|
|
86
|
+
UpdatesStorageLike,
|
|
87
|
+
} from "./modules";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The native modules the updates block reads, as the shapes it needs. Pass
|
|
3
|
+
* the real ones (`import * as Updates from "expo-updates"`,
|
|
4
|
+
* `import * as Application from "expo-application"`); tests pass fakes.
|
|
5
|
+
* Both are optional peers: without `expo-updates` there is no update banner
|
|
6
|
+
* and no channel switching, and without `expo-application` no force upgrade.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** `expo-updates` (SDK 58): the constants and calls this block uses. */
|
|
10
|
+
export interface UpdatesModuleLike {
|
|
11
|
+
/** False in development, Expo Go, or a build without updates configured. */
|
|
12
|
+
readonly isEnabled: boolean;
|
|
13
|
+
/** The running update's id; `null` when updates are off. */
|
|
14
|
+
readonly updateId: string | null;
|
|
15
|
+
/** The build's channel; `null` in development builds and Expo Go. */
|
|
16
|
+
readonly channel: string | null;
|
|
17
|
+
readonly runtimeVersion: string | null;
|
|
18
|
+
/** True when the code that shipped in the binary is running, not a downloaded update. */
|
|
19
|
+
readonly isEmbeddedLaunch: boolean;
|
|
20
|
+
/** True when expo-updates fell back to the embedded update after a failed launch. */
|
|
21
|
+
readonly isEmergencyLaunch?: boolean;
|
|
22
|
+
readonly emergencyLaunchReason?: string | null;
|
|
23
|
+
checkForUpdateAsync(): Promise<{ isAvailable: boolean; isRollBackToEmbedded?: boolean; manifest?: unknown }>;
|
|
24
|
+
fetchUpdateAsync(): Promise<{ isNew: boolean; isRollBackToEmbedded?: boolean; manifest?: unknown }>;
|
|
25
|
+
reloadAsync(options?: { reloadScreenOptions?: object }): Promise<void>;
|
|
26
|
+
/** Overrides the `expo-channel-name` header (persists on the device); `null` removes the override. */
|
|
27
|
+
setUpdateRequestHeadersOverride?(requestHeaders: Record<string, string> | null): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `expo-application`: the installed store version and build number. */
|
|
31
|
+
export interface ApplicationLike {
|
|
32
|
+
/** `CFBundleShortVersionString` / `versionName`, e.g. `"1.4.0"`. */
|
|
33
|
+
readonly nativeApplicationVersion: string | null;
|
|
34
|
+
/** `CFBundleVersion` / `versionCode`, e.g. `"37"`. */
|
|
35
|
+
readonly nativeBuildVersion: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** AsyncStorage, MMKV's async adapter, or a stand-in. */
|
|
39
|
+
export interface UpdatesStorageLike {
|
|
40
|
+
getItem(key: string): Promise<string | null>;
|
|
41
|
+
setItem(key: string, value: string): Promise<void>;
|
|
42
|
+
removeItem?(key: string): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** React Native's `AppState`. */
|
|
46
|
+
export interface UpdatesAppStateLike {
|
|
47
|
+
addEventListener(type: "change", listener: (state: string) => void): { remove(): void };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Where a reported error came from. */
|
|
51
|
+
export type UpdatesErrorSource = "policy" | "check" | "reload" | "storage" | "channel";
|
|
52
|
+
|
|
53
|
+
export type UpdatesOnError = (error: Error, source: UpdatesErrorSource) => void;
|
|
54
|
+
|
|
55
|
+
export function toError(value: unknown): Error {
|
|
56
|
+
return value instanceof Error ? value : new Error(typeof value === "string" ? value : "Unknown error");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The `id` (and `runtimeVersion`) of an update manifest, when it has them. */
|
|
60
|
+
export function manifestField(manifest: unknown, field: "id" | "runtimeVersion"): string | null {
|
|
61
|
+
if (!manifest || typeof manifest !== "object") return null;
|
|
62
|
+
const value = (manifest as Record<string, unknown>)[field];
|
|
63
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
64
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The release policy: the oldest store version each platform may run, a
|
|
3
|
+
* maintenance switch, and release notes. It comes from an
|
|
4
|
+
* `UpdatePolicySource` (a static object, Supabase, or your own), is cached on
|
|
5
|
+
* the phone, and **fails open**: no answer, a timeout, a malformed value or
|
|
6
|
+
* an untrusted cache never blocks anyone.
|
|
7
|
+
*
|
|
8
|
+
* What may block, and from where:
|
|
9
|
+
* - Force upgrade: a policy fetched this session, or a cached one no older
|
|
10
|
+
* than `cacheMaxAgeMs` (7 days) by the phone's clock. A cache whose time is
|
|
11
|
+
* in the future (the clock moved back) is not trusted. A minimum without a
|
|
12
|
+
* store link for the platform forces nothing (there would be no way out).
|
|
13
|
+
* - Maintenance: only a policy fetched from the network this session. A
|
|
14
|
+
* cached maintenance flag never blocks a cold start.
|
|
15
|
+
*/
|
|
16
|
+
import { createStore, delay, type Store } from "./store";
|
|
17
|
+
import { isBelowMinimum } from "./version";
|
|
18
|
+
import { toError, type UpdatesOnError, type UpdatesStorageLike } from "./modules";
|
|
19
|
+
|
|
20
|
+
export type UpdatePlatform = "ios" | "android";
|
|
21
|
+
|
|
22
|
+
export interface ReleaseNote {
|
|
23
|
+
/** The store version these notes describe, e.g. `"1.4.0"`. */
|
|
24
|
+
version: string;
|
|
25
|
+
title?: string;
|
|
26
|
+
body: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface UpdatePolicy {
|
|
30
|
+
/** The oldest store version still supported, per platform. Missing or malformed: no minimum. */
|
|
31
|
+
minimumVersion?: Partial<Record<UpdatePlatform, string>>;
|
|
32
|
+
/** Where "Update" goes (`https://apps.apple.com/…`, `itms-apps://…`, `market://…`, `https://play.google.com/…`). */
|
|
33
|
+
storeUrl?: Partial<Record<UpdatePlatform, string>>;
|
|
34
|
+
maintenance?: { active: boolean; message: string | null };
|
|
35
|
+
notes?: readonly ReleaseNote[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Where the policy comes from. `load` throws (or rejects) when it can't answer; resolving `null` means "no policy". */
|
|
39
|
+
export interface UpdatePolicySource {
|
|
40
|
+
load(): Promise<UpdatePolicy | null | undefined>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A policy written in the app (or a test). */
|
|
44
|
+
export function staticPolicySource(policy: UpdatePolicy): UpdatePolicySource {
|
|
45
|
+
return { load: async () => policy };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A Supabase client: anything with `from(table)` (a real `SupabaseClient` fits without a cast). */
|
|
49
|
+
export interface PolicyClientLike {
|
|
50
|
+
from(table: string): unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface PolicyQuery {
|
|
54
|
+
select(columns: string): { eq(column: string, value: string): { maybeSingle(): PromiseLike<{ data: unknown; error: unknown }> } };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SupabasePolicySourceOptions {
|
|
58
|
+
/** The `app_id` row to read. Default `"default"`. */
|
|
59
|
+
appId?: string;
|
|
60
|
+
/** Default `"app_release_policy"` (migration `0002_genesis_app_release_policy.sql` in `@marktiderman/genesis-supabase`). */
|
|
61
|
+
table?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const COLUMNS = "min_version_ios,min_version_android,store_url_ios,store_url_android,maintenance,maintenance_message,notes";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Reads one row of `public.app_release_policy` (readable by anon and
|
|
68
|
+
* authenticated, written only with the service role). No row is no policy.
|
|
69
|
+
*/
|
|
70
|
+
export function supabasePolicySource(client: PolicyClientLike, options: SupabasePolicySourceOptions = {}): UpdatePolicySource {
|
|
71
|
+
const { appId = "default", table = "app_release_policy" } = options;
|
|
72
|
+
return {
|
|
73
|
+
async load() {
|
|
74
|
+
const query = client.from(table) as PolicyQuery;
|
|
75
|
+
const { data, error } = await query.select(COLUMNS).eq("app_id", appId).maybeSingle();
|
|
76
|
+
if (error) throw toError(error instanceof Error ? error : (error as { message?: unknown }).message ?? error);
|
|
77
|
+
if (!data || typeof data !== "object") return {};
|
|
78
|
+
const row = data as Record<string, unknown>;
|
|
79
|
+
return {
|
|
80
|
+
minimumVersion: pick({ ios: row.min_version_ios, android: row.min_version_android }),
|
|
81
|
+
storeUrl: pick({ ios: row.store_url_ios, android: row.store_url_android }),
|
|
82
|
+
maintenance: { active: row.maintenance === true, message: typeof row.maintenance_message === "string" ? row.maintenance_message : null },
|
|
83
|
+
notes: Array.isArray(row.notes) ? (row.notes as ReleaseNote[]) : [],
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function pick(values: Record<UpdatePlatform, unknown>): Partial<Record<UpdatePlatform, string>> {
|
|
90
|
+
const out: Partial<Record<UpdatePlatform, string>> = {};
|
|
91
|
+
for (const platform of ["ios", "android"] as const) {
|
|
92
|
+
const value = values[platform];
|
|
93
|
+
if (typeof value === "string" && value.trim() !== "") out[platform] = value.trim();
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const STORE_URL = /^(https:\/\/|itms-apps:\/\/|market:\/\/)/i;
|
|
99
|
+
|
|
100
|
+
/** Keeps only well-formed fields; throws when `raw` isn't an object at all (so the cache answers instead). */
|
|
101
|
+
export function sanitizePolicy(raw: unknown): UpdatePolicy {
|
|
102
|
+
if (raw === null || raw === undefined) return {};
|
|
103
|
+
if (typeof raw !== "object" || Array.isArray(raw)) throw new Error("The release policy is not an object");
|
|
104
|
+
const value = raw as Record<string, unknown>;
|
|
105
|
+
const policy: UpdatePolicy = {};
|
|
106
|
+
const minimum = value.minimumVersion && typeof value.minimumVersion === "object" ? (value.minimumVersion as Record<UpdatePlatform, unknown>) : null;
|
|
107
|
+
if (minimum) policy.minimumVersion = pick(minimum);
|
|
108
|
+
const store = value.storeUrl && typeof value.storeUrl === "object" ? pick(value.storeUrl as Record<UpdatePlatform, unknown>) : null;
|
|
109
|
+
if (store) {
|
|
110
|
+
for (const platform of ["ios", "android"] as const) if (store[platform] && !STORE_URL.test(store[platform])) delete store[platform];
|
|
111
|
+
policy.storeUrl = store;
|
|
112
|
+
}
|
|
113
|
+
const maintenance = value.maintenance as { active?: unknown; message?: unknown } | null | undefined;
|
|
114
|
+
if (maintenance && typeof maintenance === "object") {
|
|
115
|
+
policy.maintenance = {
|
|
116
|
+
active: maintenance.active === true,
|
|
117
|
+
message: typeof maintenance.message === "string" && maintenance.message.trim() !== "" ? maintenance.message : null,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (Array.isArray(value.notes)) {
|
|
121
|
+
policy.notes = value.notes.flatMap((note): ReleaseNote[] => {
|
|
122
|
+
if (!note || typeof note !== "object") return [];
|
|
123
|
+
const { version, title, body } = note as Record<string, unknown>;
|
|
124
|
+
if (typeof version !== "string" || typeof body !== "string") return [];
|
|
125
|
+
return [{ version: version.trim(), body, ...(typeof title === "string" ? { title } : {}) }];
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return policy;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type PolicyOrigin = "network" | "cache" | "none";
|
|
132
|
+
|
|
133
|
+
export interface PolicyState {
|
|
134
|
+
/** `loading` until the first answer (or the timeout); then `ready`, with or without a policy. */
|
|
135
|
+
status: "loading" | "ready";
|
|
136
|
+
policy: UpdatePolicy | null;
|
|
137
|
+
/** `network`: fetched this session. `cache`: read from the phone. `none`: no answer yet (fail open). */
|
|
138
|
+
origin: PolicyOrigin;
|
|
139
|
+
/** When it was fetched, by the phone's clock (ms). */
|
|
140
|
+
fetchedAt: number | null;
|
|
141
|
+
/** The last failed fetch, if the most recent one failed. */
|
|
142
|
+
error: Error | null;
|
|
143
|
+
/** A fetch is in flight. */
|
|
144
|
+
refreshing: boolean;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface PolicyStoreOptions {
|
|
148
|
+
source: UpdatePolicySource;
|
|
149
|
+
storage?: UpdatesStorageLike;
|
|
150
|
+
/** Default `"genesis:updates:policy"`. */
|
|
151
|
+
cacheKey?: string;
|
|
152
|
+
/** The longest `load()` waits before carrying on without an answer. Default 3000 ms. */
|
|
153
|
+
timeoutMs?: number;
|
|
154
|
+
now?: () => number;
|
|
155
|
+
onError?: UpdatesOnError;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface PolicyStore extends Pick<Store<PolicyState>, "getState" | "subscribe"> {
|
|
159
|
+
/** The first load: the cache and the network together, for at most `timeoutMs`. Never rejects; runs once. */
|
|
160
|
+
load(): Promise<void>;
|
|
161
|
+
/** Fetch again (maintenance "Try again", the app coming to the front). Never rejects; resolves true on an answer. */
|
|
162
|
+
refresh(): Promise<boolean>;
|
|
163
|
+
/** `refresh()` unless one ran less than `minIntervalMs` ago. */
|
|
164
|
+
refreshIfDue(minIntervalMs: number): Promise<boolean>;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface CacheEntry {
|
|
168
|
+
v: 1;
|
|
169
|
+
savedAt: number;
|
|
170
|
+
policy: UpdatePolicy;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function createPolicyStore(options: PolicyStoreOptions): PolicyStore {
|
|
174
|
+
const { source, storage, cacheKey = "genesis:updates:policy", now = Date.now, onError } = options;
|
|
175
|
+
const timeoutMs = validTimeout(options.timeoutMs, 3000);
|
|
176
|
+
const store = createStore<PolicyState>({ status: "loading", policy: null, origin: "none", fetchedAt: null, error: null, refreshing: false });
|
|
177
|
+
let inflight: Promise<boolean> | null = null;
|
|
178
|
+
let firstLoad: Promise<void> | null = null;
|
|
179
|
+
let lastAttempt: number | null = null;
|
|
180
|
+
const report = (error: unknown, from: "policy" | "storage") => {
|
|
181
|
+
try {
|
|
182
|
+
onError?.(toError(error), from);
|
|
183
|
+
} catch {
|
|
184
|
+
// A failing reporter must not change the answer.
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const fetchNetwork = (): Promise<boolean> => {
|
|
189
|
+
if (inflight) return inflight;
|
|
190
|
+
lastAttempt = now();
|
|
191
|
+
store.setState({ refreshing: true });
|
|
192
|
+
// eslint-disable-next-line prefer-const -- read in `finally`, after the assignment below
|
|
193
|
+
let run!: Promise<boolean>;
|
|
194
|
+
run = (async () => {
|
|
195
|
+
// Never settle synchronously: a source that throws before its first await must not leave a settled `inflight` behind.
|
|
196
|
+
await null;
|
|
197
|
+
try {
|
|
198
|
+
const policy = sanitizePolicy(await source.load());
|
|
199
|
+
const fetchedAt = now();
|
|
200
|
+
store.setState({ status: "ready", policy, origin: "network", fetchedAt, error: null });
|
|
201
|
+
if (storage) {
|
|
202
|
+
const entry: CacheEntry = { v: 1, savedAt: fetchedAt, policy };
|
|
203
|
+
Promise.resolve()
|
|
204
|
+
.then(() => storage.setItem(cacheKey, JSON.stringify(entry)))
|
|
205
|
+
.catch((error) => report(error, "storage"));
|
|
206
|
+
}
|
|
207
|
+
return true;
|
|
208
|
+
} catch (error) {
|
|
209
|
+
report(error, "policy");
|
|
210
|
+
// A network answer from earlier this session stays; the cache (if any) was already applied.
|
|
211
|
+
store.setState({ error: toError(error) });
|
|
212
|
+
return false;
|
|
213
|
+
} finally {
|
|
214
|
+
if (inflight === run) inflight = null;
|
|
215
|
+
store.setState({ refreshing: false });
|
|
216
|
+
}
|
|
217
|
+
})();
|
|
218
|
+
inflight = run;
|
|
219
|
+
return run;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const readCache = async () => {
|
|
223
|
+
if (!storage) return;
|
|
224
|
+
try {
|
|
225
|
+
const entry = parseCache(await storage.getItem(cacheKey));
|
|
226
|
+
if (entry && store.getState().origin !== "network") {
|
|
227
|
+
store.setState({ policy: entry.policy, origin: "cache", fetchedAt: entry.savedAt });
|
|
228
|
+
}
|
|
229
|
+
} catch (error) {
|
|
230
|
+
report(error, "storage");
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
getState: store.getState,
|
|
236
|
+
subscribe: store.subscribe,
|
|
237
|
+
load() {
|
|
238
|
+
firstLoad ??= (async () => {
|
|
239
|
+
const cache = readCache();
|
|
240
|
+
const network = fetchNetwork();
|
|
241
|
+
const answered = network.then(async (ok) => {
|
|
242
|
+
if (!ok) await cache;
|
|
243
|
+
});
|
|
244
|
+
await Promise.race([answered, delay(timeoutMs)]);
|
|
245
|
+
store.setState({ status: "ready" });
|
|
246
|
+
})();
|
|
247
|
+
return firstLoad;
|
|
248
|
+
},
|
|
249
|
+
refresh: fetchNetwork,
|
|
250
|
+
refreshIfDue(minIntervalMs) {
|
|
251
|
+
const at = now();
|
|
252
|
+
// A clock that moved back makes the last attempt look in the future: refresh.
|
|
253
|
+
if (lastAttempt !== null && at >= lastAttempt && at - lastAttempt < minIntervalMs) return Promise.resolve(false);
|
|
254
|
+
return fetchNetwork();
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** A finite, non-negative timeout, or `fallback` (a NaN or Infinity would wait forever). */
|
|
260
|
+
export function validTimeout(value: number | undefined, fallback: number): number {
|
|
261
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function parseCache(raw: string | null): CacheEntry | null {
|
|
265
|
+
if (!raw) return null;
|
|
266
|
+
try {
|
|
267
|
+
const entry = JSON.parse(raw) as Partial<CacheEntry>;
|
|
268
|
+
if (entry?.v !== 1 || typeof entry.savedAt !== "number" || !Number.isFinite(entry.savedAt)) return null;
|
|
269
|
+
return { v: 1, savedAt: entry.savedAt, policy: sanitizePolicy(entry.policy) };
|
|
270
|
+
} catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** What the policy says this install must do. */
|
|
276
|
+
export type UpdateVerdict =
|
|
277
|
+
| { kind: "ok" }
|
|
278
|
+
| { kind: "forceUpgrade"; minimumVersion: string; installedVersion: string; storeUrl: string }
|
|
279
|
+
| { kind: "maintenance"; message: string | null };
|
|
280
|
+
|
|
281
|
+
export interface EvaluatePolicyOptions {
|
|
282
|
+
/** `Platform.OS`. Only `ios` and `android` can be forced to upgrade. */
|
|
283
|
+
platform: string;
|
|
284
|
+
/** The installed store version (`Application.nativeApplicationVersion`). `null`: never forced. */
|
|
285
|
+
installedVersion: string | null;
|
|
286
|
+
now: number;
|
|
287
|
+
/** How old a cached policy may be and still force an upgrade. Default 7 days. */
|
|
288
|
+
cacheMaxAgeMs?: number;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** How far in the future a cache's time may be (clocks drift) before it isn't trusted. */
|
|
292
|
+
export const CLOCK_SKEW_MS = 5 * 60_000;
|
|
293
|
+
export const WEEK_MS = 7 * 24 * 60 * 60_000;
|
|
294
|
+
|
|
295
|
+
/** Force upgrade first (the store is the way out), then maintenance, else ok. Fails open. */
|
|
296
|
+
export function evaluatePolicy(state: PolicyState, options: EvaluatePolicyOptions): UpdateVerdict {
|
|
297
|
+
const { policy, origin, fetchedAt } = state;
|
|
298
|
+
if (!policy) return { kind: "ok" };
|
|
299
|
+
const { platform, installedVersion, now, cacheMaxAgeMs = WEEK_MS } = options;
|
|
300
|
+
const age = fetchedAt === null ? null : now - fetchedAt;
|
|
301
|
+
const trusted = origin === "network" || (origin === "cache" && age !== null && age >= -CLOCK_SKEW_MS && age <= cacheMaxAgeMs);
|
|
302
|
+
if (trusted && (platform === "ios" || platform === "android")) {
|
|
303
|
+
const minimum = policy.minimumVersion?.[platform];
|
|
304
|
+
const storeUrl = policy.storeUrl?.[platform];
|
|
305
|
+
// Without a store link there is no way out of the screen, so no store link means no force upgrade.
|
|
306
|
+
if (minimum && storeUrl && installedVersion && isBelowMinimum(installedVersion, minimum)) {
|
|
307
|
+
return { kind: "forceUpgrade", minimumVersion: minimum.trim(), installedVersion, storeUrl };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (origin === "network" && policy.maintenance?.active === true) {
|
|
311
|
+
return { kind: "maintenance", message: policy.maintenance.message };
|
|
312
|
+
}
|
|
313
|
+
return { kind: "ok" };
|
|
314
|
+
}
|