lecodes-cli 0.6.4 → 0.7.1

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 (48) hide show
  1. package/README.md +1 -0
  2. package/dist/index.js +920 -636
  3. package/package.json +9 -4
  4. package/runtime/scene-harness.json +1 -0
  5. package/runtime/sdk/compile/assetMacro.ts +4 -3
  6. package/runtime/sdk/compile/bundler.ts +35 -3
  7. package/runtime/sdk/compile/compileProject.ts +14 -2
  8. package/runtime/sdk/compile/libraryImports.ts +47 -0
  9. package/runtime/sdk/compile/sceneEditor.ts +16 -4
  10. package/runtime/sdk/core/Aspect.ts +37 -0
  11. package/runtime/sdk/core/InspectorUI.ts +212 -0
  12. package/runtime/sdk/core/fields.ts +15 -3
  13. package/runtime/sdk/g2/Scene2D.ts +51 -5
  14. package/runtime/sdk/gl/Model.ts +9 -0
  15. package/runtime/sdk/gl/Node.ts +4 -1
  16. package/runtime/sdk/gl/Scene.ts +76 -11
  17. package/runtime/sdk/gl/scenarios.ts +349 -0
  18. package/runtime/sdk/inject.ts +28 -8
  19. package/runtime/sdk/kit/UITabs.ts +105 -0
  20. package/runtime/sdk/plugins/camera.ts +81 -0
  21. package/runtime/sdk/plugins/geolocation.ts +123 -0
  22. package/runtime/sdk/plugins/permission.ts +7 -0
  23. package/runtime/sdk/plugins/qr.ts +61 -24
  24. package/runtime/sdk/runtime/app.ts +37 -0
  25. package/runtime/sdk/runtime/appEvents.ts +29 -0
  26. package/runtime/sdk/runtime/channel.ts +50 -0
  27. package/runtime/sdk/runtime/clipboard.ts +20 -0
  28. package/runtime/sdk/runtime/datetime.ts +2 -4
  29. package/runtime/sdk/runtime/device.ts +137 -4
  30. package/runtime/sdk/runtime/misc.ts +4 -0
  31. package/runtime/sdk/runtime/service.ts +82 -0
  32. package/runtime/sdk/runtime/touch.ts +26 -0
  33. package/runtime/sdk/scene/defineScene.ts +651 -17
  34. package/runtime/sdk/scene/editorPlugins.ts +86 -0
  35. package/runtime/sdk/ui/NativeView.ts +144 -0
  36. package/runtime/sdk/ui/UI.ts +6 -1
  37. package/runtime/sdk/ui/UIButton.ts +26 -3
  38. package/runtime/sdk/ui/UIInput.ts +2 -2
  39. package/runtime/sdk/ui/UINode.ts +24 -4
  40. package/runtime/sdk/ui/UIScreen.ts +86 -29
  41. package/runtime/sdk/ui/UIScreenHost.ts +213 -0
  42. package/runtime/sdk/ui/UIText.ts +1 -1
  43. package/runtime/sdk/ui/UIVideo.ts +48 -2
  44. package/runtime/sdk/ui/UIWidget.ts +34 -1
  45. package/runtime/sdk/ui/presentable.ts +116 -0
  46. package/runtime/sdk/ui/router.ts +73 -29
  47. package/runtime/sdk-types.json +1 -1
  48. package/runtime/sdk/runtime/camera.ts +0 -31
@@ -0,0 +1,123 @@
1
+ // Geolocation — the device position as a typed service plugin (docs/service-channel-plan.md).
2
+ // A wrapper over the host-registered "geolocation" service, exactly like QRScanner wraps the
3
+ // "qrScanner" NativeView — except services have no UI surface, so there is nothing to present:
4
+ // just `getCurrent()` and `watch()`.
5
+ //
6
+ // Host-OPTIONAL, like every plugin: it exists where the host registered a "geolocation" service
7
+ // (web hosts do out of the box; Android via MainActivity) — gate on `Geolocation.isSupported`.
8
+ // The OS permission prompt happens on the first call that needs it (host-side); denial is a
9
+ // rejection. Both methods reject immediately on unsupported hosts, BEFORE any permission path.
10
+ //
11
+ // Service contract ("geolocation"): calls `getCurrent(options) → GeoPosition`,
12
+ // `startWatch(options)` (resolves once watching), `stopWatch()`; event `position` → GeoPosition.
13
+
14
+ import { ServiceClient } from "../runtime/service"
15
+
16
+ export interface GeoPosition {
17
+ latitude: number
18
+ longitude: number
19
+ /** Horizontal accuracy radius, meters. */
20
+ accuracy: number
21
+ /** Meters above sea level, or null when the provider doesn't know. */
22
+ altitude: number | null
23
+ /** Direction of travel in degrees clockwise from north, or null when stationary/unknown. */
24
+ heading: number | null
25
+ /** Ground speed in m/s, or null when unknown. */
26
+ speed: number | null
27
+ /** When this fix was taken, ms epoch. */
28
+ timestamp: number
29
+ }
30
+
31
+ export interface GeoOptions {
32
+ /** Prefer the precise (GPS) provider — slower first fix, more battery. Default false. */
33
+ highAccuracy?: boolean
34
+ /** getCurrent only: reject with "timeout" if no fix arrives in this many ms. */
35
+ timeout?: number
36
+ }
37
+
38
+ /** A live `Geolocation.watch()` subscription — `stop()` it when the screen goes away. */
39
+ export interface GeoWatch {
40
+ stop(): void
41
+ }
42
+
43
+ /**
44
+ * The device position. `Geolocation.isSupported` reports whether this host registered the
45
+ * service — check it before offering location features.
46
+ */
47
+ // PURE IIFE so an app that never uses location tree-shakes the whole plugin away (see NativeView).
48
+ export const Geolocation: {
49
+ /** Whether this host registered a "geolocation" service. */
50
+ readonly isSupported: boolean
51
+ /** One position fix. Prompts for permission on first use; rejects on denial ("denied"), no
52
+ * provider ("unavailable"), or `options.timeout` elapsing ("timeout"). */
53
+ getCurrent(options?: GeoOptions): Promise<GeoPosition>
54
+ /** Continuous updates. Resolves once watching (permission granted + provider started) — so a
55
+ * denial is a rejection, never a silently dead callback. One host watch serves every
56
+ * subscriber; the options of the watch that starts it win. Always `stop()` when done. */
57
+ watch(callback: (position: GeoPosition) => void, options?: GeoOptions): Promise<GeoWatch>
58
+ } = /*#__PURE__*/ (() => {
59
+ let client: ServiceClient | null = null
60
+ const service = (): ServiceClient => client ??= new ServiceClient("geolocation")
61
+
62
+ // Watch state: one host watch fans out to N subscribers (SDK-side), the last stop() releases it.
63
+ const watchers: ((position: GeoPosition) => void)[] = []
64
+ let positionHooked = false
65
+ let startPromise: Promise<void> | null = null
66
+
67
+ const assertSupported = (): void => {
68
+ // Fail before the host is touched — a permission dialog for a feature that can't exist
69
+ // would be worse than the error (same ordering rule as QRScanner.open).
70
+ if (!ServiceClient.isSupported("geolocation")) {
71
+ throw new Error('Geolocation isn\'t available on this host — it needs a registered "geolocation" service.')
72
+ }
73
+ }
74
+
75
+ const removeWatcher = (callback: (position: GeoPosition) => void): void => {
76
+ const i = watchers.indexOf(callback)
77
+ if (i >= 0) watchers.splice(i, 1)
78
+ if (watchers.length === 0 && startPromise !== null) {
79
+ startPromise = null
80
+ service().call("stopWatch").catch(() => {})
81
+ }
82
+ }
83
+
84
+ const geolocation = {
85
+ async getCurrent(options?: GeoOptions): Promise<GeoPosition> {
86
+ assertSupported()
87
+ return await service().call("getCurrent", options ?? {})
88
+ },
89
+
90
+ async watch(callback: (position: GeoPosition) => void, options?: GeoOptions): Promise<GeoWatch> {
91
+ assertSupported()
92
+ if (!positionHooked) {
93
+ positionHooked = true
94
+ service().on("position", (position?: GeoPosition) => {
95
+ if (position) for (const cb of [...watchers]) cb(position)
96
+ })
97
+ }
98
+ watchers.push(callback)
99
+ startPromise ??= service().call("startWatch", options ?? {})
100
+ try {
101
+ await startPromise
102
+ } catch (e) {
103
+ // The watch never started — unwind without the stopWatch a live watch would need.
104
+ const i = watchers.indexOf(callback)
105
+ if (i >= 0) watchers.splice(i, 1)
106
+ if (watchers.length === 0) startPromise = null
107
+ throw e
108
+ }
109
+ let stopped = false
110
+ return {
111
+ stop(): void {
112
+ if (stopped) return
113
+ stopped = true
114
+ removeWatcher(callback)
115
+ },
116
+ }
117
+ },
118
+ }
119
+ Object.defineProperty(geolocation, "isSupported", {
120
+ get: (): boolean => ServiceClient.isSupported("geolocation"),
121
+ })
122
+ return geolocation as any
123
+ })()
@@ -0,0 +1,7 @@
1
+ // Camera permission, host-gated: hosts expose it as `_creatorUtils.requestCamera`. Hosts without
2
+ // that bridge resolve immediately — their view factory prompts on its own (e.g. getUserMedia on
3
+ // the web). Shared by ARScene.prepare, QRScanner and CameraView.
4
+ export const _requestCameraPermission = (): Promise<void> =>
5
+ typeof _creatorUtils.requestCamera === "function"
6
+ ? new Promise<void>((res, rej) => _creatorUtils.requestCamera!(res, rej))
7
+ : Promise.resolve()
@@ -1,36 +1,73 @@
1
- // QRScanner — an OPTIONAL, host-provided capability. The native scanner is implemented by some hosts
2
- // (the le.codes app) and not others (the generic web viewer), so it's gated on host support and throws
3
- // a clear error where it's missing — the same approach as physics (`physicsHasSupport`) and AR
4
- // (`requestCamera`). Ported 1:1 from worker/src/utils/QRScanner.ts.
1
+ // QRScanner — the host's camera QR scanner as a Presentable. A typed wrapper over the
2
+ // `NativeView("qrScanner")` capability (docs/navigation-presentable-plan.md): open it fullscreen
3
+ // (`await scanner.open()`), push it (`Router.push(scanner)`), or embed it among a screen's
4
+ // children as a live preview node — everything a NativeView can do, plus a typed `onScan`.
5
5
  //
6
- // It lives under `plugins/` rather than the core engine folders to mark it as host-optional. This is a
7
- // *convention*, not a plugin *system* — when there are several of these (NFC, Bluetooth, …) we can
8
- // extract a real mechanism; for one capability that would be premature.
6
+ // Host-OPTIONAL, like every plugin: it exists where the host registers a "qrScanner" view
7
+ // (Android; web can via `_creatorUI._registerView`) — gate on `QRScanner.isSupported`. Opening
8
+ // prepares first (camera permission) while the current destination stays visible — the same
9
+ // prepare-then-present contract as ARScene.
10
+ //
11
+ // The pre-Presentable `_creatorUtils.openScanner/closeScanner` bridges are gone from the ABI
12
+ // (2026-07-15): hosts keep their implementations only to serve previously compiled bundles.
13
+
14
+ import { _navEpoch, type PresentOptions } from "../ui/presentable"
15
+ import { NativeView, NativeViewElement } from "../ui/NativeView"
16
+ import { _requestCameraPermission } from "./permission"
9
17
 
10
- export class QRScanner {
11
- private readonly _onData: (data: string | null) => void
18
+ export interface QRScanner extends NativeView {
19
+ /** Fires per decoded camera frame: the decoded string, or `null` for a frame with no readable
20
+ * code (expect those repeatedly while the user aims). The same code can be reported more than
21
+ * once — `close()` or debounce once you have what you need. */
22
+ onScan(callback: (data: string | null) => void): this
23
+ /** Request camera permission, then present the scanner. The current destination (and its
24
+ * loading state) stays visible until the scanner is ready; rejects if the camera is denied,
25
+ * the host can't scan, or another navigation superseded this one. */
26
+ open(options?: PresentOptions): Promise<void>
27
+ }
12
28
 
13
- /** `onData` fires for every value the scanner decodes (null = a frame with no readable code). */
14
- constructor(onData: (data: string | null) => void) {
15
- this._onData = onData
29
+ class QRScannerElement extends NativeViewElement {
30
+ constructor() {
31
+ super("qrScanner", {})
16
32
  }
17
33
 
18
- /** Whether this host provides a native QR scanner (the le.codes app does; many hosts don't). */
19
- static get isSupported(): boolean {
20
- return typeof _creatorUtils.openScanner === "function"
34
+ onScan(callback: (data: string | null) => void): this {
35
+ return this.on("scan", (payload?: { data?: string | null }) => callback(payload?.data ?? null))
21
36
  }
22
37
 
23
- /** Ask for camera access, then open the native scanner. Rejects if the host can't scan or access is denied. */
24
- async open(): Promise<void> {
25
- if (!QRScanner.isSupported) {
26
- throw new Error("QRScanner isn't available on this host — it needs the le.codes app's native scanner.")
38
+ /** @internal prepare-then-present: permission before the swap (see Presentable._prepare). */
39
+ async _prepare(): Promise<void> {
40
+ const epoch = _navEpoch()
41
+ await _requestCameraPermission()
42
+ if (_navEpoch() !== epoch) {
43
+ throw new Error("QRScanner.open() superseded: another destination was opened while preparing")
27
44
  }
28
- await new Promise<void>((res, rej) => _creator.requestCamera(res, rej))
29
- await new Promise<void>((res, rej) => _creatorUtils.openScanner(res, rej, this._onData))
30
45
  }
31
46
 
32
- /** Close the scanner and release the camera. */
33
- close(): void {
34
- _creatorUtils.closeScanner()
47
+ override async open(options?: PresentOptions): Promise<void> {
48
+ // Fail before prompting for the camera — a permission dialog for a scanner that can't exist
49
+ // would be worse than the error.
50
+ if (!NativeView.isSupported("qrScanner")) {
51
+ throw new Error("QRScanner isn't available on this host — it needs a registered \"qrScanner\" view.")
52
+ }
53
+ await this._prepare()
54
+ super.open(options)
35
55
  }
36
56
  }
57
+
58
+ /**
59
+ * Create a QR scanner view. `QRScanner.isSupported` reports whether this host can scan at all —
60
+ * check it before offering the feature.
61
+ */
62
+ // PURE IIFE so an app that never scans tree-shakes the whole plugin away (see NativeView).
63
+ export const QRScanner: {
64
+ (): QRScanner
65
+ /** Whether this host registered a "qrScanner" view. */
66
+ readonly isSupported: boolean
67
+ } = /*#__PURE__*/ (() => {
68
+ const factory = (): QRScanner => new QRScannerElement() as unknown as QRScanner
69
+ Object.defineProperty(factory, "isSupported", {
70
+ get: (): boolean => NativeView.isSupported("qrScanner"),
71
+ })
72
+ return factory as any
73
+ })()
@@ -0,0 +1,37 @@
1
+ // The app's own lifecycle + how it was opened (docs/app-lifecycle-plan.md). Events are semantic —
2
+ // "pause"/"resume", never Activity/Scene vocabulary — and the current values are synchronous pulls
3
+ // so render-loop code can read them without subscribing.
4
+
5
+ import { appEventsOn, appEventsOff } from "./appEvents"
6
+
7
+ export type AppState = "active" | "background"
8
+
9
+ type AppEventMap = {
10
+ /** The app left the foreground (home button, tab hidden, another app on top). Delivered before
11
+ * the host halts the frame loop — the last chance to persist state / pause work. */
12
+ pause: () => void
13
+ /** The app returned to the foreground; frames are running again. */
14
+ resume: () => void
15
+ /** A link arrived while the app was running (warm deep link). `app.launchUrl` is already
16
+ * updated to the same value when this fires. */
17
+ url: (url: string) => void
18
+ }
19
+
20
+ export const app = {
21
+ /** Current lifecycle state. `"background"` while the app is not the foreground app / the tab is
22
+ * hidden. `"active"` on hosts that don't track it. */
23
+ get state(): AppState {
24
+ return _creatorUtils.appState?.() === "background" ? "background" : "active"
25
+ },
26
+ /** The URL the app was (most recently) opened with — the deep link — or `null` for a plain
27
+ * launch. Warm links update it and fire the `"url"` event. */
28
+ get launchUrl(): string | null {
29
+ return _creatorUtils.getLaunchUrl?.() ?? null
30
+ },
31
+ addEventListener<E extends keyof AppEventMap>(event: E, callback: AppEventMap[E]): void {
32
+ appEventsOn(event, callback as (data?: string) => void)
33
+ },
34
+ removeEventListener<E extends keyof AppEventMap>(event: E, callback: AppEventMap[E]): void {
35
+ appEventsOff(event, callback as (data?: string) => void)
36
+ },
37
+ }
@@ -0,0 +1,29 @@
1
+ // The shared app-event dispatcher. The host exposes ONE registerAppEvent slot per JS world
2
+ // (docs/app-lifecycle-plan.md), so every SDK consumer (the `app` global's pause/resume/url, the
3
+ // `device` global's online/offline) subscribes here and this module registers with the host once,
4
+ // lazily on the first subscription — no top-level side effect, tree-shaking drops it when unused.
5
+
6
+ type AppEventCallback = (data?: string) => void
7
+
8
+ const listeners: Record<string, AppEventCallback[]> = {}
9
+ let registered = false
10
+
11
+ const dispatch = (event: string, data?: string): void => {
12
+ const arr = listeners[event]
13
+ if (arr) for (const cb of arr.slice()) cb(data)
14
+ }
15
+
16
+ export const appEventsOn = (event: string, cb: AppEventCallback): void => {
17
+ ;(listeners[event] ??= []).push(cb)
18
+ if (!registered) {
19
+ registered = true
20
+ _creatorUtils.registerAppEvent?.(dispatch)
21
+ }
22
+ }
23
+
24
+ export const appEventsOff = (event: string, cb: AppEventCallback): void => {
25
+ const arr = listeners[event]
26
+ if (!arr) return
27
+ const i = arr.indexOf(cb)
28
+ if (i >= 0) arr.splice(i, 1)
29
+ }
@@ -0,0 +1,50 @@
1
+ // The JSON wire protocol shared by the two host plugin channels — NativeView
2
+ // (`_creatorUI.viewCall`, see ui/NativeView.ts) and headless services
3
+ // (`_creatorUtils.serviceCall`, see runtime/service.ts). One protocol, two transports:
4
+ // `args`, results, and event payloads travel as JSON strings, and an absent/empty string
5
+ // decodes to `undefined`. Typed plugins (QRScanner, CameraView, Geolocation) layer methods
6
+ // over this — see src/plugins/.
7
+
8
+ /** @internal event-name → listeners. NativeView exposes it as `nvl` (host contract). */
9
+ export type ChannelListeners = Record<string, ((data?: any) => void)[]>
10
+
11
+ /** @internal */
12
+ export const _channelOn = (listeners: ChannelListeners, event: string, callback: (data?: any) => void): void => {
13
+ (listeners[event] ??= []).push(callback)
14
+ }
15
+
16
+ /** @internal */
17
+ export const _channelOff = (listeners: ChannelListeners, event: string, callback: (data?: any) => void): void => {
18
+ const list = listeners[event]
19
+ if (!list) return
20
+ const i = list.indexOf(callback)
21
+ if (i >= 0) list.splice(i, 1)
22
+ }
23
+
24
+ /** @internal deliver a host event: decode the JSON payload, fan out (copy-before-iterate). */
25
+ export const _channelEmit = (listeners: ChannelListeners, event: string, dataJson?: string): void => {
26
+ const list = listeners[event]
27
+ if (!list || list.length === 0) return
28
+ const data = dataJson === undefined || dataJson === "" ? undefined : JSON.parse(dataJson)
29
+ for (const cb of [...list]) cb(data)
30
+ }
31
+
32
+ type ChannelCallBridge = (
33
+ id: number, method: string, args: string,
34
+ onComplete: (result?: string) => void, onError: (err: string) => void,
35
+ ) => void
36
+
37
+ /** @internal one JSON call over a channel bridge; `bridge` may be absent (optional ABI member). */
38
+ export const _channelCall = (
39
+ bridge: ChannelCallBridge | undefined, unsupportedError: string,
40
+ id: number, method: string, args: any[],
41
+ ): Promise<any> =>
42
+ new Promise((res, rej) => {
43
+ if (typeof bridge !== "function") {
44
+ rej(new Error(unsupportedError))
45
+ return
46
+ }
47
+ bridge(id, method, JSON.stringify(args),
48
+ (result?: string) => res(result === undefined || result === "" ? undefined : JSON.parse(result)),
49
+ (err: string) => rej(new Error(err)))
50
+ })
@@ -0,0 +1,20 @@
1
+ // System clipboard (docs/app-lifecycle-plan.md). Write is fire-and-forget; read is async because
2
+ // web gates it behind a permission prompt (native hosts complete synchronously).
3
+
4
+ export const clipboard = {
5
+ /** Put `text` on the system clipboard. Silent no-op on hosts without clipboard access. */
6
+ write(text: string): void {
7
+ _creatorUtils.clipboardWrite?.(text)
8
+ },
9
+ /** Read text from the system clipboard. Rejects when the platform has no clipboard access, the
10
+ * user denied it, or there is nothing readable. */
11
+ read(): Promise<string> {
12
+ return new Promise((resolve, reject) => {
13
+ if (typeof _creatorUtils.clipboardRead !== "function") {
14
+ reject(new Error("Clipboard is not supported on this platform"))
15
+ return
16
+ }
17
+ _creatorUtils.clipboardRead(resolve, (err: string) => reject(new Error(err)))
18
+ })
19
+ },
20
+ }
@@ -243,10 +243,8 @@ export class DateValue {
243
243
  startOf(unit: Unit): DateValue {
244
244
  const d = new Date(this.t)
245
245
  switch (unit) {
246
- case "year": d.setMonth(0)
247
- // falls through
248
- case "month": d.setDate(1)
249
- // falls through
246
+ case "year": d.setMonth(0); d.setDate(1); d.setHours(0, 0, 0, 0); break
247
+ case "month": d.setDate(1); d.setHours(0, 0, 0, 0); break
250
248
  case "day": d.setHours(0, 0, 0, 0); break
251
249
  case "hour": d.setMinutes(0, 0, 0); break
252
250
  case "minute": d.setSeconds(0, 0); break
@@ -1,8 +1,20 @@
1
1
  // Platform info + the host resize event. The resize listener registers with the host lazily on the
2
2
  // first subscription (no top-level side effect, so tree-shaking can drop this entirely if unused).
3
3
 
4
+ import { Quat } from "../math/quat"
5
+ import { Vec3 } from "../math/vec"
6
+ import { appEventsOn, appEventsOff } from "./appEvents"
7
+
4
8
  type ResizeCallback = (width: number, height: number) => void
5
9
 
10
+ type DeviceEventMap = {
11
+ resize: ResizeCallback
12
+ /** Connectivity came back (best-effort, navigator.onLine semantics). */
13
+ online: () => void
14
+ /** Connectivity was lost. */
15
+ offline: () => void
16
+ }
17
+
6
18
  const resizeListeners: ResizeCallback[] = []
7
19
  let resizeRegistered = false
8
20
 
@@ -10,6 +22,49 @@ const onResize = (width: number, height: number): void => {
10
22
  for (const cb of resizeListeners.slice()) cb(width, height)
11
23
  }
12
24
 
25
+ /** Semantic haptic styles for `device.vibrate`. Impact styles (`light`/`medium`/`heavy`/`soft`/
26
+ * `rigid`) are a physical "tap" of varying weight; notification styles (`success`/`warning`/`error`)
27
+ * cue an outcome; `selection` is a light tick for a value change. Chosen to map 1:1 onto iOS
28
+ * `UIFeedbackGenerator` and, on Android, `HapticFeedbackConstants` / `VibrationEffect` — so the same
29
+ * call feels native everywhere, rather than a duration that only web/Android can honor. */
30
+ export type HapticStyle =
31
+ | "light" | "medium" | "heavy" | "soft" | "rigid"
32
+ | "success" | "warning" | "error"
33
+ | "selection"
34
+
35
+ /** Options for `device.motion.start`. */
36
+ export interface MotionOptions {
37
+ /** Sensor update interval in seconds (default 1/60). The sensor fuses at ≥ this rate in the
38
+ * background; you poll the freshest sample each frame, so this is a floor, not a sync. */
39
+ interval?: number
40
+ /** Frame the readables are delivered in. "world" (default): engine Y-up, screen-oriented — drops
41
+ * straight into `camera.quaternion` / any node. "device": the raw sensor frame, no conversion. */
42
+ frame?: "world" | "device"
43
+ }
44
+
45
+ // --- device.motion state (pull-per-frame model) ---
46
+ let motionEnabled = false
47
+ let motionFrame: "world" | "device" = "world"
48
+ let motionYawOffset = 0 // radians about world up, captured by recenter()
49
+
50
+ // Calibration constants — VERIFY ON A PHYSICAL DEVICE (the simulator reports no motion).
51
+ // • CM_TO_WORLD: Core Motion's reference frame is Z-up; the engine world is Y-up. If a level horizon
52
+ // reads tilted ~90°, this is the term to check.
53
+ // • ORIENTATION_ROLL: roll about the view axis so camera-up tracks screen-up, indexed by the native
54
+ // interface-orientation code [portrait, landscapeLeft, landscapeRight, upsideDown]. If landscape is
55
+ // upside-down or rolled the wrong way, flip the ±π/2 signs.
56
+ // If the whole panorama turns the wrong way (look right → view goes left), conjugate `raw` below.
57
+ const CM_TO_WORLD = Quat.fromAxisAngle([1, 0, 0], -Math.PI / 2)
58
+ const ORIENTATION_ROLL = [0, Math.PI / 2, -Math.PI / 2, Math.PI]
59
+
60
+ // Device attitude → engine world frame, with the interface-orientation roll folded in (yaw NOT yet
61
+ // recentered). Shared by `attitude` (which adds the recenter) and `gravity` (which is yaw-invariant).
62
+ const orientedAttitude = (s: ArrayLike<number>): Quat => {
63
+ const raw = new Quat(s[0], s[1], s[2], s[3])
64
+ const roll = Quat.fromAxisAngle([0, 0, 1], ORIENTATION_ROLL[s[7]] ?? 0)
65
+ return CM_TO_WORLD.mul(raw).mul(roll)
66
+ }
67
+
13
68
  export const device = {
14
69
  get platform(): "web" | "android" | "ios" | string {
15
70
  return _creatorUtils.platform
@@ -43,17 +98,95 @@ export const device = {
43
98
  setPreciseTouch(enabled: boolean): void {
44
99
  _creatorUtils.setPreciseTouch?.(enabled)
45
100
  },
46
- addEventListener(channel: "resize", callback: ResizeCallback): void {
101
+ /** Fire a one-shot haptic of the given semantic `style` (default `"medium"`) — a physical tap on
102
+ * supported hardware (iOS Taptic Engine, Android vibrator). Chosen by meaning, not duration, so it
103
+ * feels native on each platform; see {@link HapticStyle}. Host-gated: a silent no-op where there's
104
+ * no haptic hardware (iPad, older iPhones, web, headless). */
105
+ vibrate(style: HapticStyle = "medium"): void {
106
+ _creatorUtils.vibrate?.(style)
107
+ },
108
+ /** Device-orientation sensor (gyro + accelerometer, fused) for tilt/steering and magic-window /
109
+ * 360° panoramas. Poll `attitude` / `gravity` inside setLoop; they return the freshest fused
110
+ * sample, so the sensor rate need not match your frame rate. Host-gated: a no-op with no sensor. */
111
+ motion: {
112
+ /** Whether this device has the motion sensors at all (no gyro → false; iPad/older, web, headless). */
113
+ get available(): boolean {
114
+ return _creatorUtils.motionAvailable?.() ?? false
115
+ },
116
+ /** Whether updates are currently running (start succeeded and stop hasn't been called). */
117
+ get enabled(): boolean {
118
+ return motionEnabled
119
+ },
120
+ /** Begin sensor updates. Resolves to whether it actually started (false = no sensor / denied).
121
+ * Async so a web host can await its permission prompt; native resolves immediately. */
122
+ async start(options: MotionOptions = {}): Promise<boolean> {
123
+ motionFrame = options.frame ?? "world"
124
+ motionEnabled = !!(await (_creatorUtils.motionStart?.(options.interval ?? 1 / 60) ?? false))
125
+ return motionEnabled
126
+ },
127
+ /** Stop sensor updates and release the sensor (battery). */
128
+ stop(): void {
129
+ _creatorUtils.motionStop?.()
130
+ motionEnabled = false
131
+ },
132
+ /** Capture the current heading as "forward" — a yaw-only recenter (pitch/roll stay gravity-
133
+ * referenced, so the horizon stays level). No-op in the "device" frame. */
134
+ recenter(): void {
135
+ const s = _creatorUtils.getMotionSample?.()
136
+ if (!s || motionFrame === "device") { motionYawOffset = 0; return }
137
+ const f = orientedAttitude(s).rotateVec3([0, 0, -1]) // world-space camera forward
138
+ motionYawOffset = Math.atan2(-f.x, -f.z) // heading about world up
139
+ },
140
+ /** The device's current orientation as a `Quat`. In the "world" frame (default) it's engine Y-up
141
+ * and screen-oriented, so `camera.quaternion = device.motion.attitude` is a complete magic-window
142
+ * / panorama camera. `Quat.identity` until the first sample arrives / when not running. */
143
+ get attitude(): Quat {
144
+ const s = _creatorUtils.getMotionSample?.()
145
+ if (!s) return Quat.identity
146
+ if (motionFrame === "device") return new Quat(s[0], s[1], s[2], s[3])
147
+ const q = orientedAttitude(s)
148
+ return Quat.fromAxisAngle([0, 1, 0], -motionYawOffset).mul(q) // yaw recenter (world-frame)
149
+ },
150
+ /** Gravity direction for tilt controls. In the "world" frame it's SCREEN space (x → right, y →
151
+ * down, matching clientX/clientY), orientation-aware — a 2D game reads `gravity.x / gravity.y`.
152
+ * `(0,0,0)` when not running. */
153
+ get gravity(): Vec3 {
154
+ const s = _creatorUtils.getMotionSample?.()
155
+ if (!s) return new Vec3(0, 0, 0)
156
+ if (motionFrame === "device") return new Vec3(s[4], s[5], s[6])
157
+ // Re-express the raw device-frame gravity in screen space: undo the interface roll (about the
158
+ // screen normal), then flip Y to screen-down. Uses the unambiguous CMDeviceMotion.gravity, so
159
+ // it stays correct regardless of the attitude-quaternion convention above.
160
+ const unroll = Quat.fromAxisAngle([0, 0, 1], -(ORIENTATION_ROLL[s[7]] ?? 0))
161
+ const g = new Vec3(s[4], s[5], s[6]).rotate(unroll)
162
+ return new Vec3(g.x, -g.y, g.z)
163
+ },
164
+ },
165
+ /** Current connectivity — best-effort navigator.onLine semantics: `false` only when the platform
166
+ * is sure there's no network. `true` on hosts that don't track it. Change events: `"online"` /
167
+ * `"offline"`. */
168
+ get online(): boolean {
169
+ return _creatorUtils.isOnline?.() ?? true
170
+ },
171
+ addEventListener<E extends keyof DeviceEventMap>(channel: E, callback: DeviceEventMap[E]): void {
172
+ if (channel === "online" || channel === "offline") {
173
+ appEventsOn(channel, callback as () => void)
174
+ return
175
+ }
47
176
  if (channel !== "resize") return
48
- resizeListeners.push(callback)
177
+ resizeListeners.push(callback as ResizeCallback)
49
178
  if (!resizeRegistered) {
50
179
  resizeRegistered = true
51
180
  _creatorUI.registerResizeEvent(onResize)
52
181
  }
53
182
  },
54
- removeEventListener(channel: "resize", callback: ResizeCallback): void {
183
+ removeEventListener<E extends keyof DeviceEventMap>(channel: E, callback: DeviceEventMap[E]): void {
184
+ if (channel === "online" || channel === "offline") {
185
+ appEventsOff(channel, callback as () => void)
186
+ return
187
+ }
55
188
  if (channel !== "resize") return
56
- const i = resizeListeners.indexOf(callback)
189
+ const i = resizeListeners.indexOf(callback as ResizeCallback)
57
190
  if (i >= 0) resizeListeners.splice(i, 1)
58
191
  },
59
192
  }
@@ -3,6 +3,10 @@
3
3
  /** Show a transient toast notification. */
4
4
  export const toast = (msg: string): void => _creatorUI.toast(msg)
5
5
 
6
+ /** Open `url` in the system browser / external handler (fire-and-forget, like `share`). Silent
7
+ * no-op on hosts without one (headless). */
8
+ export const openURL = (url: string): void => _creatorUtils.openUrl?.(url)
9
+
6
10
  export type SvgSourceValue = {
7
11
  readonly svg: string
8
12
  tintColor: string | null
@@ -0,0 +1,82 @@
1
+ // A host-registered headless service (geolocation, bluetooth, contacts, …) — the UI-less sibling
2
+ // of NativeView (docs/service-channel-plan.md). The host registers a *capability*
3
+ // (`registerService("geolocation") { params, channel -> ... }`); the SDK talks to it over the
4
+ // shared JSON call/event protocol (runtime/channel.ts) via `_creatorUtils.service*`.
5
+ //
6
+ // Not a public global: apps meet services only through typed plugin wrappers (Geolocation —
7
+ // src/plugins/), which subclass or wrap this exactly like QRScanner wraps NativeViewElement.
8
+
9
+ import { _channelCall, _channelEmit, _channelOff, _channelOn, type ChannelListeners } from "./channel"
10
+
11
+ /** @internal Base for typed service plugins. The host session opens lazily on the first `call()`
12
+ * (`on()` only records listeners), and `close()` releases it — a later `call()` reopens a fresh
13
+ * session. Session ids are host-allocated (websocket model): the `onEvent` callback is handed
14
+ * over at open, so no SDK-side ref registry is needed. */
15
+ export class ServiceClient {
16
+ readonly serviceName: string
17
+ readonly params: any
18
+ private _serviceId: number | null = null
19
+ private readonly _listeners: ChannelListeners = {}
20
+
21
+ constructor(serviceName: string, params: any = {}) {
22
+ this.serviceName = serviceName
23
+ this.params = params
24
+ }
25
+
26
+ /** Whether this host registered a `registerService` factory under `name`. */
27
+ static isSupported(name: string): boolean {
28
+ return typeof _creatorUtils !== "undefined" && typeof _creatorUtils.isServiceSupported === "function"
29
+ && _creatorUtils.isServiceSupported(name)
30
+ }
31
+
32
+ /** @internal open (or reuse) the host session; throws when the host has no channel/factory. */
33
+ private _ensureOpen(): number {
34
+ if (this._serviceId !== null) return this._serviceId
35
+ if (typeof _creatorUtils === "undefined" || typeof _creatorUtils.serviceOpen !== "function") {
36
+ throw new Error(`Service("${this.serviceName}") is not supported on this host (requires _creatorUtils.serviceOpen)`)
37
+ }
38
+ const id = _creatorUtils.serviceOpen(this.serviceName, JSON.stringify(this.params ?? {}),
39
+ (event: string, dataJson?: string) => _channelEmit(this._listeners, event, dataJson))
40
+ if (id < 0) {
41
+ throw new Error(`Service("${this.serviceName}") is not registered on this host`)
42
+ }
43
+ this._serviceId = id
44
+ return id
45
+ }
46
+
47
+ /** Invoke a method on the service (`geo.call("getCurrent", options)`). Args and the result are
48
+ * JSON-serialized. Rejects if the host has no such service/method. */
49
+ call(method: string, ...args: any[]): Promise<any> {
50
+ let id: number
51
+ try {
52
+ id = this._ensureOpen()
53
+ } catch (e) {
54
+ return Promise.reject(e)
55
+ }
56
+ return _channelCall(
57
+ typeof _creatorUtils !== "undefined" ? _creatorUtils.serviceCall : undefined,
58
+ `Service("${this.serviceName}") is not supported on this host (requires _creatorUtils.serviceCall)`,
59
+ id, method, args)
60
+ }
61
+
62
+ /** Subscribe to an event the service emits (`geo.on("position", cb)`). */
63
+ on(event: string, callback: (data?: any) => void): this {
64
+ _channelOn(this._listeners, event, callback)
65
+ return this
66
+ }
67
+ off(event: string, callback: (data?: any) => void): this {
68
+ _channelOff(this._listeners, event, callback)
69
+ return this
70
+ }
71
+
72
+ /** Close the host session (stops whatever the service was doing). Listeners stay registered —
73
+ * a later `call()` opens a fresh session that delivers to them again. */
74
+ close(): void {
75
+ if (this._serviceId === null) return
76
+ const id = this._serviceId
77
+ this._serviceId = null
78
+ if (typeof _creatorUtils !== "undefined" && typeof _creatorUtils.serviceClose === "function") {
79
+ _creatorUtils.serviceClose(id)
80
+ }
81
+ }
82
+ }