@wireai/activation 0.14.0 → 0.14.2
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/CHANGELOG.md +81 -0
- package/README.md +5 -0
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/coachmarks/index.js +69 -6
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs +69 -6
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/index.d.mts +7 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.js +29 -8
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +29 -8
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.js +14 -6
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +14 -6
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.js +14 -6
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +14 -6
- package/dist/reviews/index.mjs.map +1 -1
- package/metro/index.js +3 -1
- package/package.json +1 -1
- package/src/WireOnboarding.tsx +44 -5
- package/src/analytics/currentSession.ts +3 -2
- package/src/coachmarks/SpotlightOverlay.tsx +38 -11
- package/src/coachmarks/expoBlur.ts +135 -0
- package/src/permissions/permissionMemory.ts +39 -14
- package/src/reviews/runtime.ts +75 -23
package/metro/index.js
CHANGED
|
@@ -71,7 +71,7 @@ const SINGLE_INSTANCE_DEPS = ['react', 'react-native', 'wireai-rn', 'zod'];
|
|
|
71
71
|
* only thing holding it up.
|
|
72
72
|
*
|
|
73
73
|
* ONLY modules read through a guarded require/import belong here. A module the kit imports
|
|
74
|
-
* STATICALLY at module scope (react-native-reanimated,
|
|
74
|
+
* STATICALLY at module scope (react-native-reanimated,
|
|
75
75
|
* @blazejkustra/react-native-onboarding) must NOT be stubbed: an empty module would turn a loud
|
|
76
76
|
* build-time "Unable to resolve module" into a silent `undefined` component that crashes at
|
|
77
77
|
* render, which is strictly worse. `test/canary/metroResolution.test.tsx` keeps this list in sync
|
|
@@ -89,6 +89,8 @@ const OPTIONAL_MODULES = [
|
|
|
89
89
|
'expo-store-review',
|
|
90
90
|
// src/haptics/haptics.ts — haptic feedback; absent → silent no-op.
|
|
91
91
|
'expo-haptics',
|
|
92
|
+
// src/coachmarks/expoBlur.ts — the coachmark spotlight frost; absent → plain dimmed scrim.
|
|
93
|
+
'expo-blur',
|
|
92
94
|
];
|
|
93
95
|
|
|
94
96
|
function withWireOnboarding(config, options) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wireai/activation",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Premium, fully-themable drop-in AI onboarding kit for React Native / Expo, on top of the open-source wireai-rn SDK.",
|
|
6
6
|
"author": "Malik Chohra <malik@getwireai.com>",
|
package/src/WireOnboarding.tsx
CHANGED
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
} from "./session/persistedSession";
|
|
37
37
|
import {
|
|
38
38
|
clearSettledPermissions,
|
|
39
|
-
|
|
39
|
+
loadSettledPermissionsOutcome,
|
|
40
40
|
permissionStorageKey,
|
|
41
41
|
saveSettledPermissions,
|
|
42
42
|
} from "./permissions/permissionMemory";
|
|
@@ -384,18 +384,57 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
384
384
|
// a late arrival cannot recreate the A2A adapter. The flow simply suppresses permission screens
|
|
385
385
|
// while `permissionsPending` is true, and that window closes long before the first card arrives
|
|
386
386
|
// (one timeout-capped storage read against a backend round trip).
|
|
387
|
+
//
|
|
388
|
+
// ── AN UNREADABLE RECORD IS NOT AN EMPTY ONE (ruled 2026-08-18: skip, never re-ask) ──────────
|
|
389
|
+
//
|
|
390
|
+
// A read that timed out or threw used to arrive here as `[]`, i.e. "this session has settled
|
|
391
|
+
// nothing", and that guess is wrong in the expensive direction TWICE. The flow re-asks a
|
|
392
|
+
// permission the OS grants exactly once, AND the first answer then persists a set grown from
|
|
393
|
+
// that `[]`, overwriting a stored record that may have held several ids with a single one. A
|
|
394
|
+
// slow adapter could therefore permanently shrink the memory it exists to keep.
|
|
395
|
+
//
|
|
396
|
+
// So `unknown` is now its own outcome and it means: leave the memory UNRESOLVED. Because
|
|
397
|
+
// `permissionsPending` below is exactly `settledPermissions === undefined`, an unresolved memory
|
|
398
|
+
// suppresses every permission screen for this mount — which is the ruling, and it also closes
|
|
399
|
+
// the write path for free (no screen renders → nothing settles → `saveSettledPermissions` is
|
|
400
|
+
// never called → the stored record is left exactly as it was).
|
|
401
|
+
//
|
|
402
|
+
// The cost is stated rather than hidden: on a host whose adapter never answers, configured
|
|
403
|
+
// permission screens silently never show. That is the failure `placement.ts` calls out as the
|
|
404
|
+
// one a host cannot see, so it warns in dev naming the adapter.
|
|
387
405
|
const wantsPermissionMemory = Boolean(storage) && (permissionScreens?.length ?? 0) > 0;
|
|
388
406
|
const [settledPermissions, setSettledPermissions] = useState<string[] | undefined>(undefined);
|
|
407
|
+
// LATCHED: without it, a hung or throwing adapter is re-read on every single render — an
|
|
408
|
+
// unbounded read loop against the storage that is already failing.
|
|
409
|
+
const [permissionMemoryUnreadable, setPermissionMemoryUnreadable] = useState(false);
|
|
389
410
|
useEffect(() => {
|
|
390
|
-
if (!wantsPermissionMemory || !storage || !sessionId
|
|
411
|
+
if (!wantsPermissionMemory || !storage || !sessionId) return;
|
|
412
|
+
if (settledPermissions !== undefined || permissionMemoryUnreadable) return;
|
|
391
413
|
let cancelled = false;
|
|
392
|
-
void
|
|
393
|
-
if (
|
|
414
|
+
void loadSettledPermissionsOutcome(storage, permissionsKey, sessionId).then((outcome) => {
|
|
415
|
+
if (cancelled) return;
|
|
416
|
+
if (outcome.status === "read") {
|
|
417
|
+
setSettledPermissions(outcome.ids);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
setPermissionMemoryUnreadable(true);
|
|
421
|
+
warnInDev(
|
|
422
|
+
"[wireai] the permission-screen memory could not be read (the storage adapter timed out " +
|
|
423
|
+
"or threw), so permission screens are suppressed for this session rather than re-asking " +
|
|
424
|
+
"a permission the OS grants once. Check the `storage` adapter passed to WireOnboarding.",
|
|
425
|
+
);
|
|
394
426
|
});
|
|
395
427
|
return () => {
|
|
396
428
|
cancelled = true;
|
|
397
429
|
};
|
|
398
|
-
}, [
|
|
430
|
+
}, [
|
|
431
|
+
wantsPermissionMemory,
|
|
432
|
+
storage,
|
|
433
|
+
permissionsKey,
|
|
434
|
+
sessionId,
|
|
435
|
+
settledPermissions,
|
|
436
|
+
permissionMemoryUnreadable,
|
|
437
|
+
]);
|
|
399
438
|
|
|
400
439
|
// Persist the settled set as it grows. Fire-and-forget: a failed write only costs a re-ask on a
|
|
401
440
|
// resume, never a broken flow. Without `storage` there is nothing to write to and the
|
|
@@ -63,8 +63,9 @@ import { warnInDev } from "../utils/warnInDev";
|
|
|
63
63
|
* @globalSlot LIVE — every app-open overwrites this with that open's id, so a reader that captures
|
|
64
64
|
* it into a module-local (or a `const` taken once at mount) posts the PREVIOUS open's session to a
|
|
65
65
|
* server that has already moved on. Read it at the moment of use, through `getCurrentSessionId()` /
|
|
66
|
-
* `ensureCurrentSessionId()`. `reviews/runtime`'s `currentOpenId()`
|
|
67
|
-
*
|
|
66
|
+
* `ensureCurrentSessionId()`. `reviews/runtime`'s `currentOpenId()` derives its own pinned value
|
|
67
|
+
* from this slot: it holds one sample for the duration of an app-open and re-pins when a genuinely
|
|
68
|
+
* NEW open is registered here, so it follows this slot deliberately and late, never eagerly.
|
|
68
69
|
*/
|
|
69
70
|
const CURRENT_SESSION_ID_SLOT: unique symbol = Symbol.for(
|
|
70
71
|
"@wireai/activation:currentSessionId",
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { BlurView } from "expo-blur";
|
|
2
1
|
import React, { useEffect, useMemo } from "react";
|
|
3
2
|
import {
|
|
4
3
|
Pressable,
|
|
@@ -20,11 +19,10 @@ import Animated, {
|
|
|
20
19
|
} from "react-native-reanimated";
|
|
21
20
|
|
|
22
21
|
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
22
|
+
import { resolveBlurView } from "./expoBlur";
|
|
23
23
|
import { GestureHint } from "./GestureHint";
|
|
24
24
|
import type { GestureKind, Placement, TargetRect } from "./types";
|
|
25
25
|
|
|
26
|
-
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
|
|
27
|
-
|
|
28
26
|
const BLUR_INTENSITY = 26;
|
|
29
27
|
const FADE_MS = 280;
|
|
30
28
|
/** Breathing room between the highlighted element and the glowing ring. */
|
|
@@ -51,13 +49,20 @@ export interface SpotlightOverlayProps {
|
|
|
51
49
|
}
|
|
52
50
|
|
|
53
51
|
/**
|
|
54
|
-
* A single coachmark step: the whole screen is frosted with an animated blur
|
|
55
|
-
*
|
|
56
|
-
*
|
|
52
|
+
* A single coachmark step: the whole screen is frosted with an animated blur
|
|
53
|
+
* (or a plain dimmed scrim when the optional `expo-blur` peer is absent), and a
|
|
54
|
+
* bright pulsing ring + tooltip point at one measured target. Mounted at the app
|
|
55
|
+
* root (above the tab bar) via CoachmarkOverlayHost.
|
|
57
56
|
*
|
|
58
57
|
* Performance: one BlurView (mounted only while a step is visible), the ring
|
|
59
58
|
* glow is a single reanimated view, and all animation runs on the UI thread.
|
|
60
59
|
* Reduce Motion drops the pulse.
|
|
60
|
+
*
|
|
61
|
+
* `expo-blur` is OPTIONAL. It is resolved through a guarded lazy require (see
|
|
62
|
+
* `expoBlur.ts`) and the animated component is built once on first use, so the
|
|
63
|
+
* subpath never carries a static top-level import of the peer. When it is not
|
|
64
|
+
* installed the frost drops to an equivalent dimmed scrim — a decorative frost
|
|
65
|
+
* missing must never blank a tour, so the ring, copy and gestures are unchanged.
|
|
61
66
|
*/
|
|
62
67
|
const SpotlightOverlayComponent: React.FC<SpotlightOverlayProps> = ({
|
|
63
68
|
message,
|
|
@@ -76,6 +81,14 @@ const SpotlightOverlayComponent: React.FC<SpotlightOverlayProps> = ({
|
|
|
76
81
|
const accent = accentColor ?? theme.colors.primary;
|
|
77
82
|
const accentText = accentTextColor ?? theme.colors.onPrimary;
|
|
78
83
|
|
|
84
|
+
// Resolve the optional `expo-blur` peer and build its animated component ONCE, on first render,
|
|
85
|
+
// instead of at module scope — so merely importing this file (and thus the coachmarks subpath)
|
|
86
|
+
// never needs the peer. `null` = the peer is absent → the scrim path renders instead.
|
|
87
|
+
const AnimatedBlurView = useMemo(() => {
|
|
88
|
+
const BlurView = resolveBlurView();
|
|
89
|
+
return BlurView ? Animated.createAnimatedComponent(BlurView) : null;
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
79
92
|
// Tap / double-tap steps show ONLY the ring + tooltip — no gesture glyph. The
|
|
80
93
|
// pulsing highlight ring already reads as "tap here", so an extra glyph is
|
|
81
94
|
// noise. Every app inherits this centrally, with no config change.
|
|
@@ -138,11 +151,20 @@ const SpotlightOverlayComponent: React.FC<SpotlightOverlayProps> = ({
|
|
|
138
151
|
entering={FadeIn.duration(FADE_MS)}
|
|
139
152
|
exiting={FadeOut.duration(FADE_MS)}
|
|
140
153
|
>
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
154
|
+
{AnimatedBlurView ? (
|
|
155
|
+
<AnimatedBlurView
|
|
156
|
+
tint="dark"
|
|
157
|
+
animatedProps={blurAnimatedProps}
|
|
158
|
+
style={StyleSheet.absoluteFill}
|
|
159
|
+
/>
|
|
160
|
+
) : (
|
|
161
|
+
// `expo-blur` absent → a plain dimmed scrim stands in for the frost. Same dark backdrop
|
|
162
|
+
// the tour reads against, so the ring, tooltip and gestures behave identically.
|
|
163
|
+
<Animated.View
|
|
164
|
+
style={[StyleSheet.absoluteFill, styles.scrim]}
|
|
165
|
+
pointerEvents="none"
|
|
166
|
+
/>
|
|
167
|
+
)}
|
|
146
168
|
|
|
147
169
|
{/* Tap-the-backdrop to move on (dismisses the tour on the last step). */}
|
|
148
170
|
<Pressable
|
|
@@ -210,6 +232,11 @@ const styles = StyleSheet.create({
|
|
|
210
232
|
zIndex: 9999,
|
|
211
233
|
elevation: 9999,
|
|
212
234
|
},
|
|
235
|
+
// Fallback backdrop when `expo-blur` is absent. A structural dim (not a brand token), tuned to
|
|
236
|
+
// read like the dark blur at BLUR_INTENSITY so the ring + tooltip keep the same contrast.
|
|
237
|
+
scrim: {
|
|
238
|
+
backgroundColor: "rgba(0, 0, 0, 0.55)",
|
|
239
|
+
},
|
|
213
240
|
ring: {
|
|
214
241
|
position: "absolute",
|
|
215
242
|
borderWidth: 2.5,
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* expoBlur — resolve `expo-blur`'s `BlurView`, lazily and optionally.
|
|
3
|
+
*
|
|
4
|
+
* ── WHY THIS DOES NOT BREAK THE NO-NATIVE-DEPENDENCY POLICY ──────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* `expo-blur` is an OPTIONAL peer of the coachmarks subpath. The spotlight overlay frosts the
|
|
7
|
+
* screen with it WHEN it is installed, and degrades to a plain dimmed scrim when it is not — the
|
|
8
|
+
* ring, tooltip and gestures behave identically either way (see SpotlightOverlay). So a host that
|
|
9
|
+
* never installs `expo-blur` can still run the guided tour, and the free tier that must load under
|
|
10
|
+
* Expo Go is not forced into a native module it cannot take.
|
|
11
|
+
*
|
|
12
|
+
* Before this file, `SpotlightOverlay` did `import { BlurView } from "expo-blur"` at module scope,
|
|
13
|
+
* and `coachmarks/index.ts` re-exports the overlay with no wildcard escape — so importing ANYTHING
|
|
14
|
+
* from `@wireai/activation/coachmarks`, even `setCoachmarkStorage`, dragged the peer in and a host
|
|
15
|
+
* without it hit a Metro resolution failure. This resolver removes that static edge.
|
|
16
|
+
*
|
|
17
|
+
* ── THE SPECIFIER MUST BE A STRING LITERAL, INSIDE A TRY/CATCH ───────────────────────────
|
|
18
|
+
*
|
|
19
|
+
* This follows `icons/expoIcons.ts` exactly, and its header carries the full autopsy. In short:
|
|
20
|
+
*
|
|
21
|
+
* • Metro collects dependencies STATICALLY, matching a call whose callee is literally the
|
|
22
|
+
* identifier `require` and whose argument is a STRING LITERAL. A variable specifier
|
|
23
|
+
* (`const req = require; req(name)`) is collected NOWHERE, so the module never enters the
|
|
24
|
+
* bundle — and the aliased `require` is Metro's own numeric-id-keyed `metroRequire`, which can
|
|
25
|
+
* never resolve a package-name string. That shape shipped broken once; do not restore it.
|
|
26
|
+
* • The call sitting inside a TRY/CATCH is literally how Metro marks the dependency `isOptional`:
|
|
27
|
+
* when the peer is absent Metro puts `null` in the dependencyMap and the require throws "Cannot
|
|
28
|
+
* find module" straight into the catch below. Do NOT "simplify" the try/catch away — it is what
|
|
29
|
+
* keeps the peer optional, not just tidy. (`withWireOnboarding` in metro/index.js adds a
|
|
30
|
+
* stub-to-empty-module safety net for hosts that disable Metro's `allowOptionalDependencies`.)
|
|
31
|
+
*/
|
|
32
|
+
import type { ComponentType } from "react";
|
|
33
|
+
|
|
34
|
+
// Metro injects a module-scoped `require`; it is ABSENT in a pure-ESM runtime (the kit's own
|
|
35
|
+
// tests run under `node --test` as ESM). Declared locally so this type-checks without ambient
|
|
36
|
+
// Node types; the `typeof` guard keeps the reference ESM-safe.
|
|
37
|
+
declare const require: ((id: string) => unknown) | undefined;
|
|
38
|
+
|
|
39
|
+
/** A `require`-like resolver. Injectable in tests; production uses the guarded literal require. */
|
|
40
|
+
export type OptionalRequire = (moduleName: string) => unknown;
|
|
41
|
+
|
|
42
|
+
/** The minimal prop shape the overlay uses from `expo-blur`'s `BlurView`. */
|
|
43
|
+
export type BlurViewComponent = ComponentType<{
|
|
44
|
+
intensity?: number;
|
|
45
|
+
tint?: "light" | "dark" | "default";
|
|
46
|
+
style?: unknown;
|
|
47
|
+
children?: unknown;
|
|
48
|
+
}>;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The production resolver. The specifier is a LITERAL so Metro collects it (see the header); the
|
|
52
|
+
* `moduleName` parameter exists only to keep the `OptionalRequire` seam shape, so anything other
|
|
53
|
+
* than the one module this file owns resolves to undefined.
|
|
54
|
+
*/
|
|
55
|
+
const runtimeRequire: OptionalRequire = (moduleName) => {
|
|
56
|
+
if (moduleName !== "expo-blur") return undefined;
|
|
57
|
+
if (typeof require !== "function") return undefined;
|
|
58
|
+
try {
|
|
59
|
+
return require("expo-blur");
|
|
60
|
+
} catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* TEST-ONLY seam. `SpotlightOverlay`'s public props are frozen, so it cannot take a `requireModule`
|
|
67
|
+
* the way `WireIcon` does — this lets a component render exercise the PRESENT path without the
|
|
68
|
+
* native peer installed. Production never sets it; `runtimeRequire` is the only resolver.
|
|
69
|
+
*/
|
|
70
|
+
let testRequire: OptionalRequire | undefined;
|
|
71
|
+
export const __setBlurRequireForTests = (fn: OptionalRequire | undefined): void => {
|
|
72
|
+
testRequire = fn;
|
|
73
|
+
cached = undefined;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Read a module's `default` (Expo modules are consumed as default exports) or the namespace. */
|
|
77
|
+
const interop = (mod: unknown): Record<string, unknown> | undefined => {
|
|
78
|
+
if (!mod || typeof mod !== "object") return undefined;
|
|
79
|
+
const ns = mod as Record<string, unknown>;
|
|
80
|
+
// `expo-blur` exposes `BlurView` as a NAMED export; prefer the namespace when it already carries
|
|
81
|
+
// it, and only fall back to `default` for a CJS-interop wrapper.
|
|
82
|
+
if (ns.BlurView) return ns;
|
|
83
|
+
const def = (mod as { default?: unknown }).default;
|
|
84
|
+
if (def && typeof def === "object") return def as Record<string, unknown>;
|
|
85
|
+
return ns;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Narrow an unknown export to something mountable. `BlurView` is a real React component (function
|
|
90
|
+
* or class across versions), and React.memo / forwardRef wrappers are objects carrying `$$typeof`.
|
|
91
|
+
* Reject anything else rather than handing the reconciler a non-component.
|
|
92
|
+
*/
|
|
93
|
+
const asComponent = (value: unknown): BlurViewComponent | undefined => {
|
|
94
|
+
if (typeof value === "function") return value as BlurViewComponent;
|
|
95
|
+
if (value && typeof value === "object" && "$$typeof" in (value as object)) {
|
|
96
|
+
return value as BlurViewComponent;
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Module-level memo. `null` = "we looked and it is not there" (distinct from "not looked yet"),
|
|
103
|
+
* so an absent peer costs exactly one failed require per process, not one per overlay mount.
|
|
104
|
+
*/
|
|
105
|
+
let cached: BlurViewComponent | null | undefined;
|
|
106
|
+
|
|
107
|
+
/** Reset the memo. TEST-ONLY seam — production never calls it. */
|
|
108
|
+
export const resetBlurModuleCache = (): void => {
|
|
109
|
+
cached = undefined;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve the `BlurView` component, or undefined when the peer is absent/unresolvable.
|
|
114
|
+
* Never throws: an absent blur must degrade to a plain scrim, never break the tour.
|
|
115
|
+
*
|
|
116
|
+
* `requireModule` is injectable so tests can exercise BOTH the found and absent paths without
|
|
117
|
+
* installing the native peer (same convention as `resolveIconFamily` / `detectAppVersion`).
|
|
118
|
+
*/
|
|
119
|
+
export const resolveBlurView = (
|
|
120
|
+
requireModule: OptionalRequire = testRequire ?? runtimeRequire,
|
|
121
|
+
): BlurViewComponent | undefined => {
|
|
122
|
+
try {
|
|
123
|
+
if (cached === undefined || requireModule !== runtimeRequire) {
|
|
124
|
+
const resolved = interop(requireModule("expo-blur"));
|
|
125
|
+
const component = resolved ? asComponent(resolved.BlurView) : undefined;
|
|
126
|
+
// Don't poison the module memo from an injected test require.
|
|
127
|
+
if (requireModule === runtimeRequire) cached = component ?? null;
|
|
128
|
+
return component;
|
|
129
|
+
}
|
|
130
|
+
if (cached === null) return undefined;
|
|
131
|
+
return cached;
|
|
132
|
+
} catch {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
@@ -48,29 +48,54 @@ export const readSettledPermissions = (
|
|
|
48
48
|
}
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Did the store ANSWER, or did it not? A read that timed out or threw is `unknown` — which is NOT
|
|
53
|
+
* the same fact as "this session has settled nothing", even though both used to arrive as `[]`.
|
|
54
|
+
*
|
|
55
|
+
* The distinction is load-bearing in two directions. A caller that treats `unknown` as empty
|
|
56
|
+
* (a) re-asks a permission the OS grants exactly once, and (b) persists a set it grew from that
|
|
57
|
+
* `[]`, SHRINKING the stored record to a single id. A successful read of a corrupt, absent, or
|
|
58
|
+
* other-session entry is a genuine `[]`: the store answered, and there is nothing here for us.
|
|
59
|
+
*/
|
|
60
|
+
export type SettledPermissionsOutcome =
|
|
61
|
+
| { status: "read"; ids: string[] }
|
|
62
|
+
| { status: "unknown" };
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read the settled ids for `sessionId`, reporting WHETHER the store answered. Never throws, never
|
|
66
|
+
* hangs past the shared read ceiling.
|
|
67
|
+
*/
|
|
68
|
+
export const loadSettledPermissionsOutcome = async (
|
|
53
69
|
storage: WireOnboardingStorage,
|
|
54
70
|
key: string,
|
|
55
71
|
sessionId: string,
|
|
56
|
-
): Promise<
|
|
72
|
+
): Promise<SettledPermissionsOutcome> => {
|
|
57
73
|
try {
|
|
58
74
|
const raw = await withTimeout(storage.getItem(key), READ_TIMEOUT_MS);
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
// Stated explicitly rather than left to an accident.
|
|
62
|
-
//
|
|
63
|
-
// ⚠️ KNOWN, NOT FIXED HERE: the CALLER (`WireOnboarding`) then persists the set it grows from
|
|
64
|
-
// this `[]`, so a timed-out read can shrink the stored set to one id. It is the same class as
|
|
65
|
-
// the two sites above, but the fix is a UX ruling, not a mechanical one — on an unknown set,
|
|
66
|
-
// does a resumed flow re-ask a permission or skip it? Reported, deliberately not guessed at.
|
|
67
|
-
if (raw === READ_TIMED_OUT) return [];
|
|
68
|
-
return readSettledPermissions(raw, sessionId);
|
|
75
|
+
if (raw === READ_TIMED_OUT) return { status: "unknown" };
|
|
76
|
+
return { status: "read", ids: readSettledPermissions(raw, sessionId) };
|
|
69
77
|
} catch {
|
|
70
|
-
|
|
78
|
+
// The adapter threw or rejected: the store did not answer, so we know nothing either way.
|
|
79
|
+
return { status: "unknown" };
|
|
71
80
|
}
|
|
72
81
|
};
|
|
73
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Read the settled ids for `sessionId`, collapsing "the store did not answer" to `[]`.
|
|
85
|
+
*
|
|
86
|
+
* Kept as the module's simple reader for callers that have no way to act on the difference. The
|
|
87
|
+
* one caller that CAN — `WireOnboarding`, which would otherwise re-ask and then overwrite the
|
|
88
|
+
* stored record — uses {@link loadSettledPermissionsOutcome} instead.
|
|
89
|
+
*/
|
|
90
|
+
export const loadSettledPermissions = async (
|
|
91
|
+
storage: WireOnboardingStorage,
|
|
92
|
+
key: string,
|
|
93
|
+
sessionId: string,
|
|
94
|
+
): Promise<string[]> => {
|
|
95
|
+
const outcome = await loadSettledPermissionsOutcome(storage, key, sessionId);
|
|
96
|
+
return outcome.status === "read" ? outcome.ids : [];
|
|
97
|
+
};
|
|
98
|
+
|
|
74
99
|
/** Persist the settled ids for `sessionId` - fire-and-forget, all errors swallowed. */
|
|
75
100
|
export const saveSettledPermissions = (
|
|
76
101
|
storage: WireOnboardingStorage,
|
package/src/reviews/runtime.ts
CHANGED
|
@@ -125,7 +125,7 @@ const readStr = (storage: CoachmarkStorage | null, key: string): string | undefi
|
|
|
125
125
|
// for the same reason `currentSession` does: tsup inlines this module into several bundles and a
|
|
126
126
|
// plain module-local `let` would give each bundle its own "process".
|
|
127
127
|
//
|
|
128
|
-
// ── WHY THE UNIT IS PINNED
|
|
128
|
+
// ── WHY THE UNIT IS PINNED, AND WHEN IT IS ALLOWED TO MOVE ───────────────────────────────────
|
|
129
129
|
// The first shape of this function read the two tiers LIVE on every call: the registered session id
|
|
130
130
|
// when there was one, else the process id. That let the UNIT change mid-launch, and React's own
|
|
131
131
|
// ordering guarantees it does. The gates call `bumpSessionCount` from a `useState` INITIALIZER,
|
|
@@ -137,39 +137,91 @@ const readStr = (storage: CoachmarkStorage | null, key: string): string | undefi
|
|
|
137
137
|
// remount: gate initializer → SESSION id ≠ the stored open → count = 2
|
|
138
138
|
//
|
|
139
139
|
// Two "sessions" inside one app open, which makes the fail-closed `minSessions: 2` default (added
|
|
140
|
-
// after the 2026-07-16 one-star incident) satisfiable in the very launch it exists to guard.
|
|
141
|
-
// FIRST read pins whatever it resolved into the process slot and every later read returns that,
|
|
142
|
-
// regardless of what the session registry does afterwards. The client counter only has to be
|
|
143
|
-
// monotone and per-launch; the server's own `min_sessions` still counts real `app.session_started`
|
|
144
|
-
// events, so nothing downstream needs the two ids to be identical.
|
|
140
|
+
// after the 2026-07-16 one-star incident) satisfiable in the very launch it exists to guard.
|
|
145
141
|
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
142
|
+
// THE FIRST FIX PINNED THE WHOLE LAUNCH — one sample, never revisited. That closed the cold start
|
|
143
|
+
// and opened the opposite defect. `useLifecycleEvents` defines an app-open as a foreground after 30
|
|
144
|
+
// minutes
|
|
145
|
+
// in the background and fires a fresh `app.session_started` for it, but on iOS an app is SUSPENDED,
|
|
146
|
+
// not killed, so ONE JS process outlives many opens. A user who opens the app daily for a week
|
|
147
|
+
// produces seven server-side opens while `wire_review_<id>_sessions` stays at 1, and the local
|
|
148
|
+
// `minSessions: 2` rule is unsatisfiable on a phone that is never force-quit — which is most
|
|
149
|
+
// phones. It failed in the SAFE direction (a prompt that never shows, not one that shows too
|
|
150
|
+
// early), but the counter's own docstring promised "once per APP-OPEN" and did not deliver it.
|
|
151
|
+
//
|
|
152
|
+
// THE DISTINCTION THAT RESOLVES BOTH. The two ids in the cold-start trace name the SAME open; a
|
|
153
|
+
// LATER registration names a DIFFERENT one. Nothing else has to change: remember the registered id
|
|
154
|
+
// that was live when the pin was taken, and read it lazily.
|
|
155
|
+
//
|
|
156
|
+
// • pin unset → pin = the registered id ?? a freshly minted one, and remember which
|
|
157
|
+
// registered id that was (`undefined` when we had to mint).
|
|
158
|
+
// • remembered `undefined` → the FIRST registration merely NAMES the open the gate already
|
|
159
|
+
// counted under a minted id. Adopt it, leave the PIN alone. This is
|
|
160
|
+
// the cold-start case, and it stays shut.
|
|
161
|
+
// • live id ≠ remembered → a genuinely new app-open. Re-pin to it; the counter bumps once.
|
|
162
|
+
//
|
|
163
|
+
// ⚠️ ONE ACCEPTED MISS, deliberately in the safe direction: if the mount open never registers (a
|
|
164
|
+
// refused non-durable device key over a broken store) and a LATER foreground is the first
|
|
165
|
+
// registration the launch ever sees, that registration is ADOPTED rather than counted, costing one
|
|
166
|
+
// open on an already storage-degraded device. Under-counting is the only direction a fail-closed
|
|
167
|
+
// gate may err in, so this is accepted rather than chased.
|
|
168
|
+
//
|
|
169
|
+
// The client counter only has to be monotone and to move once per real open; the server's own
|
|
170
|
+
// `min_sessions` still counts real `app.session_started` events, so nothing downstream needs the
|
|
171
|
+
// two ids to be identical.
|
|
172
|
+
//
|
|
173
|
+
// @globalSlot LIVE — it re-pins on a genuinely new app-open, so a reader that caches the returned
|
|
174
|
+
// string across opens re-freezes the counter at 1, and the local minSessions rule stops advancing
|
|
175
|
+
// forever on a suspended-not-killed app. The slot holds a PAIR rather than a bare id, because "is this a new
|
|
176
|
+
// open?" is only answerable against the registered id that was live when the pin was taken.
|
|
151
177
|
const PROCESS_OPEN_ID_SLOT: unique symbol = Symbol.for("@wireai/activation:processOpenId");
|
|
152
178
|
|
|
153
|
-
|
|
179
|
+
/** The pinned open id plus the registered session id that was live at the moment it was pinned. */
|
|
180
|
+
type OpenPin = {
|
|
181
|
+
/** The id gate counting treats as THIS app-open. Never empty. */
|
|
182
|
+
openId: string;
|
|
183
|
+
/** The registered session id observed at pin time; `undefined` when `openId` was minted. */
|
|
184
|
+
observed: string | undefined;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
type GlobalWithOpenId = typeof globalThis & { [PROCESS_OPEN_ID_SLOT]?: OpenPin };
|
|
154
188
|
|
|
155
189
|
const openIdGlobal = globalThis as GlobalWithOpenId;
|
|
156
190
|
|
|
157
191
|
/**
|
|
158
|
-
* The id identifying THIS app-open for gate counting
|
|
159
|
-
*
|
|
160
|
-
*
|
|
192
|
+
* The id identifying THIS app-open for gate counting: the live per-open `session_id` once one has
|
|
193
|
+
* been registered, else a minted per-process id. Stable for the whole of an app-open and across
|
|
194
|
+
* every remount inside it, and it moves exactly once when a genuinely new open is registered.
|
|
195
|
+
* Never empty.
|
|
161
196
|
*/
|
|
162
197
|
export const currentOpenId = (): string => {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
198
|
+
const live = getCurrentSessionId();
|
|
199
|
+
const pin = openIdGlobal[PROCESS_OPEN_ID_SLOT];
|
|
200
|
+
|
|
201
|
+
// First read of the launch. Adopt the registered id when the host wired lifecycle BEFORE any gate
|
|
202
|
+
// rendered — that is the same unit the server counts — otherwise mint one and record that we did.
|
|
203
|
+
if (!pin) {
|
|
204
|
+
const openId = live ?? makeSessionId();
|
|
205
|
+
openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId, observed: live };
|
|
206
|
+
return openId;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Nothing registered yet, or still the same registered open: the pin stands.
|
|
210
|
+
if (!live || live === pin.observed) return pin.openId;
|
|
211
|
+
|
|
212
|
+
// The first registration of the launch NAMES the open we already pinned under a minted id, so it
|
|
213
|
+
// is adopted, not counted. Re-pinning here is exactly the cold-start double-count described above.
|
|
214
|
+
if (pin.observed === undefined) {
|
|
215
|
+
openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: pin.openId, observed: live };
|
|
216
|
+
return pin.openId;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// A second, DIFFERENT registration is a genuinely new app-open.
|
|
220
|
+
openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: live, observed: live };
|
|
221
|
+
return live;
|
|
170
222
|
};
|
|
171
223
|
|
|
172
|
-
/** Test-only: forget the
|
|
224
|
+
/** Test-only: forget the pinned open so a unit test starts from a clean launch. */
|
|
173
225
|
export const resetProcessOpenId = (): void => {
|
|
174
226
|
openIdGlobal[PROCESS_OPEN_ID_SLOT] = undefined;
|
|
175
227
|
};
|