ShareRing Me Modules Developer Guide

Estimated reading time: less than 1 minute

This guide is for external developers who want to build a Me Module for the ShareRing Me app.

  • A Me Module is a web application that runs inside the ShareRing Me mobile app (in an embedded WebView).
  • A Me Module communicates with the ShareRing Me app only via a message bridge (postMessage and addEventListener).
  • This document covers the Me Module API.

What you build (high-level)

  • A static web app (React/Vue/Svelte/Vanilla JS—your choice)
  • Hosted over HTTPS at the root of its own (sub)domain
  • With a required ShareRing manifest (manifest.json) at the domain root — a PWA-style manifest with ShareRing-specific fields, including webview_version: "WEB_VIEW_V2" and a start_url
  • Optionally packaged for offline caching using a zip bundle

The two most common reasons a new module fails to load or has no wallet/vault access:

  1. The manifest is missing webview_version: "WEB_VIEW_V2" — the app then hosts your module in the legacy V1 WebView, which denies every WALLET_*, VAULT_* and CRYPTO_* event.
  2. The manifest is missing start_url (or it points somewhere other than your module's own public HTTPS URL) — the app uses start_url as the URL it actually loads.

You can use any stack. For best results (TypeScript + fast iteration + predictable build output) use Vite + React + TypeScript.

1) Scaffold a module

npm create vite@latest sharering-me-module -- --template react-ts
cd sharering-me-module
npm install

Target file layout (what a working module looks like):

sharering-me-module/
  index.html               ← viewport meta + <link rel="manifest">
  vite.config.ts           ← base: "./"
  public/
    manifest.json          ← ShareRing manifest (copied VERBATIM to dist/ on build)
    icon106.png            ← 106×106 tile shown in the ShareRing Me dApp list
    icon192.png
    favicon.ico
  src/
    shareringMeBridge.ts   ← bridge helper (below)
    App.tsx

2) Make the build work with relative paths

Assets must resolve via relative URLs — both for offline mode (the app loads index.html from a local file path) and because the module is served from its own domain root.

Update vite.config.ts:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  // Critical: ensures assets resolve when loaded from a local file path
  base: "./",
});

Update index.html — reference the manifest and use a mobile-safe viewport:

<meta
  name="viewport"
  content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<link rel="manifest" href="./manifest.json" />

(maximum-scale=1, user-scalable=no prevents iOS from zooming the layout when an input is focused; viewport-fit=cover enables env(safe-area-inset-*).)

If you use client-side routing, note that:

  • Online mode: You can use either hash routing (e.g. /#/route) or path routing.
  • Offline mode: You must use hash routing (e.g. /#/route) because your module will be loaded from local file paths.

3) Add a small bridge helper

The bridge has several non-obvious platform behaviors that a naive postMessage + addEventListener pairing will miss. This helper encodes all of them (each is explained in the pitfalls section further down):

  • Posting too early is silently dropped. The wrapper injects window.ReactNativeWebView after your page starts running. Wait ~300ms, then poll for the bridge (up to 5s) before posting.
  • Responses are matched by type, not by id. The app does not echo a request id back, so keep a FIFO queue of pending calls per event type.
  • The message listener must be registered with useCapture: true. Some wrapper builds only deliver native-injected messages in the capture phase.
  • Android may deliver via document events. Register the same handler on document as well.
  • Two response shapes exist. V2 wrappers respond with a payload field; legacy V1 wrappers inject window.onMessageFromApp(json) with a data field (sometimes URI-encoded JSON), and some builds put the response fields at the top level of the envelope. Accept all of them.
  • Omit the payload key entirely when there is no payload. Some native handlers reject an envelope with an empty payload — send { "type": "..." }, not { "type": "...", "payload": undefined }.

Create src/shareringMeBridge.ts:

declare global {
  interface Window {
    ReactNativeWebView?: { postMessage: (data: string) => void };
    // Legacy V1 transport: native injects JS that calls this with a JSON string.
    onMessageFromApp?: (message: string) => void;
  }
}

type PendingResolver = {
  resolve: (value: unknown) => void;
  reject: (err: Error) => void;
  timer: ReturnType<typeof setTimeout>;
};

const pendingByType = new Map<string, PendingResolver[]>();
let handlersInstalled = false;

/** True when running inside the ShareRing Me app (vs a plain browser). */
export function isHostedInShareRingMe(): boolean {
  return typeof window !== "undefined" && !!window.ReactNativeWebView;
}

function dispatchIncoming(raw: string) {
  let msg: {
    type?: string;
    payload?: unknown;
    data?: unknown;
    error?: unknown;
    [k: string]: unknown;
  };
  try {
    msg = JSON.parse(raw);
  } catch {
    return;
  }
  if (!msg || typeof msg.type !== "string") return;

  const queue = pendingByType.get(msg.type);
  if (!queue || queue.length === 0) return;
  const next = queue.shift()!;
  clearTimeout(next.timer);

  if (msg.error) {
    next.reject(new Error(typeof msg.error === "string" ? msg.error : String(msg.error)));
    return;
  }

  // V2 responds with `payload`; V1 responds with `data` (sometimes
  // URI-encoded JSON); some builds put the fields at the TOP LEVEL.
  let result: unknown = msg.payload;
  if (result === undefined) {
    let d = msg.data;
    if (typeof d === "string") {
      try {
        d = JSON.parse(decodeURIComponent(d));
      } catch {
        // leave as raw string
      }
    }
    result = d;
  }
  if (result === undefined || result === null) {
    const { type: _t, payload: _p, data: _d, error: _e, ...rest } = msg;
    if (Object.keys(rest).length > 0) result = rest;
  }
  next.resolve(result);
}

function installInboundHandlers() {
  if (handlersInstalled) return;
  handlersInstalled = true;

  // V1 transport: wrap (don't replace) any handler already present.
  const prev = window.onMessageFromApp;
  window.onMessageFromApp = (raw: string) => {
    if (typeof prev === "function") {
      try { prev(raw); } catch { /* ignore */ }
    }
    dispatchIncoming(raw);
  };

  // V2 transport: RN-WebView fires a `message` event whose `data` is the
  // JSON string. useCapture: true is load-bearing.
  window.addEventListener(
    "message",
    (e: MessageEvent) => {
      if (typeof e.data === "string") dispatchIncoming(e.data);
    },
    true
  );

  // Some Android RN-WebView builds fire `document` message events instead.
  document.addEventListener("message", ((e: Event) => {
    const me = e as MessageEvent;
    if (typeof me.data === "string") dispatchIncoming(me.data);
  }) as EventListener);
}

/**
 * Send one request to the ShareRing Me app and await its response.
 *
 * Use timeoutMs = 60_000 for PIN/biometric-gated events (the user has to
 * interact) — the default 8s is only right for instant lookups.
 */
export function send<T = unknown>(
  type: string,
  payload?: unknown,
  timeoutMs = 8000
): Promise<T> {
  installInboundHandlers();
  return new Promise<T>((resolve, reject) => {
    const timer = setTimeout(() => {
      const queue = pendingByType.get(type) ?? [];
      const idx = queue.findIndex((p) => p.timer === timer);
      if (idx >= 0) queue.splice(idx, 1);
      reject(new Error(`${type} timeout after ${timeoutMs}ms`));
    }, timeoutMs);

    const queue = pendingByType.get(type) ?? [];
    queue.push({ resolve: resolve as (v: unknown) => void, reject, timer });
    pendingByType.set(type, queue);

    // Settle ~300ms, then poll for the native bridge (up to 5s) BEFORE
    // posting. A request posted before the wrapper injects the bridge is
    // silently dropped (this promise would then reject via the timer).
    void (async () => {
      await new Promise((r) => setTimeout(r, 300));
      for (let i = 0; i < 50 && !window.ReactNativeWebView?.postMessage; i++) {
        await new Promise((r) => setTimeout(r, 100));
      }
      const rnwv = window.ReactNativeWebView;
      if (!rnwv?.postMessage) return; // never injected — let the timer reject
      rnwv.postMessage(
        JSON.stringify(payload === undefined ? { type } : { type, payload })
      );
    })();
  });
}

Tip — mock bridge for browser dev: wrap send behind an interface and, when isHostedInShareRingMe() is false, return a mock implementation backed by localStorage/fixtures. That lets you develop and test the whole module in a plain browser and only exercise the real bridge inside the app.

4) Use the bridge in your UI

Example src/App.tsx:

import { useState } from "react";
import { send, isHostedInShareRingMe } from "./shareringMeBridge";

export default function App() {
  const [result, setResult] = useState<any>(null);
  const [error, setError] = useState<string>("");

  async function readAppInfo() {
    setError("");
    setResult(null);
    try {
      setResult(await send("COMMON_APP_INFO"));
    } catch (e: any) {
      setError(e?.message ?? String(e));
    }
  }

  return (
    <div style={{ padding: 16, fontFamily: "system-ui" }}>
      <h2>Hello Me Module (V2)</h2>
      <p>Hosted in ShareRing Me: {String(isHostedInShareRingMe())}</p>
      <button onClick={readAppInfo}>COMMON_APP_INFO</button>
      {error ? <pre style={{ color: "crimson" }}>{error}</pre> : null}
      {result ? <pre>{JSON.stringify(result, null, 2)}</pre> : null}
    </div>
  );
}

5) Add manifest.json (required)

Your module must serve a ShareRing manifest at the domain root (/manifest.json). This is a PWA-style web app manifest extended with ShareRing-specific fields — the four-field "minimal" manifest that older docs showed is not sufficient: without webview_version and start_url the module either fails to load or loads with no wallet/vault access.

If you use Vite, create public/manifest.json (plus the icon files it references) so Vite copies them verbatim into dist/ on every build:

  • dev: http://localhost:5173/manifest.json
  • prod build: dist/manifest.json

Working example (online-only):

{
  "short_name": "My Module",
  "name": "My Module",
  "description": "One-two sentences shown in the dApp listing.",
  "short_description": "Short listing blurb.",
  "icons": [
    { "src": "icon106.png", "sizes": "106x106", "type": "image/png" },
    { "src": "icon192.png", "sizes": "192x192", "type": "image/png" }
  ],
  "images": [
    { "src": "favicon.ico", "sizes": "64x64 32x32 24x24 16x16", "type": "image/x-icon" },
    { "src": "icon106.png", "sizes": "106x106", "type": "image/png" },
    { "src": "icon192.png", "sizes": "192x192", "type": "image/png" }
  ],
  "start_url": "https://module.example.com/",
  "display": "standalone",
  "version": "0.0.1",
  "checksum": "",
  "zip_name": "",
  "offline_mode": false,
  "isMaintenance": false,
  "enable_secure_screen": false,
  "webview_version": "WEB_VIEW_V2"
}

Three fields deserve special attention:

  • webview_version: "WEB_VIEW_V2" — required for any module that touches wallet, vault, or cryptography. If omitted (or set to V1), the app hosts your module in the legacy V1 WebView, which denies every WALLET_*, VAULT_* and CRYPTO_* event. There is no error dialog — the calls just fail.
  • start_url — the absolute URL the app actually loads, i.e. your module's own public HTTPS root. Keep it neutral: never embed query parameters, session tokens, or per-user data (the manifest is one shared static file — a token in it would send every user into one user's session). If you develop against a LAN URL, remember the production build must ship the production start_url — since public/manifest.json is copied verbatim on every npm run build, either keep the production URL in the source manifest permanently, or patch dist/manifest.json as a mandatory post-build step. A silently reverted start_url is a classic recurring deploy bug.
  • icons (106×106) — the 106×106 icon is the tile ShareRing Me shows in its dApp listing; include it or the module renders without an icon.

6) Build & host

npm run build

Host the dist/ folder at the root of an HTTPS domain (any static hosting works — nginx, Caddy, a CDN). HTTPS is not optional: wallet/vault access and the biometric/PIN prompts require it (plain-HTTP LAN URLs will render the page, but identity features fail).


Testing inside ShareRing Me (Developer Mode → Custom dApps)

To test a module during development:

  1. Ensure your ShareRing Me user has Developer Mode enabled (this is typically enabled per user/account).
  2. In the app, go to Settings → Developer Tool → Add Custom dApps.
  3. Paste your module URL and save.
    • If you're using a local dev server, use a URL reachable from the device (LAN IP or a tunnel).
  4. Open it from the same area (or wherever your build exposes it in the app UI).

Practical testing notes:

  • The app fetches manifest.json from your URL when registering/opening the module — if it 404s or is invalid JSON, the module can refuse to load with no useful error.
  • Wallet/vault/crypto events require an HTTPS URL and webview_version: "WEB_VIEW_V2" in the manifest. Over plain HTTP (e.g. a LAN dev server) the page renders but identity calls fail — for full end-to-end testing put the dev build behind an HTTPS tunnel (e.g. a Cloudflare tunnel).
  • The app caches manifest data. After changing the manifest, bump version and force-close/reopen the app; a freshly minted tunnel hostname can also be bitten by the device negative-caching DNS for a couple of minutes.
  • Async storage (COMMON_*_ASYNC_STORAGE) is scoped to your module's domain name — switching between a tunnel URL and a production domain gives you two separate, empty-looking storage buckets.

Required hosting

Your module MUST serve both index.html and manifest.json at the domain root of the host, over HTTPS.

Important: Me modules do not support hosting in subpaths. You must use a dedicated domain or subdomain for your module. (The app also derives your async-storage scope — dAppsDomainName — from the URL host, so the domain is your module's identity.)

Examples:

  • https://example.comindex.html and manifest.json at https://example.com/
  • https://module.example.comindex.html and manifest.json at https://module.example.com/

If manifest.json is missing or invalid, the ShareRing Me app can refuse to load the module (especially on first open).

Manifest schema

The manifest is a PWA web app manifest extended with ShareRing-specific fields. See the full working example in the Quickstart above.

Standard PWA fields the app reads:

  • name / short_name (string): shown in the dApp listing.
  • description / short_description (string): listing blurbs.
  • icons (array): include a 106×106 PNG (the dApp tile) and a 192×192.
  • images (array): additional listing imagery (favicon, larger icons, optional banner).
  • display (string): use "standalone".

ShareRing-specific fields:

  • webview_version (string): must be "WEB_VIEW_V2" for any module using wallet/vault/crypto events. V1 (or omitted) denies them all.
  • start_url (string): the absolute HTTPS URL of your module — the URL the app actually loads. Must be neutral (no tokens/query params).
  • version (string): version identifier. Bump this when you deploy a new build (the app caches manifest data).
  • offline_mode (boolean): enable offline zip caching. Use ""/false placeholders (checksum: "", zip_name: "", offline_mode: false) when not using it.
  • zip_name (string): zip file name (required when offline_mode: true).
  • checksum (string, optional): checksum for the zip file (see below).
  • isMaintenance (boolean, optional): if true, the app may show a maintenance page.
  • enable_secure_screen (boolean, optional): request screenshot prevention while your module is open.

Offline-enabled example (delta from the Quickstart manifest)

{
  "version": "1.0.3",
  "offline_mode": true,
  "zip_name": "build-1.0.3.zip",
  "checksum": "PUT_SHA256_OF_BASE64_ZIP_HERE",
  "isMaintenance": false,
  "enable_secure_screen": true
}

(Keep all the PWA + webview_version + start_url fields from the Quickstart example — only the offline fields change.)


Offline mode (zip bundle) (optional — use only when you need it)

Offline mode lets the ShareRing Me app download a zip build and load it locally.

Offline mode is powerful, but it has real trade-offs:

  • It increases first-run bandwidth (the zip must be downloaded).
  • It increases device storage usage (the extracted bundle is cached locally).
  • It can increase memory pressure (unzipping + loading larger local assets).

When offline mode is useful

  • Low / unreliable bandwidth: users can still open and use the module after a successful initial cache.
  • No mobile service: modules that must work in-flight/underground/remote areas.
  • Stateful/offline-first workflows: e.g. ticketing / scanning / check-in flows that must keep working and sync later.

When to avoid offline mode

  • The module is used infrequently (downloading a zip is wasted bandwidth/storage).
  • The module changes often (users will download many versions over time).
  • The module is mostly static content that works fine online.
  • The module requires real-time data or frequent API calls (offline caching provides no benefit).

1) Zip download URL

The app downloads the zip from:

<moduleUrl>/<zip_name>

Examples:

  • module URL https://example.com + zip_name: "build-1.0.3.zip"https://example.com/build-1.0.3.zip
  • module URL https://example.com/my-module + zip_name: "build-1.0.3.zip"https://example.com/my-module/build-1.0.3.zip

2) Zip file contents (critical)

When unzipped, the app expects:

  • index.html at the root of the extracted folder
  • your static assets referenced by relative paths

Do not ship a zip that nests everything inside another folder level.

Good zip (root):

index.html
assets/...
manifest.json   (recommended to include too)

Bad zip (nested):

my-build/
  index.html
  assets/...

3) Checksum format

If you provide checksum, the app verifies it as:

  1. read the zip file as a base64 string
  2. compute sha256(base64String) → hex

Example command (Node.js) to generate this checksum:

node -e "const fs=require('fs'); const crypto=require('crypto'); const b64=fs.readFileSync('build-1.0.3.zip').toString('base64'); console.log(crypto.createHash('sha256').update(b64).digest('hex'));"

When deploying a new version:

  1. Build your web app (npm run build).
  2. Zip the build output with index.html at the zip root.
  3. Upload the zip to <moduleUrl>/<zip_name>.
  4. Update the domain-root manifest.json:
    • bump version
    • set zip_name to the new file
    • set checksum if used

Messaging protocol

Message envelopes

All messages MUST be JSON objects.

Request (WebView → App)

type Request = { type: string; payload?: unknown };

Response (App → WebView)

type Response = { type: string; payload: unknown; error?: unknown };

Conventions

  • type is an event name (string literal), e.g. 'COMMON_APP_INFO'.
  • payload varies by event:
    • scalar (e.g. string, boolean)
    • object
    • array
  • error is optional and only present when the native handler fails.
  • No request id is echoed back — the app matches responses to requests by type only. Keep at most one in-flight request per type (or a FIFO queue per type, as in the bridge helper above).
  • Omit payload entirely when the event takes none — send { "type": "EVENT_TYPE" }. Some native handlers reject an envelope carrying an empty/undefined payload.

Web → App (request)

window.ReactNativeWebView?.postMessage(JSON.stringify({
  type: 'EVENT_TYPE',
  payload: { /* your data (optional) — omit the key entirely if unused */ }
}));

Timing caveat: window.ReactNativeWebView is injected by the wrapper after your page starts executing. A message posted before injection is silently dropped — no error, no response. Wait ~300ms and poll for the bridge before your first post (the Quickstart helper does this).

App → Web (response)

const handleMessage = (event: MessageEvent) => {
  if (typeof event.data !== "string") return;
  const msg = JSON.parse(event.data);
  // msg.type, msg.payload, msg.error
}
// useCapture: true is required — some app builds only deliver
// native-injected messages in the capture phase.
window.addEventListener("message", handleMessage, true);
// Some Android builds deliver on `document` instead — register there too.
document.addEventListener("message", handleMessage);

// later on remove the listeners to avoid memory leaks and/or collisions
// window.removeEventListener('message', handleMessage, true);

Legacy note: older (V1) wrapper builds don't fire message events at all — they inject a call to window.onMessageFromApp(jsonString), and the response value lives in a data field (sometimes URI-encoded JSON) instead of payload. The Quickstart bridge helper handles both transports; if you write your own, do the same.


User confirmation (PIN) for sensitive operations

Some calls require the ShareRing Me app to show a PIN confirmation UI to the user. Your module must handle:

  • a delay (user is interacting)
  • the user cancelling (you'll receive an error)

PIN confirmation is required for:

  • CRYPTO_DECRYPT
  • CRYPTO_SIGN
  • WALLET_SIGN_TRANSACTION
  • WALLET_SIGN_AND_BROADCAST_TRANSACTION
  • WALLET_SIGN_ARBITRARY_MESSAGE
  • VAULT_EXEC_QUERY_SILENT
  • VAULT_VCT_TOKEN_ID (first call per session)

Use a long timeout (e.g. 60 seconds) for these events — the user has to interact with a biometric/PIN prompt, and for broadcast events the app then also talks to the chain. The 8-second default that suits instant lookups will time out mid-prompt.


Best practices & common pitfalls

0) "My module loads but nothing works" checklist

Worked-through failure modes, in the order to check them:

  1. Wallet/vault/crypto events all fail or never respond → manifest is missing webview_version: "WEB_VIEW_V2", or the module is served over plain HTTP. Fix the manifest, serve over HTTPS, bump version, force-close and reopen the app.
  2. Module doesn't open at all / opens a blank or wrong pagemanifest.json missing/invalid at the domain root, or start_url absent/pointing at the wrong URL (a dev/LAN URL shipped to production is the classic case — remember npm run build copies public/manifest.json verbatim).
  3. The first request after page load times out with no response → you posted before the wrapper injected window.ReactNativeWebView. Early posts are silently dropped; use the settle-then-poll pattern from the Quickstart helper.
  4. Requests work in a plain browser test harness but not in the app → your message listener isn't capture-phase (useCapture: true), or you're on an Android build that delivers via document events.
  5. Async-storage reads always come back empty in the app → single-key reads must send the key as a bare string payload (see COMMON_READ_ASYNC_STORAGE), and the bucket is scoped per domain — a URL change means a different (empty) bucket.
  6. PIN-gated calls "randomly" fail → your timeout is too short for a human to answer the biometric/PIN prompt. Use ~60s for those events.

1) Always validate messages

On receive, validate:

  • type exists and is a string
  • payload shape matches what your code expects

Also be liberal in where you look for the response value: depending on the app build it arrives in payload (V2), in data — possibly URI-encoded (V1) — or at the top level of the envelope.

2) Treat the bridge like an RPC channel

  • Responses are matched by type only (no request id) — don't fire concurrent requests of the same type without a FIFO queue.
  • Don't fire many concurrent requests without a strategy.
  • The safest approach is sequential RPC (the provided bridge helper).

3) Keep storage small and non-sensitive

COMMON_WRITE_ASYNC_STORAGE is for small preferences/state. Do not store secrets. Do not assume it is backed up.

4) Offline mode: make all asset paths relative

If your module must work offline, validate by loading the built dist/index.html from disk in a browser and ensuring assets resolve correctly.

5) Be a good mobile web citizen

Design principles (mobile-first)

  • Responsive layout by default: avoid fixed widths/heights; use flexible layouts (Flex/Grid), max-width, and responsive spacing.
  • Safe areas / notches: ensure content isn't hidden behind rounded corners/notches. Consider using:
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />

and CSS like:

/* Example: keep content away from device cutouts */
.page {
  padding-top: env(safe-area-inset-top);
  padding-right: env(safe-area-inset-right);
  padding-bottom: env(safe-area-inset-bottom);
  padding-left: env(safe-area-inset-left);
}
  • Touch targets: design for thumbs; keep interactive elements comfortably sized and spaced (avoid tiny icons with no padding).
  • Keyboard & forms: expect the on-screen keyboard to cover parts of the page; ensure focused inputs scroll into view and primary actions remain reachable.
  • Dark mode & language: respect app theme (COMMON_APP_INFO.darkMode) and language (COMMON_APP_INFO.language).
  • Accessibility: good contrast, visible focus states, labels for inputs, and sensible heading structure. Support reduced motion where possible.
  • Performance on mid-range devices: avoid heavy animations and huge bundles; lazy-load large features, compress images, and keep JS work per frame small.

Testing checklist (practical)

  • Screen sizes: small phone, large phone, and tablet; portrait + landscape.
  • Text scaling: increase system font size / display size and verify layout doesn't clip or overlap.
  • Keyboard behavior: test every form field; ensure it's never obscured and the page doesn't get "stuck" after closing the keyboard.
  • Theme: dark + light mode; verify contrast and any images/icons.
  • Network:
    • slow network (throttled)
    • offline
    • if using offline mode: first run (download) vs subsequent runs (cached)
  • Error handling: test user cancellation / timeouts for PIN-gated and long-running operations and show clear recovery options.