@rightkit/onboarding 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,7 +13,7 @@ First-run onboarding framework for Right Suite apps. The legal agreement gate co
13
13
 
14
14
  | Import | Contents |
15
15
  |---|---|
16
- | `@rightkit/onboarding` | `Onboarding`, `isLegalAccepted`, `onboardingStateKey`, `licenseStep`, `modelsStep`, `permissionsStep`, `signInStep` (Google/Apple/OAuth buttons that call the shared `rightkit-oauth` owner; copy says "Opens your browser"; never an in-app webview, bogusyogi/rightkit#279); types from `types.ts`, `ModelsStepOptions` |
16
+ | `@rightkit/onboarding` | `Onboarding`, `isLegalAccepted`, `onboardingStateKey`, `licenseStep`, `modelsStep`, `permissionsStep`, `signInStep` (Google/Apple/OAuth buttons that call the shared `rightkit-oauth` owner; copy says "Opens your browser"; never an in-app webview, bogusyogi/rightkit#279); native receipt: `createNativeLegalReceiptPort`, `LEGAL_RECEIPT_COMMANDS`, `reopenLegalAcceptance`, `resetLegalAcceptance`; adapters: `licenseBackendFromView`, `licenseStateFromView`, `signInBackendFromOAuth`; types from `types.ts` (including `LegalReceiptPort`, `LegalReceiptCreateRequest`, `DeclaredLegalDocument`), `LicenseStepCopy`, `LicenseStepOptions`, `ModelsStepOptions`, `LicenseViewClient`, `LicenseViewLike`, `LicenseViewBackendOptions`, `OAuthSignInPort`, `LegalReceiptInvoke`, `NativeLegalReceiptPortOptions` |
17
17
  | `@rightkit/onboarding/onboarding.css` | Stylesheet |
18
18
 
19
19
  ## Peer dependencies
@@ -21,7 +21,7 @@ First-run onboarding framework for Right Suite apps. The legal agreement gate co
21
21
  | Peer | Range |
22
22
  |---|---|
23
23
  | `@rightkit/react-shell` | `>=0.2.0 <1` |
24
- | `@rightkit/legal-ui` | `>=0.1.2 <1` |
24
+ | `@rightkit/legal-ui` | `>=0.4.1 <1` |
25
25
  | `react` | `>=18 <21` |
26
26
 
27
27
  ## Example
@@ -45,7 +45,37 @@ import "@rightkit/onboarding/onboarding.css";
45
45
 
46
46
  Free or open-source apps without a legal gate pass `legalMode="none"` (no legal step, `legal` not needed, `appName` for the rail) or `legalMode="notice"` (no gate or acceptance; a first Notice step lists `legal.documents` as links). The default is `"gate"`.
47
47
 
48
- Acceptance is stored as `legalAcceptance: { version, documentsHash, acceptedAt, eligibilityBasis }`. An app that already keeps its own acceptance receipt can pass `legalReceipt` (`read()` and `write(record)`) and keep that store as the source of truth. Steps can set `canContinue(context)`, `onContinue(context)` (a veto: `false` or a throw keeps the step), `continueLabel`, and `skippable` (a per-step Skip button). `ctx.setResult(value)` stores a value that `onComplete({ completed, skipped, steps })` receives. `labels` overrides the button and status text, and `licenseStep(backend, { copy })` overrides the licence copy.
48
+ `layout` is `"window"` by default (the surface fills the viewport, as in a dedicated onboarding window). `layout="contained"` removes the viewport minimum height so the host sizes the surface; it is passed to the legal gate as well (`@rightkit/legal-ui` `>=0.4.1`).
49
+
50
+ Acceptance is stored as `legalAcceptance: { version, documentsHash, acceptedAt, eligibilityBasis }`. An app can pass `legalReceipt` and keep its own receipt as the source of truth (`read()` is consulted on load and overrides the settings copy). The receipt is recorded one of two ways:
51
+
52
+ - **Native (preferred): `create(request)`.** The renderer declares `{ version, documentsHash, documents, eligibilityBasis, surface: "first_run" }` and no time; the native service stamps its own UTC `acceptedAt`, compares the declaration with the manifest it embeds, stores the receipt and returns the record. `receiptDocuments` sets the declared document set (default: the gate's titles and URLs; document bodies are never sent). Build the declaration with `acceptanceDeclaration(manifest)` and the digest with `materialAcceptanceDigestAsync(manifest)` from `@rightkit/legal/browser`. A throw, an invalid record, or a record for another version, digest or eligibility keeps the gate closed with an alert; if only the settings save failed afterwards, a retry reuses the receipt already created instead of stamping a second one.
53
+ - **Legacy: `write(record)` (deprecated).** The renderer builds the record, including `acceptedAt` from the webview clock, and the app stores it. It is used only when the port has no `create`. A port with neither cannot accept.
54
+
55
+ `createNativeLegalReceiptPort({ invoke })` is the port over the host's legal-receipt service (`rightkit_legal_receipt_read`, `rightkit_legal_receipt_create`, `rightkit_legal_receipt_reset`; `LEGAL_RECEIPT_COMMANDS`, override with `commands`). The native side registers those commands once under the host caller policy (`rightkit-tauri-shell` host services, trusted for the window that shows the gate) and stores the receipt under the app root. Command names are provisional until that service is published. A persistence failure, in any path, keeps the gate closed, and a bare legacy `legalAccepted` flag with no record never passes.
56
+
57
+ `reopenLegalAcceptance({ settings })` makes the gate show again as a re-acceptance on the next mount (the old record stays until the user agrees; progress is kept). `resetLegalAcceptance({ settings, legalReceipt? })` clears the receipt first (`legalReceipt.reset`, required when a port is given) and then the settings copy, so the next mount shows the first-run gate.
58
+
59
+ ```tsx
60
+ import { createNativeLegalReceiptPort } from "@rightkit/onboarding";
61
+ import { acceptanceDeclaration, materialAcceptanceDigestAsync } from "@rightkit/legal/browser";
62
+ import { invoke } from "@tauri-apps/api/core";
63
+
64
+ const declaration = acceptanceDeclaration(manifest);
65
+ <Onboarding
66
+ legalReceipt={createNativeLegalReceiptPort({ invoke })}
67
+ requiredVersion={declaration.acceptanceVersion}
68
+ requiredDocumentsHash={await materialAcceptanceDigestAsync(manifest)}
69
+ receiptDocuments={declaration.documents}
70
+ /* ... */
71
+ />
72
+ ```
73
+
74
+ ### Licence and sign-in adapters
75
+
76
+ `licenseBackendFromView(client, options)` turns the native licence client (`createLicenseClient` from `@rightkit/license` 0.3.0, structurally: this package does not import it) into the `LicenseBackend` the licence step takes. The lifecycle decides the status (`grace`, `lifetime` and `verification_required` show as active; `migration_required`, `clock_repair_required`, `revoked` and `invalid` as invalid; anything unknown is invalid, never active). Pass `messageText: licenseMessage` for the detail text, `trialEmail` (where the trial email comes from; a trial is offered only with it and `client.requestTrial`), and optionally `purchaseUrl`, `openExternal`, `labels`. The step displays what the native service reports and decides nothing.
77
+
78
+ `signIn` adds the built-in sign-in step after the licence step and is handed to every step as `ctx.signIn`. `signInBackendFromOAuth(port, providers)` builds it over the app's `rightkit-oauth` commands (`port.status()` and `port.signIn(provider)`, which call `OAuthClient::sign_in_with_browser` and open the default browser); only `signedIn` and `account` are read from the answers, and a sign-in that resolves without `signedIn: true` rejects so the step shows its retry message. `signInStep(backend)` still works as an app step. Steps can set `canContinue(context)`, `onContinue(context)` (a veto: `false` or a throw keeps the step), `continueLabel`, and `skippable` (a per-step Skip button). `ctx.setResult(value)` stores a value that `onComplete({ completed, skipped, steps })` receives. `labels` overrides the button and status text, and `licenseStep(backend, { copy })` overrides the licence copy.
49
79
 
50
80
  ## When the legal gate re-prompts
51
81
 
@@ -1,5 +1,23 @@
1
- import type { OnboardingProps, PersistedOnboardingState } from "./types.js";
1
+ import type { LegalAcceptanceRecord, OnboardingProps, PersistedOnboardingState, SettingsBackend } from "./types.js";
2
+ /** @internal Validates one stored or returned acceptance record; `undefined` when it is not a well-formed record. */
3
+ export declare function readAcceptance(value: unknown): LegalAcceptanceRecord | undefined;
4
+ /** @internal Normalises the persisted `onboarding.state` value. */
5
+ export declare function readState(value: unknown): PersistedOnboardingState;
2
6
  /** True when the stored acceptance satisfies the host's requirement. */
3
7
  export declare function isLegalAccepted(state: PersistedOnboardingState, requiredVersion?: string, custom?: (state: PersistedOnboardingState) => boolean, requiredDocumentsHash?: string): boolean;
8
+ /**
9
+ * True when the setup step `stepId` is settled in a persisted onboarding state: the step is completed or skipped, or the
10
+ * whole setup was set aside ("Set up later", `skipped: true`). Reads the raw persisted value, so callers need no cast.
11
+ * Missing or malformed input is false; it never throws. Which steps gate an app stays with the app.
12
+ */
13
+ export declare function isStepSettled(state: unknown, stepId: string): boolean;
14
+ /**
15
+ * Restarts the setup at its first step ("Show onboarding again"). The setup progress (completed and skipped steps, the
16
+ * set-aside flag) is cleared, but the legal acceptance is kept: the agreement is not asked again, and `legal` stays
17
+ * completed. Reads the stored value first, so the rest of the settings are untouched.
18
+ */
19
+ export declare function restartOnboardingSetup({ settings }: {
20
+ settings: SettingsBackend;
21
+ }): Promise<void>;
4
22
  export declare function onboardingStateKey(): string;
5
- export declare function Onboarding({ settings, legal, legalMode, appName, steps, permissions, models, license, onComplete, onSkip, className, layout, requiredVersion, requiredDocumentsHash, isAccepted, documentsHash, beforeLegalGate, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro, allowSkip, titlebar, dragRegion, legalDragRegion, legalReceipt, labels: labelOverrides }: OnboardingProps): import("react").JSX.Element | null;
23
+ export declare function Onboarding({ settings, legal, legalMode, appName, steps, permissions, models, license, signIn, receiptDocuments, onComplete, onSkip, className, layout, requiredVersion, requiredDocumentsHash, isAccepted, documentsHash, beforeLegalGate, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro, allowSkip, titlebar, dragRegion, legalDragRegion, legalReceipt, labels: labelOverrides, frame, density, trafficLightInset }: OnboardingProps): import("react").JSX.Element | null;
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { LegalGate } from "@rightkit/legal-ui";
4
4
  import { Button } from "@rightkit/react-shell/react";
5
5
  import { cx } from "./ui.js";
6
- import { licenseStep, modelsStep, permissionsStep } from "./steps.js";
6
+ import { licenseStep, modelsStep, permissionsStep, signInStep } from "./steps.js";
7
7
  const KEY = "onboarding.state";
8
8
  const INITIAL = { version: 1, currentStep: 0, completed: [], skipped: false, legalAccepted: false };
9
9
  const DEFAULT_LABELS = { skip: "Set up later", skipStep: "Skip", back: "Back", continue: "Continue", finish: "Finish setup", loading: "Loading setup…" };
@@ -14,7 +14,8 @@ function isCanonicalUtc(value) {
14
14
  const parsed = Date.parse(value);
15
15
  return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
16
16
  }
17
- function readAcceptance(value) {
17
+ /** @internal Validates one stored or returned acceptance record; `undefined` when it is not a well-formed record. */
18
+ export function readAcceptance(value) {
18
19
  if (!value || typeof value !== "object" || Array.isArray(value))
19
20
  return undefined;
20
21
  const r = value;
@@ -28,7 +29,8 @@ function readAcceptance(value) {
28
29
  return undefined;
29
30
  return { version: r.version, documentsHash: r.documentsHash, acceptedAt: r.acceptedAt, eligibilityBasis: r.eligibilityBasis };
30
31
  }
31
- function readState(value) {
32
+ /** @internal Normalises the persisted `onboarding.state` value. */
33
+ export function readState(value) {
32
34
  if (!value || typeof value !== "object")
33
35
  return INITIAL;
34
36
  const candidate = value;
@@ -44,18 +46,34 @@ function readState(value) {
44
46
  legalAccepted: legalAcceptance !== undefined || (!hasStoredAcceptance && candidate.legalAccepted === true),
45
47
  eligibilityBasis: legalAcceptance?.eligibilityBasis ?? (candidate.eligibilityBasis === "individual" || candidate.eligibilityBasis === "enterprise" ? candidate.eligibilityBasis : undefined),
46
48
  ...(legalAcceptance ? { legalAcceptance } : {}),
49
+ ...(candidate.legalReopened === true ? { legalReopened: true } : {}),
47
50
  ...(Array.isArray(candidate.skippedSteps) ? { skippedSteps: candidate.skippedSteps.filter((x) => typeof x === "string") } : {}),
48
51
  };
49
52
  }
50
- /** Replace the acceptance in `state` with the one from the app's receipt (`undefined`: not accepted). */
51
- function withReceipt(state, record) {
53
+ /**
54
+ * Replace the acceptance in `state` with the one from the app's receipt (`undefined`: not accepted). A `historical` record
55
+ * (`HistoricalLegalReceipt`) is kept as the acceptance for the re-acceptance variant but never counts as accepted: it is
56
+ * flagged reopened, so the gate shows again and the setup progress is left alone.
57
+ */
58
+ function withReceipt(state, record, historical = false) {
52
59
  const { legalAcceptance: _drop, eligibilityBasis: _basis, ...rest } = state;
53
60
  void _drop;
54
61
  void _basis;
55
- return record ? { ...rest, legalAccepted: true, eligibilityBasis: record.eligibilityBasis, legalAcceptance: record } : { ...rest, legalAccepted: false };
62
+ if (!record)
63
+ return { ...rest, legalAccepted: false };
64
+ if (historical)
65
+ return { ...rest, legalAccepted: false, legalReopened: true, eligibilityBasis: record.eligibilityBasis, legalAcceptance: record };
66
+ return { ...rest, legalAccepted: true, eligibilityBasis: record.eligibilityBasis, legalAcceptance: record };
67
+ }
68
+ /** True when a `LegalReceiptPort.read()` value is a historical receipt (`{ record, current: false }`). */
69
+ function isHistoricalReceipt(value) {
70
+ return typeof value === "object" && value !== null && !Array.isArray(value) && "record" in value && value.current === false;
56
71
  }
57
72
  /** True when the stored acceptance satisfies the host's requirement. */
58
73
  export function isLegalAccepted(state, requiredVersion, custom, requiredDocumentsHash) {
74
+ // A reopened agreement (`reopenLegalAcceptance`) is not accepted until the user agrees again, whatever the host's check says.
75
+ if (state.legalReopened === true)
76
+ return false;
59
77
  if (custom)
60
78
  return custom(state);
61
79
  // A stored record whose timestamp is not canonical UTC is not an acceptance,
@@ -72,6 +90,39 @@ export function isLegalAccepted(state, requiredVersion, custom, requiredDocument
72
90
  // `legalAccepted` flag carries no version, time or basis, so it re-prompts once (as re-acceptance).
73
91
  return state.legalAcceptance !== undefined;
74
92
  }
93
+ /**
94
+ * True when the setup step `stepId` is settled in a persisted onboarding state: the step is completed or skipped, or the
95
+ * whole setup was set aside ("Set up later", `skipped: true`). Reads the raw persisted value, so callers need no cast.
96
+ * Missing or malformed input is false; it never throws. Which steps gate an app stays with the app.
97
+ */
98
+ export function isStepSettled(state, stepId) {
99
+ try {
100
+ if (typeof state !== "object" || state === null || typeof stepId !== "string")
101
+ return false;
102
+ const candidate = state;
103
+ if (candidate.skipped === true)
104
+ return true;
105
+ const completed = Array.isArray(candidate.completed) && candidate.completed.includes(stepId);
106
+ const skipped = Array.isArray(candidate.skippedSteps) && candidate.skippedSteps.includes(stepId);
107
+ return completed || skipped;
108
+ }
109
+ catch {
110
+ return false;
111
+ }
112
+ }
113
+ /**
114
+ * Restarts the setup at its first step ("Show onboarding again"). The setup progress (completed and skipped steps, the
115
+ * set-aside flag) is cleared, but the legal acceptance is kept: the agreement is not asked again, and `legal` stays
116
+ * completed. Reads the stored value first, so the rest of the settings are untouched.
117
+ */
118
+ export async function restartOnboardingSetup({ settings }) {
119
+ const key = onboardingStateKey();
120
+ const snapshot = await settings.load();
121
+ const state = readState(snapshot.values[key]);
122
+ await settings.patch({
123
+ [key]: { ...state, currentStep: 0, skipped: false, skippedSteps: [], completed: state.completed.filter((id) => id === "legal") },
124
+ }, snapshot.revision);
125
+ }
75
126
  /** FNV-1a fallback when SubtleCrypto is unavailable. */
76
127
  function fnv(text) {
77
128
  let h = 0x811c9dc5;
@@ -104,7 +155,7 @@ function NoticeList({ documents }) {
104
155
  return _jsx("p", { className: "rk-onboarding__muted", children: "No notices to show." });
105
156
  return _jsx("ul", { className: "rk-onboarding__notices", children: documents.map((d) => _jsx("li", { children: d.onOpen ? _jsx("button", { type: "button", onClick: d.onOpen, children: d.title }) : d.url ? _jsx("a", { href: d.url, target: "_blank", rel: "noreferrer", children: d.title }) : d.title }, d.title)) });
106
157
  }
107
- export function Onboarding({ settings, legal, legalMode = "gate", appName, steps = [], permissions, models, license, onComplete, onSkip, className, layout = "window", requiredVersion, requiredDocumentsHash, isAccepted, documentsHash, beforeLegalGate, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro, allowSkip = true, titlebar, dragRegion, legalDragRegion = false, legalReceipt, labels: labelOverrides }) {
158
+ export function Onboarding({ settings, legal, legalMode = "gate", appName, steps = [], permissions, models, license, signIn, receiptDocuments, onComplete, onSkip, className, layout = "window", requiredVersion, requiredDocumentsHash, isAccepted, documentsHash, beforeLegalGate, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro, allowSkip = true, titlebar, dragRegion, legalDragRegion = false, legalReceipt, labels: labelOverrides, frame = "rail", density = "default", trafficLightInset = false }) {
108
159
  if (legalMode === "gate" && !legal)
109
160
  throw new Error("Onboarding: `legal` is required when legalMode is \"gate\"");
110
161
  const L = { ...DEFAULT_LABELS, ...labelOverrides };
@@ -121,6 +172,8 @@ export function Onboarding({ settings, legal, legalMode = "gate", appName, steps
121
172
  /** Values passed to `ctx.setResult`, keyed by step id; handed to `onComplete`. */
122
173
  const stepResults = useRef({});
123
174
  const [continuing, setContinuing] = useState(false);
175
+ /** A receipt the native service already created for this agreement when saving the settings failed; a retry reuses it instead of stamping a second one. */
176
+ const createdReceipt = useRef(null);
124
177
  /** Persists a patch; throws when the settings backend rejects it. */
125
178
  const persist = useCallback(async (patch) => {
126
179
  setSaving(true);
@@ -161,14 +214,22 @@ export function Onboarding({ settings, legal, legalMode = "gate", appName, steps
161
214
  }
162
215
  // The app's receipt is the source of truth for acceptance; an unreadable receipt counts as none.
163
216
  let record;
217
+ let historical = false;
164
218
  try {
165
- record = readAcceptance(await legalReceipt.read());
219
+ const value = await legalReceipt.read();
220
+ if (isHistoricalReceipt(value)) {
221
+ record = readAcceptance(value.record);
222
+ historical = record !== undefined;
223
+ }
224
+ else
225
+ record = readAcceptance(value);
166
226
  }
167
227
  catch {
168
228
  record = undefined;
229
+ historical = false;
169
230
  }
170
231
  if (live)
171
- setState(withReceipt(loaded, record));
232
+ setState(withReceipt(loaded, record, historical));
172
233
  }
173
234
  catch (e) {
174
235
  if (live)
@@ -183,8 +244,9 @@ export function Onboarding({ settings, legal, legalMode = "gate", appName, steps
183
244
  ...(permissions ? [permissionsStep(permissions)] : []),
184
245
  ...(models ? [modelsStep(models, { required: requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, intro: modelsIntro, startGuard: modelsStarted })] : []),
185
246
  ...(license ? [licenseStep(license)] : []),
247
+ ...(signIn ? [signInStep(signIn)] : []),
186
248
  ...steps,
187
- ], [gated, legalMode, legal?.documents, license, models, permissions, steps, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro]);
249
+ ], [gated, legalMode, legal?.documents, license, signIn, models, permissions, steps, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro]);
188
250
  const activeState = state ?? INITIAL;
189
251
  const accepted = !gated || isLegalAccepted(activeState, requiredVersion, isAccepted, requiredDocumentsHash);
190
252
  const needsGate = state !== null && !accepted;
@@ -216,7 +278,7 @@ export function Onboarding({ settings, legal, legalMode = "gate", appName, steps
216
278
  return () => { live = false; };
217
279
  }, [needsGate, gateCleared, beforeLegalGate]);
218
280
  const setResult = useCallback((value) => { stepResults.current[activeId] = value; }, [activeId]);
219
- const context = { state: activeState, settings, setState: saveStep, permissions, models, license, ready: readyMap, setReady, setResult };
281
+ const context = { state: activeState, settings, setState: saveStep, permissions, models, license, signIn, ready: readyMap, setReady, setResult };
220
282
  const canContinue = active?.canContinue ? active.canContinue(context) : true;
221
283
  const runCallback = async (callback) => {
222
284
  try {
@@ -266,7 +328,7 @@ export function Onboarding({ settings, legal, legalMode = "gate", appName, steps
266
328
  }
267
329
  await markAndMove(index + 1, { completed: active.id });
268
330
  };
269
- const rootClass = cx("rk-onboarding", layout === "contained" && "rk-onboarding--contained", className);
331
+ const rootClass = cx("rk-onboarding", layout === "contained" && "rk-onboarding--contained", density === "compact" && "rk-onboarding--compact", trafficLightInset && "rk-onboarding--lights", frame === "card" && "rk-onboarding--card", className);
270
332
  if (error && !state)
271
333
  return _jsxs("div", { className: rootClass, children: [_jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }), _jsx(Button, { onClick: () => window.location.reload(), children: "Retry" })] });
272
334
  if (state && !active)
@@ -279,24 +341,57 @@ export function Onboarding({ settings, legal, legalMode = "gate", appName, steps
279
341
  if (!legal)
280
342
  return null;
281
343
  const reacceptance = activeState.legalAccepted || activeState.legalAcceptance !== undefined;
344
+ /** Records the acceptance and returns the record to mirror into the settings state. A throw keeps the gate closed. */
345
+ const recordAcceptance = async (gate, basis) => {
346
+ const version = requiredVersion ?? "1";
347
+ const hash = documentsHash ?? requiredDocumentsHash ?? await hashDocuments(gate.documents);
348
+ if (legalReceipt?.create) {
349
+ // Native path: the service stamps the time and stores the receipt; nothing is built in the renderer.
350
+ const key = [version, hash, basis].join("\n");
351
+ if (createdReceipt.current?.key === key)
352
+ return createdReceipt.current.record;
353
+ const declared = receiptDocuments ?? gate.documents.map((d) => ({ title: d.title, ...(d.url ? { url: d.url } : {}) }));
354
+ const created = readAcceptance(await legalReceipt.create({ version, documentsHash: hash, documents: declared, eligibilityBasis: basis, surface: "first_run" }));
355
+ if (!created)
356
+ throw new Error("The legal receipt service returned an invalid record.");
357
+ // Bind the response to the declaration sent and any independent required hash.
358
+ if (created.eligibilityBasis !== basis || created.version !== version || created.documentsHash !== hash || (requiredDocumentsHash !== undefined && created.documentsHash !== requiredDocumentsHash)) {
359
+ throw new Error("The legal receipt does not match the agreement that was shown.");
360
+ }
361
+ createdReceipt.current = { key, record: created };
362
+ return created;
363
+ }
364
+ // LEGACY: the renderer builds the record with the webview clock. Deprecated; implement `legalReceipt.create`.
365
+ const record = { version, documentsHash: hash, acceptedAt: new Date().toISOString(), eligibilityBasis: basis };
366
+ if (legalReceipt) {
367
+ if (!legalReceipt.write)
368
+ throw new Error("The legal receipt can neither create nor write an acceptance.");
369
+ await legalReceipt.write(record);
370
+ }
371
+ return record;
372
+ };
282
373
  return _jsxs("div", { className: cx("rk-onboarding rk-onboarding--legal", layout === "contained" && "rk-onboarding--contained", className), "data-testid": "onboarding-legal", "data-variant": reacceptance ? "re-acceptance" : "first-run", children: [_jsx(TitleStrip, { titlebar: titlebar, dragRegion: legalDragRegion }), _jsx(LegalGate, { ...legal, layout: layout, variant: reacceptance ? "re-acceptance" : "first-run", onQuit: () => void legal.onQuit?.(), onAgree: async ({ eligibilityBasis }) => {
283
- const legalAcceptance = { version: requiredVersion ?? "1", documentsHash: documentsHash ?? requiredDocumentsHash ?? await hashDocuments(legal.documents), acceptedAt: new Date().toISOString(), eligibilityBasis };
284
- if (legalReceipt) {
285
- try {
286
- await legalReceipt.write(legalAcceptance);
287
- }
288
- catch (e) {
289
- setError(e instanceof Error ? e.message : String(e));
290
- throw e;
291
- }
374
+ let legalAcceptance;
375
+ try {
376
+ legalAcceptance = await recordAcceptance(legal, eligibilityBasis);
377
+ }
378
+ catch (e) {
379
+ setError(e instanceof Error ? e.message : String(e));
380
+ throw e;
292
381
  }
293
382
  const progress = reacceptance ? {} : { completed: ["legal"], currentStep: builtins.length > 1 ? 1 : 0 };
294
- await persist({ legalAccepted: true, eligibilityBasis, legalAcceptance, ...progress });
383
+ await persist({ legalAccepted: true, legalReopened: false, eligibilityBasis, legalAcceptance, ...progress });
384
+ createdReceipt.current = null;
295
385
  if (!reacceptance && builtins.length === 1)
296
386
  await onComplete(resultOf(["legal"], []));
297
387
  } })] });
298
388
  }
299
389
  const body = active.render(context);
390
+ if (frame === "card") {
391
+ // The HeardRight v5 frame: one centred card, the app name on top, a dot pager and no step rail.
392
+ return _jsxs("main", { className: rootClass, "data-testid": "onboarding", "data-step-id": active.id, "data-frame": "card", children: [_jsx(TitleStrip, { titlebar: titlebar, dragRegion: dragRegion }), _jsxs("section", { className: "rk-onboarding__main rk-onboarding__main--card", children: [_jsx("div", { className: "rk-onboarding__brand rk-onboarding__brand--card", children: appName ?? legal?.appName }), _jsxs("div", { className: "rk-onboarding__frame", children: [active.title ? _jsx("h1", { children: active.title }) : null, active.description ? _jsx("p", { className: "rk-onboarding__description", children: active.description }) : null, _jsx("div", { className: "rk-onboarding__content", children: body }), error ? _jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }) : null, _jsxs("footer", { children: [_jsx(Button, { variant: "secondary", disabled: index <= lead || saving || continuing, onClick: () => void save({ currentStep: index - 1 }), children: L.back }), _jsx("nav", { className: "rk-onboarding__dots", "aria-label": "Setup progress", children: builtins.map((step, i) => _jsx("button", { type: "button", className: cx("rk-onboarding__dot", i === index && "is-active", activeState.completed.includes(step.id) && "is-done"), "aria-label": `Step ${i + 1}: ${step.label}`, "aria-current": i === index ? "step" : undefined, disabled: (gated && i === 0 && builtins.length > 1) || continuing, onClick: () => { if (!continuing && i >= lead && (i <= index || canContinue))
393
+ void save({ currentStep: i }); } }, step.id)) }), _jsx("span", { className: "rk-onboarding__hint", children: !canContinue && active.blockedHint ? active.blockedHint : null }), allowSkip ? _jsx(Button, { variant: "ghost", disabled: saving || continuing, onClick: () => { void save({ currentStep: index, skipped: true }).then((ok) => ok ? runCallback(onSkip) : undefined); }, children: L.skip }) : null, active.skippable ? _jsx(Button, { variant: "ghost", disabled: saving || continuing, onClick: () => void markAndMove(index + 1, { skipped: active.id }), children: L.skipStep }) : null, _jsx(Button, { variant: "primary", disabled: saving || continuing || !canContinue, onClick: () => void advance(), children: active.continueLabel ?? (index === builtins.length - 1 ? L.finish : L.continue) })] })] })] })] });
394
+ }
300
395
  return _jsxs("main", { className: rootClass, "data-testid": "onboarding", "data-step-id": active.id, children: [_jsx(TitleStrip, { titlebar: titlebar, dragRegion: dragRegion }), _jsxs("aside", { className: "rk-onboarding__rail", children: [_jsx("div", { className: "rk-onboarding__brand", children: appName ?? legal?.appName }), _jsx("nav", { "aria-label": "Setup progress", children: builtins.map((step, i) => _jsxs("button", { type: "button", className: cx("rk-onboarding__step", i === index && "is-active", activeState.completed.includes(step.id) && "is-done"), "aria-current": i === index ? "step" : undefined, disabled: (gated && i === 0 && builtins.length > 1) || continuing, onClick: () => { if (!continuing && i >= lead && (i <= index || canContinue))
301
396
  void save({ currentStep: i }); }, children: [_jsx("span", { children: activeState.completed.includes(step.id) ? "✓" : i + 1 }), step.label] }, step.id)) })] }), _jsxs("section", { className: "rk-onboarding__main", children: [_jsxs("header", { children: [_jsxs("p", { className: "rk-onboarding__eyebrow", children: ["Step ", index + 1, " of ", builtins.length] }), active.title ? _jsx("h1", { children: active.title }) : null, active.description ? _jsx("p", { className: "rk-onboarding__description", children: active.description }) : null] }), _jsx("div", { className: "rk-onboarding__content", children: body }), error ? _jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }) : null, _jsxs("footer", { children: [allowSkip ? _jsx(Button, { variant: "ghost", disabled: saving || continuing, onClick: () => { void save({ currentStep: index, skipped: true }).then((ok) => ok ? runCallback(onSkip) : undefined); }, children: L.skip }) : null, active.skippable ? _jsx(Button, { variant: "ghost", disabled: saving || continuing, onClick: () => void markAndMove(index + 1, { skipped: active.id }), children: L.skipStep }) : null, _jsx("span", { className: "rk-onboarding__hint", children: !canContinue && active.blockedHint ? active.blockedHint : null }), _jsx(Button, { variant: "secondary", disabled: index <= lead || saving || continuing, onClick: () => void save({ currentStep: index - 1 }), children: L.back }), _jsx(Button, { variant: "primary", disabled: saving || continuing || !canContinue, onClick: () => void advance(), children: active.continueLabel ?? (index === builtins.length - 1 ? L.finish : L.continue) })] })] })] });
302
397
  }
@@ -0,0 +1,59 @@
1
+ import type { LicenseBackend, LicenseState, SignInBackend, SignInProvider } from "./types.js";
2
+ /**
3
+ * The fields of `@rightkit/license` 0.3.0 `LicenseView` that the adapter reads. Structural on purpose: this package does not
4
+ * import the licence package, so `createLicenseClient(...)` output is accepted as it is and an app that has no licence step
5
+ * never loads it.
6
+ */
7
+ export interface LicenseViewLike {
8
+ status: string;
9
+ lifecycle?: string;
10
+ tier?: string | null;
11
+ /** Unix milliseconds. */
12
+ validUntil?: number | null;
13
+ /** A closed `MessageCode`; text comes from `messageText`. */
14
+ message?: string;
15
+ }
16
+ /** The slice of `createLicenseClient(...)` the licence step needs. */
17
+ export interface LicenseViewClient {
18
+ status(): Promise<LicenseViewLike>;
19
+ activate(key: string): Promise<LicenseViewLike>;
20
+ /** Present on the real client. A trial needs `trialEmail` as well. */
21
+ requestTrial?(email: string): Promise<{
22
+ accepted: boolean;
23
+ message?: string;
24
+ }>;
25
+ }
26
+ export interface LicenseViewBackendOptions {
27
+ /** Text for a `MessageCode`: pass `licenseMessage` from `@rightkit/license`. Without it the step shows no detail text. */
28
+ messageText?: (code: string) => string;
29
+ /**
30
+ * Where the trial email comes from (an input the app renders, or the signed-in account). A trial is offered only when this
31
+ * and the client's `requestTrial` both exist. Returning nothing refuses the trial with a message.
32
+ */
33
+ trialEmail?: () => string | undefined | Promise<string | undefined>;
34
+ purchaseUrl?: string;
35
+ openExternal?: (url: string) => void | Promise<void>;
36
+ /** Replaces the English status labels. */
37
+ labels?: Partial<Record<LicenseState["status"], string>>;
38
+ }
39
+ /** Maps a native `LicenseView` to the step's `LicenseState`. The lifecycle decides; the coarse status is the fallback. An unknown value is `invalid`, never `active`. */
40
+ export declare function licenseStateFromView(view: LicenseViewLike, options?: Pick<LicenseViewBackendOptions, "messageText" | "labels">): LicenseState;
41
+ /**
42
+ * `LicenseBackend` over a native licence client (`@rightkit/license` 0.3.0 `createLicenseClient`). The step shows the state
43
+ * the native service reports and never decides entitlement itself; errors from the client reach the step unchanged (they are
44
+ * closed, fixed-text `LicenseClientError`s).
45
+ */
46
+ export declare function licenseBackendFromView(client: LicenseViewClient, options?: LicenseViewBackendOptions): LicenseBackend;
47
+ /**
48
+ * The app's `rightkit-oauth` commands as a typed port. Both calls go to the shared owner
49
+ * (`OAuthClient::sign_in_with_browser` through the app's command): the default browser opens, no in-app webview, and no token
50
+ * reaches the renderer. Results are untrusted `unknown`; only `signedIn` and `account` are read.
51
+ */
52
+ export interface OAuthSignInPort {
53
+ /** Account already signed in, if any. */
54
+ status(): Promise<unknown>;
55
+ /** Open the default browser for `provider`; resolves when the sign-in finishes. */
56
+ signIn(provider: SignInProvider): Promise<unknown>;
57
+ }
58
+ /** `SignInBackend` over `port`. A sign-in that resolves without `signedIn: true` (closed browser tab, cancel) rejects, so the step shows its retry message. */
59
+ export declare function signInBackendFromOAuth(port: OAuthSignInPort, providers: SignInBackend["providers"]): SignInBackend;
@@ -0,0 +1,93 @@
1
+ const BY_LIFECYCLE = {
2
+ unlicensed: "none",
3
+ migration_required: "invalid",
4
+ trial: "trial",
5
+ expired_trial: "expired",
6
+ active: "active",
7
+ grace: "active",
8
+ lifetime: "active",
9
+ lifetime_provisional: "active",
10
+ verification_required: "active",
11
+ expired: "expired",
12
+ clock_repair_required: "invalid",
13
+ revoked: "invalid",
14
+ invalid: "invalid",
15
+ };
16
+ const BY_STATUS = {
17
+ none: "none",
18
+ active: "active",
19
+ grace: "active",
20
+ expired: "expired",
21
+ revoked: "invalid",
22
+ invalid: "invalid",
23
+ unverifiable: "invalid",
24
+ };
25
+ const DEFAULT_LABELS = {
26
+ none: "No licence",
27
+ trial: "Trial",
28
+ active: "Active",
29
+ expired: "Expired",
30
+ invalid: "Needs attention",
31
+ };
32
+ /** Maps a native `LicenseView` to the step's `LicenseState`. The lifecycle decides; the coarse status is the fallback. An unknown value is `invalid`, never `active`. */
33
+ export function licenseStateFromView(view, options = {}) {
34
+ const status = (view.lifecycle !== undefined ? BY_LIFECYCLE[view.lifecycle] : undefined) ?? BY_STATUS[view.status] ?? "invalid";
35
+ const text = view.message !== undefined && view.message !== "none" ? options.messageText?.(view.message) : undefined;
36
+ const validUntil = typeof view.validUntil === "number" && Number.isFinite(view.validUntil) ? new Date(view.validUntil).toISOString() : null;
37
+ return {
38
+ status,
39
+ label: options.labels?.[status] ?? DEFAULT_LABELS[status],
40
+ ...(text ? { detail: text } : {}),
41
+ ...(typeof view.tier === "string" && view.tier ? { tier: view.tier } : {}),
42
+ validUntil,
43
+ };
44
+ }
45
+ /**
46
+ * `LicenseBackend` over a native licence client (`@rightkit/license` 0.3.0 `createLicenseClient`). The step shows the state
47
+ * the native service reports and never decides entitlement itself; errors from the client reach the step unchanged (they are
48
+ * closed, fixed-text `LicenseClientError`s).
49
+ */
50
+ export function licenseBackendFromView(client, options = {}) {
51
+ const state = (view) => licenseStateFromView(view, options);
52
+ const { requestTrial } = client;
53
+ const { trialEmail } = options;
54
+ return {
55
+ load: async () => state(await client.status()),
56
+ activate: async (key) => state(await client.activate(key)),
57
+ ...(requestTrial && trialEmail ? {
58
+ startTrial: async () => {
59
+ const email = (await trialEmail())?.trim();
60
+ if (!email)
61
+ throw new Error("An email address is needed to start a trial.");
62
+ const outcome = await requestTrial.call(client, email);
63
+ const current = state(await client.status());
64
+ const text = outcome.message !== undefined && outcome.message !== "none" ? options.messageText?.(outcome.message) : undefined;
65
+ if (!outcome.accepted)
66
+ throw new Error(text ?? "A trial could not be started.");
67
+ return text ? { ...current, detail: text } : current;
68
+ },
69
+ } : {}),
70
+ ...(options.purchaseUrl ? { purchaseUrl: options.purchaseUrl } : {}),
71
+ ...(options.openExternal ? { openExternal: options.openExternal } : {}),
72
+ };
73
+ }
74
+ function accountOf(value) {
75
+ if (!value || typeof value !== "object" || Array.isArray(value))
76
+ return { signedIn: false };
77
+ const v = value;
78
+ const account = typeof v.account === "string" && v.account.trim() ? v.account.trim().slice(0, 320) : undefined;
79
+ return v.signedIn === true ? { signedIn: true, ...(account ? { account } : {}) } : { signedIn: false };
80
+ }
81
+ /** `SignInBackend` over `port`. A sign-in that resolves without `signedIn: true` (closed browser tab, cancel) rejects, so the step shows its retry message. */
82
+ export function signInBackendFromOAuth(port, providers) {
83
+ return {
84
+ providers,
85
+ load: async () => accountOf(await port.status()),
86
+ start: async (provider) => {
87
+ const result = accountOf(await port.signIn(provider));
88
+ if (!result.signedIn)
89
+ throw new Error("Sign-in did not finish. Try again.");
90
+ return result;
91
+ },
92
+ };
93
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,8 @@
1
- export { Onboarding, isLegalAccepted, onboardingStateKey } from "./Onboarding.js";
1
+ export { Onboarding, isLegalAccepted, isStepSettled, onboardingStateKey, restartOnboardingSetup } from "./Onboarding.js";
2
2
  export { licenseStep, modelsStep, permissionsStep, signInStep } from "./steps.js";
3
- export type { ModelsStepOptions } from "./steps.js";
3
+ export type { LicenseStepCopy, LicenseStepOptions, ModelsStepOptions } from "./steps.js";
4
+ export { licenseBackendFromView, licenseStateFromView, signInBackendFromOAuth } from "./adapters.js";
5
+ export type { LicenseViewBackendOptions, LicenseViewClient, LicenseViewLike, OAuthSignInPort } from "./adapters.js";
6
+ export { LEGAL_RECEIPT_COMMANDS, createNativeLegalReceiptPort, importLegacyAcceptance, reopenLegalAcceptance, resetLegalAcceptance } from "./legal-receipt.js";
7
+ export type { LegacyAcceptanceImportOptions, LegalReceiptInvoke, NativeLegalReceiptPortOptions } from "./legal-receipt.js";
4
8
  export type * from "./types.js";
package/dist/index.js CHANGED
@@ -1,2 +1,4 @@
1
- export { Onboarding, isLegalAccepted, onboardingStateKey } from "./Onboarding.js";
1
+ export { Onboarding, isLegalAccepted, isStepSettled, onboardingStateKey, restartOnboardingSetup } from "./Onboarding.js";
2
2
  export { licenseStep, modelsStep, permissionsStep, signInStep } from "./steps.js";
3
+ export { licenseBackendFromView, licenseStateFromView, signInBackendFromOAuth } from "./adapters.js";
4
+ export { LEGAL_RECEIPT_COMMANDS, createNativeLegalReceiptPort, importLegacyAcceptance, reopenLegalAcceptance, resetLegalAcceptance } from "./legal-receipt.js";
@@ -0,0 +1,70 @@
1
+ import type { LegalAcceptanceRecord, LegalReceiptCreateRequest, LegalReceiptPort, SettingsBackend } from "./types.js";
2
+ /**
3
+ * The commands of the native legal-receipt host service. The native side registers them once under the host caller policy
4
+ * (`rightkit-tauri-shell` host services); the trusted window is the one that shows the legal gate. Names are provisional
5
+ * until the host service is published; an app whose host registers other names passes `commands`.
6
+ */
7
+ export declare const LEGAL_RECEIPT_COMMANDS: Readonly<{
8
+ read: "rightkit_legal_receipt_read";
9
+ create: "rightkit_legal_receipt_create";
10
+ reset: "rightkit_legal_receipt_reset";
11
+ /** Imports a renderer-held legacy acceptance once (see `importLegacyAcceptance`). */
12
+ import: "rightkit_legal_receipt_import";
13
+ }>;
14
+ /** Tauri's `invoke`, structurally (`@tauri-apps/api/core`). No import, so the package stays free of Tauri. */
15
+ export type LegalReceiptInvoke = (command: string, args?: Record<string, unknown>) => Promise<unknown>;
16
+ export interface NativeLegalReceiptPortOptions {
17
+ invoke: LegalReceiptInvoke;
18
+ commands?: Partial<typeof LEGAL_RECEIPT_COMMANDS>;
19
+ }
20
+ /**
21
+ * `LegalReceiptPort` over the native legal-receipt service.
22
+ *
23
+ * - `read` returns the stored record, or `null` when there is none or the stored value is not a well-formed record. A
24
+ * historical receipt (a version or digest that no longer matches) comes back as `{ record, current: false }`, which keeps
25
+ * the re-acceptance variant and the setup progress; the native service offers it that way.
26
+ * - `create` sends the declaration (`version`, `documentsHash`, `documents`, `eligibilityBasis`, `surface`) and NO time. The
27
+ * service compares it with the manifest it embeds, stamps native UTC time, stores the receipt and returns the record; a
28
+ * refusal or a malformed answer throws, which keeps the gate closed.
29
+ * - `reset` clears the receipt.
30
+ * There is no `write`: the renderer never supplies a stored record.
31
+ */
32
+ export declare function createNativeLegalReceiptPort({ invoke, commands }: NativeLegalReceiptPortOptions): LegalReceiptPort;
33
+ export interface LegacyAcceptanceImportOptions {
34
+ invoke: LegalReceiptInvoke;
35
+ /** Key of the renderer-held legacy receipt, for example `scraperight.legal-acceptance.v1`. */
36
+ storageKey: string;
37
+ /**
38
+ * Maps the stored text to the declaration the native service verifies against its manifest (hashes, versions, documents).
39
+ * `null`: not an importable record; nothing is sent and the legacy key stays in place.
40
+ */
41
+ parse: (raw: string) => LegalReceiptCreateRequest | null;
42
+ /** Storage that holds the legacy key. Default `localStorage` when present. `null`: no legacy storage to read. */
43
+ storage?: Pick<Storage, "getItem" | "removeItem"> | null;
44
+ commands?: Partial<typeof LEGAL_RECEIPT_COMMANDS>;
45
+ }
46
+ /**
47
+ * One-time import of a renderer-held (legacy) acceptance into the native receipt. The legacy record is submitted once to the
48
+ * native `import` command, which verifies it against the manifest and stamps the import with its own clock. Only after the
49
+ * native service confirms the record is the legacy key removed. A refusal or a failed call throws and leaves the key in place,
50
+ * so the next launch retries; no acceptance is invented here. Returns the imported record, or `null` when there is nothing to
51
+ * import.
52
+ */
53
+ export declare function importLegacyAcceptance({ invoke, storageKey, parse, storage, commands }: LegacyAcceptanceImportOptions): Promise<LegalAcceptanceRecord | null>;
54
+ /**
55
+ * Re-opens the agreement: the stored acceptance stops counting, so the gate shows as a re-acceptance on the next mount of
56
+ * `Onboarding`. The old record (and the app's receipt) stays until the user agrees again; agreeing creates the new receipt and
57
+ * clears the flag. Progress through the other steps is kept.
58
+ */
59
+ export declare function reopenLegalAcceptance({ settings }: {
60
+ settings: SettingsBackend;
61
+ }): Promise<void>;
62
+ /**
63
+ * Forgets the acceptance entirely: the app's receipt is cleared first (`legalReceipt.reset`, required when a receipt port is
64
+ * given, so a failed reset cannot leave the receipt accepted), then the settings copy, so the next mount shows the first-run
65
+ * gate. Other steps' progress is kept.
66
+ */
67
+ export declare function resetLegalAcceptance({ settings, legalReceipt }: {
68
+ settings: SettingsBackend;
69
+ legalReceipt?: LegalReceiptPort;
70
+ }): Promise<void>;
@@ -0,0 +1,148 @@
1
+ import { onboardingStateKey, readAcceptance, readState } from "./Onboarding.js";
2
+ /**
3
+ * The commands of the native legal-receipt host service. The native side registers them once under the host caller policy
4
+ * (`rightkit-tauri-shell` host services); the trusted window is the one that shows the legal gate. Names are provisional
5
+ * until the host service is published; an app whose host registers other names passes `commands`.
6
+ */
7
+ export const LEGAL_RECEIPT_COMMANDS = Object.freeze({
8
+ read: "rightkit_legal_receipt_read",
9
+ create: "rightkit_legal_receipt_create",
10
+ reset: "rightkit_legal_receipt_reset",
11
+ /** Imports a renderer-held legacy acceptance once (see `importLegacyAcceptance`). */
12
+ import: "rightkit_legal_receipt_import",
13
+ });
14
+ /** Host commands reject with `{ code, message }`; the gate and steps show `Error#message`. */
15
+ function hostError(error) {
16
+ if (error instanceof Error)
17
+ return error;
18
+ const message = error && typeof error === "object" ? error.message : error;
19
+ return new Error(typeof message === "string" && message.trim() ? message : "The legal receipt service failed.");
20
+ }
21
+ async function call(invoke, command, args) {
22
+ try {
23
+ return await invoke(command, args);
24
+ }
25
+ catch (error) {
26
+ throw hostError(error);
27
+ }
28
+ }
29
+ /** The declaration the native service compares with its manifest: never a time. */
30
+ function declaration(request) {
31
+ return {
32
+ version: request.version,
33
+ documentsHash: request.documentsHash,
34
+ documents: request.documents,
35
+ eligibilityBasis: request.eligibilityBasis,
36
+ surface: request.surface,
37
+ };
38
+ }
39
+ /** True for a historical receipt value (`{ record, current: false }`) as the native read may return it. */
40
+ function isHistoricalValue(value) {
41
+ return typeof value === "object" && value !== null && !Array.isArray(value) && "record" in value && value.current === false;
42
+ }
43
+ /**
44
+ * `LegalReceiptPort` over the native legal-receipt service.
45
+ *
46
+ * - `read` returns the stored record, or `null` when there is none or the stored value is not a well-formed record. A
47
+ * historical receipt (a version or digest that no longer matches) comes back as `{ record, current: false }`, which keeps
48
+ * the re-acceptance variant and the setup progress; the native service offers it that way.
49
+ * - `create` sends the declaration (`version`, `documentsHash`, `documents`, `eligibilityBasis`, `surface`) and NO time. The
50
+ * service compares it with the manifest it embeds, stamps native UTC time, stores the receipt and returns the record; a
51
+ * refusal or a malformed answer throws, which keeps the gate closed.
52
+ * - `reset` clears the receipt.
53
+ * There is no `write`: the renderer never supplies a stored record.
54
+ */
55
+ export function createNativeLegalReceiptPort({ invoke, commands }) {
56
+ const names = { ...LEGAL_RECEIPT_COMMANDS, ...commands };
57
+ return {
58
+ async read() {
59
+ const value = await call(invoke, names.read);
60
+ if (value === null || value === undefined)
61
+ return null;
62
+ if (isHistoricalValue(value)) {
63
+ const record = readAcceptance(value.record);
64
+ return record ? { record, current: false } : null;
65
+ }
66
+ return readAcceptance(value) ?? null;
67
+ },
68
+ async create(request) {
69
+ const created = readAcceptance(await call(invoke, names.create, { request: declaration(request) }));
70
+ if (!created)
71
+ throw new Error("The legal receipt service returned an invalid record.");
72
+ return created;
73
+ },
74
+ async reset() { await call(invoke, names.reset); },
75
+ };
76
+ }
77
+ function browserStorage() {
78
+ try {
79
+ return typeof localStorage === "undefined" ? null : localStorage;
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
85
+ /**
86
+ * One-time import of a renderer-held (legacy) acceptance into the native receipt. The legacy record is submitted once to the
87
+ * native `import` command, which verifies it against the manifest and stamps the import with its own clock. Only after the
88
+ * native service confirms the record is the legacy key removed. A refusal or a failed call throws and leaves the key in place,
89
+ * so the next launch retries; no acceptance is invented here. Returns the imported record, or `null` when there is nothing to
90
+ * import.
91
+ */
92
+ export async function importLegacyAcceptance({ invoke, storageKey, parse, storage, commands }) {
93
+ const names = { ...LEGAL_RECEIPT_COMMANDS, ...commands };
94
+ const backing = storage === undefined ? browserStorage() : storage;
95
+ let raw;
96
+ try {
97
+ raw = backing?.getItem(storageKey) ?? null;
98
+ }
99
+ catch {
100
+ raw = null;
101
+ }
102
+ if (raw === null)
103
+ return null;
104
+ const request = parse(raw);
105
+ if (!request)
106
+ return null;
107
+ const imported = readAcceptance(await call(invoke, names.import, { request: declaration(request) }));
108
+ if (!imported)
109
+ throw new Error("The legal receipt service returned an invalid record.");
110
+ try {
111
+ backing?.removeItem(storageKey);
112
+ }
113
+ catch {
114
+ /* the native receipt is stored; the native import is idempotent, so a later run only repeats the confirmation */
115
+ }
116
+ return imported;
117
+ }
118
+ /**
119
+ * Re-opens the agreement: the stored acceptance stops counting, so the gate shows as a re-acceptance on the next mount of
120
+ * `Onboarding`. The old record (and the app's receipt) stays until the user agrees again; agreeing creates the new receipt and
121
+ * clears the flag. Progress through the other steps is kept.
122
+ */
123
+ export async function reopenLegalAcceptance({ settings }) {
124
+ const key = onboardingStateKey();
125
+ const snapshot = await settings.load();
126
+ const state = readState(snapshot.values[key]);
127
+ await settings.patch({ [key]: { ...state, legalReopened: true } }, snapshot.revision);
128
+ }
129
+ /**
130
+ * Forgets the acceptance entirely: the app's receipt is cleared first (`legalReceipt.reset`, required when a receipt port is
131
+ * given, so a failed reset cannot leave the receipt accepted), then the settings copy, so the next mount shows the first-run
132
+ * gate. Other steps' progress is kept.
133
+ */
134
+ export async function resetLegalAcceptance({ settings, legalReceipt }) {
135
+ if (legalReceipt) {
136
+ if (!legalReceipt.reset)
137
+ throw new Error("The legal receipt cannot be reset.");
138
+ await legalReceipt.reset();
139
+ }
140
+ const key = onboardingStateKey();
141
+ const snapshot = await settings.load();
142
+ const next = { ...readState(snapshot.values[key]), legalAccepted: false };
143
+ delete next.legalAcceptance;
144
+ delete next.eligibilityBasis;
145
+ delete next.legalReopened;
146
+ next.completed = next.completed.filter((id) => id !== "legal");
147
+ await settings.patch({ [key]: next }, snapshot.revision);
148
+ }
package/dist/steps.js CHANGED
@@ -24,7 +24,7 @@ function PermissionsBody({ backend }) {
24
24
  const [error, setError] = useState(null);
25
25
  const reload = useCallback(() => backend.list().then(setItems, (e) => setError(e instanceof Error ? e.message : String(e))), [backend]);
26
26
  useEffect(() => void reload(), [reload]);
27
- return (_jsxs("div", { className: "rk-onboarding__card", "data-testid": "onboarding-permissions", children: [error ? _jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }) : null, items.length === 0 && !error ? _jsx("p", { className: "rk-onboarding__muted", children: "Checking permissions\u2026" }) : null, items.map((item) => (_jsxs("div", { className: "rk-onboarding__row", "data-permission-id": item.id, children: [_jsxs("div", { children: [_jsx("strong", { children: item.label }), _jsx("p", { children: item.description ?? item.detail })] }), _jsxs("div", { className: "rk-onboarding__row-actions", children: [_jsx(Badge, { tone: permissionTone(item.state), children: item.state }), item.state === "needed" || item.state === "denied" ? (_jsx(Button, { size: "sm", variant: "secondary", disabled: busy === item.id, onClick: () => { setBusy(item.id); setError(null); void backend.request(item.id).then((next) => setItems((current) => current.map((x) => x.id === next.id ? next : x)), (e) => setError(e instanceof Error ? e.message : String(e))).finally(() => setBusy(null)); }, children: item.state === "denied" ? "Try again" : "Allow" })) : null] })] }, item.id)))] }));
27
+ return (_jsxs("div", { className: "rk-onboarding__card", "data-testid": "onboarding-permissions", children: [error ? _jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }) : null, items.length === 0 && error ? _jsx(Button, { size: "sm", variant: "secondary", onClick: () => { setError(null); void reload(); }, children: "Try again" }) : null, items.length === 0 && !error ? _jsx("p", { className: "rk-onboarding__muted", children: "Checking permissions\u2026" }) : null, items.map((item) => (_jsxs("div", { className: "rk-onboarding__row", "data-permission-id": item.id, children: [_jsxs("div", { children: [_jsx("strong", { children: item.label }), _jsx("p", { children: item.description ?? item.detail })] }), _jsxs("div", { className: "rk-onboarding__row-actions", children: [_jsx(Badge, { tone: permissionTone(item.state), children: item.state }), item.state === "needed" || item.state === "denied" ? (_jsx(Button, { size: "sm", variant: "secondary", disabled: busy === item.id, onClick: () => { setBusy(item.id); setError(null); void backend.request(item.id).then((next) => setItems((current) => current.map((x) => x.id === next.id ? next : x)), (e) => setError(e instanceof Error ? e.message : String(e))).finally(() => setBusy(null)); }, children: item.state === "denied" ? "Try again" : "Allow" })) : null] })] }, item.id)))] }));
28
28
  }
29
29
  export function permissionsStep(backend, options = {}) {
30
30
  return { id: "permissions", label: options.label ?? "Permissions", title: options.title ?? "Allow what this app needs", description: options.description ?? "You can change these permissions later in System Settings.", skippable: true, render: () => _jsx(PermissionsBody, { backend: backend }) };
@@ -82,7 +82,7 @@ function ModelsBody({ backend, onReady, autoStartDownload, shouldAutoStart, onMo
82
82
  // run is intentionally omitted: it only closes over setters.
83
83
  // eslint-disable-next-line react-hooks/exhaustive-deps
84
84
  }, [autoStartDownload, shouldAutoStart, onModelsStepEnter, loaded, models, backend]);
85
- return (_jsxs("div", { className: "rk-onboarding__card", "data-testid": "onboarding-models", children: [intro ? _jsx("div", { className: "rk-onboarding__intro", "data-testid": "onboarding-models-intro", children: intro }) : null, error ? _jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }) : null, models.map((model) => {
85
+ return (_jsxs("div", { className: "rk-onboarding__card", "data-testid": "onboarding-models", children: [intro ? _jsx("div", { className: "rk-onboarding__intro", "data-testid": "onboarding-models-intro", children: intro }) : null, error ? _jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }) : null, error && !loaded ? _jsx(Button, { size: "sm", variant: "secondary", onClick: () => { setError(null); void reload(); }, children: "Try again" }) : null, models.map((model) => {
86
86
  const progress = model.progress ?? (model.state === "installed" ? 1 : 0);
87
87
  const active = model.state === "downloading" || model.state === "verifying";
88
88
  const cancel = backend.cancel;
package/dist/types.d.ts CHANGED
@@ -88,15 +88,65 @@ export interface LegalAcceptanceRecord {
88
88
  acceptedAt: string;
89
89
  eligibilityBasis: EligibilityBasis;
90
90
  }
91
+ /** One document of the set the user was shown. Shaped so `@rightkit/legal`'s `acceptanceDeclaration(manifest).documents` fits as is. */
92
+ export interface DeclaredLegalDocument {
93
+ id?: string;
94
+ role?: string;
95
+ title?: string;
96
+ url?: string;
97
+ version?: string;
98
+ sha256?: string;
99
+ treatment?: string;
100
+ material?: boolean;
101
+ }
102
+ /**
103
+ * What the renderer declares to a native receipt service when the user agrees. It carries no time: the service stamps its
104
+ * own UTC `acceptedAt`, compares `version`, `documentsHash` and `documents` with the manifest it embeds, and refuses a
105
+ * declaration that does not match.
106
+ */
107
+ export interface LegalReceiptCreateRequest {
108
+ /** Acceptance version the app requires (`requiredVersion`, else "1"). */
109
+ version: string;
110
+ /** Digest of the document set shown (`documentsHash`, else `requiredDocumentsHash`, else a hash of titles and URLs). */
111
+ documentsHash: string;
112
+ /** The declared set (`receiptDocuments`, else the gate's documents as `{ title, url }`). */
113
+ documents: readonly DeclaredLegalDocument[];
114
+ eligibilityBasis: EligibilityBasis;
115
+ /** Always "first_run": onboarding is the first-run and re-acceptance surface. */
116
+ surface: "first_run";
117
+ }
91
118
  /**
92
119
  * App-owned persistence for the legal acceptance. Lets an app keep its existing receipt (any shape, any store) and map it
93
120
  * to and from the package's record. When supplied it is the source of truth: `read()` is consulted on load and overrides
94
- * whatever acceptance the settings backend holds (`null` means "not accepted", so the gate shows), and `write()` is
95
- * awaited when the user agrees, before the settings backend is patched.
121
+ * whatever acceptance the settings backend holds (`null` means "not accepted", so the gate shows).
122
+ *
123
+ * Recording the acceptance, in order of preference:
124
+ * - `create(request)`: the NATIVE path. The service owns the time and the stored receipt and returns the record it stored;
125
+ * the renderer builds nothing. A throw, or a record that does not match the required version or hash, keeps the gate
126
+ * closed. When `create` exists, `write` is not called.
127
+ * - `write(record)`: LEGACY. The renderer builds the record, including its `acceptedAt` from the webview clock, and the app
128
+ * stores it. Kept for apps that have not moved to a native receipt service.
129
+ *
130
+ * `reset()` clears the stored receipt (used by `resetLegalAcceptance`). A port needs `create` or `write`; with neither,
131
+ * agreeing fails and the gate stays closed.
132
+ */
133
+ /**
134
+ * A receipt that exists but no longer counts: its version or documents differ from the current agreement. `read()` may
135
+ * return it so the gate shows as a re-acceptance and the setup progress is kept (`current: false`). Any other return
136
+ * value is a current record or `null`.
96
137
  */
138
+ export interface HistoricalLegalReceipt {
139
+ record: LegalAcceptanceRecord;
140
+ current: false;
141
+ }
97
142
  export interface LegalReceiptPort {
98
- read(): LegalAcceptanceRecord | null | Promise<LegalAcceptanceRecord | null>;
99
- write(record: LegalAcceptanceRecord): void | Promise<void>;
143
+ read(): LegalAcceptanceRecord | HistoricalLegalReceipt | null | Promise<LegalAcceptanceRecord | HistoricalLegalReceipt | null>;
144
+ /** Native receipt creation: returns the stored record, stamped with native time. Preferred over `write`. */
145
+ create?(request: LegalReceiptCreateRequest): LegalAcceptanceRecord | Promise<LegalAcceptanceRecord>;
146
+ /** @deprecated Renderer-built record with webview time. Implement `create` on a native receipt service instead. */
147
+ write?(record: LegalAcceptanceRecord): void | Promise<void>;
148
+ /** Clear the stored receipt so the gate shows again as a first run. */
149
+ reset?(): void | Promise<void>;
100
150
  }
101
151
  export interface PersistedOnboardingState {
102
152
  version: 1;
@@ -108,6 +158,11 @@ export interface PersistedOnboardingState {
108
158
  /** Legacy mirror of `legalAcceptance.eligibilityBasis`. */
109
159
  eligibilityBasis?: EligibilityBasis;
110
160
  legalAcceptance?: LegalAcceptanceRecord;
161
+ /**
162
+ * Set by `reopenLegalAcceptance`: the stored acceptance no longer counts, so the gate shows as a re-acceptance. The old
163
+ * record stays until a new acceptance replaces it, which clears the flag.
164
+ */
165
+ legalReopened?: boolean;
111
166
  /** Step ids the user skipped with the per-step Skip button (not marked complete). */
112
167
  skippedSteps?: string[];
113
168
  }
@@ -180,6 +235,19 @@ export type LegalMode = "gate" | "notice" | "none";
180
235
  export interface OnboardingProps {
181
236
  /** Window-sized by default; use `contained` when the host sizes the setup surface. */
182
237
  layout?: "window" | "contained";
238
+ /**
239
+ * Setup chrome. `rail` (default): a left step rail with a "Step n of N" eyebrow. `card`: one centred card with the app
240
+ * name on top and a dot pager, no rail (the HeardRight v5 onboarding mock). Applies to the setup steps only; the legal
241
+ * gate keeps its own layout.
242
+ */
243
+ frame?: "rail" | "card";
244
+ /** Vertical density of the setup steps. `compact` (rail frame): main padding 18px top, content 8px, rail 188px wide. Default `default`. */
245
+ density?: "default" | "compact";
246
+ /**
247
+ * Clears the macOS traffic lights: the rail (or card brand row) and the titlebar strip start past them. Use with a
248
+ * `titlebar` or `dragRegion` on macOS windows with overlay title bars. Default false.
249
+ */
250
+ trafficLightInset?: boolean;
183
251
  settings: SettingsBackend;
184
252
  /** Required when `legalMode` is `gate` (default). Optional for `notice` (the documents shown) and `none`. */
185
253
  legal?: LegalStepConfig;
@@ -192,6 +260,13 @@ export interface OnboardingProps {
192
260
  permissions?: PermissionsBackend;
193
261
  models?: ModelsBackend;
194
262
  license?: LicenseBackend;
263
+ /**
264
+ * Adds the built-in sign-in step after the licence step and hands the backend to every step as `ctx.signIn`. Build it
265
+ * with `signInBackendFromOAuth` over the app's `rightkit-oauth` commands.
266
+ */
267
+ signIn?: SignInBackend;
268
+ /** The document set declared to `legalReceipt.create`. Default: the gate's documents as `{ title, url }`. */
269
+ receiptDocuments?: readonly DeclaredLegalDocument[];
195
270
  /** Called once when setup finishes, with the completed and skipped step ids and the step results. */
196
271
  onComplete: (result: OnboardingResult) => void | Promise<void>;
197
272
  onSkip?: () => void | Promise<void>;
@@ -224,13 +299,16 @@ export interface OnboardingProps {
224
299
  modelsIntro?: ReactNode;
225
300
  /** Show "Set up later". Default true. */
226
301
  allowSkip?: boolean;
227
- /** Optional titlebar content rendered in a strip along the top. */
302
+ /** Optional titlebar content rendered in a strip along the top. On macOS with `trafficLightInset`, the strip starts past the lights. */
228
303
  titlebar?: ReactNode;
229
304
  /** Render a `data-tauri-drag-region` strip at the top of the setup steps. Default false. Never applied to the legal stage; see `legalDragRegion`. */
230
305
  dragRegion?: boolean;
231
306
  /** Also render the drag region on the legal stage (loading and gate). Default false: the legal gate has no strip. */
232
307
  legalDragRegion?: boolean;
233
- /** Read and write the legal acceptance in the app's own receipt instead of (overriding) the settings backend's copy. */
308
+ /**
309
+ * Read and record the legal acceptance in the app's own (preferably native) receipt instead of (overriding) the settings
310
+ * backend's copy. See `LegalReceiptPort`; `createNativeLegalReceiptPort` builds one over the host's receipt commands.
311
+ */
234
312
  legalReceipt?: LegalReceiptPort;
235
313
  }
236
314
  export type { LegalGateDocument, LegalGateTheme };
package/onboarding.css CHANGED
@@ -40,3 +40,23 @@
40
40
  .rk-onboarding__titlebar { position: absolute; inset: 0 0 auto 0; z-index: 2; display: flex; align-items: center; height: var(--rk-onboarding-titlebar-h, 32px); padding: 0 12px; }
41
41
  .rk-onboarding__hint { color: var(--rk-ink-2, #6b655c); font-size: 12px; text-align: right; }
42
42
  .rk-onboarding__intro { margin-bottom: 12px; color: var(--rk-ink-2, #6b655c); }
43
+ /* Density (HeardRight: onboarding too tall). compact: rail 188px, main 18px top, content 8px. */
44
+ .rk-onboarding--compact { grid-template-columns: 188px minmax(0, 1fr); }
45
+ .rk-onboarding--compact .rk-onboarding__main { padding-top: 18px; }
46
+ .rk-onboarding--compact .rk-onboarding__content { padding: 8px 0; }
47
+ /* macOS traffic lights: the rail and the titlebar strip start past them (lights-top: rail top; lights width from shell.css). */
48
+ .rk-onboarding--lights .rk-onboarding__rail { padding-top: var(--rk-onboarding-lights-top, 40px); }
49
+ .rk-onboarding--lights .rk-onboarding__titlebar { padding-left: calc(var(--rk-lights-width, 72px) + 6px); }
50
+ .rk-onboarding--lights .rk-onboarding__brand--card { padding-left: calc(var(--rk-lights-width, 72px) + 6px); }
51
+ /* card frame (HeardRight v5 mock): one centred card, the app name on top, a dot pager and no rail. */
52
+ .rk-onboarding--card { display: flex; flex-direction: column; align-items: stretch; }
53
+ .rk-onboarding--card .rk-onboarding__main--card { flex: 1; display: flex; flex-direction: column; align-items: center; max-width: none; width: 100%; padding: 0 clamp(24px, 5vw, 56px) 24px; }
54
+ .rk-onboarding__brand--card { align-self: stretch; padding: 18px 20px; font-size: 18px; font-weight: 900; letter-spacing: -.03em; }
55
+ .rk-onboarding__frame { display: flex; flex-direction: column; flex: 1; width: min(860px, 100%); min-height: 0; padding-top: 24px; }
56
+ .rk-onboarding--card .rk-onboarding__content { flex: 1; }
57
+ .rk-onboarding--card footer .rk-onboarding__hint { flex: none; }
58
+ .rk-onboarding__dots { display: flex; align-items: center; justify-content: center; gap: 10px; flex: 1; }
59
+ .rk-onboarding__dot { width: 10px; height: 10px; padding: 0; border: 0; border-radius: 999px; background: var(--rk-line-strong, rgba(35,31,26,.18)); cursor: pointer; transition: width .2s ease, background .2s ease; }
60
+ .rk-onboarding__dot.is-done { background: var(--rk-ok, #1e7f3c); }
61
+ .rk-onboarding__dot.is-active { width: 42px; background: var(--rk-accent, #7040c4); }
62
+ .rk-onboarding__dot:disabled { cursor: not-allowed; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/onboarding",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "First-run onboarding framework: ordered steps, progress, skip/resume, legal gate first, permissions, model download and licence/trial steps.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
@@ -29,21 +29,11 @@
29
29
  "react": ">=18 <21"
30
30
  },
31
31
  "devDependencies": {
32
- "@rightkit/react-shell": "0.7.1",
33
- "@rightkit/legal-ui": "0.4.1",
34
- "@rightkit/qa-chrome": "0.1.8",
35
- "@testing-library/jest-dom": "^7.0.1",
36
- "@testing-library/dom": "^10.4.2",
37
- "@testing-library/react": "^16.3.3",
38
- "@testing-library/user-event": "^14.6.8",
32
+ "@rightkit/react-shell": "0.7.3",
33
+ "@rightkit/legal-ui": "0.4.2",
39
34
  "@types/react": "^19.3.0",
40
- "@types/react-dom": "^19.3.0",
41
- "jsdom": "^30.1.3",
42
35
  "react": "^19.3.1",
43
- "react-dom": "^19.3.1",
44
- "typescript": "^7.0.2",
45
- "vite": "^8.3.4",
46
- "vitest": "^5.0.5"
36
+ "typescript": "^7.0.2"
47
37
  },
48
38
  "publishConfig": {
49
39
  "registry": "https://registry.npmjs.org",
@@ -56,8 +46,6 @@
56
46
  },
57
47
  "scripts": {
58
48
  "build": "tsc -p tsconfig.json",
59
- "typecheck": "tsc -p tsconfig.json --noEmit",
60
- "pretest": "pnpm --filter \"@rightkit/onboarding^...\" run build",
61
- "test": "vitest run"
49
+ "typecheck": "tsc -p tsconfig.json --noEmit"
62
50
  }
63
51
  }