@wireai/activation 0.12.2 → 0.13.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/AGENTS.md +3 -1
- package/CHANGELOG.md +259 -1
- package/README.md +87 -3
- package/dist/analytics/index.d.mts +2 -2
- package/dist/analytics/index.d.ts +2 -2
- package/dist/analytics/index.js +174 -36
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +174 -37
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/{currentSession-BlCeDP0f.d.mts → currentSession-ClkLjcJ0.d.mts} +456 -19
- package/dist/{currentSession-BxEB37xt.d.ts → currentSession-DOVZEWJl.d.ts} +456 -19
- package/dist/index.d.mts +197 -16
- package/dist/index.d.ts +197 -16
- package/dist/index.js +1056 -390
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +836 -193
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.js +20 -7
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +20 -7
- package/dist/reviews/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/OnboardingFlow.tsx +141 -3
- package/src/WireOnboarding.tsx +178 -34
- package/src/activation/wireActivation.ts +13 -7
- package/src/analytics/analyticsEvent.ts +16 -1
- package/src/analytics/analyticsFacade.ts +11 -10
- package/src/analytics/currentSession.ts +6 -20
- package/src/analytics/eventQueue.ts +71 -1
- package/src/analytics/index.ts +1 -1
- package/src/analytics/reportClientEvent.ts +157 -38
- package/src/cards/PermissionCard.tsx +438 -0
- package/src/cards/index.ts +7 -0
- package/src/config/wireConfigFromEnv.ts +1 -10
- package/src/context/deviceId.ts +77 -16
- package/src/context/userContext.ts +4 -15
- package/src/identity/identityRecord.ts +123 -0
- package/src/identity/userIdentity.ts +45 -9
- package/src/illustrations/defaultIllustrations.tsx +44 -3
- package/src/index.ts +44 -4
- package/src/permissions/index.ts +64 -0
- package/src/permissions/permissionCopy.ts +87 -0
- package/src/permissions/permissionEvents.ts +76 -0
- package/src/permissions/permissionMemory.ts +88 -0
- package/src/permissions/placement.ts +88 -0
- package/src/permissions/types.ts +131 -0
- package/src/session/persistedSession.ts +10 -3
- package/src/session-analytics/useLifecycleEvents.ts +10 -1
- package/src/types.ts +77 -1
- package/src/utils/deriveAnswers.ts +6 -2
- package/src/utils/readProgress.ts +4 -0
- package/src/utils/warnInDev.ts +33 -0
- package/src/components/DoneBlock.tsx +0 -37
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wireai/activation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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/OnboardingFlow.tsx
CHANGED
|
@@ -36,6 +36,17 @@ import { deriveAnswers } from "./utils/deriveAnswers";
|
|
|
36
36
|
import { readProgress } from "./utils/readProgress";
|
|
37
37
|
import { reportClientEvent, type ClientEventTarget } from "./analytics/reportClientEvent";
|
|
38
38
|
import { sendPreview } from "./analytics/sendPreview";
|
|
39
|
+
import { PermissionCardView, PERMISSION_CARD_NAME } from "./cards/PermissionCard";
|
|
40
|
+
import { permissionEventName, permissionEventProps } from "./permissions/permissionEvents";
|
|
41
|
+
import { selectDuePermissionScreen } from "./permissions/placement";
|
|
42
|
+
import { resolvePermissionCopy } from "./permissions/permissionCopy";
|
|
43
|
+
import type {
|
|
44
|
+
PermissionScreenConfig,
|
|
45
|
+
PermissionStage,
|
|
46
|
+
WirePermissionKind,
|
|
47
|
+
WirePermissionOutcome,
|
|
48
|
+
WirePermissionStatus,
|
|
49
|
+
} from "./permissions/types";
|
|
39
50
|
import type { DeviceContext } from "./device/deviceContext";
|
|
40
51
|
import type { OnboardingCopy, OnboardingEvent, OnboardingResult, StepValidator } from "./types";
|
|
41
52
|
|
|
@@ -75,6 +86,25 @@ type OnboardingFlowProps = {
|
|
|
75
86
|
* `started` so host funnels don't double-count the session.
|
|
76
87
|
*/
|
|
77
88
|
resumed?: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Host-declared permission screens injected into this server-driven stream. See
|
|
91
|
+
* `permissions/types.ts`: the OS dialog is only ever reached from the primary tap.
|
|
92
|
+
*/
|
|
93
|
+
permissionScreens?: PermissionScreenConfig[];
|
|
94
|
+
/**
|
|
95
|
+
* Screen ids already settled in THIS session, restored from `storage` by WireOnboarding so an
|
|
96
|
+
* app kill mid-flow does not re-ask. Empty (the default) means "nothing settled yet".
|
|
97
|
+
*/
|
|
98
|
+
settledPermissions?: readonly string[];
|
|
99
|
+
/**
|
|
100
|
+
* True while that restore is still in flight. Permission screens are suppressed until it lands,
|
|
101
|
+
* because showing one before the record is read is exactly the re-ask the record prevents. The
|
|
102
|
+
* read is short (one storage get, timeout-capped) and cannot outlive the first card's round trip
|
|
103
|
+
* in practice, so this normally never shows up as a delay.
|
|
104
|
+
*/
|
|
105
|
+
permissionsPending?: boolean;
|
|
106
|
+
/** Persist one settled screen id (fire-and-forget; WireOnboarding owns the storage). */
|
|
107
|
+
onPermissionSettled?: (id: string, outcome: WirePermissionOutcome) => void;
|
|
78
108
|
};
|
|
79
109
|
|
|
80
110
|
// The kit's per-question Skip sends this sentinel; the backend skips ONE question
|
|
@@ -112,6 +142,10 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
112
142
|
sessionId,
|
|
113
143
|
resumed = false,
|
|
114
144
|
clientContext,
|
|
145
|
+
permissionScreens,
|
|
146
|
+
settledPermissions,
|
|
147
|
+
permissionsPending = false,
|
|
148
|
+
onPermissionSettled,
|
|
115
149
|
}) => {
|
|
116
150
|
const { messages, error, isLoading, sendMessage, reset } = useWireAIThread();
|
|
117
151
|
const makeActions = useWireAIAction(sendMessage);
|
|
@@ -156,6 +190,11 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
156
190
|
const [timedOut, setTimedOut] = useState(false);
|
|
157
191
|
const [validationError, setValidationError] = useState<string | undefined>();
|
|
158
192
|
const [validating, setValidating] = useState(false);
|
|
193
|
+
// Permission screens settled during THIS mount, on top of whatever was restored from storage.
|
|
194
|
+
// A settled screen never comes back, which is the "shown exactly once" half of the contract.
|
|
195
|
+
const [locallySettledPermissions, setLocallySettledPermissions] = useState<string[]>([]);
|
|
196
|
+
const onPermissionSettledRef = useRef(onPermissionSettled);
|
|
197
|
+
onPermissionSettledRef.current = onPermissionSettled;
|
|
159
198
|
|
|
160
199
|
const lastCard = useMemo<Message | undefined>(
|
|
161
200
|
() => [...messages].reverse().find((m) => m.role === "assistant" && m.response?.action === "render"),
|
|
@@ -324,6 +363,51 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
324
363
|
sendMessage(SKIP_ONE_SENTINEL);
|
|
325
364
|
}, [isLoading, sendMessage]);
|
|
326
365
|
|
|
366
|
+
// ── Permission screens (host-injected, never server-driven in this release) ─────────────────
|
|
367
|
+
//
|
|
368
|
+
// Analytics for one moment of a permission screen. It rides the SAME plumbing as the rest of the
|
|
369
|
+
// funnel: an `app_event` on `/v1/events` whose `question_key` is the canonical `wire_permission_*`
|
|
370
|
+
// name (exactly how `WIRE_PURCHASE_EVENTS` reach the wire), carrying the same device snapshot and
|
|
371
|
+
// `user_context`, so the `device_key` join that makes every other number real covers these too.
|
|
372
|
+
// It is ALSO surfaced on `onEvent`, so a host keeps its own analytics without a second wiring.
|
|
373
|
+
//
|
|
374
|
+
// `screen_index` is the last card index shown, NOT a new one: a permission screen is not a
|
|
375
|
+
// question, so it must never introduce a screen index the server never emitted a `screen_shown`
|
|
376
|
+
// for. Nothing here can throw into the UI (reportClientEvent is fire-and-forget by contract).
|
|
377
|
+
const emitPermissionStage = useCallback(
|
|
378
|
+
(permission: WirePermissionKind, stage: PermissionStage, status?: WirePermissionStatus) => {
|
|
379
|
+
onEventRef.current?.({ type: "permission", permission, stage, status });
|
|
380
|
+
reportClientEvent(reportTargetRef.current, {
|
|
381
|
+
event_type: "app_event",
|
|
382
|
+
session_id: sessionIdRef.current,
|
|
383
|
+
question_key: permissionEventName(stage),
|
|
384
|
+
component: PERMISSION_CARD_NAME,
|
|
385
|
+
screen_index: lastScreenIndexRef.current >= 0 ? lastScreenIndexRef.current : undefined,
|
|
386
|
+
meta: JSON.stringify(permissionEventProps(permission, status)),
|
|
387
|
+
device: clientContextRef.current?.device,
|
|
388
|
+
user_context: clientContextRef.current?.userContext,
|
|
389
|
+
user_id: clientContextRef.current?.userId,
|
|
390
|
+
});
|
|
391
|
+
},
|
|
392
|
+
[],
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
// A screen is DONE: remember it (so it never returns), let the host persist that across a kill,
|
|
396
|
+
// and hand the outcome to `onResult`, the seam a host schedules its first local notification
|
|
397
|
+
// from. A throwing host callback is swallowed: the flow continues on every path, always.
|
|
398
|
+
const settlePermission = useCallback(
|
|
399
|
+
(id: string, screen: PermissionScreenConfig, outcome: WirePermissionOutcome) => {
|
|
400
|
+
setLocallySettledPermissions((prev) => (prev.includes(id) ? prev : [...prev, id]));
|
|
401
|
+
onPermissionSettledRef.current?.(id, outcome);
|
|
402
|
+
try {
|
|
403
|
+
screen.onResult?.(screen.permission, outcome);
|
|
404
|
+
} catch {
|
|
405
|
+
// A host's post-grant work (scheduling a reminder) must never break onboarding.
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
[],
|
|
409
|
+
);
|
|
410
|
+
|
|
327
411
|
// Prefetch on SELECT — the fix for "the first AI question is always the slowest". Q1 is a
|
|
328
412
|
// deterministic base card (instant); Q2 is the first LLM turn, and neither cache protects it
|
|
329
413
|
// (flow-cache misses a novel answer combo, the provider prompt-cache is cold on the first
|
|
@@ -356,8 +440,15 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
356
440
|
const wrappedActions = useCallback(
|
|
357
441
|
(messageId: string) => {
|
|
358
442
|
const base = makeActions(messageId);
|
|
359
|
-
|
|
360
|
-
|
|
443
|
+
// ⚠️ HOST VALIDATORS LIVE IN THE SAME KEY NAMESPACE `deriveAnswers` files answers under, so
|
|
444
|
+
// moving the answer key to `slot_id` without moving this lookup would silently UNBIND every
|
|
445
|
+
// host validator — no error, no warning, values just stop being checked. Try the slot first
|
|
446
|
+
// (matching the answer key), then fall back to the question key, so a host that has not yet
|
|
447
|
+
// renamed its validator map keeps working while a server rolls slots out.
|
|
448
|
+
const validators = validatorsRef.current;
|
|
449
|
+
const validator =
|
|
450
|
+
(progress.slot_id ? validators?.[progress.slot_id] : undefined) ??
|
|
451
|
+
(progress.key ? validators?.[progress.key] : undefined);
|
|
361
452
|
if (!validator) return base;
|
|
362
453
|
|
|
363
454
|
const out: Record<string, (...args: unknown[]) => void> = { ...base };
|
|
@@ -387,7 +478,7 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
387
478
|
}
|
|
388
479
|
return out;
|
|
389
480
|
},
|
|
390
|
-
[makeActions, progress.key],
|
|
481
|
+
[makeActions, progress.key, progress.slot_id],
|
|
391
482
|
);
|
|
392
483
|
|
|
393
484
|
// ── Render ────────────────────────────────────────────────────────────────
|
|
@@ -431,6 +522,53 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
431
522
|
const total = progress.total ?? approxScreens;
|
|
432
523
|
const isTerminal = response.action === "render" && response.component === "StatusCard";
|
|
433
524
|
|
|
525
|
+
// A permission screen sits IN FRONT of the card the flow was about to render (including the
|
|
526
|
+
// terminal recap, which is what `"beforeEnd"` means), and hands that card straight back once it
|
|
527
|
+
// settles. Nothing is sent to the backend, nothing is appended to the thread, and `step` is
|
|
528
|
+
// untouched, so the progress bar, the funnel, and `deriveAnswers` cannot tell it happened.
|
|
529
|
+
// Placement is resolved against the card ABOUT to render, because the stream length is
|
|
530
|
+
// server-driven; an `afterCard` the stream never reaches clamps to the terminal position.
|
|
531
|
+
const duePermission = permissionsPending
|
|
532
|
+
? undefined
|
|
533
|
+
: selectDuePermissionScreen(
|
|
534
|
+
permissionScreens,
|
|
535
|
+
settledPermissions
|
|
536
|
+
? [...settledPermissions, ...locallySettledPermissions]
|
|
537
|
+
: locallySettledPermissions,
|
|
538
|
+
{ cardIndex: renderedCount - 1, isTerminal },
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
if (duePermission) {
|
|
542
|
+
const { screen, id } = duePermission;
|
|
543
|
+
const copy = resolvePermissionCopy(screen.permission, screen.copy);
|
|
544
|
+
return (
|
|
545
|
+
<OnboardingScaffold step={step} approxScreens={total}>
|
|
546
|
+
{/* The same shell + handoff every card uses; "spring" is the value-beat register the
|
|
547
|
+
InterstitialCard already wears, which is what this screen is. */}
|
|
548
|
+
<CardHandoff transitionKey={`permission:${id}`} variant="spring">
|
|
549
|
+
<PermissionCardView
|
|
550
|
+
key={id}
|
|
551
|
+
permission={screen.permission}
|
|
552
|
+
title={copy.title}
|
|
553
|
+
message={copy.message}
|
|
554
|
+
primaryLabel={copy.primaryLabel}
|
|
555
|
+
secondaryLabel={copy.secondaryLabel}
|
|
556
|
+
blockedTitle={copy.blockedTitle}
|
|
557
|
+
blockedMessage={copy.blockedMessage}
|
|
558
|
+
settingsLabel={copy.settingsLabel}
|
|
559
|
+
continueLabel={copy.continueLabel}
|
|
560
|
+
illustration={screen.illustration}
|
|
561
|
+
request={screen.request}
|
|
562
|
+
getStatus={screen.getStatus}
|
|
563
|
+
openSettings={screen.openSettings}
|
|
564
|
+
onStage={(stage, status) => emitPermissionStage(screen.permission, stage, status)}
|
|
565
|
+
onSettled={(outcome) => settlePermission(id, screen, outcome)}
|
|
566
|
+
/>
|
|
567
|
+
</CardHandoff>
|
|
568
|
+
</OnboardingScaffold>
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
434
572
|
// Terminal recap: show what the app will now do for the user (backend-authored),
|
|
435
573
|
// and finish only when they tap the CTA.
|
|
436
574
|
if (isTerminal) {
|
package/src/WireOnboarding.tsx
CHANGED
|
@@ -22,8 +22,9 @@ import { OnboardingFlow, DEFAULT_COPY } from "./OnboardingFlow";
|
|
|
22
22
|
import { onboardingComponents } from "./cards";
|
|
23
23
|
import { makeSessionId, reportClientEvent, type ClientEventTarget } from "./analytics/reportClientEvent";
|
|
24
24
|
import { collectDeviceContext, type DeviceContext } from "./device/deviceContext";
|
|
25
|
-
import {
|
|
25
|
+
import { hydrateDeviceIdentity } from "./context/deviceId";
|
|
26
26
|
import { activationJoinContext } from "./context/userContext";
|
|
27
|
+
import { hostIdentity, resolveIdentity } from "./identity/identityRecord";
|
|
27
28
|
import { sanitizeUserId } from "./identity/userIdentity";
|
|
28
29
|
import {
|
|
29
30
|
clearPersistedSession,
|
|
@@ -33,23 +34,24 @@ import {
|
|
|
33
34
|
DEFAULT_SESSION_TTL_MS,
|
|
34
35
|
type LoadedSession,
|
|
35
36
|
} from "./session/persistedSession";
|
|
37
|
+
import {
|
|
38
|
+
clearSettledPermissions,
|
|
39
|
+
loadSettledPermissions,
|
|
40
|
+
permissionStorageKey,
|
|
41
|
+
saveSettledPermissions,
|
|
42
|
+
} from "./permissions/permissionMemory";
|
|
36
43
|
import type { OnboardingResult, WireOnboardingProps } from "./types";
|
|
37
|
-
|
|
38
|
-
/** RN sets this global; absent under node/SSR. Read defensively via {@link warnInDev}. */
|
|
39
|
-
declare const __DEV__: boolean | undefined;
|
|
40
|
-
|
|
41
|
-
/** Emit a one-line developer warning, but ONLY in a dev build (RN `__DEV__`). No-op in prod/tests.
|
|
42
|
-
* Same idiom as `analytics/analyticsFacade.warnInDev`. */
|
|
43
|
-
const warnInDev = (message: string): void => {
|
|
44
|
-
if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
|
|
45
|
-
console.warn(message);
|
|
46
|
-
}
|
|
47
|
-
};
|
|
44
|
+
import { warnInDev } from "./utils/warnInDev";
|
|
48
45
|
|
|
49
46
|
/** Ceiling on the auto-join-key storage read — a hung adapter degrades to no key, never a stuck
|
|
50
47
|
* loader. Matches the persisted-session read's own ceiling. */
|
|
51
48
|
const AUTO_JOIN_HYDRATION_TIMEOUT_MS = 1_500;
|
|
52
49
|
|
|
50
|
+
/** The settled result of the auto-join read: a key to inject, or `null` plus WHY the kit declined.
|
|
51
|
+
* `timeout` = the adapter never answered; `not-durable` = it answered but did not persist the id,
|
|
52
|
+
* so injecting it would mean a different key on every launch. */
|
|
53
|
+
type AutoJoinOutcome = { key: string } | { key: null; reason: "timeout" | "not-durable" };
|
|
54
|
+
|
|
53
55
|
export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
54
56
|
config,
|
|
55
57
|
theme,
|
|
@@ -74,6 +76,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
74
76
|
userContext,
|
|
75
77
|
userId,
|
|
76
78
|
autoJoinKey = true,
|
|
79
|
+
permissionScreens,
|
|
77
80
|
}) => {
|
|
78
81
|
// The host's own opaque user id (trimmed + capped, NO PII) so onboarding sessions can be
|
|
79
82
|
// reconciled to real users later. `sanitizeUserId` is a pure string transform → the memoized
|
|
@@ -109,8 +112,23 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
109
112
|
// onboarding session to everything the app reports later, and the prop that carries it is optional
|
|
110
113
|
// and named `userContext` — so forgetting it is the default, and the failure is a silent zero in the
|
|
111
114
|
// `activated` funnel rather than an error. Two real consumers shipped without it.
|
|
112
|
-
|
|
113
|
-
|
|
115
|
+
//
|
|
116
|
+
// A host-supplied key is also RECORDED on the process-wide provenance registry, so a sibling
|
|
117
|
+
// surface can later ask "does this app own a device id?" — see the K9 warning below. The registry
|
|
118
|
+
// write is idempotent (same key, same value), so running it from a memo is safe under StrictMode's
|
|
119
|
+
// double-invoke; it lives here rather than in an effect so the answer is already true for any
|
|
120
|
+
// surface constructed later in the same tick.
|
|
121
|
+
const hostJoinIdentity = useMemo(
|
|
122
|
+
() =>
|
|
123
|
+
resolveIdentity({
|
|
124
|
+
value: userContextStable?.device_key,
|
|
125
|
+
space: "device",
|
|
126
|
+
source: "host",
|
|
127
|
+
scope: config.appId,
|
|
128
|
+
}),
|
|
129
|
+
[userContextStable, config.appId],
|
|
130
|
+
);
|
|
131
|
+
const missingJoinKey = !hostJoinIdentity;
|
|
114
132
|
|
|
115
133
|
// AUTO-JOIN (0.12.2). When the host supplied no join key, the kit supplies its OWN — the same
|
|
116
134
|
// per-install `device_key` the analytics surfaces auto-mint and persist — so the default wiring
|
|
@@ -119,12 +137,21 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
119
137
|
// • `missingJoinKey` — a host-supplied key ALWAYS wins, verbatim, and is never touched.
|
|
120
138
|
// • `autoJoinKey !== false` — the documented opt-out for a host that genuinely wants an
|
|
121
139
|
// unlinked onboarding session; opting out restores the pre-0.12.2 behavior exactly.
|
|
122
|
-
// • `storage` —
|
|
123
|
-
// would carry a DIFFERENT key. That is worse than no key: the server counts
|
|
124
|
-
// by distinct opens grouped on `device_key`, so a per-launch key corrupts the
|
|
125
|
-
// than leaving it empty
|
|
140
|
+
// • `storage` that actually WORKS — without persistence the auto id is process-scoped, so every
|
|
141
|
+
// launch would carry a DIFFERENT key. That is worse than no key: the server counts
|
|
142
|
+
// `min_sessions` by distinct opens grouped on `device_key`, so a per-launch key corrupts the
|
|
143
|
+
// counter rather than leaving it empty, and it inflates distinct-device counts on top. Same
|
|
144
|
+
// reason `useLifecycleEvents` gates its own fallback on storage.
|
|
126
145
|
//
|
|
127
|
-
//
|
|
146
|
+
// ⚠️ 0.13.0 (K1): the third condition used to be `Boolean(storage)` — the PRESENCE of the prop,
|
|
147
|
+
// never the SUCCESS of the write. A REJECTING adapter (a locked / full / permission-denied
|
|
148
|
+
// AsyncStorage, the most common real breakage) therefore walked the per-launch key in through the
|
|
149
|
+
// front door: `hydrateAutoDeviceKey` resolves to the in-memory mint on every failure branch, and a
|
|
150
|
+
// string is a string. The gate now reads `IdentityRecord.durable`, which is `true` ONLY when the id
|
|
151
|
+
// was adopted from storage or written to it successfully — so a broken adapter lands on exactly the
|
|
152
|
+
// same "declined, and here is why" path as no adapter at all.
|
|
153
|
+
//
|
|
154
|
+
// It resolves through `hydrateDeviceIdentity`, never the sync `resolveAutoDeviceKey`: the sync
|
|
128
155
|
// contract returns the freshly minted id and adopts the persisted one milliseconds later, which
|
|
129
156
|
// is exactly the 0.12.1 H1 defect — the key stamped here must be the one the analytics side
|
|
130
157
|
// stamps, not a fresh mint per launch. The value is therefore awaited BEHIND THE LOADER GATE
|
|
@@ -133,33 +160,35 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
133
160
|
// provider mounted would recreate the A2A adapter and drop the server-learned `contextId`.
|
|
134
161
|
const autoJoinPossible = Boolean(storage) && autoJoinKey !== false;
|
|
135
162
|
const wantsAutoJoin = missingJoinKey && autoJoinPossible;
|
|
136
|
-
// `undefined` = the hydration is still in flight (the gate below holds)
|
|
137
|
-
//
|
|
138
|
-
const [
|
|
163
|
+
// `undefined` = the hydration is still in flight (the gate below holds). Otherwise a settled
|
|
164
|
+
// outcome: a `key` to inject, or `null` plus the reason the kit declined, which the warning names.
|
|
165
|
+
const [autoJoinOutcome, setAutoJoinOutcome] = useState<AutoJoinOutcome | undefined>(undefined);
|
|
139
166
|
|
|
140
167
|
useEffect(() => {
|
|
141
|
-
if (!wantsAutoJoin ||
|
|
168
|
+
if (!wantsAutoJoin || autoJoinOutcome !== undefined || !storage) return;
|
|
142
169
|
let cancelled = false;
|
|
143
170
|
// Ceiling on the read, the same discipline as `loadPersistedSession`: a hung storage adapter
|
|
144
171
|
// must degrade to "no auto key", never to a stuck loader over the host's onboarding. Giving up
|
|
145
172
|
// is deliberately NOT "fall back to the synchronous mint" — that key is per-launch, which is
|
|
146
173
|
// the corruption this feature is gated on `storage` to avoid.
|
|
147
174
|
const timer = setTimeout(() => {
|
|
148
|
-
if (!cancelled)
|
|
175
|
+
if (!cancelled) setAutoJoinOutcome({ key: null, reason: "timeout" });
|
|
149
176
|
}, AUTO_JOIN_HYDRATION_TIMEOUT_MS);
|
|
150
|
-
void
|
|
177
|
+
void hydrateDeviceIdentity({ appId: config.appId, storage }).then((identity) => {
|
|
151
178
|
if (cancelled) return;
|
|
152
179
|
clearTimeout(timer);
|
|
153
|
-
|
|
180
|
+
setAutoJoinOutcome(
|
|
181
|
+
identity?.durable ? { key: identity.value } : { key: null, reason: "not-durable" },
|
|
182
|
+
);
|
|
154
183
|
});
|
|
155
184
|
return () => {
|
|
156
185
|
cancelled = true;
|
|
157
186
|
clearTimeout(timer);
|
|
158
187
|
};
|
|
159
|
-
}, [wantsAutoJoin,
|
|
188
|
+
}, [wantsAutoJoin, autoJoinOutcome, config.appId, storage]);
|
|
160
189
|
|
|
161
|
-
const injectedJoinKey = wantsAutoJoin
|
|
162
|
-
const autoJoinPending = wantsAutoJoin &&
|
|
190
|
+
const injectedJoinKey = wantsAutoJoin ? (autoJoinOutcome?.key ?? undefined) : undefined;
|
|
191
|
+
const autoJoinPending = wantsAutoJoin && autoJoinOutcome === undefined;
|
|
163
192
|
|
|
164
193
|
// What actually goes on the wire: the host's context, with the auto join key merged in ONLY when
|
|
165
194
|
// the host left the slot empty. Every other key the host passed survives untouched, and a host
|
|
@@ -172,6 +201,36 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
172
201
|
[userContextStable, injectedJoinKey],
|
|
173
202
|
);
|
|
174
203
|
|
|
204
|
+
// THE JOIN DECISION IS FROZEN AT THE MOMENT THE GATE OPENS (K5), exactly as `startupUserIdRef`
|
|
205
|
+
// below freezes the user id, and for the identical reason: `userContext` feeds the `llm` memo, and
|
|
206
|
+
// wireai-rn recreates its A2A adapter whenever the `llm` identity changes — the adapter ctor resets
|
|
207
|
+
// `contextId` (`this.contextId = void 0`), so the server-learned session id is dropped.
|
|
208
|
+
//
|
|
209
|
+
// THE TEARDOWN THIS PREVENTS: a host that resolves its device key ASYNCHRONOUSLY renders once
|
|
210
|
+
// without it → auto-join injects `wdev_*` → the host key lands a tick later → `effectiveUserContext`
|
|
211
|
+
// flips → the memo rebuilds → the adapter is recreated → the user restarts onboarding mid-flow and
|
|
212
|
+
// the server is left holding an orphaned session. It is not a hypothetical shape: it is the default
|
|
213
|
+
// shape of any host that reads its device id out of async storage.
|
|
214
|
+
//
|
|
215
|
+
// Frozen at gate-open rather than at first render because the auto-join key itself arrives
|
|
216
|
+
// asynchronously — freezing earlier would freeze `undefined`. The value is carried as a JSON STRING
|
|
217
|
+
// so it can sit in a dependency array by value; a ref alone would not re-run the memo when it fills.
|
|
218
|
+
// Client events deliberately keep reading the LIVE `effectiveUserContext`: they are posted one at a
|
|
219
|
+
// time and carry no adapter, so late host context enriches them at no risk — the same split the
|
|
220
|
+
// user id already uses (frozen in session-start metadata, live via the `identify` event).
|
|
221
|
+
const frozenWireContextRef = useRef<string | null>(null);
|
|
222
|
+
if (!autoJoinPending && frozenWireContextRef.current === null) {
|
|
223
|
+
frozenWireContextRef.current = effectiveUserContext ? JSON.stringify(effectiveUserContext) : "";
|
|
224
|
+
}
|
|
225
|
+
const startupUserContextKey = frozenWireContextRef.current ?? "";
|
|
226
|
+
const startupUserContext = useMemo<Record<string, string | number | boolean> | undefined>(
|
|
227
|
+
() =>
|
|
228
|
+
startupUserContextKey
|
|
229
|
+
? (JSON.parse(startupUserContextKey) as Record<string, string | number | boolean>)
|
|
230
|
+
: undefined,
|
|
231
|
+
[startupUserContextKey],
|
|
232
|
+
);
|
|
233
|
+
|
|
175
234
|
// Dev-only warning (never a throw, never a wire change), once per mount, naming the exact fix.
|
|
176
235
|
// Reconciled with auto-join: it fires only when the gap is still OPEN — injection was impossible
|
|
177
236
|
// (no `storage`), the host opted out, or the read gave up. When auto-join covered the gap the
|
|
@@ -184,7 +243,11 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
184
243
|
: !storage
|
|
185
244
|
? "it got no `storage` prop, and without persistence its key would be different on every " +
|
|
186
245
|
"launch, which corrupts min_sessions instead of merely leaving the join empty."
|
|
187
|
-
:
|
|
246
|
+
: autoJoinOutcome?.key === null && autoJoinOutcome.reason === "not-durable"
|
|
247
|
+
? "your `storage` adapter answered but could not persist the key (the read or the write " +
|
|
248
|
+
"failed), so the key would be different on every launch, which corrupts min_sessions " +
|
|
249
|
+
"instead of merely leaving the join empty. Check the adapter you passed as `storage`."
|
|
250
|
+
: "your `storage` adapter did not answer in time.";
|
|
188
251
|
useEffect(() => {
|
|
189
252
|
if (!warnMissingJoinKey) return;
|
|
190
253
|
warnInDev(
|
|
@@ -198,6 +261,31 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
198
261
|
);
|
|
199
262
|
}, [warnMissingJoinKey, autoJoinReason]);
|
|
200
263
|
|
|
264
|
+
// THE SECOND CASE AUTO-JOIN WOULD OTHERWISE SILENCE (K9). 0.12.2 suppressed the missing-join-key
|
|
265
|
+
// warning whenever injection succeeded, which is right when the host genuinely owns no device id —
|
|
266
|
+
// and wrong when it owns one and simply forgot it HERE. That host used to get a loud warning and
|
|
267
|
+
// now gets a silent third id space: its app events under its own key, this onboarding session under
|
|
268
|
+
// `wdev_*`, and `activated` still zero. Nothing a single mount can see distinguishes the two cases,
|
|
269
|
+
// which is why the provenance registry exists: any surface constructed with a HOST-supplied device
|
|
270
|
+
// key records it, so this mount can ask whether the process demonstrably owns one.
|
|
271
|
+
//
|
|
272
|
+
// It WARNS rather than declining to inject: declining would leave the onboarding side with no key
|
|
273
|
+
// at all, which is strictly worse than a self-consistent one. It also does not silently ADOPT the
|
|
274
|
+
// other surface's key — that would be the kit guessing which of two ids a host meant, on the
|
|
275
|
+
// strength of construction order, and quietly changing what lands on the wire.
|
|
276
|
+
const hostKeyElsewhere = missingJoinKey ? hostIdentity("device", config.appId) : undefined;
|
|
277
|
+
const warnHostKeyElsewhere = Boolean(hostKeyElsewhere) && Boolean(injectedJoinKey);
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
if (!warnHostKeyElsewhere) return;
|
|
280
|
+
warnInDev(
|
|
281
|
+
"[wireai] <WireOnboarding> got no user_context.device_key, so the kit injected its own — but " +
|
|
282
|
+
"another surface in this app was constructed WITH a host-supplied device key " +
|
|
283
|
+
`("${hostKeyElsewhere}"). Your app events carry that id and this onboarding session carries ` +
|
|
284
|
+
"the kit's, so the two land in disjoint id spaces and the `activated` funnel still reads " +
|
|
285
|
+
"zero. Pass the SAME id here: userContext={activationJoinContext(deviceKey)}.",
|
|
286
|
+
);
|
|
287
|
+
}, [warnHostKeyElsewhere, hostKeyElsewhere]);
|
|
288
|
+
|
|
201
289
|
// The one case auto-join would otherwise SILENCE and must not: the host hand-wrote
|
|
202
290
|
// `userContext={{ deviceKey }}`. That bucket is not the wire key, so the kit injects its own —
|
|
203
291
|
// but the host demonstrably owns an id, and their app-side events carry THAT one, so the two
|
|
@@ -240,6 +328,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
240
328
|
storage ? null : { id: makeSessionId(), resumed: false },
|
|
241
329
|
);
|
|
242
330
|
const storageKey = persistKey ?? sessionStorageKey(config.appId);
|
|
331
|
+
const permissionsKey = permissionStorageKey(config.appId);
|
|
243
332
|
|
|
244
333
|
useEffect(() => {
|
|
245
334
|
if (!storage || session) return;
|
|
@@ -270,14 +359,63 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
270
359
|
// host that does not opt in lands on the byte-for-byte legacy clear-on-complete path.
|
|
271
360
|
const handleComplete = useCallback(
|
|
272
361
|
(result: OnboardingResult) => {
|
|
273
|
-
if (storage && shouldClearOnComplete(retainSessionOnComplete))
|
|
362
|
+
if (storage && shouldClearOnComplete(retainSessionOnComplete)) {
|
|
363
|
+
clearPersistedSession(storage, storageKey);
|
|
364
|
+
// The permission record is keyed by session id, so a stale one already reads as empty,
|
|
365
|
+
// this only keeps the adapter from carrying a dead entry forever.
|
|
366
|
+
clearSettledPermissions(storage, permissionsKey);
|
|
367
|
+
}
|
|
274
368
|
onComplete(result);
|
|
275
369
|
},
|
|
276
|
-
[storage, storageKey, retainSessionOnComplete, onComplete],
|
|
370
|
+
[storage, storageKey, permissionsKey, retainSessionOnComplete, onComplete],
|
|
277
371
|
);
|
|
278
372
|
|
|
279
373
|
const sessionId = session?.id ?? "";
|
|
280
374
|
|
|
375
|
+
// ── Permission-screen memory: "shown once per session", kept true across an app KILL ─────────
|
|
376
|
+
//
|
|
377
|
+
// The in-memory guard covers one mount. A resumed session (the whole point of `storage`) is a
|
|
378
|
+
// NEW mount of the SAME session, so without a persisted record it would re-show a screen the user
|
|
379
|
+
// already answered, and on the ask path that is a second attempt at a prompt iOS grants once.
|
|
380
|
+
//
|
|
381
|
+
// Unlike the auto-join key, this does NOT hold the loader gate: it feeds no `llm` dependency, so
|
|
382
|
+
// a late arrival cannot recreate the A2A adapter. The flow simply suppresses permission screens
|
|
383
|
+
// while `permissionsPending` is true, and that window closes long before the first card arrives
|
|
384
|
+
// (one timeout-capped storage read against a backend round trip).
|
|
385
|
+
const wantsPermissionMemory = Boolean(storage) && (permissionScreens?.length ?? 0) > 0;
|
|
386
|
+
const [settledPermissions, setSettledPermissions] = useState<string[] | undefined>(undefined);
|
|
387
|
+
useEffect(() => {
|
|
388
|
+
if (!wantsPermissionMemory || !storage || !sessionId || settledPermissions !== undefined) return;
|
|
389
|
+
let cancelled = false;
|
|
390
|
+
void loadSettledPermissions(storage, permissionsKey, sessionId).then((ids) => {
|
|
391
|
+
if (!cancelled) setSettledPermissions(ids);
|
|
392
|
+
});
|
|
393
|
+
return () => {
|
|
394
|
+
cancelled = true;
|
|
395
|
+
};
|
|
396
|
+
}, [wantsPermissionMemory, storage, permissionsKey, sessionId, settledPermissions]);
|
|
397
|
+
|
|
398
|
+
// Persist the settled set as it grows. Fire-and-forget: a failed write only costs a re-ask on a
|
|
399
|
+
// resume, never a broken flow. Without `storage` there is nothing to write to and the
|
|
400
|
+
// in-memory guard alone carries the once-only contract for this mount.
|
|
401
|
+
// Mirrored in a ref so the handler can compute the next set WITHOUT doing the storage write
|
|
402
|
+
// inside a state updater (React invokes updaters twice under StrictMode; a write belongs outside).
|
|
403
|
+
const settledPermissionsRef = useRef<string[] | undefined>(undefined);
|
|
404
|
+
settledPermissionsRef.current = settledPermissions;
|
|
405
|
+
const handlePermissionSettled = useCallback(
|
|
406
|
+
(id: string) => {
|
|
407
|
+
const current = settledPermissionsRef.current ?? [];
|
|
408
|
+
if (current.includes(id)) return;
|
|
409
|
+
const next = [...current, id];
|
|
410
|
+
settledPermissionsRef.current = next;
|
|
411
|
+
setSettledPermissions(next);
|
|
412
|
+
if (storage && wantsPermissionMemory && sessionId) {
|
|
413
|
+
saveSettledPermissions(storage, permissionsKey, sessionId, next);
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
[storage, wantsPermissionMemory, permissionsKey, sessionId],
|
|
417
|
+
);
|
|
418
|
+
|
|
281
419
|
// Session-start snapshot of the bound user id. It seeds the A2A `metadata.userId` so the
|
|
282
420
|
// adapter is built ONCE with whatever id was known at session start. A mid-session userId
|
|
283
421
|
// change must NOT flow through this memo — recreating `llm` recreates the A2A adapter,
|
|
@@ -311,7 +449,9 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
311
449
|
// Device snapshot + host-injected user context ride the session-start metadata so the
|
|
312
450
|
// backend can segment the funnel. Old servers ignore these unknown keys (backward compat).
|
|
313
451
|
device,
|
|
314
|
-
|
|
452
|
+
// The FROZEN wire context, never the live one — see the freeze note above. A change here
|
|
453
|
+
// recreates the A2A adapter and drops `contextId`.
|
|
454
|
+
...(startupUserContext ? { userContext: startupUserContext } : {}),
|
|
315
455
|
// The session-start user id (from the ref) rides the session-start metadata so the
|
|
316
456
|
// server binds the session to a real user at creation. Reading the ref — not
|
|
317
457
|
// `boundUserId` — keeps this memo off the userId dependency, so a mid-session change
|
|
@@ -320,7 +460,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
320
460
|
},
|
|
321
461
|
timeoutMs: 60_000,
|
|
322
462
|
};
|
|
323
|
-
}, [config.serverUrl, config.appId, config.apiKey, metadataStable, sessionId, componentsStable, device,
|
|
463
|
+
}, [config.serverUrl, config.appId, config.apiKey, metadataStable, sessionId, componentsStable, device, startupUserContext]);
|
|
324
464
|
|
|
325
465
|
// Where client-reported events are POSTed (`/v1/events`). Same tenant creds as the flow.
|
|
326
466
|
const reportTarget = useMemo<ClientEventTarget>(
|
|
@@ -395,6 +535,10 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
395
535
|
sessionId={sessionId}
|
|
396
536
|
resumed={session.resumed}
|
|
397
537
|
clientContext={clientContext}
|
|
538
|
+
permissionScreens={permissionScreens}
|
|
539
|
+
settledPermissions={settledPermissions}
|
|
540
|
+
permissionsPending={wantsPermissionMemory && settledPermissions === undefined}
|
|
541
|
+
onPermissionSettled={handlePermissionSettled}
|
|
398
542
|
/>
|
|
399
543
|
</WireAIProvider>
|
|
400
544
|
</IconRegistryProvider>
|
|
@@ -29,7 +29,8 @@ import {
|
|
|
29
29
|
type ClientEventTarget,
|
|
30
30
|
} from "../analytics/reportClientEvent";
|
|
31
31
|
import { resolveAutoDeviceKey, type ResolveAutoDeviceKeyOptions } from "../context/deviceId";
|
|
32
|
-
import { resolveUserContext, type WireUserContext } from "../context/userContext";
|
|
32
|
+
import { cleanString, resolveUserContext, type WireUserContext } from "../context/userContext";
|
|
33
|
+
import { resolveIdentity } from "../identity/identityRecord";
|
|
33
34
|
import { detectAppVersion } from "../device/appVersion";
|
|
34
35
|
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
35
36
|
import {
|
|
@@ -38,12 +39,8 @@ import {
|
|
|
38
39
|
subscribeActivationRevalidation,
|
|
39
40
|
} from "./revalidation";
|
|
40
41
|
|
|
41
|
-
/**
|
|
42
|
-
const clean =
|
|
43
|
-
if (typeof value !== "string") return undefined;
|
|
44
|
-
const trimmed = value.trim();
|
|
45
|
-
return trimmed.length > 0 ? trimmed : undefined;
|
|
46
|
-
};
|
|
42
|
+
/** The shared trim helper (was a byte-identical private copy named `clean` in this file). */
|
|
43
|
+
const clean = cleanString;
|
|
47
44
|
|
|
48
45
|
/**
|
|
49
46
|
* Tenant transport + context inputs for {@link createWireActivation}. `serverUrl`/`apiKey` are the
|
|
@@ -109,6 +106,15 @@ export const createWireActivation = (config: WireActivationConfig): WireActivati
|
|
|
109
106
|
// `createAnalytics` instance carry the SAME `device_key` for the same install. Minting locally here
|
|
110
107
|
// gave one install two auto ids — see the registry note in context/deviceId.ts.
|
|
111
108
|
const explicitDeviceKey = clean(config.deviceKey) ?? clean(config.userContext?.deviceKey);
|
|
109
|
+
// Record a HOST-supplied key on the process provenance registry, so a `<WireOnboarding>` mount that
|
|
110
|
+
// was NOT given one can tell "this app owns no device id" (fine, inject) from "this app owns one
|
|
111
|
+
// and forgot it here" (the silent third id space — K9). Recording only; nothing reads it on this path.
|
|
112
|
+
resolveIdentity({
|
|
113
|
+
value: explicitDeviceKey,
|
|
114
|
+
space: "device",
|
|
115
|
+
source: "host",
|
|
116
|
+
scope: config.appId,
|
|
117
|
+
});
|
|
112
118
|
const autoDeviceKeyOptions: ResolveAutoDeviceKeyOptions = {
|
|
113
119
|
appId: config.appId,
|
|
114
120
|
// An explicit key opts out of minting AND persisting (unchanged contract).
|
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
* `onEvent`) — the app logs it explicitly on `onComplete` using the constant below, so the
|
|
16
16
|
* funnel name stays canonical.
|
|
17
17
|
*/
|
|
18
|
+
import {
|
|
19
|
+
permissionEventName,
|
|
20
|
+
permissionEventProps,
|
|
21
|
+
type WirePermissionEventName,
|
|
22
|
+
} from "../permissions/permissionEvents";
|
|
18
23
|
import type { OnboardingEvent } from "../types";
|
|
19
24
|
|
|
20
25
|
export const WIRE_ONBOARDING_EVENTS = {
|
|
@@ -33,7 +38,12 @@ export type WireOnboardingEventName =
|
|
|
33
38
|
(typeof WIRE_ONBOARDING_EVENTS)[keyof typeof WIRE_ONBOARDING_EVENTS];
|
|
34
39
|
|
|
35
40
|
export type AnalyticsEvent = {
|
|
36
|
-
|
|
41
|
+
/**
|
|
42
|
+
* A permission screen maps to its own canonical `wire_permission_*` name rather than to an
|
|
43
|
+
* onboarding one: it is a distinct funnel (see `permissions/permissionEvents.ts`), and folding it
|
|
44
|
+
* into `wire_onboarding_turn` would make every permission rate unreadable.
|
|
45
|
+
*/
|
|
46
|
+
name: WireOnboardingEventName | WirePermissionEventName;
|
|
37
47
|
params?: Record<string, unknown>;
|
|
38
48
|
};
|
|
39
49
|
|
|
@@ -61,6 +71,11 @@ export const toAnalyticsEvent = (event: OnboardingEvent): AnalyticsEvent => {
|
|
|
61
71
|
};
|
|
62
72
|
case "fallback":
|
|
63
73
|
return { name: WIRE_ONBOARDING_EVENTS.fallback, params: { reason: event.reason } };
|
|
74
|
+
case "permission":
|
|
75
|
+
return {
|
|
76
|
+
name: permissionEventName(event.stage),
|
|
77
|
+
params: permissionEventProps(event.permission, event.status),
|
|
78
|
+
};
|
|
64
79
|
default: {
|
|
65
80
|
const _exhaustive: never = event;
|
|
66
81
|
return _exhaustive;
|