@wix/himalaya-cli 0.1320.0 → 0.1322.0

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.
Files changed (64) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/app-config.d.ts +1 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/app-module.d.ts +3 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/biometrics/src/index.d.ts +11 -1
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/catalog-pricing-contract.d.ts +4 -0
  5. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/host-session.d.ts +4 -0
  6. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/index.d.ts +13 -0
  7. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/mapping-source.d.ts +8 -3
  8. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/wix-payments.d.ts +5 -4
  9. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/sdk-types/src/capabilities.generated.d.ts +1 -1
  10. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/webview/src/index.d.ts +4 -0
  11. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/webview-page/src/index.d.ts +16 -1
  12. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/tokens.d.ts +9 -1
  13. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/apple-iap-contract.d.ts +5 -0
  14. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/apple-iap-core.d.ts +3 -1
  15. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/checkout-source.d.ts +4 -0
  16. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/commerce-contract.d.ts +83 -0
  17. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/commerce-core.d.ts +36 -0
  18. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/commerce.d.ts +8 -0
  19. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/index.d.ts +3 -0
  20. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/purchase-fields.d.ts +4 -0
  21. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/stores-v1/src/display.d.ts +21 -0
  22. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/audio-record.mjs +1 -1
  23. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/calendar.mjs +1 -1
  24. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/calls.mjs +1 -1
  25. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/contacts.mjs +1 -1
  26. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap-checkout.mjs +1205 -71
  27. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap.mjs +125 -13
  28. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/location.mjs +1 -1
  29. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/motion.mjs +1 -1
  30. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/photos.mjs +1 -1
  31. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/sdk-types.mjs +1 -1
  32. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/sdui.mjs +1 -0
  33. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/tasks.mjs +1 -1
  34. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/webview-page.mjs +78 -0
  35. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/webview.mjs +16 -0
  36. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-stores.mjs +13 -4
  37. package/dist/cli.mjs +87 -23
  38. package/dist/preview-assets.generated.bin +0 -0
  39. package/guides/getting-started.md +4 -2
  40. package/guides/himi-skill.md +4 -2
  41. package/guides/iap-pricing.md +33 -26
  42. package/guides/index.json +2 -1
  43. package/guides/sdk-reference.json +50 -8
  44. package/guides/tokens.md +22 -1
  45. package/guides/web-page-native-sdk.md +154 -33
  46. package/guides/webview-page-sdk.md +11 -0
  47. package/package.json +2 -2
  48. package/templates/beauty-salon/dev/index.ts +492 -0
  49. package/templates/beauty-salon/tier5-src/wix-spaces-collection-detail/index.ts +146 -30
  50. package/templates/branded-lite/dev/index.ts +533 -0
  51. package/templates/branded-lite/tier5-src/_shared/iap-worker.ts +21 -44
  52. package/templates/branded-lite/tier5-src/himi-branded-lite-program/index.ts +77 -47
  53. package/templates/branded-lite/tier5-src/himi-branded-lite-programs/index.ts +24 -38
  54. package/templates/branded-lite/tier5-src/wix-spaces-collection-detail/index.ts +146 -30
  55. package/templates/gym-fitness/dev/index.ts +492 -0
  56. package/templates/gym-fitness/tier5-src/wix-spaces-collection-detail/index.ts +146 -30
  57. package/templates/portfolio-creative/dev/index.ts +492 -0
  58. package/templates/portfolio-creative/tier5-src/wix-spaces-collection-detail/index.ts +146 -30
  59. package/templates/retail-store/dev/index.ts +492 -0
  60. package/templates/retail-store/tier5-src/wix-spaces-collection-detail/index.ts +146 -30
  61. package/templates/yoga-studio/dev/index.ts +41 -0
  62. package/templates/yoga-studio/tier5-src/_shared/iap-worker.ts +21 -44
  63. package/templates/yoga-studio/tier5-src/himi-branded-lite-program/index.ts +77 -47
  64. package/templates/yoga-studio/tier5-src/himi-branded-lite-programs/index.ts +24 -38
@@ -6,9 +6,11 @@ displays or sells: a program, a product, a plan, or anything else you registered
6
6
  which items are sold through the App Store, fetches their prices from Apple, and returns display
7
7
  state plus a payment route. Your screen binds the result to its own UI.
8
8
 
9
- > **Not switched on yet.** Wix has not connected the service that maps your catalog to App Store
10
- > products. Until it does, every screen keeps its existing price and checkout. Apple purchasing is
11
- > not available in any case: this layer shows prices and blocks the wrong checkout, but never buys.
9
+ > **On for bound apps only.** The mapping is read from the app's purchases catalog, which
10
+ > `himi store iap connect` sets up. An app with no purchases-app binding reads every item as
11
+ > `disabled` and keeps its existing price and checkout. This page shows prices and blocks the wrong
12
+ > checkout; selling an App Store item is the shared checkout's job (`createIapCommerce` +
13
+ > `commerce.purchaseFields`, [Wix Apple-IAP checkout](iap-wix-checkout.md#using-it-from-a-screen)).
12
14
  > Only one-time purchases (non-consumables) are supported. Subscriptions and consumables are not.
13
15
 
14
16
  ## What you get per item
@@ -20,7 +22,7 @@ what to do:
20
22
  |---|---|---|
21
23
  | `disabled` | Your screen's own price, exactly as before | Allowed |
22
24
  | `resolving` | A "Loading price…" label, never your own price | Blocked |
23
- | `appleIap` | `displayPrice` exactly as Apple formats it, plus a "One-time purchase" note | Blocked; purchasing is not available yet |
25
+ | `appleIap` | `displayPrice` exactly as Apple formats it, plus a "One-time purchase" note | Blocked; the shared checkout sells it |
24
26
  | `regular` | `displayPrice` (the commerce price you passed), or nothing | Allowed |
25
27
  | `unavailable` | "Price unavailable", plus Retry when `retryable` | Blocked |
26
28
 
@@ -34,10 +36,9 @@ does not mean the item is free. Only an explicit `regular` route keeps your exis
34
36
  returns `null` for any other route. It is for a checkout to compare with the product it is asked to
35
37
  sell. Do not put it in screen state.
36
38
 
37
- > **Buying is not switched on.** `@wix/himalaya/iap-checkout` is a shared App Store checkout,
38
- > `createAppleIapCheckout`, built on this layer. It answers `notEnabled` and makes no call until Wix
39
- > enables it, so keep App Store items blocked as described above. Never build your own purchase
40
- > flow for them.
39
+ > **Buy through the shared checkout only.** `@wix/himalaya/iap-checkout` (`createIapCommerce`) is
40
+ > the App Store checkout built on this layer, and it sells only in a bound app. Keep App Store items
41
+ > blocked in your own checkout as described above. Never build your own purchase flow for them.
41
42
 
42
43
  ## Catalog references
43
44
 
@@ -52,13 +53,16 @@ when it registers the item with Apple:
52
53
 
53
54
  ## Add it to a worker
54
55
 
55
- This is a complete worker for one screen. Copy the pattern, not the course names.
56
+ This is a complete worker for one screen that shows an App Store price and guards its existing
57
+ checkout. Copy the pattern, not the course names. A screen that also SELLS the item passes its
58
+ vertical adapter to `createIapCommerce` and binds Get access with `commerce.purchaseFields` instead of
59
+ blocking ([checkout recipe](iap-wix-checkout.md#using-it-from-a-screen)).
56
60
 
57
61
  ```ts
58
62
  // catalog-pricing-worker.ts
59
63
  import { defineService } from "@himalaya/state";
60
- import { get as appInfo } from "@himalaya/app-info";
61
- import { createIapCatalogPricing, iapPriceFields, onlineProgramItem } from "@himalaya/iap";
64
+ import { iapPriceFields, onlineProgramItem } from "@himalaya/iap";
65
+ import { createIapCommerce } from "@himalaya/iap-checkout";
62
66
 
63
67
  const COPY = {
64
68
  loading: "Loading price…",
@@ -74,25 +78,25 @@ type CourseState = Record<string, unknown>;
74
78
 
75
79
  /** `startCheckout` is the screen's existing commerce flow. */
76
80
  export function registerCourseScreen(course: Course, startCheckout: () => Promise<string>) {
77
- const pricing = createIapCatalogPricing({ onChange: () => { void service.publish(); } });
81
+ // No adapters: this screen shows the price and keeps its own checkout. `commerce.pricing` is the
82
+ // shared `createIapCatalogPricing` layer; the host scopes its lookup (app identity, platform, and
83
+ // the app's purchases-app site when it has one).
84
+ const commerce = createIapCommerce({ onChange: () => { void service.publish(); } });
85
+ const pricing = commerce.pricing;
78
86
  const item = onlineProgramItem({ id: course.id }, course.commercePrice)!;
79
87
  let message = "";
80
88
  let scopeIncomplete = false;
81
89
 
82
- async function hostScope() {
83
- const info = await appInfo().catch(() => undefined);
84
- scopeIncomplete = !info?.appId || !info?.bundleId || !info?.platform;
85
- return { scope: { appId: info?.appId ?? "", bundleId: info?.bundleId ?? "" }, platform: info?.platform ?? "" };
86
- }
87
-
88
90
  async function loadPrices() {
89
- if (pricing.enabled) await pricing.load({ ...(await hostScope()), items: [item] });
91
+ if (pricing.enabled) scopeIncomplete = !(await commerce.loadPrices([item])).scopeComplete;
90
92
  void service.publish();
91
93
  }
92
94
 
93
95
  function present(state: CourseState): CourseState {
94
- if (!pricing.enabled) return { ...state, priceText: course.commercePrice, buyDisabled: false, message };
95
- const price = iapPriceFields(pricing.view(item.reference), COPY);
96
+ const view = pricing.view(item.reference);
97
+ // `disabled`: no source, or an app that sells nothing through the App Store — the screen's own price.
98
+ if (view.route === "disabled") return { ...state, priceText: course.commercePrice, buyDisabled: false, message };
99
+ const price = iapPriceFields(view, COPY);
96
100
  return { ...state, priceText: price.priceText, priceNote: price.priceNote, hidePriceRetry: price.hidePriceRetry && !scopeIncomplete,
97
101
  buyDisabled: price.actionDisabled, buyNote: price.actionNote, message };
98
102
  }
@@ -125,14 +129,14 @@ export function registerCourseScreen(course: Course, startCheckout: () => Promis
125
129
  }
126
130
  ```
127
131
 
128
- Bind the fields in the screen descriptor and declare both capabilities the worker calls. To show
132
+ Bind the fields in the screen descriptor and declare the capabilities the worker calls. To show
129
133
  real App Store prices, the app must also grant `iap` (and `app_info`) in its `config.ts`
130
134
  `capabilities`, so its native shell registers the store bridge; until it does, the screen fails
131
135
  closed to "Price unavailable" rather than showing a price it could not confirm.
132
136
 
133
137
  ```json
134
138
  {
135
- "logicBundle": { "declaredCapabilities": ["iap", "app_info"] },
139
+ "logicBundle": { "declaredCapabilities": ["iap", "app_info", "wix_api"] },
136
140
  "price": { "type": "Text", "props": { "text": "$service:course.priceText", "collapseWhenEmpty": true } },
137
141
  "note": { "type": "Text", "props": { "text": "$service:course.priceNote", "collapseWhenEmpty": true } },
138
142
  "retry": { "type": "Button", "props": { "title": "Try again", "action": { "actionId": "tier5.course.refreshPrice" } },
@@ -163,8 +167,9 @@ closed to "Price unavailable" rather than showing a price it could not confirm.
163
167
  - **Keep prices out of hydration** with `notHydrated`, so a cold start never shows an old amount.
164
168
  - **Content first.** A pricing failure is per item. Never replace the screen with an error because a
165
169
  price could not load.
166
- - **iOS only.** On Android, web and macOS, App Store routes come back `unavailable`. Regular
167
- routes are unaffected.
170
+ - **iOS only.** Android, web and macOS binaries carry no purchases-app binding, so the shipped
171
+ source reads every item there as `disabled` and the screen's own price and checkout apply. A
172
+ mapping that does name an App Store product on those hosts comes back `unavailable`.
168
173
 
169
174
  ## Adopting it
170
175
 
@@ -173,7 +178,9 @@ closed to "Price unavailable" rather than showing a price it could not confirm.
173
178
  - **Existing screens, including apps created from a template before this existed:** an SDK update
174
179
  never changes a screen that does not call this layer. Add the worker calls, the bindings and the
175
180
  two capabilities above. After that, screens pick up layer fixes on their next build.
176
- - **Custom screens:** follow the worker above for any item you sell, whatever its vertical.
181
+ - **Custom screens:** follow the worker above for any item you price, and the
182
+ [checkout recipe](iap-wix-checkout.md#using-it-from-a-screen) for any item you sell, whatever its
183
+ vertical.
177
184
  - **New item types:** use the kind `himi store iap` registers for it, build `{ kind, id }`, and call
178
185
  the same layer. Never write your own mapping lookup, App Store price request or payment guard.
179
186
 
package/guides/index.json CHANGED
@@ -20,7 +20,8 @@
20
20
  ],
21
21
  "guardrails": [
22
22
  "Prefer Tier-5 (SDUI descriptor + JS worker) over native screens — reach for native only when SDUI genuinely can't express it.",
23
- "A screen that shows or sells an item that may be an App Store in-app purchase (a program, product or plan — template or custom screen alike) uses the shared layer `createIapCatalogPricing` from `@himalaya/iap` (`himi docs iap-pricing`): it batches the mapping lookup, fetches Apple prices and decides the payment route. Never hand-wire a mapping, an Apple price request or a payment guard in a worker. Call `checkoutGuard` immediately before a commerce flow starts, and `continueCheckoutGuard` before each later step of a flow already under way (a payment after a join), so a routine price refresh cannot strand it halfway. It is off until Wix connects the mapping service, and Apple purchasing is not available yet.",
23
+ "A screen that shows or sells an item that may be an App Store in-app purchase (a program, product or plan — template or custom screen alike) uses the shared layer `createIapCatalogPricing` from `@himalaya/iap` (`himi docs iap-pricing`): it batches the mapping lookup, fetches Apple prices and decides the payment route. Never hand-wire a mapping, an Apple price request or a payment guard in a worker. Call `checkoutGuard` immediately before a commerce flow starts, and `continueCheckoutGuard` before each later step of a flow already under way (a payment after a join), so a routine price refresh cannot strand it halfway. It is off until Wix connects the mapping service.",
24
+ "To SELL an App Store item, a screen composes `createIapCommerce` from `@himalaya/iap-checkout` once per worker (its catalog pricing plus the shared checkout, with the vertical's order adapter) and binds its Get access action with `commerce.purchaseFields`: the order, Apple's sheet, proof delivery, finishing and the access read all happen inside it. Never call StoreKit (`purchaseForVerification`), `finishTransaction` or the payment client from a worker, never fall back to Wix checkout for an App Store item, and show access only when it reports `accessReady`. Purchasing stays unavailable until Wix provides its routes and mapping.",
24
25
  "Keep structure descriptor-authored: use FragmentSlot for enumerable variants and bound list/grid templates for repetition. Use WorkerSubtree only for runtime-unknowable structure, with an explicit screen allowlist, policy budgets, and shipped fallback.",
25
26
  "Bindings ($service:<svc>.<path>, $item.<field>, $flag:<key>) are plain strings in ANY prop — INCLUDING Modifiers token refs (background, color, tint), so a worker can drive per-item theming, not just text. They CANNOT target strict-enum fields (style, size, alignment, severity, presentation, …) — those fail at render, not validation.",
26
27
  "Workers are sandboxed: no DOM, no timers, no raw fetch. Produce state synchronously in actions; do not rely on an ambient refresh tick.",
@@ -432,8 +432,8 @@
432
432
  {
433
433
  "name": "BiometricKind",
434
434
  "kind": "type",
435
- "jsdoc": "",
436
- "decl": "export type BiometricKind = \"face\" | \"fingerprint\" | \"iris\" | \"none\";"
435
+ "jsdoc": "/**\n * The enrolled biometric, as far as the platform can tell:\n * - `\"face\"` / `\"fingerprint\"` / `\"iris\"` — that modality.\n * - `\"biometric\"` — a biometric is enrolled but the platform can't say which\n * (Android phones with both face and fingerprint hardware; the web). Label\n * the unlock neutrally (\"Use biometrics\") rather than guessing a modality.\n * - `\"none\"` — nothing enrolled, or no hardware.\n *\n * Additive: treat any value you don't recognise like `\"biometric\"`.\n */",
436
+ "decl": "export type BiometricKind = \"face\" | \"fingerprint\" | \"iris\" | \"biometric\" | \"none\";"
437
437
  },
438
438
  {
439
439
  "name": "available",
@@ -1878,7 +1878,7 @@
1878
1878
  "name": "IapMappingSource",
1879
1879
  "kind": "interface",
1880
1880
  "jsdoc": "/** A deployed Wix→Apple mapping backend. Never supplied by a screen. */",
1881
- "decl": "export interface IapMappingSource {\n mapping: PricingMappingProvider;\n /** The site this backend serves, used when the caller passes no site. */\n siteId?: string;\n}"
1881
+ "decl": "export interface IapMappingSource {\n mapping: PricingMappingProvider;\n /** Whether this app sells through the App Store at all — the binary carries a purchases-app binding.\n * When it answers false, every item reads `disabled` with no lookup, exactly as with no source, so the\n * screen's own commerce applies; asked again on every load. Absent means always active. */\n active?(): Promise<boolean>;\n /** The site this backend serves, used when the caller passes no site. */\n siteId?: string;\n}"
1882
1882
  },
1883
1883
  {
1884
1884
  "name": "IapPollDirective",
@@ -2065,7 +2065,7 @@
2065
2065
  {
2066
2066
  "name": "createWixIapClient",
2067
2067
  "kind": "function",
2068
- "jsdoc": "/**\n * Creates a client for Wix's in-app-purchase payments service (`mobile-apps-wix-iap`), which pays\n * a Wix order with a store purchase the member makes in the app.\n *\n * NOT ENABLED and not production-ready. No app wires it, and no call reaches the service: it has\n * no public gateway route yet, so `route` must be supplied and there is no default. It calls\n * StartPayment (`POST …/payments/start`), CompletePayment (`POST …/payments/complete`)\n * and GetPayment (`GET …/payments/{id}`) through the\n * `wix_api` bridge. A matched or approved Payment is not durable access and never finishes the\n * store transaction.\n *\n * Production use stays blocked: there is no public route or member authorization on the service,\n * no trusted app-binding source, and checkout stays disabled. When this client uses the host\n * bridge, it also requires the host's identity clock and passes that captured expectation\n * through the bridge. An injected transport keeps the previous call shape unless the session\n * itself exposes an identity.\n */",
2068
+ "jsdoc": "/**\n * Creates a client for Wix's in-app-purchase payments service (`mobile-apps-wix-iap`), which pays\n * a Wix order with a store purchase the member makes in the app.\n *\n * The shared checkout (`createIapCommerce`) builds it with Serve's IAP route for an app whose binary\n * carries a purchases-app binding; `route` stays a parameter with no default. It calls\n * StartPayment (`POST …/payments/start`), CompletePayment (`POST …/payments/complete`)\n * and GetPayment (`GET …/payments/{id}`) through the\n * `wix_api` bridge. A matched or approved Payment is not durable access and never finishes the\n * store transaction.\n *\n * A sale needs the binding baked into the binary and an active mapping for the item; without them\n * nothing is offered, and before a production Serve release carries the IAP routes the mapping read\n * fails, so nothing is offered either. When this client uses the host\n * bridge, it also requires the host's identity clock and passes that captured expectation\n * through the bridge. An injected transport keeps the previous call shape unless the session\n * itself exposes an identity.\n */",
2069
2069
  "decl": "export function createWixIapClient(deps: WixIapClientDependencies): WixIapClient;",
2070
2070
  "verified": false
2071
2071
  },
@@ -2079,7 +2079,7 @@
2079
2079
  "name": "HostIdentitySource",
2080
2080
  "kind": "interface",
2081
2081
  "jsdoc": "/** A session provider that can also name the principal captured for one generation. */",
2082
- "decl": "export interface HostIdentitySource {\n readonly identityBound: boolean;\n identity(session: SessionSnapshot): HostCallExpectation | null;\n}"
2082
+ "decl": "export interface HostIdentitySource {\n readonly identityBound: boolean;\n identity(session: SessionSnapshot): HostCallExpectation | null;\n /** The site the native bridge fences protected calls to, from the latest ready snapshot. Null\n * when the host installed no session source: iOS publishes the bridge (`identityBound`) to\n * every worker that has `wix_api`, with or without one. */\n site(): string | null;\n}"
2083
2083
  },
2084
2084
  {
2085
2085
  "name": "HostSessionProvider",
@@ -2248,6 +2248,18 @@
2248
2248
  "kind": "const",
2249
2249
  "jsdoc": "/** The managed native verbs as the delivery coordinator's store. */",
2250
2250
  "decl": "export const appleManagedStore: TransactionStore = { purchaseForVerification, getUnfinishedTransactions, finishTransaction };"
2251
+ },
2252
+ {
2253
+ "name": "hostAppBindingSource",
2254
+ "kind": "function",
2255
+ "jsdoc": "/** The purchases-service binding the host baked into this binary at build time (the app's build\n * contract → Info.plist), checked natively against the running bundle, app and site. Never read\n * from a screen, OTA config or navigation param: a worker cannot choose which purchases app it\n * sells through. Null means not provisioned: an unbound build, an older binary without the verb,\n * or a platform with no managed App Store purchasing. A host that has the verb and fails to answer\n * proves neither, so that read rejects and is not kept: the caller must not fall back to the\n * unbound app's commerce. The binding cannot change while the process runs, so a source asks the\n * host once. */",
2256
+ "decl": "export function hostAppBindingSource(): AppBindingSource;"
2257
+ },
2258
+ {
2259
+ "name": "sharedHostAppBindingSource",
2260
+ "kind": "function",
2261
+ "jsdoc": "/** One binding source per worker, shared by pricing and checkout, so one screen never acts on two\n * different answers to the same question. */",
2262
+ "decl": "export function sharedHostAppBindingSource(): AppBindingSource;"
2251
2263
  }
2252
2264
  ]
2253
2265
  },
@@ -4176,6 +4188,18 @@
4176
4188
  "jsdoc": "",
4177
4189
  "decl": "export interface PageCapabilityResponse {\n v: 1;\n id: string;\n ok: boolean;\n result?: unknown;\n error?: { message: string; code?: string };\n}"
4178
4190
  },
4191
+ {
4192
+ "name": "PAGE_HANDSHAKE_CAPABILITY",
4193
+ "kind": "const",
4194
+ "jsdoc": "/** `call(\"himalaya\", \"capabilities\")` answers `{ v: 1, capabilities: { <id>: [<method>…] } }`:\n * exactly the allowlist this worker was given. Reserved, so no real capability can take it. */",
4195
+ "decl": "export const PAGE_HANDSHAKE_CAPABILITY = \"himalaya\";"
4196
+ },
4197
+ {
4198
+ "name": "PAGE_HANDSHAKE_METHOD",
4199
+ "kind": "const",
4200
+ "jsdoc": "",
4201
+ "decl": "export const PAGE_HANDSHAKE_METHOD = \"capabilities\";"
4202
+ },
4179
4203
  {
4180
4204
  "name": "ExposedPageCapability",
4181
4205
  "kind": "interface",
@@ -4228,9 +4252,27 @@
4228
4252
  {
4229
4253
  "name": "call",
4230
4254
  "kind": "function",
4231
- "jsdoc": "/** Invoke one method through the companion worker. Primarily useful to generated bindings. */",
4255
+ "jsdoc": "/** Invoke one method through the companion worker. Primarily useful to generated bindings.\n * A call that shows native UI (a permission prompt, the share sheet) answers only when the user\n * is done with it: pass a long `timeoutMs` (e.g. 120_000) rather than the 30-second default. */",
4232
4256
  "decl": "export function call<T>(\n capability: string,\n method: string,\n args: unknown[] = [],\n timeoutMs = 30_000,\n): Promise<T>;"
4233
4257
  },
4258
+ {
4259
+ "name": "enabledCapabilities",
4260
+ "kind": "function",
4261
+ "jsdoc": "/**\n * The capabilities and methods this page's companion worker answers, as `{ <id>: [<method>…] }`;\n * `null` outside the app, when the app is older than the handshake, or nothing answered in\n * `timeoutMs`. A list is remembered (and returned at once, whatever `timeoutMs`); a miss is not,\n * because a worker that is still booting drops the first messages.\n */",
4262
+ "decl": "export async function enabledCapabilities(timeoutMs = 3_000): Promise<Record<string, string[]> | null>;"
4263
+ },
4264
+ {
4265
+ "name": "isEnabled",
4266
+ "kind": "function",
4267
+ "jsdoc": "/** Whether the companion worker lets this page call `capability.method`, read from\n * enabledCapabilities() — nothing is called. `false` when there is no list. */",
4268
+ "decl": "export async function isEnabled(capability: string, method: string, timeoutMs = 3_000): Promise<boolean>;"
4269
+ },
4270
+ {
4271
+ "name": "probe",
4272
+ "kind": "function",
4273
+ "jsdoc": "/** For an app older than the handshake: CALL `capability.method` and report whether it answered\n * (even with an error). It runs the method, so pass only a read-only one. */",
4274
+ "decl": "export async function probe(capability: string, method: string, args: unknown[] = [], timeoutMs = 3_000): Promise<boolean>;"
4275
+ },
4234
4276
  {
4235
4277
  "name": "himalaya",
4236
4278
  "kind": "const",
@@ -23096,12 +23138,12 @@
23096
23138
  "name": "StoreProductSearch",
23097
23139
  "kind": "interface",
23098
23140
  "jsdoc": "/** User-facing product discovery. V3 name search/sorting belong to Search\n * Products; Query Products deliberately does not support the name field.\n * V1 uses its documented JSON-string filter/sort dialect. */",
23099
- "decl": "export interface StoreProductSearch {\n query?: string;\n sort?: 'default' | 'az' | 'za';\n ids?: string[];\n limit?: number;\n cursor?: string;\n /** Extra v3 fieldsets. Additive — widens the default, never replaces it. */\n fields?: readonly string[];\n}"
23141
+ "decl": "export interface StoreProductSearch {\n query?: string;\n sort?: 'default' | 'az' | 'za';\n ids?: string[];\n /**\n * Only products in this category (V3) / collection (V1) — a category screen's\n * \"products in X\". ANDed with `ids` when both are given.\n *\n * V3 matches `allCategoriesInfo.categories`, so a parent category also lists its\n * subcategories' products, as the site does. Only Search Products can filter by\n * category — Query Products answers `not declared as filterable` — so a\n * `categoryId` always routes the V3 read to `/products/search`. V1 matches\n * `collections.id`.\n */\n categoryId?: string;\n limit?: number;\n cursor?: string;\n /** Extra v3 fieldsets. Additive — widens the default, never replaces it. */\n fields?: readonly string[];\n}"
23100
23142
  },
23101
23143
  {
23102
23144
  "name": "searchStoreProducts",
23103
23145
  "kind": "function",
23104
- "jsdoc": "",
23146
+ "jsdoc": "/**\n * Search, sort, and filter products on whichever catalog this site runs — the read\n * behind a shop list, a saved-products list, and a category screen.\n *\n * `categoryId` narrows to one category's products (V3 `allCategoriesInfo`, so a parent\n * category includes its subcategories; V1 `collections.id`), ANDed with `ids`. An\n * empty `ids` array resolves to an empty page WITHOUT a request. Paging is one\n * contract on both catalogs: pass back `pagingMetadata.cursors.next` as `cursor`.\n * A non-catalog failure (401/403, offline) rejects; it is never retried on V1.\n */",
23105
23147
  "decl": "export async function searchStoreProducts(options: StoreProductSearch = {}): Promise<StoreProductsResponse>;",
23106
23148
  "http": "POST /stores/v1/products/query"
23107
23149
  },
package/guides/tokens.md CHANGED
@@ -18,7 +18,7 @@ one registry, mirrored byte-for-byte by the iOS + Android decoders.
18
18
  | `motions` | name → { duration, easing?, spring? } | optional |
19
19
  | `materials` | name → { style, tint?, opacity?, blur? } | optional |
20
20
  | `tabBar` / `drawer` / `sidebar` | nav-chrome style blocks | optional |
21
- | `kit` | { material?, variant?, density?, motion? } | optional — the active design kit |
21
+ | `kit` | { material?, variant?, density?, motion?, reduceMotion?, colorScheme? } | optional — the active design kit |
22
22
  | `contentMaxWidth` | { base, medium?, expanded? } | optional |
23
23
  | `elevations` | name → { level?, tint?, shadow?, border? } | optional — a Card wears `card` |
24
24
  | `controls` | { button?: { sm?, md?, lg?, variants? } } | optional — every Button's size and paint |
@@ -39,6 +39,27 @@ platform's own UI face — SF Pro on iOS/macOS, Roboto on Android, the system UI
39
39
  A color is a hex string, OR a per-scheme object `{ light, dark?, highContrast? }`. In the
40
40
  object form **`light` is required**; `dark`/`highContrast` fall back to it.
41
41
 
42
+ ## Light or dark — `kit.colorScheme`
43
+
44
+ By default an app follows the device appearance. To pin it, set `kit.colorScheme` — it ships
45
+ OTA with the tokens, so a published release re-skins installed apps without a new binary:
46
+
47
+ | value | effect |
48
+ |---|---|
49
+ | `"system"` (or absent) | follow the device light/dark setting |
50
+ | `"light"` | always light |
51
+ | `"dark"` | always dark |
52
+
53
+ Only those three lowercase values are recognised: another string counts as `"system"` and
54
+ `himi validate` warns; a non-string (a number, a boolean) is an error, because it fails the
55
+ whole tokens.json on device. The pin replaces only the device light/dark signal: an explicit
56
+ in-app theme choice (`theme.set`, a host's own Appearance setting), a preview's scheme pin,
57
+ and the OS high-contrast setting still win over it. Native chrome — the status bar and the
58
+ navigation bar — follows the pin too. Binaries older than the core release that added it
59
+ ignore the key and keep following the device. Set it in the top-level `kit` block (an idiom
60
+ overlay cannot change it), merged into the existing block rather than replacing it; adopting a
61
+ design kit keeps it.
62
+
42
63
  ## Motion
43
64
 
44
65
  `easing` is `linear`, `easeIn`, `easeOut`, `easeInOut`, `spring` or `emphasized` (Material 3's
@@ -31,19 +31,30 @@ Your page can only use capabilities the **app owner** turned on for your site. I
31
31
 
32
32
  - `capabilities` — the capability ids your page will call (see the
33
33
  [Web page SDK reference](https://apps.wix.com/_api/himi-serve/docs/web-page-sdk-reference.md) for every id and method).
34
- - `permissionCopy` — the sentence the phone shows when it asks the user for permission. Write it
35
- for the end user, about what *they* get.
34
+ - `permissionCopy` — optional: the sentence the phone shows when it asks the user for permission,
35
+ at most 500 characters. Write it for the end user, about what *they* get. Only capabilities that
36
+ prompt need one (Health, camera, photos, location, microphone…); `haptic` and `share` never ask,
37
+ and a capability left out gets the app's built-in generic sentence.
36
38
  - No worker code is needed. Brandy generates the companion worker that answers your page from
37
- `capabilities`, allowing every method the capability's page lists — except
39
+ `capabilities`, allowing **every** method the capability's page lists — except
38
40
  `presigned_upload.putFile`, which reads a device file and is allowed only when `files` is enabled
39
- too. (An owner who wants a
40
- narrower allowlist can still name their own worker in `bundle`.)
41
+ too. So enabling a capability is enough; the owner does not list methods.
41
42
  - Enabling a capability changes the app's native permissions, so the owner ships a **new app
42
43
  build** with it before your page can call it. Until then the call times out.
43
44
 
44
- When you hand work to the app owner, give them the list as `capability.method` pairs, for example
45
- `health.availability`, `health.requestPermission`, `health.query`, `haptic.play`. A method that is
46
- not on the allowlist never answers — your call times out (see *Errors*).
45
+ **You cannot enable anything from the page, or with any public command.** The app owner is
46
+ whoever builds the app (for an app Wix builds for a customer, the team that ships it). Hand them a
47
+ request they can act on without asking you anything back — for example:
48
+
49
+ > Please enable these web page capabilities for `https://www.example.com` in the app's `webSdk`
50
+ > provisioning and ship a new build: `["health", "haptic", "share"]`, with
51
+ > `permissionCopy.health` = "Show your step count for today." The page checks what is enabled and
52
+ > hides the rest, so it is safe to ship before the build.
53
+
54
+ An owner who wrote their own companion worker (named in `webSdk.bundle`) chooses methods one by
55
+ one: for that app only, add the exact `capability.method` pairs your page calls, for example
56
+ `health.availability`, `health.requestPermission`, `health.query`, `haptic.play`. A method that
57
+ worker does not allow never answers — your call times out (see *Errors*).
47
58
 
48
59
  Your page must be served from the site origin the app was built for. Pages in iframes, and pages
49
60
  reached through a redirect to another origin, do not get the bridge.
@@ -100,7 +111,12 @@ export function isInHimiApp(): boolean {
100
111
  return bridge() !== undefined;
101
112
  }
102
113
 
103
- /** Call one native method, e.g. call("health", "query", [{ type: "steps", from, to }]). */
114
+ /**
115
+ * Call one native method, e.g. call("health", "query", [{ type: "steps", from, to }]).
116
+ * A call that shows native UI (a permission prompt, the share sheet) answers only when the user is
117
+ * done: give it a long timeoutMs, such as 120_000. Never wait without a deadline — a message the app
118
+ * drops (see "Keep what you send small") gets no answer at all.
119
+ */
104
120
  export function call<T = unknown>(capability: string, method: string, args: unknown[] = [], timeoutMs = 30_000): Promise<T> {
105
121
  const target = bridge();
106
122
  if (!target) return Promise.reject(new HimalayaError("Not running inside a Himi app", "not_in_app"));
@@ -119,8 +135,86 @@ export function call<T = unknown>(capability: string, method: string, args: unkn
119
135
  });
120
136
  }
121
137
 
122
- /** True when `capability.method` answers within `timeoutMs`. Use a read-only method. */
123
- export async function isEnabled(capability: string, method: string, args: unknown[] = [], timeoutMs = 3_000): Promise<boolean> {
138
+ let manifest: { on: Bridge; list: Record<string, string[]> } | undefined;
139
+
140
+ let asking: { on: Bridge; until: number; answer: Promise<Record<string, string[]> | null> } | undefined;
141
+
142
+ /**
143
+ * ONE question loop per bridge, however many callers wait on it: every message counts against the
144
+ * app's rate limit, so twenty gated controls must not ask twenty times a second. It asks every
145
+ * half second until the latest caller's deadline (a message sent before the app is ready is lost);
146
+ * each question listens for 2 s, so a slow answer still lands and at most four are in flight.
147
+ */
148
+ function askShared(target: Bridge, budget: number): Promise<Record<string, string[]> | null> {
149
+ const until = Date.now() + budget;
150
+ if (asking?.on === target) {
151
+ asking.until = Math.max(asking.until, until);
152
+ return asking.answer;
153
+ }
154
+ const state = { on: target, until, answer: Promise.resolve<Record<string, string[]> | null>(null) };
155
+ asking = state; // before the first ask: a loop that ends at once must clear itself
156
+ state.answer = new Promise((resolve) => {
157
+ let done = false;
158
+ const finish = (list: Record<string, string[]> | null) => {
159
+ if (done) return;
160
+ done = true;
161
+ clearInterval(again);
162
+ if (asking === state) asking = undefined;
163
+ if (list) manifest = { on: target, list };
164
+ resolve(list);
165
+ };
166
+ const ask = () => {
167
+ if (done) return;
168
+ const left = state.until - Date.now();
169
+ if (left <= 0 || bridge() !== target) return finish(null); // deadline, or the page's bridge changed
170
+ call<{ capabilities?: Record<string, string[]> }>("himalaya", "capabilities", [], Math.max(1, Math.min(2_000, left))).then(
171
+ (reply) => finish(reply?.capabilities ?? null),
172
+ // A timed-out question is not an answer; the deadline decides. Anything else is final.
173
+ (error) => { if (!(error instanceof HimalayaError && error.code === "timeout")) finish(null); },
174
+ );
175
+ };
176
+ const again = setInterval(ask, 500);
177
+ ask();
178
+ });
179
+ return state.answer;
180
+ }
181
+
182
+ /**
183
+ * What this app lets the page call, as { capability: [method, …] }. null outside the app, in an app
184
+ * build older than this question, or when nothing answered within timeoutMs. A list is remembered
185
+ * (and returned at once, whatever timeoutMs); a miss is not, because an app that is still starting
186
+ * drops the first messages.
187
+ */
188
+ export async function enabledCapabilities(timeoutMs = 3_000): Promise<Record<string, string[]> | null> {
189
+ const target = bridge();
190
+ if (!target) return null;
191
+ if (manifest?.on === target) return manifest.list;
192
+ if (!(timeoutMs > 0)) return null; // no time to ask: nothing can answer
193
+ // setTimeout cannot wait longer than 2^31-1 ms (a longer delay fires at once), so clamp it.
194
+ const budget = Math.min(timeoutMs, 2_147_483_647);
195
+ const answer = askShared(target, budget);
196
+ // This caller's own deadline; the shared question may keep going for a caller who waits longer.
197
+ return new Promise((resolve) => {
198
+ const stop = setTimeout(() => resolve(manifest?.on === target ? manifest.list : null), budget);
199
+ void answer.then((list) => { clearTimeout(stop); resolve(list); });
200
+ });
201
+ }
202
+
203
+ /**
204
+ * True when the app lets the page call `capability.method`, read from enabledCapabilities(): nothing
205
+ * is called and nothing happens on the phone. false when there is no list (outside the app, an app
206
+ * build older than the list, or no answer yet).
207
+ */
208
+ export async function isEnabled(capability: string, method: string, timeoutMs = 3_000): Promise<boolean> {
209
+ const enabled = await enabledCapabilities(timeoutMs);
210
+ return enabled?.[capability]?.includes(method) ?? false;
211
+ }
212
+
213
+ /**
214
+ * For an app build older than enabledCapabilities(): CALL `capability.method` and report whether the
215
+ * app answered it (even with an error). It runs the method, so pass only a read-only one.
216
+ */
217
+ export async function probe(capability: string, method: string, args: unknown[] = [], timeoutMs = 3_000): Promise<boolean> {
124
218
  try {
125
219
  await call(capability, method, args, timeoutMs);
126
220
  return true;
@@ -154,15 +248,15 @@ Capability ids are snake_case (`health`, `haptic`, `secure_storage`, `local_data
154
248
  names are exactly the SDK's function names. Arguments and results are plain JSON.
155
249
 
156
250
  ```ts
157
- import { himalaya, HimalayaError } from "./lib/himalaya";
251
+ import { call, himalaya, HimalayaError } from "./lib/himalaya";
158
252
 
159
253
  // Light, fire-and-forget feedback.
160
254
  await himalaya.haptic.play("success");
161
255
 
162
- // Health: check, ask (from a button tap), then read.
256
+ // Health: check, ask (from a button tap), then read. The prompt waits for the user: allow two minutes.
163
257
  const { available } = await himalaya.health.availability();
164
258
  if (available) {
165
- await himalaya.health.requestPermission({ read: ["steps"] });
259
+ await call("health", "requestPermission", [{ read: ["steps"] }], 120_000);
166
260
  const midnight = new Date();
167
261
  midnight.setHours(0, 0, 0, 0);
168
262
  const { samples } = await himalaya.health.query({
@@ -174,13 +268,21 @@ if (available) {
174
268
  ```
175
269
 
176
270
  `call("health", "availability")` is the same thing without the proxy, and is handy when the
177
- capability or method name is a variable.
271
+ capability or method name is a variable — or when you need a timeout other than the 30-second
272
+ default. **Give every call that shows native UI a long deadline** — `call(..., 120_000)` for a
273
+ permission prompt, the share sheet, the camera or a photo picker: it answers when the user is
274
+ done, and the 30-second default can turn a slow user into a `timeout` your page reads as "not
275
+ enabled". Keep a deadline even there: a message the app drops gets no answer at all.
276
+
277
+ `steps`, `activeEnergy` and `distance` come back summed (one sample for the whole range, or one
278
+ per `bucket: "hour" | "day"`), and `heartRate` averaged; summing `samples` for a total is right
279
+ either way.
178
280
 
179
281
  ## Step 3 — React pattern
180
282
 
181
283
  ```tsx
182
284
  import { useEffect, useState } from "react";
183
- import { call, himalaya, isInHimiApp, HimalayaError } from "./lib/himalaya";
285
+ import { call, himalaya, isEnabled, isInHimiApp, HimalayaError } from "./lib/himalaya";
184
286
 
185
287
  export function StepsCard() {
186
288
  const [state, setState] = useState<"checking" | "browser" | "off" | "ready" | "denied" | "failed">("checking");
@@ -188,8 +290,10 @@ export function StepsCard() {
188
290
 
189
291
  useEffect(() => {
190
292
  if (!isInHimiApp()) return setState("browser");
191
- // Answering is not enough: a phone without a health store answers { available: false }.
192
- call<{ available: boolean }>("health", "availability", [], 3_000)
293
+ // Enabled for this site? Answered from the app's list, without calling anything.
294
+ isEnabled("health", "availability")
295
+ .then((on) => (on ? call<{ available: boolean }>("health", "availability", [], 3_000) : { available: false }))
296
+ // Enabled is not enough: a phone without a health store answers { available: false }.
193
297
  .then(({ available }) => setState(available ? "ready" : "off"))
194
298
  // Not enabled for this site (timeout) hides the feature; any other failure offers a retry.
195
299
  .catch((error) => setState(error instanceof HimalayaError && error.code === "timeout" ? "off" : "failed"));
@@ -197,7 +301,7 @@ export function StepsCard() {
197
301
 
198
302
  async function connect() {
199
303
  try {
200
- await himalaya.health.requestPermission({ read: ["steps"] });
304
+ await call("health", "requestPermission", [{ read: ["steps"] }], 120_000); // waits for the user
201
305
  const from = new Date(); from.setHours(0, 0, 0, 0);
202
306
  const { samples } = await himalaya.health.query({ type: "steps", from: from.toISOString(), to: new Date().toISOString() });
203
307
  setSteps(samples.reduce((sum: number, s: { value: number }) => sum + s.value, 0));
@@ -232,9 +336,21 @@ The same page must keep working in an ordinary browser (and in Base44 / Lovable
232
336
  device (`availability()` tells you). Show a calm fallback, not an error. On iOS a denied Health
233
337
  **read** is not reported as `not_authorized`: the read succeeds with no samples, exactly like a
234
338
  day with no data. Word an empty result so it also covers "access is off".
235
- - **Hide features the app doesn't enable.** Probe once with `isEnabled(...)` on a read-only method
236
- and hide the UI when it is `false`. `true` only means the method is enabled — still read its
237
- answer (`availability()` can say `{ available: false }`).
339
+ - **Hide features the app doesn't enable.** Check with `isEnabled(capability, method)` and hide the
340
+ UI when it is `false`. The app answers from its list of enabled capabilities
341
+ (`enabledCapabilities()`), so nothing is called and nothing happens on the phone — this works
342
+ for `haptic.play` too. An app build older than that list has none, so `isEnabled` answers
343
+ `false` there. To support such a build, fall back to `probe(capability, method)`, which **calls**
344
+ the method — use it only with a read-only one (`health.availability`, never `haptic.play`).
345
+ `true` only means the method is enabled — still read its answer (`availability()` can say
346
+ `{ available: false }`).
347
+ - **Ask for a permission once, then trust the result you read.** `health.requestPermission`
348
+ resolves with `{ granted }`, but on iOS a read type counts as granted once asked, whatever the
349
+ user chose — so read the data and treat an empty result as "no data or access off". The phone
350
+ shows each permission prompt once; asking again answers without showing anything.
351
+ - **Check for the app when you need it.** In current app builds the bridge exists before your
352
+ first script runs. On an older Android System WebView it is added as the page loads, so call
353
+ `isInHimiApp()` where you use it (for example in `useEffect`), not once at module load.
238
354
  - **Plain JSON only.** Arguments and results cross as JSON: no functions, `Date` objects (use ISO
239
355
  strings), `Blob`s or class instances.
240
356
  - **Keep what you send small.** The app checks every message a page sends and silently drops one
@@ -258,7 +374,7 @@ Every failure rejects with a `HimalayaError` that has a `code`:
258
374
  | `code` | Meaning | What to do |
259
375
  |---|---|---|
260
376
  | `not_in_app` | Ordinary browser or preview, no bridge | Render the browser fallback |
261
- | `timeout` | No answer: capability or method not enabled for this site, not on this app build, or the message broke a size rule (see *Keep what you send small*) | Check your arguments; otherwise hide the feature and ask the app owner to enable `capability.method` |
377
+ | `timeout` | No answer within `timeoutMs`: capability or method not enabled for this site, not on this app build, or the message broke a size rule (see *Keep what you send small*). A call that shows native UI can also time out while the user is still looking at it — give it a long `timeoutMs` (`120_000`) | Check your arguments; otherwise hide the feature and ask the app owner to enable the capability |
262
378
  | `bridge_refused` | The app's bridge refused the message outright | Retry later; send less often |
263
379
  | `not_authorized` | The user denied permission | Explain how to enable it in Settings |
264
380
  | `unavailable` | Not supported on this device | Hide the feature |
@@ -275,6 +391,8 @@ Codes other than the first three come from the capability itself; its reference
275
391
 
276
392
  ```ts
277
393
  const answers: Record<string, unknown> = {
394
+ // What the app answers when the page asks which capabilities it may call.
395
+ "himalaya.capabilities": { v: 1, capabilities: { health: ["availability", "query", "requestPermission"], haptic: ["play"] } },
278
396
  "health.availability": { available: true },
279
397
  "health.requestPermission": undefined,
280
398
  "health.query": { samples: [{ value: 4200 }] },
@@ -289,21 +407,24 @@ Codes other than the first three come from the capability itself; its reference
289
407
 
290
408
  Never ship the fake: a page that defines `HimalayaWorker` itself is lying to its own feature
291
409
  checks.
292
- - **On a phone** — open the page inside the app build the owner configured. Check each feature
293
- once with permission granted and once denied.
410
+ - **On a phone** — there is no public preview app that carries your site's capabilities: ask the
411
+ app owner for an installable test build of the app with your capabilities enabled, open your
412
+ page inside it, and check each feature once with permission granted and once denied. Until then,
413
+ the fake bridge above is the test.
294
414
 
295
415
  ## More recipes
296
416
 
297
417
  Each uses the client from Step 1. The exact types are on each capability's page (see *Reference*).
298
418
 
299
419
  ```ts
300
- import { himalaya, HimalayaError } from "./lib/himalaya";
420
+ import { call, himalaya } from "./lib/himalaya";
301
421
 
302
- // Share a link through the phone's share sheet (from a tap).
303
- const { completed } = await himalaya.share.share({ text: "Join me on Friday", url: "https://example.com/e/42" });
422
+ // Share a link through the phone's share sheet (from a tap). The sheet waits for the user: allow two minutes.
423
+ // Outside the app, fall back to the browser's own share (or copy the link).
424
+ const { completed } = await call<{ completed: boolean }>("share", "share", [{ text: "Join me on Friday", url: "https://example.com/e/42" }], 120_000);
304
425
 
305
426
  // Where am I? Ask first, then read once. getCurrent answers null when no fix arrives in time.
306
- const status = await himalaya.location.requestPermission();
427
+ const status = await call("location", "requestPermission", [], 120_000);
307
428
  if (status === "granted") {
308
429
  const here = await himalaya.location.getCurrent({ timeoutMs: 10_000 });
309
430
  if (here) console.log(here.lat, here.lng, `±${here.accuracy}m`);
@@ -315,7 +436,7 @@ const saved = await himalaya.secure_storage.read("refresh-token"); // string | n
315
436
 
316
437
  // Scan a QR code with the camera (iOS). null when the user cancels — and always on Android,
317
438
  // which has no built-in QR decoder — so keep a way to type the code in.
318
- const code = (await himalaya.camera.scanQR()) ?? window.prompt("Enter the code");
439
+ const code = (await call<string | null>("camera", "scanQR", [], 120_000)) ?? window.prompt("Enter the code");
319
440
  ```
320
441
 
321
442
  **Files arrive as handles.** Methods that return a photo, recording or file give you a
@@ -324,15 +445,15 @@ You cannot put it in an `<img>` directly. Hand it to another call that takes one
324
445
  save it to the photo library:
325
446
 
326
447
  ```ts
327
- const [photo] = await himalaya.photos.pick({ limit: 1 });
328
- if (photo) await himalaya.share.share({ files: [photo] });
448
+ const [photo] = await call<any[]>("photos", "pick", [{ limit: 1 }], 120_000);
449
+ if (photo) await call("share", "share", [{ files: [photo] }], 120_000);
329
450
  ```
330
451
 
331
452
  To get the bytes into the page, read them with the `files` capability (the app owner must enable
332
453
  it too). `files.readBase64` returns the contents as base64, up to 8 MiB:
333
454
 
334
455
  ```ts
335
- const [photo] = await himalaya.photos.pick({ limit: 1 });
456
+ const [photo] = await call<any[]>("photos", "pick", [{ limit: 1 }], 120_000);
336
457
  if (photo) {
337
458
  const base64 = await himalaya.files.readBase64(photo);
338
459
  const img = document.createElement("img");