@rightkit/onboarding 0.3.1 → 0.3.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/dist/Onboarding.d.ts +5 -0
- package/dist/Onboarding.js +221 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/steps.d.ts +28 -0
- package/dist/steps.js +115 -0
- package/dist/types.d.ts +172 -0
- package/dist/types.js +1 -0
- package/dist/ui.d.ts +1 -0
- package/dist/ui.js +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { OnboardingProps, PersistedOnboardingState } from "./types.js";
|
|
2
|
+
/** True when the stored acceptance satisfies the host's requirement. */
|
|
3
|
+
export declare function isLegalAccepted(state: PersistedOnboardingState, requiredVersion?: string, custom?: (state: PersistedOnboardingState) => boolean): boolean;
|
|
4
|
+
export declare function onboardingStateKey(): string;
|
|
5
|
+
export declare function Onboarding({ settings, legal, steps, permissions, models, license, onComplete, onSkip, className, requiredVersion, isAccepted, documentsHash, beforeLegalGate, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro, allowSkip, titlebar, dragRegion, legalDragRegion, legalReceipt }: OnboardingProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { LegalGate } from "@rightkit/legal-ui";
|
|
4
|
+
import { Button } from "@rightkit/app-shell/react";
|
|
5
|
+
import { cx } from "./ui.js";
|
|
6
|
+
import { licenseStep, modelsStep, permissionsStep } from "./steps.js";
|
|
7
|
+
const KEY = "onboarding.state";
|
|
8
|
+
const INITIAL = { version: 1, currentStep: 0, completed: [], skipped: false, legalAccepted: false };
|
|
9
|
+
function readAcceptance(value) {
|
|
10
|
+
if (!value || typeof value !== "object")
|
|
11
|
+
return undefined;
|
|
12
|
+
const r = value;
|
|
13
|
+
if (typeof r.version !== "string" || typeof r.documentsHash !== "string" || typeof r.acceptedAt !== "string")
|
|
14
|
+
return undefined;
|
|
15
|
+
if (r.eligibilityBasis !== "individual" && r.eligibilityBasis !== "enterprise")
|
|
16
|
+
return undefined;
|
|
17
|
+
return { version: r.version, documentsHash: r.documentsHash, acceptedAt: r.acceptedAt, eligibilityBasis: r.eligibilityBasis };
|
|
18
|
+
}
|
|
19
|
+
function readState(value) {
|
|
20
|
+
if (!value || typeof value !== "object")
|
|
21
|
+
return INITIAL;
|
|
22
|
+
const candidate = value;
|
|
23
|
+
const legalAcceptance = readAcceptance(candidate.legalAcceptance);
|
|
24
|
+
return {
|
|
25
|
+
version: 1,
|
|
26
|
+
currentStep: typeof candidate.currentStep === "number" && candidate.currentStep >= 0 ? Math.floor(candidate.currentStep) : 0,
|
|
27
|
+
completed: Array.isArray(candidate.completed) ? candidate.completed.filter((x) => typeof x === "string") : [],
|
|
28
|
+
skipped: candidate.skipped === true,
|
|
29
|
+
legalAccepted: candidate.legalAccepted === true || legalAcceptance !== undefined,
|
|
30
|
+
eligibilityBasis: legalAcceptance?.eligibilityBasis ?? (candidate.eligibilityBasis === "individual" || candidate.eligibilityBasis === "enterprise" ? candidate.eligibilityBasis : undefined),
|
|
31
|
+
...(legalAcceptance ? { legalAcceptance } : {}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** Replace the acceptance in `state` with the one from the app's receipt (`undefined`: not accepted). */
|
|
35
|
+
function withReceipt(state, record) {
|
|
36
|
+
const { legalAcceptance: _drop, eligibilityBasis: _basis, ...rest } = state;
|
|
37
|
+
void _drop;
|
|
38
|
+
void _basis;
|
|
39
|
+
return record ? { ...rest, legalAccepted: true, eligibilityBasis: record.eligibilityBasis, legalAcceptance: record } : { ...rest, legalAccepted: false };
|
|
40
|
+
}
|
|
41
|
+
/** True when the stored acceptance satisfies the host's requirement. */
|
|
42
|
+
export function isLegalAccepted(state, requiredVersion, custom) {
|
|
43
|
+
if (custom)
|
|
44
|
+
return custom(state);
|
|
45
|
+
if (requiredVersion !== undefined)
|
|
46
|
+
return state.legalAcceptance?.version === requiredVersion;
|
|
47
|
+
return state.legalAccepted;
|
|
48
|
+
}
|
|
49
|
+
/** FNV-1a fallback when SubtleCrypto is unavailable. */
|
|
50
|
+
function fnv(text) {
|
|
51
|
+
let h = 0x811c9dc5;
|
|
52
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
53
|
+
h ^= text.charCodeAt(i);
|
|
54
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
55
|
+
}
|
|
56
|
+
return `fnv1a:${h.toString(16).padStart(8, "0")}`;
|
|
57
|
+
}
|
|
58
|
+
async function hashDocuments(documents) {
|
|
59
|
+
const text = JSON.stringify(documents.map((d) => [d.title, d.url ?? ""]));
|
|
60
|
+
try {
|
|
61
|
+
const subtle = globalThis.crypto?.subtle;
|
|
62
|
+
if (subtle) {
|
|
63
|
+
const digest = await subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
64
|
+
return `sha256:${[...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch { /* fall through */ }
|
|
68
|
+
return fnv(text);
|
|
69
|
+
}
|
|
70
|
+
export function onboardingStateKey() { return KEY; }
|
|
71
|
+
function TitleStrip({ titlebar, dragRegion }) {
|
|
72
|
+
if (!titlebar && !dragRegion)
|
|
73
|
+
return null;
|
|
74
|
+
return _jsx("div", { className: "rk-onboarding__titlebar", "data-testid": "onboarding-titlebar", ...(dragRegion ? { "data-tauri-drag-region": "" } : {}), children: titlebar });
|
|
75
|
+
}
|
|
76
|
+
export function Onboarding({ settings, legal, steps = [], permissions, models, license, onComplete, onSkip, className, requiredVersion, isAccepted, documentsHash, beforeLegalGate, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro, allowSkip = true, titlebar, dragRegion, legalDragRegion = false, legalReceipt }) {
|
|
77
|
+
const [state, setState] = useState(null);
|
|
78
|
+
const [saving, setSaving] = useState(false);
|
|
79
|
+
const [error, setError] = useState(null);
|
|
80
|
+
const [readyMap, setReadyMap] = useState({});
|
|
81
|
+
const [gateCleared, setGateCleared] = useState(false);
|
|
82
|
+
/** One auto-start per mount, even when the models step unmounts (Back/Next) or the step list is rebuilt. */
|
|
83
|
+
const modelsStarted = useRef(false);
|
|
84
|
+
/** Persists a patch; throws when the settings backend rejects it. */
|
|
85
|
+
const persist = useCallback(async (patch) => {
|
|
86
|
+
setSaving(true);
|
|
87
|
+
setError(null);
|
|
88
|
+
const next = { ...(state ?? INITIAL), ...patch };
|
|
89
|
+
try {
|
|
90
|
+
const snap = await settings.patch({ [KEY]: next });
|
|
91
|
+
setState(readState(snap.values[KEY]));
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
95
|
+
throw e;
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
setSaving(false);
|
|
99
|
+
}
|
|
100
|
+
}, [settings, state]);
|
|
101
|
+
/** Like `persist` but reports success as a boolean; the failure is already visible via `error`. */
|
|
102
|
+
const save = useCallback(async (patch) => {
|
|
103
|
+
try {
|
|
104
|
+
await persist(patch);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}, [persist]);
|
|
111
|
+
const saveStep = useCallback(async (patch) => { await save(patch); }, [save]);
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
let live = true;
|
|
114
|
+
void (async () => {
|
|
115
|
+
try {
|
|
116
|
+
const loaded = readState((await settings.load()).values[KEY]);
|
|
117
|
+
if (!legalReceipt) {
|
|
118
|
+
if (live)
|
|
119
|
+
setState(loaded);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
// The app's receipt is the source of truth for acceptance; an unreadable receipt counts as none.
|
|
123
|
+
let record;
|
|
124
|
+
try {
|
|
125
|
+
record = readAcceptance(await legalReceipt.read());
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
record = undefined;
|
|
129
|
+
}
|
|
130
|
+
if (live)
|
|
131
|
+
setState(withReceipt(loaded, record));
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
if (live)
|
|
135
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
136
|
+
}
|
|
137
|
+
})();
|
|
138
|
+
return () => { live = false; };
|
|
139
|
+
}, [settings, legalReceipt]);
|
|
140
|
+
const builtins = useMemo(() => [
|
|
141
|
+
{ id: "legal", label: "Agreement", title: "Review agreement", skippable: false, render: () => _jsx("p", { className: "rk-onboarding__muted", children: "Agreement accepted." }) },
|
|
142
|
+
...(permissions ? [permissionsStep(permissions)] : []),
|
|
143
|
+
...(models ? [modelsStep(models, { required: requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, intro: modelsIntro, startGuard: modelsStarted })] : []),
|
|
144
|
+
...(license ? [licenseStep(license)] : []),
|
|
145
|
+
...steps,
|
|
146
|
+
], [license, models, permissions, steps, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro]);
|
|
147
|
+
const activeState = state ?? INITIAL;
|
|
148
|
+
const accepted = isLegalAccepted(activeState, requiredVersion, isAccepted);
|
|
149
|
+
const needsGate = state !== null && !accepted;
|
|
150
|
+
// The agreement gate does not depend on the saved step: until it is accepted, the active step is the first.
|
|
151
|
+
// Once accepted, the Agreement step (index 0) is never shown again when other steps exist.
|
|
152
|
+
const index = !accepted ? 0 : builtins.length > 1 ? Math.max(1, Math.min(activeState.currentStep, builtins.length - 1)) : 0;
|
|
153
|
+
const active = builtins[index];
|
|
154
|
+
const activeId = active?.id ?? "";
|
|
155
|
+
const setReady = useCallback((ready) => setReadyMap((m) => m[activeId] === ready ? m : { ...m, [activeId]: ready }), [activeId]);
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
if (!needsGate) {
|
|
158
|
+
setGateCleared(false);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (gateCleared)
|
|
162
|
+
return;
|
|
163
|
+
let live = true;
|
|
164
|
+
void (async () => { try {
|
|
165
|
+
await beforeLegalGate?.();
|
|
166
|
+
}
|
|
167
|
+
catch (e) {
|
|
168
|
+
if (live)
|
|
169
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
170
|
+
} if (live)
|
|
171
|
+
setGateCleared(true); })();
|
|
172
|
+
return () => { live = false; };
|
|
173
|
+
}, [needsGate, gateCleared, beforeLegalGate]);
|
|
174
|
+
const context = { state: activeState, settings, setState: saveStep, permissions, models, license, ready: readyMap, setReady };
|
|
175
|
+
const canContinue = active?.canContinue ? active.canContinue(context) : true;
|
|
176
|
+
const runCallback = async (callback) => {
|
|
177
|
+
try {
|
|
178
|
+
await callback?.();
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
const markAndMove = async (nextIndex, completedId) => {
|
|
185
|
+
const completed = completedId && !activeState.completed.includes(completedId) ? [...activeState.completed, completedId] : activeState.completed;
|
|
186
|
+
if (nextIndex >= builtins.length) {
|
|
187
|
+
if (await save({ completed, currentStep: Math.max(0, builtins.length - 1), skipped: false }))
|
|
188
|
+
await runCallback(onComplete);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
await save({ completed, currentStep: nextIndex, skipped: false });
|
|
192
|
+
};
|
|
193
|
+
if (error && !state)
|
|
194
|
+
return _jsxs("div", { className: cx("rk-onboarding", className), children: [_jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }), _jsx(Button, { onClick: () => window.location.reload(), children: "Retry" })] });
|
|
195
|
+
if (!state || !active)
|
|
196
|
+
return _jsx("div", { className: cx("rk-onboarding", className), "aria-busy": "true", children: _jsx("p", { className: "rk-onboarding__muted", children: "Loading setup\u2026" }) });
|
|
197
|
+
if (needsGate) {
|
|
198
|
+
if (!gateCleared)
|
|
199
|
+
return _jsxs("div", { className: cx("rk-onboarding", className), "aria-busy": "true", children: [_jsx(TitleStrip, { titlebar: titlebar, dragRegion: legalDragRegion }), _jsx("p", { className: "rk-onboarding__muted", children: "Loading setup\u2026" })] });
|
|
200
|
+
const reacceptance = activeState.legalAccepted || activeState.legalAcceptance !== undefined;
|
|
201
|
+
return _jsxs("div", { className: cx("rk-onboarding rk-onboarding--legal", className), "data-testid": "onboarding-legal", "data-variant": reacceptance ? "re-acceptance" : "first-run", children: [_jsx(TitleStrip, { titlebar: titlebar, dragRegion: legalDragRegion }), _jsx(LegalGate, { ...legal, variant: reacceptance ? "re-acceptance" : "first-run", onQuit: () => void legal.onQuit?.(), onAgree: async ({ eligibilityBasis }) => {
|
|
202
|
+
const legalAcceptance = { version: requiredVersion ?? "1", documentsHash: documentsHash ?? await hashDocuments(legal.documents), acceptedAt: new Date().toISOString(), eligibilityBasis };
|
|
203
|
+
if (legalReceipt) {
|
|
204
|
+
try {
|
|
205
|
+
await legalReceipt.write(legalAcceptance);
|
|
206
|
+
}
|
|
207
|
+
catch (e) {
|
|
208
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
209
|
+
throw e;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const progress = reacceptance ? {} : { completed: ["legal"], currentStep: builtins.length > 1 ? 1 : 0 };
|
|
213
|
+
await persist({ legalAccepted: true, eligibilityBasis, legalAcceptance, ...progress });
|
|
214
|
+
if (!reacceptance && builtins.length === 1)
|
|
215
|
+
await onComplete();
|
|
216
|
+
} })] });
|
|
217
|
+
}
|
|
218
|
+
const body = active.render(context);
|
|
219
|
+
return _jsxs("main", { className: cx("rk-onboarding", className), "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: 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: i === 0 && builtins.length > 1, onClick: () => { if (i > 0 && (i <= index || canContinue))
|
|
220
|
+
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] }), _jsx("h1", { children: active.title }), 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, onClick: () => { void save({ currentStep: index, skipped: true }).then((ok) => ok ? runCallback(onSkip) : undefined); }, children: "Set up later" }) : null, _jsx("span", { className: "rk-onboarding__hint", children: !canContinue && active.blockedHint ? active.blockedHint : null }), _jsx(Button, { variant: "secondary", disabled: index <= 1 || saving, onClick: () => void save({ currentStep: index - 1 }), children: "Back" }), _jsx(Button, { variant: "primary", disabled: saving || !canContinue, onClick: () => void markAndMove(index + 1, active.id), children: index === builtins.length - 1 ? "Finish setup" : "Continue" })] })] })] });
|
|
221
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/steps.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { LicenseBackend, ModelsAutoStart, ModelsBackend, OnboardingStep, PermissionsBackend } from "./types.js";
|
|
3
|
+
export declare function permissionsStep(backend: PermissionsBackend, options?: {
|
|
4
|
+
label?: string;
|
|
5
|
+
title?: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
}): OnboardingStep;
|
|
8
|
+
export interface ModelsStepOptions extends ModelsAutoStart {
|
|
9
|
+
label?: string;
|
|
10
|
+
title?: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
/** Continue/Finish wait until at least one model is installed. Default false. */
|
|
13
|
+
required?: boolean;
|
|
14
|
+
/** Start downloading the first missing model when the step opens. Default false. */
|
|
15
|
+
autoStartDownload?: boolean;
|
|
16
|
+
/** App copy rendered at the top of the step. */
|
|
17
|
+
intro?: ReactNode;
|
|
18
|
+
/** Shared "already auto-started" flag. `Onboarding` supplies one that survives Back/Next and re-renders; omit it and the guard lives for the step's mount only. */
|
|
19
|
+
startGuard?: {
|
|
20
|
+
current: boolean;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export declare function modelsStep(backend: ModelsBackend, options?: ModelsStepOptions): OnboardingStep;
|
|
24
|
+
export declare function licenseStep(backend: LicenseBackend, options?: {
|
|
25
|
+
label?: string;
|
|
26
|
+
title?: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
}): OnboardingStep;
|
package/dist/steps.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { Button, Input, Badge } from "@rightkit/app-shell/react";
|
|
4
|
+
function ProgressBar({ value, label }) {
|
|
5
|
+
const pct = Math.max(0, Math.min(1, value));
|
|
6
|
+
return _jsx("div", { className: "rk-onboarding__progress", role: "progressbar", "aria-label": label, "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": Math.round(pct * 100), children: _jsx("i", { style: { width: `${pct * 100}%` } }) });
|
|
7
|
+
}
|
|
8
|
+
const formatBytes = (n) => {
|
|
9
|
+
if (n === undefined)
|
|
10
|
+
return "";
|
|
11
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
12
|
+
let value = n;
|
|
13
|
+
let unit = 0;
|
|
14
|
+
while (value >= 1000 && unit < units.length - 1) {
|
|
15
|
+
value /= 1000;
|
|
16
|
+
unit += 1;
|
|
17
|
+
}
|
|
18
|
+
return `${value >= 100 || unit === 0 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
|
|
19
|
+
};
|
|
20
|
+
const permissionTone = (state) => state === "ready" ? "ok" : state === "denied" || state === "restricted" ? "bad" : state === "needed" ? "warn" : "neutral";
|
|
21
|
+
function PermissionsBody({ backend }) {
|
|
22
|
+
const [items, setItems] = useState([]);
|
|
23
|
+
const [busy, setBusy] = useState(null);
|
|
24
|
+
const [error, setError] = useState(null);
|
|
25
|
+
const reload = useCallback(() => backend.list().then(setItems, (e) => setError(e instanceof Error ? e.message : String(e))), [backend]);
|
|
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)))] }));
|
|
28
|
+
}
|
|
29
|
+
export function permissionsStep(backend, options = {}) {
|
|
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 }) };
|
|
31
|
+
}
|
|
32
|
+
function ModelsBody({ backend, onReady, autoStartDownload, shouldAutoStart, onModelsStepEnter, intro, startGuard }) {
|
|
33
|
+
const [models, setModels] = useState([]);
|
|
34
|
+
const [loaded, setLoaded] = useState(false);
|
|
35
|
+
const [busy, setBusy] = useState(null);
|
|
36
|
+
const [error, setError] = useState(null);
|
|
37
|
+
const ownGuard = useRef(false);
|
|
38
|
+
const autoStarted = startGuard ?? ownGuard;
|
|
39
|
+
const reload = useCallback(() => backend.list().then((list) => { setModels(list); setLoaded(true); }, (e) => setError(e instanceof Error ? e.message : String(e))), [backend]);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
void reload();
|
|
42
|
+
if (!backend.subscribe)
|
|
43
|
+
return;
|
|
44
|
+
let off;
|
|
45
|
+
let live = true;
|
|
46
|
+
void backend.subscribe((entry) => setModels((current) => current.some((m) => m.id === entry.id) ? current.map((m) => m.id === entry.id ? entry : m) : [...current, entry])).then((unsubscribe) => { if (live)
|
|
47
|
+
off = unsubscribe;
|
|
48
|
+
else
|
|
49
|
+
unsubscribe(); });
|
|
50
|
+
return () => { live = false; off?.(); };
|
|
51
|
+
}, [backend, reload]);
|
|
52
|
+
const hasInstalled = models.some((m) => m.state === "installed");
|
|
53
|
+
useEffect(() => { onReady?.(hasInstalled); }, [hasInstalled, onReady]);
|
|
54
|
+
const run = (id, request) => {
|
|
55
|
+
setBusy(id);
|
|
56
|
+
setError(null);
|
|
57
|
+
void request.then(reload, (e) => setError(e instanceof Error ? e.message : String(e))).finally(() => setBusy(null));
|
|
58
|
+
};
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!loaded || autoStarted.current)
|
|
61
|
+
return;
|
|
62
|
+
if (!onModelsStepEnter && !shouldAutoStart && !autoStartDownload)
|
|
63
|
+
return;
|
|
64
|
+
autoStarted.current = true;
|
|
65
|
+
if (onModelsStepEnter) {
|
|
66
|
+
// The app owns which model starts and when; the hook runs once per Onboarding mount.
|
|
67
|
+
const ctx = { models, start: (id) => backend.install(id).then(reload), refresh: () => backend.list().then((list) => { setModels(list); return list; }) };
|
|
68
|
+
void Promise.resolve().then(() => onModelsStepEnter(ctx)).catch((e) => setError(e instanceof Error ? e.message : String(e)));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (shouldAutoStart) {
|
|
72
|
+
const pick = models.find((m) => m.state === "missing" && shouldAutoStart(m, models));
|
|
73
|
+
if (pick)
|
|
74
|
+
run(pick.id, backend.install(pick.id));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (models.some((m) => m.state === "installed" || m.state === "downloading" || m.state === "verifying"))
|
|
78
|
+
return;
|
|
79
|
+
const first = models.find((m) => m.state === "missing");
|
|
80
|
+
if (first)
|
|
81
|
+
run(first.id, backend.install(first.id));
|
|
82
|
+
// run is intentionally omitted: it only closes over setters.
|
|
83
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
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) => {
|
|
86
|
+
const progress = model.progress ?? (model.state === "installed" ? 1 : 0);
|
|
87
|
+
const active = model.state === "downloading" || model.state === "verifying";
|
|
88
|
+
const cancel = backend.cancel;
|
|
89
|
+
return _jsxs("div", { className: "rk-onboarding__row", "data-model-id": model.id, children: [_jsxs("div", { className: "rk-onboarding__grow", children: [_jsx("strong", { children: model.label }), _jsxs("p", { children: [model.description ?? "", model.sizeBytes ? ` · ${formatBytes(model.sizeBytes)}` : ""] }), active ? _jsx(ProgressBar, { value: progress, label: `${model.label} download` }) : null] }), _jsxs("div", { className: "rk-onboarding__row-actions", children: [_jsx(Badge, { tone: model.state === "installed" ? "ok" : model.state === "error" ? "bad" : "neutral", children: model.state }), model.state === "installed" ? null
|
|
90
|
+
: model.state === "error" ? _jsx(Button, { size: "sm", variant: "secondary", disabled: busy === model.id, onClick: () => run(model.id, backend.install(model.id)), children: "Retry" })
|
|
91
|
+
: active ? (cancel ? _jsx(Button, { size: "sm", variant: "secondary", disabled: busy === model.id, onClick: () => run(model.id, cancel.call(backend, model.id)), children: "Pause" }) : null)
|
|
92
|
+
: _jsx(Button, { size: "sm", variant: "secondary", disabled: busy === model.id, onClick: () => run(model.id, backend.install(model.id)), children: "Download" })] })] }, model.id);
|
|
93
|
+
}), models.length === 0 && !error ? _jsx("p", { className: "rk-onboarding__muted", children: "No models available." }) : null] }));
|
|
94
|
+
}
|
|
95
|
+
export function modelsStep(backend, options = {}) {
|
|
96
|
+
return {
|
|
97
|
+
id: "models", label: options.label ?? "Models", title: options.title ?? "Choose local models", description: options.description ?? "Downloads are verified before a model is activated.", skippable: true,
|
|
98
|
+
canContinue: options.required ? (ctx) => ctx.ready.models === true : undefined,
|
|
99
|
+
blockedHint: options.required ? "Download a model to continue." : undefined,
|
|
100
|
+
render: (ctx) => _jsx(ModelsBody, { backend: backend, onReady: ctx.setReady, autoStartDownload: options.autoStartDownload, shouldAutoStart: options.shouldAutoStart, onModelsStepEnter: options.onModelsStepEnter, intro: options.intro, startGuard: options.startGuard }),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function LicenseBody({ backend }) {
|
|
104
|
+
const [state, setState] = useState({ status: "none" });
|
|
105
|
+
const [key, setKey] = useState("");
|
|
106
|
+
const [busy, setBusy] = useState(false);
|
|
107
|
+
const [error, setError] = useState(null);
|
|
108
|
+
useEffect(() => void backend.load().then(setState, (e) => setError(e instanceof Error ? e.message : String(e))), [backend]);
|
|
109
|
+
const run = (fn) => { setBusy(true); setError(null); void fn().then(setState, (e) => setError(e instanceof Error ? e.message : String(e))).finally(() => setBusy(false)); };
|
|
110
|
+
return _jsxs("div", { className: "rk-onboarding__card", "data-testid": "onboarding-license", children: [_jsxs("div", { className: "rk-onboarding__license-state", children: [_jsx(Badge, { tone: state.status === "active" || state.status === "trial" ? "ok" : state.status === "expired" || state.status === "invalid" ? "bad" : "neutral", children: state.label ?? state.status }), _jsx("span", { children: state.detail ?? "You can continue without a licence." })] }), error ? _jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }) : null, backend.activate ? _jsxs("form", { className: "rk-onboarding__form", onSubmit: (e) => { e.preventDefault(); if (key.trim())
|
|
111
|
+
run(() => backend.activate(key.trim())); }, children: [_jsx(Input, { "aria-label": "Licence key", placeholder: "Licence key", value: key, onChange: (e) => setKey(e.target.value), disabled: busy }), _jsx(Button, { size: "sm", variant: "primary", type: "submit", disabled: busy || !key.trim(), children: "Activate" })] }) : null, _jsxs("div", { className: "rk-onboarding__row-actions", children: [backend.startTrial ? _jsx(Button, { size: "sm", variant: "secondary", disabled: busy, onClick: () => run(() => backend.startTrial()), children: "Start trial" }) : null, backend.purchaseUrl ? _jsx(Button, { size: "sm", variant: "ghost", onClick: () => void backend.openExternal?.(backend.purchaseUrl), children: "View plans" }) : null] })] });
|
|
112
|
+
}
|
|
113
|
+
export function licenseStep(backend, options = {}) {
|
|
114
|
+
return { id: "license", label: options.label ?? "Licence", title: options.title ?? "Choose how you’ll use this app", description: options.description ?? "Activate a licence, start a trial, or continue with the free plan.", skippable: true, render: () => _jsx(LicenseBody, { backend: backend }) };
|
|
115
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { LegalGateDocument, LegalGateProps, LegalGateTheme } from "@rightkit/legal-ui";
|
|
3
|
+
export type SettingsValues = Record<string, unknown>;
|
|
4
|
+
export interface SettingsSnapshot {
|
|
5
|
+
values: SettingsValues;
|
|
6
|
+
revision: number;
|
|
7
|
+
}
|
|
8
|
+
/** Structural port shared with @rightkit/settings-ui and rightkit-settings. */
|
|
9
|
+
export interface SettingsBackend {
|
|
10
|
+
load(): Promise<SettingsSnapshot>;
|
|
11
|
+
patch(patch: SettingsValues, expectedRevision?: number): Promise<SettingsSnapshot>;
|
|
12
|
+
subscribe?(handler: (keys: string[]) => void): Promise<() => void>;
|
|
13
|
+
}
|
|
14
|
+
export type PermissionState = "ready" | "needed" | "waiting" | "denied" | "restricted";
|
|
15
|
+
export interface PermissionStatus {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
state: PermissionState;
|
|
20
|
+
detail?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface PermissionsBackend {
|
|
23
|
+
list(): Promise<PermissionStatus[]>;
|
|
24
|
+
request(id: string): Promise<PermissionStatus>;
|
|
25
|
+
}
|
|
26
|
+
export type ModelState = "missing" | "installed" | "downloading" | "verifying" | "error";
|
|
27
|
+
export interface ModelEntry {
|
|
28
|
+
id: string;
|
|
29
|
+
label: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
sizeBytes?: number;
|
|
32
|
+
state: ModelState;
|
|
33
|
+
progress?: number;
|
|
34
|
+
error?: {
|
|
35
|
+
code: string;
|
|
36
|
+
message: string;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export interface ModelsBackend {
|
|
40
|
+
list(): Promise<ModelEntry[]>;
|
|
41
|
+
install(id: string): Promise<void>;
|
|
42
|
+
/** Optional. The cancel/pause control is shown only when the backend supplies this. */
|
|
43
|
+
cancel?(id: string): Promise<void>;
|
|
44
|
+
subscribe?(handler: (entry: ModelEntry) => void): Promise<() => void>;
|
|
45
|
+
}
|
|
46
|
+
export interface LicenseState {
|
|
47
|
+
status: "none" | "trial" | "active" | "expired" | "invalid";
|
|
48
|
+
label?: string;
|
|
49
|
+
detail?: string;
|
|
50
|
+
tier?: string;
|
|
51
|
+
validUntil?: string | null;
|
|
52
|
+
}
|
|
53
|
+
export interface LicenseBackend {
|
|
54
|
+
load(): Promise<LicenseState>;
|
|
55
|
+
activate?(key: string): Promise<LicenseState>;
|
|
56
|
+
startTrial?(): Promise<LicenseState>;
|
|
57
|
+
purchaseUrl?: string;
|
|
58
|
+
openExternal?: (url: string) => void | Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
export type EligibilityBasis = "individual" | "enterprise";
|
|
61
|
+
/** Full record of one legal acceptance, persisted so version changes can re-prompt. */
|
|
62
|
+
export interface LegalAcceptanceRecord {
|
|
63
|
+
version: string;
|
|
64
|
+
documentsHash: string;
|
|
65
|
+
/** UTC ISO-8601 timestamp (`Date#toISOString`). */
|
|
66
|
+
acceptedAt: string;
|
|
67
|
+
eligibilityBasis: EligibilityBasis;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* App-owned persistence for the legal acceptance. Lets an app keep its existing receipt (any shape, any store) and map it
|
|
71
|
+
* to and from the package's record. When supplied it is the source of truth: `read()` is consulted on load and overrides
|
|
72
|
+
* whatever acceptance the settings backend holds (`null` means "not accepted", so the gate shows), and `write()` is
|
|
73
|
+
* awaited when the user agrees, before the settings backend is patched.
|
|
74
|
+
*/
|
|
75
|
+
export interface LegalReceiptPort {
|
|
76
|
+
read(): LegalAcceptanceRecord | null | Promise<LegalAcceptanceRecord | null>;
|
|
77
|
+
write(record: LegalAcceptanceRecord): void | Promise<void>;
|
|
78
|
+
}
|
|
79
|
+
export interface PersistedOnboardingState {
|
|
80
|
+
version: 1;
|
|
81
|
+
currentStep: number;
|
|
82
|
+
completed: string[];
|
|
83
|
+
skipped: boolean;
|
|
84
|
+
/** Legacy boolean, still written for older readers. Prefer `legalAcceptance`. */
|
|
85
|
+
legalAccepted: boolean;
|
|
86
|
+
/** Legacy mirror of `legalAcceptance.eligibilityBasis`. */
|
|
87
|
+
eligibilityBasis?: EligibilityBasis;
|
|
88
|
+
legalAcceptance?: LegalAcceptanceRecord;
|
|
89
|
+
}
|
|
90
|
+
export interface OnboardingStepContext {
|
|
91
|
+
state: PersistedOnboardingState;
|
|
92
|
+
settings: SettingsBackend;
|
|
93
|
+
setState: (next: Partial<PersistedOnboardingState>) => Promise<void>;
|
|
94
|
+
permissions?: PermissionsBackend;
|
|
95
|
+
models?: ModelsBackend;
|
|
96
|
+
license?: LicenseBackend;
|
|
97
|
+
/** Readiness reported by steps via `setReady`, keyed by step id. */
|
|
98
|
+
ready: Readonly<Record<string, boolean>>;
|
|
99
|
+
/** Reports whether the active step is ready (used by `canContinue`). Stable per step. */
|
|
100
|
+
setReady: (ready: boolean) => void;
|
|
101
|
+
}
|
|
102
|
+
export interface OnboardingStep {
|
|
103
|
+
id: string;
|
|
104
|
+
label: string;
|
|
105
|
+
title: string;
|
|
106
|
+
description?: string;
|
|
107
|
+
skippable?: boolean;
|
|
108
|
+
/** Continue/Finish stay disabled until this returns true. Default: always allowed. */
|
|
109
|
+
canContinue?: (context: OnboardingStepContext) => boolean;
|
|
110
|
+
/** Shown beside the disabled Continue/Finish button. */
|
|
111
|
+
blockedHint?: string;
|
|
112
|
+
render: (context: OnboardingStepContext) => ReactNode;
|
|
113
|
+
}
|
|
114
|
+
export interface LegalStepConfig extends Omit<LegalGateProps, "variant" | "onAgree" | "onQuit"> {
|
|
115
|
+
onQuit?: () => void;
|
|
116
|
+
}
|
|
117
|
+
export interface OnboardingProps {
|
|
118
|
+
settings: SettingsBackend;
|
|
119
|
+
legal: LegalStepConfig;
|
|
120
|
+
/** App-specific steps are appended after built-in steps, in supplied order. */
|
|
121
|
+
steps?: readonly OnboardingStep[];
|
|
122
|
+
permissions?: PermissionsBackend;
|
|
123
|
+
models?: ModelsBackend;
|
|
124
|
+
license?: LicenseBackend;
|
|
125
|
+
onComplete: () => void | Promise<void>;
|
|
126
|
+
onSkip?: () => void | Promise<void>;
|
|
127
|
+
className?: string;
|
|
128
|
+
/** Version the user must have accepted. A different (or missing) stored version re-prompts with `variant: "re-acceptance"`. */
|
|
129
|
+
requiredVersion?: string;
|
|
130
|
+
/** Custom acceptance check; takes precedence over `requiredVersion`. */
|
|
131
|
+
isAccepted?: (state: PersistedOnboardingState) => boolean;
|
|
132
|
+
/** Hash of the legal documents recorded with the acceptance. Default: hash of document titles and URLs. */
|
|
133
|
+
documentsHash?: string;
|
|
134
|
+
/** Runs (and is awaited) before the legal gate is shown, e.g. to resize or reveal the window. */
|
|
135
|
+
beforeLegalGate?: () => void | Promise<void>;
|
|
136
|
+
/** Require at least one downloaded model before Continue/Finish on the models step. Default false. */
|
|
137
|
+
requireModel?: boolean;
|
|
138
|
+
/** Start downloading the first missing model when the models step opens (skipped when any model is installed or downloading). Default false. */
|
|
139
|
+
autoStartDownload?: boolean;
|
|
140
|
+
/** Choose which missing model starts when the models step first opens: the first `state === "missing"` entry for which this returns true. Replaces the `autoStartDownload` rule (no "something is installed" guard; the app decides). Fires at most once per `Onboarding` mount, across re-renders and Back/Next. */
|
|
141
|
+
shouldAutoStart?: (entry: ModelEntry, models: readonly ModelEntry[]) => boolean;
|
|
142
|
+
/** Full control: called once, when the models step first opens and the list has loaded. Replaces `shouldAutoStart` and `autoStartDownload`. Start models through `ctx.start`. Fires at most once per `Onboarding` mount; a throw is shown in the step. */
|
|
143
|
+
onModelsStepEnter?: (ctx: ModelsStepEnterContext) => void | Promise<void>;
|
|
144
|
+
/** App copy shown at the top of the models step. */
|
|
145
|
+
modelsIntro?: ReactNode;
|
|
146
|
+
/** Show "Set up later". Default true. */
|
|
147
|
+
allowSkip?: boolean;
|
|
148
|
+
/** Optional titlebar content rendered in a strip along the top. */
|
|
149
|
+
titlebar?: ReactNode;
|
|
150
|
+
/** Render a `data-tauri-drag-region` strip at the top of the setup steps. Default false. Never applied to the legal stage; see `legalDragRegion`. */
|
|
151
|
+
dragRegion?: boolean;
|
|
152
|
+
/** Also render the drag region on the legal stage (loading and gate). Default false: the legal gate has no strip. */
|
|
153
|
+
legalDragRegion?: boolean;
|
|
154
|
+
/** Read and write the legal acceptance in the app's own receipt instead of (overriding) the settings backend's copy. */
|
|
155
|
+
legalReceipt?: LegalReceiptPort;
|
|
156
|
+
}
|
|
157
|
+
export type { LegalGateDocument, LegalGateTheme };
|
|
158
|
+
/** Context handed to `onModelsStepEnter`. */
|
|
159
|
+
export interface ModelsStepEnterContext {
|
|
160
|
+
/** The model list as loaded when the step opened. */
|
|
161
|
+
models: readonly ModelEntry[];
|
|
162
|
+
/** Start installing one model, then refresh the list. */
|
|
163
|
+
start(id: string): Promise<void>;
|
|
164
|
+
/** Re-read the list from the backend (and update the step). */
|
|
165
|
+
refresh(): Promise<ModelEntry[]>;
|
|
166
|
+
}
|
|
167
|
+
/** The auto-start options shared by `Onboarding` and `modelsStep`. */
|
|
168
|
+
export interface ModelsAutoStart {
|
|
169
|
+
autoStartDownload?: boolean;
|
|
170
|
+
shouldAutoStart?: (entry: ModelEntry, models: readonly ModelEntry[]) => boolean;
|
|
171
|
+
onModelsStepEnter?: (ctx: ModelsStepEnterContext) => void | Promise<void>;
|
|
172
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/ui.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const cx: (...parts: Array<string | false | null | undefined>) => string;
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const cx = (...parts) => parts.filter(Boolean).join(" ");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rightkit/onboarding",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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,7 +29,7 @@
|
|
|
29
29
|
"react": ">=18 <21"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@rightkit/app-shell": "0.4.
|
|
32
|
+
"@rightkit/app-shell": "0.4.1",
|
|
33
33
|
"@rightkit/legal-ui": "0.2.0",
|
|
34
34
|
"@rightkit/qa-chrome": "0.1.3",
|
|
35
35
|
"@testing-library/jest-dom": "^6.6.3",
|