zepp-web-runner
v0.2.0
Published
Run Zepp OS watch app pages in the browser — Vite plugin + React renderer that shims @zos/ui hmUI widgets.
Maintainers
Readme
Zepp Web Runner
Run Zepp OS watch app pages in the browser — a Vite plugin + React renderer that shims @zos/ui hmUI widgets as React elements, so you can develop, debug, and iterate on your watch UI on your computer instead of pushing to a device every change.
Live Demo: antonlapshin.github.io/koala — a full Tamagotchi-style virtual pet game for Amazfit Bip 6, built with Zepp Web Runner. Try it in your browser!

Why?
Developing Zepp OS apps means writing hmUI.createWidget() calls that only render on the watch. Every UI tweak requires a rebuild + deploy to device. Zepp Web Runner intercepts those widget calls and maps them to React elements, giving you instant hot-reload in the browser while your page/index.js stays unchanged — the same file runs on both platforms.
Quick Start
npm install zepp-web-runner1. Add the Vite plugin
// vite.config.mjs
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { createZeppOsPlugin } from "zepp-web-runner/plugin";
import path from "path";
const root = path.resolve(__dirname);
export default defineConfig({
plugins: [
react(),
createZeppOsPlugin({ root }),
],
});2. Create the web entry point
// src/main.jsx
import React from "react";
import ReactDOM from "react-dom/client";
import { _setPageConfig, WatchPage } from "zepp-web-runner/components/WatchPage";
// Intercept Page() calls from your watch app
globalThis.Page = _setPageConfig;
async function init() {
// Dynamically import your watch page — this triggers Page({...})
await import("../page/index.js");
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<WatchPage />
</React.StrictMode>
);
}
init();3. Run it
npx viteOpen http://localhost:5173 — your watch app runs in the browser.
How It Works
┌──────────────────────┐
│ page/index.js │ ← Same file as on watch
│ hmUI.createWidget() │
│ hmUI.widget.IMG │
│ hmUI.align.CENTER_H │
└──────┬───────────────┘
│ @zos/ui import
▼
┌──────────────────────┐
│ Vite plugin │ ← Redirects @zos/ui → zepp-web-runner/shims/hmUI
│ createZeppOsPlugin() │
└──────┬───────────────┘
│
▼
┌──────────────────────┐
│ hmUI shim │ ← Captures widget calls into an array
│ _reset() / _collect()│
└──────┬───────────────┘
│ widgets[]
▼
┌──────────────────────┐
│ WidgetRenderer │ ← Maps each widget descriptor → React element
│ renderWidget(w) │ IMG → <img>, TEXT → <div>, BUTTON → <button>, etc.
└──────┬───────────────┘
│ React elements
▼
┌──────────────────────┐
│ WatchPage │ ← React component: watch-shaped viewport
│ │ Collects widgets, renders them cycle
└──────────────────────┘Render cycle:
page/index.jscallsrender()→ N ×hmUI.createWidget()(captured into a module-level array)WatchPage.jsxcollects the array via_collect()WidgetRenderer.jsxmaps each widget descriptor to a React elementsetState()triggers React reconciliation in the 390×450 viewport- Click handlers / tick timers call
render()again, repeating the cycle
Shim Mapping
| Zepp OS Import | Web Shim | Notes |
|---|---|---|
| import hmUI from "@zos/ui" | zepp-web-runner/shims/hmUI | Widget capture: createWidget(), enums (widget, align, event) |
| import { Vibrator } from "@zos/sensor" | zepp-web-runner/shims/zos-sensor | No-op: Vibrator.start() is a no-op, Step.getCurrent() returns 0 |
| import { statSync } from "@zos/fs" | zepp-web-runner/shims/zos-fs | No-op: statSync() returns false, readFileSync() returns null |
Adapter Mappings
Your watch app likely imports project-specific adapters (storage, time, sensors). Use adapterMappings to redirect them to web equivalents:
createZeppOsPlugin({
root,
adapterMappings: {
// Redirect watch adapter → web adapter
[path.resolve(root, "utils/storageAdapter.js")]:
`export { webStorage } from "./web/adapters/storage.js";`,
[path.resolve(root, "utils/sensorAdapter.js")]:
`export { stepsAdapter } from "./web/adapters/steps.js";`,
},
});This ensures the same singleton instances are shared between page/index.js and your debug panel.
Supported Widgets
| Widget | hmUI constant | React output | Props |
|---|---|---|---|
| Image | widget.IMG | <img> | x, y, w, h, src + click |
| Animated Image | widget.IMG_ANIM | <AnimImage> (component) | anim_path, anim_prefix, anim_ext, anim_start, anim_end, anim_fps, repeat |
| Text | widget.TEXT | <div> | x, y, w, h, text, text_size, color, align_h, align_v |
| Filled Rectangle | widget.FILL_RECT | <div> | x, y, w, h, color, radius + click |
| Button | widget.BUTTON | <button> | x, y, w, h, text, text_size, color, normal_color, press_color, radius, click_func |
| Gesture | widget.GESTURE | <GestureWidget> (component) | x, y, w, h + swipeLeft/swipeRight events |
Device Presets
Import getDevice() to look up known Zepp OS watch dimensions:
import { getDevice } from "zepp-web-runner/devices";
const dims = getDevice("balance"); // { width: 480, height: 480 }Pass the result to WatchPage:
<WatchPage width={dims.width} height={dims.height} />Included presets (33 devices):
| Family | Models | |---|---| | Bip | bip-6-square (390×450), bip-6-round (480×480), bip-5 (302×368), bip-5-round (416×416), bip-3-pro (240×280), bip-s-lite (176×176) | | GTR/GTS | gtr-4 (466×466), gts-4 (390×450), gtr-4-limited (466×466), gtr-3-pro (454×454), gts-3 (390×450), gtr-2 (454×454), gts-2 (348×442), gts-2-mini (306×354), gtr-mini (416×416), gts-2e (348×442), gtr-2e (454×454) | | T-Rex | t-rex-3 (480×480), t-rex-2 (454×454), t-rex-pro (360×360), t-rex-ultra (480×480) | | Falcon/Cheetah | falcon (416×416), cheetah-round (454×454), cheetah-square (390×450), cheetah-pro (454×454) | | Balance/Active | balance (480×480), active (390×450), active-edge (360×360) | | Neo | neo (160×278) | | Zepp | zepp-e-circle (416×416), zepp-e-square (390×450), zepp-z (454×454) |
API Reference
createZeppOsPlugin(opts)
Vite plugin factory. Returns a Vite plugin object.
opts: {
root: string; // Project root (required for fs.allow)
adapterMappings?: Record<string, string>; // Absolute path → module source
shimDir?: string; // Override shim directory (default: built-in shims/)
}<WatchPage width? height? />
React component that renders the watch viewport.
width(number, default390) — Viewport width in pixelsheight(number, default450) — Viewport height in pixels
_setPageConfig(config)
Intercepts Zepp OS Page() calls. Set as globalThis.Page = _setPageConfig before importing your page module.
triggerRender()
Manually trigger a re-render (e.g. from a debug panel after changing state).
renderWidget(widget)
Maps a single widget descriptor to a React element. Useful for custom widget handling.
getDevice(preset)
Look up a device by preset name. Returns { width, height } or fallback { width: 390, height: 450 } for unknown presets.
Image Assets
The renderer expects images to be served at ${BASE_URL}images/. Place your watch assets in public/images/ in your Vite project:
public/
images/
koala/
koala_1.png
koala_2.png
...
ui/
food.png
toy.png
...
bg_day.png
bg_night.pngThe src prop in hmUI.createWidget(hmUI.widget.IMG, { src: "koala/koala_1.png" }) maps to public/images/koala/koala_1.png.
Known Limitations
- Vibrator is a no-op in the browser — no haptic feedback
- Image animation (
IMG_ANIM) usessetInterval; frame timing may drift from the watch's native implementation - File system (
@zos/fs) shims return no data — implement your own storage adapter for the web - Sensors (
@zos/sensor) return zero values — implement your own sensor adapter for the web - Multi-page apps need one
WatchPageper page; the shim currently supports a single active page config - Colors are interpreted as ARGB32 integers (
0xAARRGGBB)
License
MIT — see LICENSE.
