)[key];
+ if (typeof fn === 'function') {
+ hooksRegistry.set(uuid, fn as HookFn);
+ }
+ mapHooksId[key] = uuid;
+ });
+
+ return mapHooksId;
+}
+
+function handleEvent(event: ScreebEvent) {
if (event?.hookId != null) {
const hook = hooksRegistry.get(event.hookId);
if (hook != null) {
@@ -265,12 +309,19 @@ function handleEvent(event: { hookId?: string; payload?: unknown }) {
? JSON.stringify(event.payload)
: event.payload
: '{}';
- const result = hook(payload);
- const parsedPayload = JSON.parse(payload);
- const originalHookId = parsedPayload?.hook_id;
+ let originalHookId = event.nativeHookId;
+ if (!originalHookId) {
+ try {
+ const parsedPayload = JSON.parse(payload) as { hook_id?: string };
+ originalHookId = parsedPayload?.hook_id;
+ } catch (error) {
+ console.error(error);
+ return;
+ }
+ }
if (originalHookId) {
- if (result instanceof Promise) {
- result
+ try {
+ Promise.resolve(hook(payload))
.then((hookResult) => {
ScreebReactNative.onHookResult(originalHookId, {
result: hookResult,
@@ -278,9 +329,15 @@ function handleEvent(event: { hookId?: string; payload?: unknown }) {
})
.catch((error) => {
console.error(error);
+ ScreebReactNative.onHookResult(originalHookId, {
+ result: false,
+ });
});
- } else {
- ScreebReactNative.onHookResult(originalHookId, { result });
+ } catch (error) {
+ console.error(error);
+ ScreebReactNative.onHookResult(originalHookId, {
+ result: false,
+ });
}
}
}
@@ -329,14 +386,18 @@ function normalizeValue(value: unknown): unknown {
// Format payloads so DateTime properties are correctly interpreted by the SDK
function formatDateValue(value: Date): string {
- const timezoneOffsetHours = -value.getTimezoneOffset() / 60;
- const sign = timezoneOffsetHours >= 0 ? '+' : '-';
- const offset = Math.abs(timezoneOffsetHours).toString().padStart(2, '0');
+ const timezoneOffsetMinutes = -value.getTimezoneOffset();
+ const sign = timezoneOffsetMinutes >= 0 ? '+' : '-';
+ const absOffset = Math.abs(timezoneOffsetMinutes);
+ const offsetHours = Math.floor(absOffset / 60)
+ .toString()
+ .padStart(2, '0');
+ const offsetMinutes = (absOffset % 60).toString().padStart(2, '0');
const pad = (n: number, l = 2) => n.toString().padStart(l, '0');
const isoWithoutTimezone = `${value.getFullYear()}-${pad(
value.getMonth() + 1
)}-${pad(value.getDate())}T${pad(value.getHours())}:${pad(
value.getMinutes()
)}:${pad(value.getSeconds())}.${pad(value.getMilliseconds(), 3)}`;
- return `${isoWithoutTimezone}${sign}${offset}:00`;
+ return `${isoWithoutTimezone}${sign}${offsetHours}:${offsetMinutes}`;
}
diff --git a/packages/sdk-reactnative/test-types/hooks.ts b/packages/sdk-reactnative/test-types/hooks.ts
new file mode 100644
index 0000000..55df4d1
--- /dev/null
+++ b/packages/sdk-reactnative/test-types/hooks.ts
@@ -0,0 +1,13 @@
+import { initSdk, startMessage, startSurvey } from '../src';
+
+const hooks = {
+ version: '1.0',
+ onSurveyDisplayAllowed: async (payload: string) =>
+ payload.includes('hook_id'),
+};
+
+initSdk('channel-id', 'user-id', { plan: 'pro' }, hooks);
+
+startSurvey('survey-id', true, { source: 'test' }, true, hooks);
+
+startMessage('message-id', true, { source: 'test' }, true, hooks);
diff --git a/packages/sdk-reactnative/test-types/privacy-helpers.tsx b/packages/sdk-reactnative/test-types/privacy-helpers.tsx
new file mode 100644
index 0000000..bcf60b2
--- /dev/null
+++ b/packages/sdk-reactnative/test-types/privacy-helpers.tsx
@@ -0,0 +1,14 @@
+import { View } from 'react-native';
+import { ScreebId, ScreebMaskText, ScreebNoCapture } from '../src';
+
+export const privacyHelperNodes = [
+
+
+ ,
+
+
+ ,
+
+
+ ,
+];
diff --git a/packages/sdk-svelte/.eslintrc b/packages/sdk-svelte/.eslintrc
new file mode 100644
index 0000000..903ef04
--- /dev/null
+++ b/packages/sdk-svelte/.eslintrc
@@ -0,0 +1,8 @@
+{
+ "ignorePatterns": [
+ "dist"
+ ],
+ "extends": [
+ "@screeb/eslint-config"
+ ]
+}
diff --git a/packages/sdk-svelte/README.md b/packages/sdk-svelte/README.md
new file mode 100644
index 0000000..e29e7e4
--- /dev/null
+++ b/packages/sdk-svelte/README.md
@@ -0,0 +1,123 @@
+
+
+
+
+
+@screeb/sdk-svelte
+
+ Screeb's browser SDK, optimized for Svelte 4.
+
+ Continuous Product Discovery, Without the Time Sink.
+
+ Screeb is the only Continuous Product Discovery platform that lets you analyse users' behaviour, ask in-app questions, recruit people for interviews and analyse data in a blink with AI.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Installation
+
+This library is published in the NPM registry and can be installed using any compatible package manager.
+
+```sh
+npm install @screeb/sdk-svelte --save
+
+# For Yarn, use the command below.
+yarn add @screeb/sdk-svelte
+```
+
+## Package Size
+
+Current package size snapshot:
+
+- npm tarball: 7.1 KB
+- unpacked package: 42.3 KB
+
+## Usage
+
+Basic usage:
+
+Call `setScreebContext` once in the root component that wraps your app:
+
+```svelte
+
+
+
+
+```
+
+## Usage
+
+Use `useScreeb()` in any child component:
+
+```svelte
+
+
+ eventTrack("button-clicked")}>
+ Track event
+
+```
+
+## Identify after login
+
+```svelte
+
+
+```
+
+For a working example, see our [Screeb Svelte SDK example app](https://github.com/ScreebApp/sdk/tree/master/examples/example-svelte).
+
+## Documentation
+
+- Install guide: [developers.screeb.app/sdk-svelte/install](https://developers.screeb.app/sdk-svelte/install)
+- API reference: [developers.screeb.app/sdk-svelte/reference](https://developers.screeb.app/sdk-svelte/reference)
+
+## Support
+
+For any issues, please contact our support team at support@screeb.com.
+
+## Contributing
+
+All third party contributors acknowledge that any contributions they provide will be made under the same open source license that the open source project is provided under.
+
+## License
+
+Released under [MIT License](https://github.com/ScreebApp/sdk/blob/master/LICENSE).
diff --git a/packages/sdk-svelte/package.json b/packages/sdk-svelte/package.json
new file mode 100644
index 0000000..a639c49
--- /dev/null
+++ b/packages/sdk-svelte/package.json
@@ -0,0 +1,64 @@
+{
+ "name": "@screeb/sdk-svelte",
+ "version": "0.1.0",
+ "description": "Screeb's browser SDK, optimized for Svelte.",
+ "keywords": [
+ "product discovery",
+ "product management",
+ "survey",
+ "analytics",
+ "user feedback",
+ "user research",
+ "svelte"
+ ],
+ "homepage": "https://screeb.app",
+ "bugs": {
+ "url": "https://github.com/ScreebApp/sdk/issues",
+ "email": "support@screeb.app"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/ScreebApp/sdk.git",
+ "directory": "packages/sdk-svelte"
+ },
+ "license": "MIT",
+ "author": "Screeb's frontend team",
+ "sideEffects": false,
+ "type": "module",
+ "module": "dist/es/index.mjs",
+ "main": "dist/cjs/index.cjs",
+ "types": "dist/es/index.d.ts",
+ "files": [
+ "dist",
+ "README.md"
+ ],
+ "scripts": {
+ "build": "rollup -c ../../node_modules/@screeb/typescript-config/src/rollup.config.js",
+ "clean": "rm -Rf dist",
+ "lint": "eslint .",
+ "release:check": "npm run build --workspace=@screeb/sdk-browser && npm run build && npm run lint"
+ },
+ "dependencies": {
+ "@screeb/sdk-browser": "^0.6.0"
+ },
+ "devDependencies": {
+ "@screeb/eslint-config": "^0.1.6",
+ "@screeb/typescript-config": "^0.1.10",
+ "@types/node": "^20.8.4",
+ "@typescript-eslint/eslint-plugin": "^6.7.5",
+ "eslint": "^8.51.0",
+ "eslint-plugin-import": "^2.28.1",
+ "eslint-plugin-jsx-a11y": "^6.7.1",
+ "eslint-plugin-prettier": "^5.0.1",
+ "prettier": "^3.0.3",
+ "rollup": "^4.0.2",
+ "svelte": ">=4.0.0",
+ "typescript": "^5.2.2"
+ },
+ "peerDependencies": {
+ "svelte": ">=4.0.0"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/sdk-svelte/readme/screeb-logo.svg b/packages/sdk-svelte/readme/screeb-logo.svg
new file mode 100644
index 0000000..b74bd6e
--- /dev/null
+++ b/packages/sdk-svelte/readme/screeb-logo.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/sdk-svelte/src/client.ts b/packages/sdk-svelte/src/client.ts
new file mode 100644
index 0000000..5002b51
--- /dev/null
+++ b/packages/sdk-svelte/src/client.ts
@@ -0,0 +1,166 @@
+import * as Screeb from "@screeb/sdk-browser";
+
+import CONSTANTS from "./constants";
+import * as logger from "./logger";
+import { ScreebClient, ScreebConfig } from "./types";
+
+export const createScreebClient = (config: ScreebConfig): ScreebClient => {
+ let isInitialized = false;
+
+ const ensureScreeb = async function ReturnType>(
+ functionName: string,
+ callback: T,
+ onlyLoaded = false,
+ ): Promise> {
+ const shouldLoad = config.shouldLoad ?? true;
+ if (!Screeb.isLoaded() && !shouldLoad) {
+ const message =
+ "Screeb instance is not loaded because `shouldLoad` is set to `false` in the Svelte provider";
+ logger.log("warn", message);
+ return Promise.reject(message);
+ }
+
+ if (!isInitialized && !onlyLoaded) {
+ const message = [
+ `"${functionName}" was called but Screeb has not been initialized yet. `,
+ `Please call 'init' before calling '${functionName}' or `,
+ "set 'autoInit' to true in the Svelte provider config.",
+ ].join("");
+ logger.log("warn", message);
+ return Promise.reject(message);
+ }
+
+ return Promise.resolve(callback());
+ };
+
+ const init = async (
+ websiteId: string,
+ userId?: string,
+ userProperties?: Screeb.PropertyRecord,
+ hooks?: Screeb.HooksInit,
+ language?: string,
+ ) => {
+ await ensureScreeb(
+ "init",
+ () => {
+ if (!isInitialized) {
+ Screeb.init(websiteId, userId, userProperties, hooks, language);
+ isInitialized = true;
+ }
+ },
+ true,
+ );
+ };
+
+ const load = async (options?: Screeb.ScreebOptions) => {
+ if (!Screeb.isLoaded()) {
+ Screeb.load({
+ sdkName: "sdk-svelte",
+ sdkVersion: CONSTANTS.version,
+ ...options,
+ });
+ }
+
+ if (config.autoInit && !isInitialized) {
+ if (config.websiteId) {
+ await init(
+ config.websiteId,
+ config.userId,
+ config.userProperties,
+ config.hooks,
+ config.language,
+ );
+ } else {
+ logger.log(
+ "warn",
+ "autoInit is set to true, but no websiteId have been provided.",
+ );
+ }
+ }
+ };
+
+ const close = async () => {
+ if (Screeb.isLoaded()) {
+ await Screeb.close();
+ isInitialized = false;
+ }
+ };
+
+ return {
+ ScreebId: Screeb.ScreebId,
+ ScreebMaskText: Screeb.ScreebMaskText,
+ ScreebNoCapture: Screeb.ScreebNoCapture,
+ close,
+ debug: async () => ensureScreeb("debug", () => Screeb.debug()),
+ eventTrack: async (eventName, eventProperties) =>
+ ensureScreeb("eventTrack", () =>
+ Screeb.eventTrack(eventName, eventProperties),
+ ),
+ identity: async (userId, userProperties) =>
+ ensureScreeb("identity", () => Screeb.identity(userId, userProperties)),
+ identityGet: async () =>
+ ensureScreeb("identityGet", () => Screeb.identityGet()),
+ identityGroupAssign: async (groupName, groupType, groupProperties) =>
+ ensureScreeb("identityGroupAssign", () =>
+ Screeb.identityGroupAssign(groupName, groupType, groupProperties),
+ ),
+ identityGroupUnassign: async (groupName, groupType) =>
+ ensureScreeb("identityGroupUnassign", () =>
+ Screeb.identityGroupUnassign(groupName, groupType),
+ ),
+ identityProperties: async (userProperties) =>
+ ensureScreeb("identityProperties", () =>
+ Screeb.identityProperties(userProperties),
+ ),
+ identityReset: async () =>
+ ensureScreeb("identityReset", () => Screeb.identityReset()),
+ init,
+ load,
+ messageClose: async () =>
+ ensureScreeb("messageClose", () => Screeb.messageClose()),
+ messageStart: async (
+ messageId,
+ allowMultipleResponses,
+ hiddenFields,
+ hooks,
+ language,
+ ) =>
+ ensureScreeb("messageStart", () =>
+ Screeb.messageStart(
+ messageId,
+ allowMultipleResponses,
+ hiddenFields,
+ hooks,
+ language,
+ ),
+ ),
+ sessionReplayStart: async () =>
+ ensureScreeb("sessionReplayStart", () => Screeb.sessionReplayStart()),
+ sessionReplayStop: async () =>
+ ensureScreeb("sessionReplayStop", () => Screeb.sessionReplayStop()),
+ surveyClose: async () =>
+ ensureScreeb("surveyClose", () => Screeb.surveyClose()),
+ surveyStart: async (
+ surveyId,
+ distributionId,
+ allowMultipleResponses,
+ hiddenFields,
+ hooks,
+ language,
+ selectors,
+ ) =>
+ ensureScreeb("surveyStart", () =>
+ Screeb.surveyStart(
+ surveyId,
+ distributionId,
+ allowMultipleResponses,
+ hiddenFields,
+ hooks,
+ language,
+ selectors,
+ ),
+ ),
+ targetingDebug: async () =>
+ ensureScreeb("targetingDebug", () => Screeb.targetingDebug()),
+ };
+};
diff --git a/packages/sdk-svelte/src/constants.ts b/packages/sdk-svelte/src/constants.ts
new file mode 100644
index 0000000..fe9cb36
--- /dev/null
+++ b/packages/sdk-svelte/src/constants.ts
@@ -0,0 +1,3 @@
+const CONSTANTS = { version: "0.1.0" };
+
+export default CONSTANTS;
diff --git a/packages/sdk-svelte/src/context.ts b/packages/sdk-svelte/src/context.ts
new file mode 100644
index 0000000..a538615
--- /dev/null
+++ b/packages/sdk-svelte/src/context.ts
@@ -0,0 +1,35 @@
+import { getContext, setContext } from "svelte";
+
+import { createScreebClient } from "./client";
+import * as logger from "./logger";
+import { ScreebClient, ScreebConfig } from "./types";
+import { isSSR } from "./utils";
+
+export const SCREEB_CONTEXT_KEY = Symbol("screeb");
+
+export function setScreebContext(config: ScreebConfig): ScreebClient {
+ const client = createScreebClient(config);
+
+ setContext(SCREEB_CONTEXT_KEY, client);
+
+ if (!isSSR && (config.shouldLoad ?? true)) {
+ void client.load(config.options);
+ }
+
+ return client;
+}
+
+export const provideScreeb = setScreebContext;
+
+export function useScreeb(): ScreebClient {
+ const context = getContext(SCREEB_CONTEXT_KEY);
+
+ if (!context) {
+ logger.log(
+ "warn",
+ "`useScreeb` must be called inside a component tree where `setScreebContext` or `provideScreeb` has been called.",
+ );
+ }
+
+ return context as ScreebClient;
+}
diff --git a/packages/sdk-svelte/src/index.ts b/packages/sdk-svelte/src/index.ts
new file mode 100644
index 0000000..eb6b921
--- /dev/null
+++ b/packages/sdk-svelte/src/index.ts
@@ -0,0 +1,8 @@
+export { createScreebClient } from "./client";
+export {
+ provideScreeb,
+ SCREEB_CONTEXT_KEY,
+ setScreebContext,
+ useScreeb,
+} from "./context";
+export * from "./types";
diff --git a/packages/sdk-svelte/src/logger.ts b/packages/sdk-svelte/src/logger.ts
new file mode 100644
index 0000000..698a24c
--- /dev/null
+++ b/packages/sdk-svelte/src/logger.ts
@@ -0,0 +1,8 @@
+type LogLevel = "warn";
+
+export const log = (level: LogLevel, message: string) => {
+ if (level === "warn") {
+ // eslint-disable-next-line no-console
+ console.warn(message);
+ }
+};
diff --git a/packages/sdk-svelte/src/types.ts b/packages/sdk-svelte/src/types.ts
new file mode 100644
index 0000000..d60547d
--- /dev/null
+++ b/packages/sdk-svelte/src/types.ts
@@ -0,0 +1,132 @@
+/* eslint-disable no-unused-vars */
+import {
+ HooksInit,
+ HooksMessageStart,
+ HooksSurveyStart,
+ PropertyRecord,
+ ScreebIdentityGetReturn,
+ ScreebOptions,
+} from "@screeb/sdk-browser";
+
+/** Configuration for the Svelte Screeb provider. */
+export type ScreebConfig = {
+ /** Your website/channel id. */
+ websiteId: string;
+ /** The unique identifier of your user. */
+ userId?: string;
+ /** The properties of your user. */
+ userProperties?: PropertyRecord;
+ /** Hooks to define callbacks for Screeb events. */
+ hooks?: HooksInit;
+ /** Force a specific language (for example, "en"). Defaults to the browser language. */
+ language?: string;
+ /**
+ * Indicates if Screeb should be automatically loaded.
+ * Set to false to prevent the SDK from loading, for example during SSR or tests.
+ * @default true
+ */
+ shouldLoad?: boolean;
+ /**
+ * Indicates if Screeb should be automatically initialized after load.
+ * @default false
+ */
+ autoInit?: boolean;
+ /** Screeb tag loading options. */
+ options?: ScreebOptions;
+};
+
+export type CloseFunction = () => Promise;
+export type DebugFunction = () => Promise;
+
+export type EventTrackFunction = (
+ eventName: string,
+ eventProperties?: PropertyRecord,
+) => Promise;
+
+export type IdentityFunction = (
+ userId: string,
+ userProperties?: PropertyRecord,
+) => Promise;
+
+export type IdentityGetFunction = () => Promise;
+
+export type IdentityGroupAssignFunction = (
+ groupName: string,
+ groupType?: string,
+ groupProperties?: PropertyRecord,
+) => Promise;
+
+export type IdentityGroupUnassignFunction = (
+ groupName: string,
+ groupType?: string,
+) => Promise;
+
+export type IdentityPropertiesFunction = (
+ userProperties: PropertyRecord,
+) => Promise;
+
+export type IdentityResetFunction = () => Promise;
+
+export type InitFunction = (
+ websiteId: string,
+ userId?: string,
+ userProperties?: PropertyRecord,
+ hooks?: HooksInit,
+ language?: string,
+) => Promise;
+
+export type LoadFunction = (options?: ScreebOptions) => Promise;
+
+export type SurveyCloseFunction = () => Promise;
+
+export type SurveyStartFunction = (
+ surveyId: string,
+ distributionId?: string,
+ allowMultipleResponses?: boolean,
+ hiddenFields?: PropertyRecord,
+ hooks?: HooksSurveyStart,
+ language?: string,
+ selectors?: string | string[],
+) => Promise;
+
+export type MessageCloseFunction = () => Promise;
+
+export type MessageStartFunction = (
+ messageId: string,
+ allowMultipleResponses?: boolean,
+ hiddenFields?: PropertyRecord,
+ hooks?: HooksMessageStart,
+ language?: string,
+) => Promise;
+
+export type SessionReplayStopFunction = () => Promise;
+export type SessionReplayStartFunction = () => Promise;
+export type TargetingDebugFunction = () => Promise;
+export type ScreebMaskTextFunction = (element: T) => T;
+export type ScreebNoCaptureFunction = (element: T) => T;
+export type ScreebIdFunction = (element: T, id: string) => T;
+
+/** All Screeb methods provided by `useScreeb()`. */
+export type ScreebClient = {
+ close: CloseFunction;
+ debug: DebugFunction;
+ eventTrack: EventTrackFunction;
+ identity: IdentityFunction;
+ identityGet: IdentityGetFunction;
+ identityGroupAssign: IdentityGroupAssignFunction;
+ identityGroupUnassign: IdentityGroupUnassignFunction;
+ identityProperties: IdentityPropertiesFunction;
+ identityReset: IdentityResetFunction;
+ init: InitFunction;
+ load: LoadFunction;
+ messageClose: MessageCloseFunction;
+ messageStart: MessageStartFunction;
+ sessionReplayStart: SessionReplayStartFunction;
+ sessionReplayStop: SessionReplayStopFunction;
+ surveyClose: SurveyCloseFunction;
+ surveyStart: SurveyStartFunction;
+ targetingDebug: TargetingDebugFunction;
+ ScreebMaskText: ScreebMaskTextFunction;
+ ScreebNoCapture: ScreebNoCaptureFunction;
+ ScreebId: ScreebIdFunction;
+};
diff --git a/packages/sdk-svelte/src/utils.ts b/packages/sdk-svelte/src/utils.ts
new file mode 100644
index 0000000..19c0976
--- /dev/null
+++ b/packages/sdk-svelte/src/utils.ts
@@ -0,0 +1 @@
+export const isSSR = typeof window === "undefined";
diff --git a/packages/sdk-svelte/tsconfig.json b/packages/sdk-svelte/tsconfig.json
new file mode 100644
index 0000000..5c272f8
--- /dev/null
+++ b/packages/sdk-svelte/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "@screeb/typescript-config/src/tsconfig.json",
+ "include": ["src"],
+ "exclude": ["dist", "**/*.test.ts"],
+ "compilerOptions": {
+ "outDir": "dist",
+ "declaration": true,
+ "declarationDir": "."
+ }
+}
diff --git a/packages/sdk-vue/docs/README.md b/packages/sdk-vue/docs/README.md
index c60574e..90115a1 100644
--- a/packages/sdk-vue/docs/README.md
+++ b/packages/sdk-vue/docs/README.md
@@ -21,6 +21,9 @@
- [MessageStartFunction](README.md#messagestartfunction)
- [ScreebConfig](README.md#screebconfig)
- [ScreebContextValues](README.md#screebcontextvalues)
+- [ScreebIdFunction](README.md#screebidfunction)
+- [ScreebMaskTextFunction](README.md#screebmasktextfunction)
+- [ScreebNoCaptureFunction](README.md#screebnocapturefunction)
- [SessionReplayStartFunction](README.md#sessionreplaystartfunction)
- [SessionReplayStopFunction](README.md#sessionreplaystopfunction)
- [SurveyCloseFunction](README.md#surveyclosefunction)
@@ -312,6 +315,9 @@ All Screeb methods provided via `useScreeb()`
| Name | Type |
| :------ | :------ |
+| `ScreebId` | [`ScreebIdFunction`](README.md#screebidfunction) |
+| `ScreebMaskText` | [`ScreebMaskTextFunction`](README.md#screebmasktextfunction) |
+| `ScreebNoCapture` | [`ScreebNoCaptureFunction`](README.md#screebnocapturefunction) |
| `close` | [`CloseFunction`](README.md#closefunction) |
| `debug` | [`DebugFunction`](README.md#debugfunction) |
| `eventTrack` | [`EventTrackFunction`](README.md#eventtrackfunction) |
@@ -333,6 +339,85 @@ All Screeb methods provided via `useScreeb()`
___
+### ScreebIdFunction
+
+Ƭ **ScreebIdFunction**: \(`element`: `T`, `id`: `string`) => `T`
+
+#### Type declaration
+
+â–¸ \<`T`\>(`element`, `id`): `T`
+
+##### Type parameters
+
+| Name | Type |
+| :------ | :------ |
+| `T` | extends `Element` |
+
+##### Parameters
+
+| Name | Type |
+| :------ | :------ |
+| `element` | `T` |
+| `id` | `string` |
+
+##### Returns
+
+`T`
+
+___
+
+### ScreebMaskTextFunction
+
+Ƭ **ScreebMaskTextFunction**: \(`element`: `T`) => `T`
+
+#### Type declaration
+
+â–¸ \<`T`\>(`element`): `T`
+
+##### Type parameters
+
+| Name | Type |
+| :------ | :------ |
+| `T` | extends `Element` |
+
+##### Parameters
+
+| Name | Type |
+| :------ | :------ |
+| `element` | `T` |
+
+##### Returns
+
+`T`
+
+___
+
+### ScreebNoCaptureFunction
+
+Ƭ **ScreebNoCaptureFunction**: \(`element`: `T`) => `T`
+
+#### Type declaration
+
+â–¸ \<`T`\>(`element`): `T`
+
+##### Type parameters
+
+| Name | Type |
+| :------ | :------ |
+| `T` | extends `Element` |
+
+##### Parameters
+
+| Name | Type |
+| :------ | :------ |
+| `element` | `T` |
+
+##### Returns
+
+`T`
+
+___
+
### SessionReplayStartFunction
Ƭ **SessionReplayStartFunction**: () => `Promise`\<`unknown`\>
diff --git a/packages/sdk-vue/readme/screeb-logo.svg b/packages/sdk-vue/readme/screeb-logo.svg
new file mode 100644
index 0000000..b74bd6e
--- /dev/null
+++ b/packages/sdk-vue/readme/screeb-logo.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/sdk-vue/src/plugin.ts b/packages/sdk-vue/src/plugin.ts
index 4f0eaed..2531c58 100644
--- a/packages/sdk-vue/src/plugin.ts
+++ b/packages/sdk-vue/src/plugin.ts
@@ -231,6 +231,9 @@ export const ScreebPlugin: Plugin = {
);
const context: ScreebContextValues = {
+ ScreebId: Screeb.ScreebId,
+ ScreebMaskText: Screeb.ScreebMaskText,
+ ScreebNoCapture: Screeb.ScreebNoCapture,
close,
debug,
eventTrack,
@@ -242,12 +245,12 @@ export const ScreebPlugin: Plugin = {
identityReset,
init,
load,
- surveyClose,
- surveyStart,
messageClose,
messageStart,
- sessionReplayStop,
sessionReplayStart,
+ sessionReplayStop,
+ surveyClose,
+ surveyStart,
targetingDebug,
};
diff --git a/packages/sdk-vue/src/types.ts b/packages/sdk-vue/src/types.ts
index 9d5c3fa..ded1330 100644
--- a/packages/sdk-vue/src/types.ts
+++ b/packages/sdk-vue/src/types.ts
@@ -103,6 +103,9 @@ export type MessageStartFunction = (
export type SessionReplayStopFunction = () => Promise;
export type SessionReplayStartFunction = () => Promise;
export type TargetingDebugFunction = () => Promise;
+export type ScreebMaskTextFunction = (element: T) => T;
+export type ScreebNoCaptureFunction = (element: T) => T;
+export type ScreebIdFunction = (element: T, id: string) => T;
/** All Screeb methods provided via `useScreeb()` */
export type ScreebContextValues = {
@@ -124,4 +127,7 @@ export type ScreebContextValues = {
sessionReplayStart: SessionReplayStartFunction;
sessionReplayStop: SessionReplayStopFunction;
targetingDebug: TargetingDebugFunction;
+ ScreebMaskText: ScreebMaskTextFunction;
+ ScreebNoCapture: ScreebNoCaptureFunction;
+ ScreebId: ScreebIdFunction;
};
diff --git a/scripts/build-local-ios-xcframework.mjs b/scripts/build-local-ios-xcframework.mjs
new file mode 100755
index 0000000..55195db
--- /dev/null
+++ b/scripts/build-local-ios-xcframework.mjs
@@ -0,0 +1,106 @@
+#!/usr/bin/env node
+
+import { spawnSync } from "node:child_process";
+import { mkdirSync, mkdtempSync, readdirSync, rmSync, unlinkSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+
+function argValue(name) {
+ const index = process.argv.indexOf(name);
+ return index >= 0 ? process.argv[index + 1] : undefined;
+}
+
+function run(command, args, cwd) {
+ const result = spawnSync(command, args, {
+ cwd,
+ stdio: "inherit",
+ });
+ if (result.status !== 0) {
+ process.exit(result.status ?? 1);
+ }
+}
+
+function removeFilesMatching(path, matcher) {
+ for (const entry of readdirSync(path, { withFileTypes: true })) {
+ const child = resolve(path, entry.name);
+ if (entry.isDirectory()) {
+ removeFilesMatching(child, matcher);
+ continue;
+ }
+ if (entry.isFile() && matcher(child)) {
+ unlinkSync(child);
+ }
+ }
+}
+
+const sdkIosPath = resolve(argValue("--sdk-ios") || process.env.SCREEB_IOS_SDK_PATH || resolve(root, "../sdk-ios"));
+const output = resolve(argValue("--output") || process.env.SCREEB_IOS_XCFRAMEWORK_PATH || resolve(root, ".local/ios/Screeb.xcframework"));
+const workDir = mkdtempSync(resolve(dirname(output), ".build-"));
+const derivedDataPath = resolve(workDir, "DerivedData");
+const iosArchive = resolve(workDir, "Screeb-iOS.xcarchive");
+const simulatorArchive = resolve(workDir, "Screeb-iOS-simulator.xcarchive");
+
+rmSync(output, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
+mkdirSync(workDir, { recursive: true });
+mkdirSync(dirname(output), { recursive: true });
+
+const archiveSettings = [
+ "SKIP_INSTALL=NO",
+ "BUILD_LIBRARY_FOR_DISTRIBUTION=YES",
+ "DEPLOYMENT_POSTPROCESSING=YES",
+ "STRIP_INSTALLED_PRODUCT=YES",
+ "COPY_PHASE_STRIP=YES",
+ "STRIP_STYLE=non-global",
+ "DEBUG_INFORMATION_FORMAT=dwarf-with-dsym",
+ `OTHER_SWIFT_FLAGS=$(inherited) -debug-prefix-map ${sdkIosPath}=.`,
+];
+
+run("xcodebuild", [
+ "archive",
+ "-quiet",
+ "-project",
+ "Screeb.xcodeproj",
+ "-scheme",
+ "Screeb-Package",
+ "-destination",
+ "generic/platform=iOS",
+ "-sdk",
+ "iphoneos",
+ "-archivePath",
+ iosArchive,
+ "-derivedDataPath",
+ derivedDataPath,
+ ...archiveSettings,
+], sdkIosPath);
+
+run("xcodebuild", [
+ "archive",
+ "-quiet",
+ "-project",
+ "Screeb.xcodeproj",
+ "-scheme",
+ "Screeb-Package",
+ "-destination",
+ "generic/platform=iOS Simulator",
+ "-sdk",
+ "iphonesimulator",
+ "-archivePath",
+ simulatorArchive,
+ "-derivedDataPath",
+ derivedDataPath,
+ ...archiveSettings,
+], sdkIosPath);
+
+run("xcodebuild", [
+ "-create-xcframework",
+ "-framework",
+ resolve(iosArchive, "Products/Library/Frameworks/Screeb.framework"),
+ "-framework",
+ resolve(simulatorArchive, "Products/Library/Frameworks/Screeb.framework"),
+ "-output",
+ output,
+], sdkIosPath);
+
+removeFilesMatching(output, (file) => file.endsWith(".abi.json"));
diff --git a/scripts/report-sdk-sizes.mjs b/scripts/report-sdk-sizes.mjs
new file mode 100755
index 0000000..ece1c89
--- /dev/null
+++ b/scripts/report-sdk-sizes.mjs
@@ -0,0 +1,211 @@
+#!/usr/bin/env node
+
+import { spawnSync } from "node:child_process";
+import {
+ existsSync,
+ readdirSync,
+ readFileSync,
+ statSync,
+} from "node:fs";
+import { homedir } from "node:os";
+import { basename, dirname, join, relative, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const versions = JSON.parse(readFileSync(resolve(root, "sdk-versions.json"), "utf8"));
+const androidVersion = process.env.SCREEB_ANDROID_SDK_VERSION || versions.native.android;
+const mavenRepo = process.env.SCREEB_LOCAL_MAVEN_REPOSITORY || resolve(homedir(), ".m2/repository");
+const shouldBuild = !process.argv.includes("--no-build");
+
+const npmSdks = [
+ "packages/sdk-browser",
+ "packages/sdk-react",
+ "packages/sdk-vue",
+ "packages/sdk-svelte",
+ "packages/sdk-angular",
+ "packages/sdk-reactnative",
+];
+
+function envWithTooling(extra = {}) {
+ const env = { ...process.env, SCREEB_USE_LOCAL_SDK: "true", ...extra };
+ env.GRADLE_OPTS = `${env.GRADLE_OPTS || ""} -Dorg.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -Dkotlin.compiler.execution.strategy=in-process -Dkotlin.incremental=false`.trim();
+ env.KOTLIN_DAEMON_JVMARGS = env.KOTLIN_DAEMON_JVMARGS || "-Xmx2g -XX:MaxMetaspaceSize=1g";
+ return env;
+}
+
+function run(command, cwd, options = {}) {
+ console.log(`\n$ ${command}\n cwd: ${relative(root, cwd) || "."}`);
+ const result = spawnSync(command, {
+ cwd,
+ env: envWithTooling(options.env),
+ shell: true,
+ stdio: options.capture ? "pipe" : "inherit",
+ encoding: options.capture ? "utf8" : undefined,
+ });
+ if (result.status !== 0) {
+ const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
+ throw new Error(output || `Command failed: ${command}`);
+ }
+ return result.stdout;
+}
+
+function formatBytes(bytes) {
+ if (bytes < 1024) {
+ return `${bytes} B`;
+ }
+ if (bytes < 1024 * 1024) {
+ return `${(bytes / 1024).toFixed(1)} KB`;
+ }
+ return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
+}
+
+function walkFiles(path, options = {}) {
+ if (!existsSync(path)) {
+ return [];
+ }
+ const files = [];
+ for (const entry of readdirSync(path, { withFileTypes: true })) {
+ const child = join(path, entry.name);
+ const relativeChild = relative(path, child);
+ if (options.excludeNames?.has(entry.name)) {
+ continue;
+ }
+ if (entry.isDirectory()) {
+ files.push(...walkFiles(child, options));
+ continue;
+ }
+ if (!options.match || options.match(child, relativeChild)) {
+ files.push(child);
+ }
+ }
+ return files;
+}
+
+function totalSize(files) {
+ return files.reduce((total, file) => total + statSync(file).size, 0);
+}
+
+function printSize(label, bytes, suffix = "") {
+ console.log(`${label}: ${formatBytes(bytes)} (${bytes} bytes)${suffix}`);
+}
+
+function npmPackSize(packagePath) {
+ const output = run("npm pack --dry-run --json --ignore-scripts", resolve(root, packagePath), { capture: true });
+ const [pack] = JSON.parse(output.trim());
+ return {
+ packageSize: pack.size,
+ unpackedSize: pack.unpackedSize,
+ entryCount: pack.entryCount,
+ };
+}
+
+function buildAll() {
+ for (const packagePath of npmSdks) {
+ run("npm run build", resolve(root, packagePath));
+ }
+
+ run("./gradlew build --no-daemon", resolve(root, "packages/sdk-kmp"));
+ run("flutter pub get", resolve(root, "packages/sdk-flutter"));
+ run("dotnet build packages/sdk-maui/ScreebMaui.csproj -f net9.0-android -c Release", root);
+ run("dotnet build packages/sdk-maui/ScreebMaui.csproj -f net9.0-ios -c Release", root);
+}
+
+function reportNativeAndroid() {
+ const aar = resolve(mavenRepo, `app/screeb/sdk/survey/${androidVersion}/survey-${androidVersion}.aar`);
+ if (!existsSync(aar)) {
+ console.log(`Android native AAR ${androidVersion}: missing (${aar})`);
+ return;
+ }
+ printSize(`Android native AAR ${androidVersion}`, statSync(aar).size);
+}
+
+function reportNpmSdks() {
+ for (const packagePath of npmSdks) {
+ const packageJson = JSON.parse(readFileSync(resolve(root, packagePath, "package.json"), "utf8"));
+ const size = npmPackSize(packagePath);
+ printSize(
+ `${packageJson.name} npm tarball`,
+ size.packageSize,
+ `, unpacked ${formatBytes(size.unpackedSize)}, ${size.entryCount} files`
+ );
+ }
+}
+
+function reportFlutter() {
+ const packagePath = resolve(root, "packages/sdk-flutter");
+ const files = walkFiles(packagePath, {
+ excludeNames: new Set([".dart_tool", ".gradle", "build", ".git"]),
+ match: (path) => {
+ const rel = relative(packagePath, path);
+ return !rel.startsWith("example/") && !rel.includes("/build/");
+ },
+ });
+ printSize("plugin_screeb Flutter package source", totalSize(files), `, ${files.length} files`);
+}
+
+function reportKmp() {
+ const libs = resolve(root, "packages/sdk-kmp/build/libs");
+ const files = walkFiles(libs, {
+ match: (path) => /\.(jar|klib)$/.test(path),
+ });
+ if (files.length === 0) {
+ console.log(`Screeb KMP Maven artifacts: missing (${libs})`);
+ return;
+ }
+ printSize("Screeb KMP Maven artifacts", totalSize(files), `, ${files.length} files`);
+}
+
+function reportMaui() {
+ const packages = walkFiles(resolve(root, "packages/sdk-maui/bin/packages"), {
+ match: (path) => path.endsWith(".nupkg"),
+ });
+ if (packages.length > 0) {
+ for (const file of packages) {
+ printSize(`Screeb.Maui NuGet ${basename(file)}`, statSync(file).size);
+ }
+ return;
+ }
+
+ const dlls = walkFiles(resolve(root, "packages/sdk-maui/bin/Release"), {
+ match: (path) => /Screeb\.Maui\.dll$/.test(path),
+ });
+ if (dlls.length === 0) {
+ console.log("Screeb.Maui: no NuGet package or DLL build output found");
+ return;
+ }
+ for (const file of dlls) {
+ printSize(`Screeb.Maui ${relative(root, file)}`, statSync(file).size);
+ }
+}
+
+function reportNativeIos() {
+ const xcframework = resolve(root, "packages/sdk-kmp/native/ios/Screeb.xcframework");
+ const files = walkFiles(xcframework, {
+ match: (path) => !path.endsWith(".abi.json"),
+ });
+ if (files.length === 0) {
+ console.log(`iOS native xcframework: missing (${xcframework})`);
+ return;
+ }
+ printSize("iOS native xcframework (all slices)", totalSize(files), `, ${files.length} files`);
+
+ const deviceSlice = resolve(xcframework, "ios-arm64/Screeb.framework");
+ const deviceFiles = walkFiles(deviceSlice, {
+ match: (path) => !path.endsWith(".abi.json"),
+ });
+ if (deviceFiles.length > 0) {
+ printSize("iOS native app embed estimate (device slice)", totalSize(deviceFiles), `, ${deviceFiles.length} files`);
+ }
+}
+
+if (shouldBuild) {
+ buildAll();
+}
+
+console.log("\nSDK sizes");
+reportNativeAndroid();
+reportNativeIos();
+reportNpmSdks();
+reportFlutter();
+reportKmp();
+reportMaui();
diff --git a/scripts/sync-sdk-versions.mjs b/scripts/sync-sdk-versions.mjs
new file mode 100755
index 0000000..3a08b65
--- /dev/null
+++ b/scripts/sync-sdk-versions.mjs
@@ -0,0 +1,178 @@
+#!/usr/bin/env node
+
+import { readFileSync, writeFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const manifestPath = resolve(root, "sdk-versions.json");
+const semverPattern = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
+
+function readManifest() {
+ return JSON.parse(readFileSync(manifestPath, "utf8"));
+}
+
+function writeJson(path, value) {
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
+}
+
+function setByPath(object, dottedPath, value) {
+ const parts = dottedPath.split(".");
+ let current = object;
+ for (const part of parts.slice(0, -1)) {
+ if (!current[part] || typeof current[part] !== "object") {
+ throw new Error(`Unknown version path: ${dottedPath}`);
+ }
+ current = current[part];
+ }
+ const leaf = parts.at(-1);
+ if (!leaf || !(leaf in current)) {
+ throw new Error(`Unknown version path: ${dottedPath}`);
+ }
+ current[leaf] = value;
+}
+
+function replaceInFile(file, pattern, replacement) {
+ const path = resolve(root, file);
+ const before = readFileSync(path, "utf8");
+ if (!pattern.test(before)) {
+ throw new Error(`No version match found in ${file}`);
+ }
+ const after = before.replace(pattern, replacement);
+ writeIfNeeded(file, before, after);
+ if (!check && after !== before) {
+ console.log(`${file}`);
+ }
+}
+
+function replacePackageVersion(file, version) {
+ const path = resolve(root, file);
+ const before = readFileSync(path, "utf8");
+ const json = JSON.parse(before);
+ json.version = version;
+ const after = `${JSON.stringify(json, null, 2)}\n`;
+ writeIfNeeded(file, before, after);
+ if (!check && after !== before) {
+ console.log(`${file}`);
+ }
+}
+
+function parseArgs(args) {
+ const sets = [];
+ let check = false;
+
+ for (let index = 0; index < args.length; index += 1) {
+ const arg = args[index];
+ if (arg === "--check") {
+ check = true;
+ continue;
+ }
+ if (arg === "--set") {
+ const path = args[index + 1];
+ const version = args[index + 2];
+ if (!path || !version) {
+ throw new Error("Usage: node scripts/sync-sdk-versions.mjs --set ");
+ }
+ sets.push([path, version]);
+ index += 2;
+ continue;
+ }
+ throw new Error(`Unknown argument: ${arg}`);
+ }
+
+ return { check, sets };
+}
+
+const { check, sets } = parseArgs(process.argv.slice(2));
+const manifest = readManifest();
+const changedFiles = [];
+
+function writeIfNeeded(file, before, after) {
+ if (after === before) {
+ return;
+ }
+ if (check) {
+ changedFiles.push(file);
+ return;
+ }
+ writeFileSync(resolve(root, file), after);
+}
+
+for (const [path, version] of sets) {
+ if (!semverPattern.test(version)) {
+ throw new Error(`Invalid semantic version for ${path}: ${version}`);
+ }
+ setByPath(manifest, path, version);
+}
+
+if (sets.length > 0 && !check) {
+ writeJson(manifestPath, manifest);
+ console.log("sdk-versions.json");
+}
+
+const androidVersion = manifest.native.android;
+const iosVersion = manifest.native.ios;
+const flutterVersion = manifest.wrappers.flutter;
+const kmpVersion = manifest.wrappers.kmp;
+const mauiVersion = manifest.wrappers.maui;
+const reactNativeVersion = manifest.wrappers.reactNative;
+const svelteVersion = manifest.wrappers.svelte;
+
+replacePackageVersion("packages/sdk-reactnative/package.json", reactNativeVersion);
+replacePackageVersion("packages/sdk-svelte/package.json", svelteVersion);
+replaceInFile("packages/sdk-flutter/pubspec.yaml", /^version: .+$/m, `version: ${flutterVersion}`);
+replaceInFile("packages/sdk-flutter/android/build.gradle", /^version '.+'$/m, `version '${flutterVersion}'`);
+replaceInFile("packages/sdk-flutter/android/build.gradle", /api "app\.screeb\.sdk:survey:[^"]+"/, `api "app.screeb.sdk:survey:${androidVersion}"`);
+replaceInFile("packages/sdk-flutter/ios/plugin_screeb.podspec", /s\.version\s+=\s+'[^']+'/, `s.version = '${flutterVersion}'`);
+replaceInFile("packages/sdk-flutter/ios/plugin_screeb.podspec", /s\.dependency 'Screeb', '[^']+'/, `s.dependency 'Screeb', '${iosVersion}'`);
+replaceInFile("packages/sdk-reactnative/android/build.gradle", /api "app\.screeb\.sdk:survey:[^"]+"/, `api "app.screeb.sdk:survey:${androidVersion}"`);
+replaceInFile("packages/sdk-reactnative/ScreebReactNative.podspec", /s\.dependency "Screeb", '~> [^']+'/, `s.dependency "Screeb", '~> ${iosVersion}'`);
+replaceInFile(
+ "packages/sdk-reactnative/android/src/main/java/app/screeb/reactnative/ScreebReactNativeModule.kt",
+ /Screeb\.setSecondarySDK\("react-native", "[^"]+"\)/,
+ `Screeb.setSecondarySDK("react-native", "${reactNativeVersion}")`,
+);
+replaceInFile(
+ "packages/sdk-reactnative/ios/ScreebReactNative.swift",
+ /Screeb\.setSecondarySDK\(name: "react-native", version: "[^"]+"\)/,
+ `Screeb.setSecondarySDK(name: "react-native", version: "${reactNativeVersion}")`,
+);
+replaceInFile(
+ "packages/sdk-flutter/android/src/main/kotlin/app/screeb/plugin_screeb/PluginScreebPlugin.kt",
+ /Screeb\.setSecondarySDK\("flutter", "[^"]+"\)/,
+ `Screeb.setSecondarySDK("flutter", "${flutterVersion}")`,
+);
+replaceInFile(
+ "packages/sdk-flutter/ios/Classes/SwiftPluginScreebPlugin.swift",
+ /Screeb\.setSecondarySDK\(name: "flutter", version: "[^"]+"\)/,
+ `Screeb.setSecondarySDK(name: "flutter", version: "${flutterVersion}")`,
+);
+replaceInFile("packages/sdk-kmp/gradle.properties", /^VERSION_NAME=.*$/m, `VERSION_NAME=${kmpVersion}`);
+replaceInFile("packages/sdk-kmp/gradle.properties", /^SCREEB_ANDROID_SDK_VERSION=.*$/m, `SCREEB_ANDROID_SDK_VERSION=${androidVersion}`);
+replaceInFile("packages/sdk-kmp/gradle.properties", /^SCREEB_IOS_SDK_VERSION=.*$/m, `SCREEB_IOS_SDK_VERSION=${iosVersion}`);
+replaceInFile(
+ "packages/sdk-kmp/src/commonMain/kotlin/app/screeb/sdk/kmp/SdkVersion.kt",
+ /internal const val SDK_VERSION = "[^"]+"/,
+ `internal const val SDK_VERSION = "${kmpVersion}"`,
+);
+replaceInFile(
+ "packages/sdk-svelte/src/constants.ts",
+ /const CONSTANTS = \{ version: "[^"]+" \};/,
+ `const CONSTANTS = { version: "${svelteVersion}" };`,
+);
+replaceInFile("packages/sdk-maui/ScreebMaui.csproj", /()[^<]+(<\/Version>)/, `$1${mauiVersion}$2`);
+replaceInFile(
+ "packages/sdk-maui/ScreebMaui.csproj",
+ /(Include="app\.screeb\.sdk:survey"\s*\n\s*Version=")[^"]+(")/,
+ `$1${androidVersion}$2`,
+);
+replaceInFile("examples/example-android/app/build.gradle", /implementation 'app\.screeb\.sdk:survey:[^']+'/, `implementation 'app.screeb.sdk:survey:${androidVersion}'`);
+
+if (check && changedFiles.length > 0) {
+ console.error(`Version files are not synced:\n${changedFiles.map((file) => `- ${file}`).join("\n")}`);
+ process.exit(1);
+}
+
+if (check) {
+ console.log("Version files are synced.");
+}
diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs
new file mode 100644
index 0000000..fb3b91f
--- /dev/null
+++ b/scripts/verify-release.mjs
@@ -0,0 +1,185 @@
+#!/usr/bin/env node
+
+import { spawnSync } from "node:child_process";
+import { existsSync } from "node:fs";
+import { homedir, platform } from "node:os";
+import { dirname, relative, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const androidRoot = resolve(root, "../sdk-android");
+const iosRoot = resolve(root, "../sdk-ios");
+const isMac = platform() === "darwin";
+
+function envWithTooling(extra = {}) {
+ const env = { ...process.env, SCREEB_USE_LOCAL_SDK: "true", ...extra };
+ if (!env.SCREEB_LOCAL_MAVEN_REPOSITORY) {
+ env.SCREEB_LOCAL_MAVEN_REPOSITORY = resolve(homedir(), ".m2/repository");
+ }
+ return env;
+}
+
+function defaultIosTestDestination() {
+ if (process.env.SCREEB_IOS_TEST_DESTINATION) {
+ return process.env.SCREEB_IOS_TEST_DESTINATION;
+ }
+
+ const result = spawnSync("xcrun simctl list devices available --json", {
+ shell: true,
+ encoding: "utf8",
+ });
+ if (result.status !== 0) {
+ return "platform=iOS Simulator,name=iPhone 17 Pro,OS=latest";
+ }
+
+ const devices = JSON.parse(result.stdout);
+ for (const [runtime, runtimeDevices] of Object.entries(devices.devices ?? {})) {
+ if (!runtime.includes("iOS")) {
+ continue;
+ }
+ const device = runtimeDevices.find((candidate) => candidate.isAvailable && candidate.name.startsWith("iPhone"));
+ if (!device) {
+ continue;
+ }
+ const os = runtime.match(/iOS-(\d+-\d+)/)?.[1]?.replace("-", ".");
+ return `platform=iOS Simulator,name=${device.name}${os ? `,OS=${os}` : ""}`;
+ }
+
+ return "platform=iOS Simulator,name=iPhone 17 Pro,OS=latest";
+}
+
+const checks = [
+ {
+ scope: "versions",
+ label: "version manifest is synced",
+ cwd: root,
+ command: "npm run versions:check",
+ },
+ {
+ scope: "android",
+ label: "Android SDK release gates",
+ cwd: androidRoot,
+ command: "./gradlew :sdk:testDebugUnitTest :sdk:lintRelease :sdk:assembleRelease --no-daemon",
+ skip: () => !existsSync(resolve(androidRoot, "settings.gradle")) && !existsSync(resolve(androidRoot, "settings.gradle.kts")),
+ reason: "sdk-android checkout not found next to sdk",
+ },
+ {
+ scope: "flutter",
+ label: "Flutter wrapper analyze/tests",
+ cwd: resolve(root, "packages/sdk-flutter"),
+ command: "flutter analyze && flutter test",
+ },
+ {
+ scope: "react-native",
+ label: "React Native wrapper build/typecheck",
+ cwd: root,
+ command: "npm run build --workspace=@screeb/react-native && npm run typecheck --workspace=@screeb/react-native",
+ },
+ {
+ scope: "kmp",
+ label: "KMP wrapper tests with local native SDKs",
+ cwd: resolve(root, "packages/sdk-kmp"),
+ command: "./gradlew allTests --no-daemon",
+ },
+ {
+ scope: "maui",
+ label: "MAUI unit tests",
+ cwd: root,
+ command: "dotnet test packages/sdk-maui/tests/ScreebUtilsTests.csproj",
+ },
+ {
+ scope: "maui",
+ label: "MAUI Android release build",
+ cwd: root,
+ command: "dotnet build packages/sdk-maui/ScreebMaui.csproj -f net9.0-android -c Release",
+ },
+ {
+ scope: "maui-ios",
+ label: "MAUI iOS release build",
+ cwd: root,
+ command: "dotnet build packages/sdk-maui/ScreebMaui.csproj -f net9.0-ios -c Release",
+ skip: () => !isMac,
+ reason: "iOS build requires macOS/Xcode",
+ },
+ {
+ scope: "ios",
+ label: "iOS SDK tests",
+ cwd: iosRoot,
+ command: () => `xcodebuild test -scheme Screeb-Package -destination '${defaultIosTestDestination()}' -quiet`,
+ skip: () => !isMac || !existsSync(resolve(iosRoot, "Package.swift")),
+ reason: "sdk-ios checkout or macOS/Xcode toolchain not available",
+ },
+];
+
+function parseArgs() {
+ const scopes = new Set();
+ let listOnly = false;
+
+ for (const arg of process.argv.slice(2)) {
+ if (arg === "--list") {
+ listOnly = true;
+ } else if (arg.startsWith("--scope=")) {
+ for (const scope of arg.slice("--scope=".length).split(",")) {
+ if (scope) scopes.add(scope);
+ }
+ } else {
+ throw new Error(`Unknown argument: ${arg}`);
+ }
+ }
+
+ return { listOnly, scopes };
+}
+
+function selectedChecks(scopes) {
+ if (scopes.size === 0) {
+ return checks;
+ }
+ return checks.filter((check) => scopes.has(check.scope));
+}
+
+function printMatrix(items) {
+ console.log("Release validation matrix");
+ for (const check of items) {
+ const status = check.skip?.() ? `skip: ${check.reason}` : "run";
+ const command = typeof check.command === "function" ? check.command() : check.command;
+ console.log(`- [${check.scope}] ${check.label}: ${status}`);
+ console.log(` ${relative(root, check.cwd) || "."} $ ${command}`);
+ }
+}
+
+function run(check) {
+ if (check.skip?.()) {
+ console.log(`\n- SKIP ${check.label}: ${check.reason}`);
+ return;
+ }
+
+ console.log(`\n- RUN ${check.label}`);
+ console.log(` cwd: ${relative(root, check.cwd) || "."}`);
+ const command = typeof check.command === "function" ? check.command() : check.command;
+ console.log(` $ ${command}`);
+
+ const result = spawnSync(command, {
+ cwd: check.cwd,
+ env: envWithTooling(),
+ shell: true,
+ stdio: "inherit",
+ });
+ if (result.status !== 0) {
+ throw new Error(`${check.label} failed with exit code ${result.status}`);
+ }
+}
+
+const { listOnly, scopes } = parseArgs();
+const items = selectedChecks(scopes);
+if (items.length === 0) {
+ throw new Error(`No checks matched scopes: ${[...scopes].join(", ")}`);
+}
+
+printMatrix(items);
+
+if (!listOnly) {
+ for (const check of items) {
+ run(check);
+ }
+ console.log("\nRelease validation passed.");
+}
diff --git a/sdk-versions.json b/sdk-versions.json
new file mode 100644
index 0000000..98b1edb
--- /dev/null
+++ b/sdk-versions.json
@@ -0,0 +1,13 @@
+{
+ "native": {
+ "android": "4.0.0",
+ "ios": "4.0.0"
+ },
+ "wrappers": {
+ "flutter": "3.1.0",
+ "kmp": "0.1.0",
+ "maui": "0.1.0",
+ "reactNative": "3.3.1",
+ "svelte": "0.1.0"
+ }
+}