lecodes-cli 0.7.0 → 0.7.2

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 (36) hide show
  1. package/README.md +3 -3
  2. package/dist/index.js +256 -244
  3. package/package.json +4 -4
  4. package/runtime/sdk/compile/bundler.ts +4 -2
  5. package/runtime/sdk/compile/compileProject.ts +6 -1
  6. package/runtime/sdk/g2/Scene2D.ts +51 -5
  7. package/runtime/sdk/gl/Model.ts +9 -0
  8. package/runtime/sdk/gl/Scene.ts +76 -11
  9. package/runtime/sdk/inject.ts +15 -7
  10. package/runtime/sdk/plugins/camera.ts +81 -0
  11. package/runtime/sdk/plugins/geolocation.ts +123 -0
  12. package/runtime/sdk/plugins/permission.ts +7 -0
  13. package/runtime/sdk/plugins/qr.ts +61 -24
  14. package/runtime/sdk/runtime/app.ts +37 -0
  15. package/runtime/sdk/runtime/appEvents.ts +29 -0
  16. package/runtime/sdk/runtime/channel.ts +50 -0
  17. package/runtime/sdk/runtime/clipboard.ts +20 -0
  18. package/runtime/sdk/runtime/datetime.ts +2 -4
  19. package/runtime/sdk/runtime/device.ts +137 -4
  20. package/runtime/sdk/runtime/misc.ts +4 -0
  21. package/runtime/sdk/runtime/service.ts +82 -0
  22. package/runtime/sdk/runtime/touch.ts +26 -0
  23. package/runtime/sdk/ui/NativeView.ts +144 -0
  24. package/runtime/sdk/ui/UI.ts +5 -1
  25. package/runtime/sdk/ui/UIButton.ts +26 -3
  26. package/runtime/sdk/ui/UIInput.ts +2 -2
  27. package/runtime/sdk/ui/UINode.ts +33 -5
  28. package/runtime/sdk/ui/UIScreen.ts +63 -29
  29. package/runtime/sdk/ui/UIScreenHost.ts +3 -2
  30. package/runtime/sdk/ui/UIText.ts +1 -1
  31. package/runtime/sdk/ui/UIVideo.ts +48 -2
  32. package/runtime/sdk/ui/UIWidget.ts +34 -1
  33. package/runtime/sdk/ui/presentable.ts +116 -0
  34. package/runtime/sdk/ui/router.ts +73 -29
  35. package/runtime/sdk-types.json +1 -1
  36. package/runtime/sdk/runtime/camera.ts +0 -31
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lecodes-cli",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Clone, edit and push LeCodes projects from your machine.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,11 +30,11 @@
30
30
  "prepublishOnly": "bun run sync-peers && bun run build"
31
31
  },
32
32
  "dependencies": {
33
- "@letary/chisel": "^0.4.0"
33
+ "@letary/chisel": "^0.4.1"
34
34
  },
35
35
  "peerDependencies": {
36
- "lecodes-design": "^0.3.1",
37
- "lecodes-renderer": "^0.6.0",
36
+ "lecodes-design": "^0.3.2",
37
+ "lecodes-renderer": "^0.6.2",
38
38
  "lecodes-3d-editor": "^0.1.0"
39
39
  },
40
40
  "peerDependenciesMeta": {
@@ -194,7 +194,8 @@ export const bundleProject = async (
194
194
  ): Promise<string> => (await bundleProjectChisel(entrypoint, files, format, false)).code
195
195
 
196
196
  /** Like `bundleProject`, but also returns chisel's source map. The map's `sources` are the original
197
- * module paths and its generated positions are relative to `code` (line 1 = first line of `code`). */
197
+ * module paths and its generated positions are relative to `code` (line 1 = first line of `code`).
198
+ * Pass `sourcemap: false` to skip map generation entirely — `map` then comes back null. */
198
199
  export const bundleProjectWithMap = async (
199
200
  entrypoint: string,
200
201
  files: MemoryFiles,
@@ -202,4 +203,5 @@ export const bundleProjectWithMap = async (
202
203
  minify?: boolean,
203
204
  editor?: boolean,
204
205
  libraries?: Record<string, MemoryFiles>,
205
- ): Promise<{ code: string, map: string | null }> => bundleProjectChisel(entrypoint, files, format, true, minify, editor, libraries)
206
+ sourcemap = true,
207
+ ): Promise<{ code: string, map: string | null }> => bundleProjectChisel(entrypoint, files, format, sourcemap, minify, editor, libraries)
@@ -39,6 +39,9 @@ export type CompileOptions = {
39
39
  /** Override minification (default: on, `LECODES_CHISEL_MINIFY` env aside). Scene-editor bundles
40
40
  * compile with `false`: mangling erases class names, which the editor matches aspect schemas by. */
41
41
  minify?: boolean
42
+ /** Generate a source map (default true) and inline it into the bundle. `false` skips the map
43
+ * entirely — a smaller bundle, but runtime errors report generated positions only. */
44
+ sourcemap?: boolean
42
45
  /** Editor compile: the `EDITOR` global compiles to `true` (scene-editor bundles). Default false —
43
46
  * shipped bundles get `EDITOR` = `false`, const-folded so `if (EDITOR)` branches are DCE'd. */
44
47
  editor?: boolean
@@ -96,7 +99,9 @@ export const compileProject = async (opts: CompileOptions): Promise<string> => {
96
99
  }
97
100
  }
98
101
 
99
- const { code, map } = await bundleProjectWithMap(entry, files, opts.format ?? "esm", opts.minify, opts.editor, opts.libraries)
102
+ const { code, map } = await bundleProjectWithMap(
103
+ entry, files, opts.format ?? "esm", opts.minify, opts.editor, opts.libraries, opts.sourcemap ?? true,
104
+ )
100
105
  if (opts.header === false) {
101
106
  return map ? `${code}\n${inlineSourceMapComment(map)}` : code
102
107
  }
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { Color, type ColorInput } from "../core/color"
5
5
  import { _installAspectFrames } from "../core/Aspect"
6
+ import { _bumpNavEpoch, _navSupported, _setCurrent, Presentable, type PresentOptions } from "../ui/presentable"
6
7
  import { cx, cy, type Vec2Like } from "../math/vec"
7
8
  import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
8
9
  import { Camera2D } from "./Camera2D"
@@ -48,7 +49,7 @@ class LayerHandle {
48
49
  }
49
50
  }
50
51
 
51
- export class Scene2D {
52
+ export class Scene2D implements Presentable {
52
53
  /** Native scene handle. */
53
54
  readonly id: number
54
55
  readonly camera: Camera2D
@@ -111,17 +112,62 @@ export class Scene2D {
111
112
  return this
112
113
  }
113
114
 
114
- /** Make this the active scene (brings up the 2D engine on the canvas; closes any other). */
115
- open(): this {
115
+ // ---- Presentable (docs/navigation-presentable-plan.md) ----
116
+
117
+ /** @internal presentation lifecycle — fired by the host on present/dismiss (new navigation),
118
+ * or synchronously by open()/close() on legacy hosts. */
119
+ readonly ol: (() => void)[] = []
120
+ readonly cl: (() => void)[] = []
121
+ _backButtonCallback?: () => void
122
+ private _vd?: object
123
+
124
+ /** Fires when the scene becomes the visible destination (incl. a pop revealing it). */
125
+ onOpen(callback: () => void): this {
126
+ this.ol.push(callback)
127
+ return this
128
+ }
129
+ /** Fires when the scene stops being visible (closed, replaced, or covered by a push). */
130
+ onClose(callback: () => void): this {
131
+ this.cl.push(callback)
132
+ return this
133
+ }
134
+ onBackPressed(callback: () => void): this {
135
+ this._backButtonCallback = callback
136
+ return this
137
+ }
138
+
139
+ /** @internal stable wire descriptor; hosts hand it back via router onChange (`_p` = this). */
140
+ _viewDesc(): object {
141
+ return this._vd ??= { type: "scene2d", sceneId: this.id, _p: this }
142
+ }
143
+
144
+ /** Make this the active scene — shows it as the current destination (replaces a screen /
145
+ * another scene; only the active scene renders). */
146
+ open(options?: PresentOptions): this {
147
+ _bumpNavEpoch()
116
148
  // Reassert this scene's texture filter (the sampler is global — see Scene2DOptions.filter).
117
149
  if (this._filter !== undefined) _creator2d.setDefaultFilter(this._filter === 'linear')
118
- _creator2d.openScene(this.id)
150
+ if (_navSupported()) {
151
+ // The host activates the engine (openScene) as part of presenting the destination.
152
+ _creatorUI.openView!(this._viewDesc(), options?.transition ?? "none")
153
+ } else {
154
+ _creator2d.openScene(this.id)
155
+ for (const cb of this.ol) cb()
156
+ }
119
157
  Scene2D._active = this
158
+ _setCurrent(this)
120
159
  return this
121
160
  }
122
161
  close(): this {
123
- _creator2d.closeScene()
162
+ _bumpNavEpoch()
163
+ if (_navSupported()) {
164
+ _creatorUI.closeView!()
165
+ } else {
166
+ _creator2d.closeScene()
167
+ for (const cb of this.cl) cb()
168
+ }
124
169
  if (Scene2D._active === this) Scene2D._active = null
170
+ if (Presentable.current === this) _setCurrent(null)
125
171
  return this
126
172
  }
127
173
  destroy(): void {
@@ -17,6 +17,15 @@ export class Model extends Node {
17
17
  this.aspect(ModelAnimation)
18
18
  }
19
19
 
20
+ /** Duplicate this model — a deep copy of the GLB (meshes, skeleton, animation clips), attached to
21
+ * the same parent and scene and sharing this model's current transform. The clone has its own
22
+ * independent animation state (reach it via clone.anim). Mirrors Model.load's culling default. */
23
+ clone(): Model {
24
+ const id = _creator.cloneEntity(this.id)
25
+ _creator.setGlbCulling(id, false)
26
+ return new Model(id)
27
+ }
28
+
20
29
  /** Load a GLB model. Returns its root as a Model; play its baked clips via model.anim. */
21
30
  static load(
22
31
  source: string | FetchResponse,
@@ -7,8 +7,10 @@
7
7
 
8
8
  import { Color, type ColorInput } from "../core/color"
9
9
  import { _installAspectFrames } from "../core/Aspect"
10
+ import { _bumpNavEpoch, _navEpoch, _navSupported, _setCurrent, Presentable, type PresentOptions } from "../ui/presentable"
10
11
  import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
11
12
  import type { FetchResponse } from "../runtime/fetch"
13
+ import { _requestCameraPermission } from "../plugins/permission"
12
14
  import { Camera } from "./Camera"
13
15
  import { attachControls, type ControlsHandle, type ControlsOptions } from "./controls"
14
16
  import { Material } from "./Material"
@@ -46,7 +48,7 @@ const installRenderSyncedFrames = (): void => {
46
48
  })
47
49
  }
48
50
 
49
- export class Scene {
51
+ export class Scene implements Presentable {
50
52
  /** @internal native scene handle. */
51
53
  readonly _id: number
52
54
  readonly camera: Camera
@@ -115,16 +117,64 @@ export class Scene {
115
117
  return new Promise<void>((res) => _creator.warmRender(this._id, res))
116
118
  }
117
119
 
118
- /** Make this the active scene (brings up the 3D engine; closes any other). */
119
- open(): void {
120
- _creator.openScene(this._id)
120
+ // ---- Presentable (docs/navigation-presentable-plan.md) ----
121
+ // A scene is a destination like a screen: opening it replaces whatever is visible (suspending
122
+ // an active Router until Router.restore()), and it can be pushed onto the Router stack. The
123
+ // scene graph survives while covered — only the view detaches and rendering pauses.
124
+
125
+ /** @internal presentation lifecycle — fired by the host on present/dismiss (new navigation),
126
+ * or synchronously by open()/close() on legacy hosts. */
127
+ readonly ol: (() => void)[] = []
128
+ readonly cl: (() => void)[] = []
129
+ _backButtonCallback?: () => void
130
+ private _vd?: object
131
+
132
+ /** Fires when the scene becomes the visible destination (incl. a pop revealing it). */
133
+ onOpen(callback: () => void): this {
134
+ this.ol.push(callback)
135
+ return this
136
+ }
137
+ /** Fires when the scene stops being visible (closed, replaced, or covered by a push). */
138
+ onClose(callback: () => void): this {
139
+ this.cl.push(callback)
140
+ return this
141
+ }
142
+ onBackPressed(callback: () => void): this {
143
+ this._backButtonCallback = callback
144
+ return this
145
+ }
146
+
147
+ /** @internal stable wire descriptor; hosts hand it back via router onChange (`_p` = this). */
148
+ _viewDesc(): object {
149
+ return this._vd ??= { type: "scene3d", sceneId: this._id, _p: this }
150
+ }
151
+
152
+ /** Make this the active scene — shows it as the current destination (replaces a screen /
153
+ * another scene; only the active scene renders). */
154
+ open(options?: PresentOptions): void | Promise<void> {
155
+ _bumpNavEpoch()
156
+ if (_navSupported()) {
157
+ // The host activates the engine (openScene) as part of presenting the destination.
158
+ _creatorUI.openView!(this._viewDesc(), options?.transition ?? "none")
159
+ } else {
160
+ _creator.openScene(this._id)
161
+ for (const cb of this.ol) cb()
162
+ }
121
163
  Scene._active = this
122
164
  glState.activeScene = this
165
+ _setCurrent(this)
123
166
  }
124
167
  close(): void {
125
- _creator.closeScene()
168
+ _bumpNavEpoch()
169
+ if (_navSupported()) {
170
+ _creatorUI.closeView!()
171
+ } else {
172
+ _creator.closeScene()
173
+ for (const cb of this.cl) cb()
174
+ }
126
175
  if (Scene._active === this) Scene._active = null
127
176
  if (glState.activeScene === this) glState.activeScene = null
177
+ if (Presentable.current === this) _setCurrent(null)
128
178
  }
129
179
 
130
180
  addEventListener(channel: "click", callback: (ev: ClickEvent<Node | null>) => void): void
@@ -188,11 +238,26 @@ export class ARScene<T extends ARMode = "default"> extends Scene {
188
238
  return attachControls(this, target, options)
189
239
  }
190
240
 
191
- override async open(): Promise<void> {
192
- await new Promise<void>((res, rej) => _creator.requestCamera(res, rej))
193
- if (this.useWarmRender) { _creator.createGLView(); await this.warmRender() }
241
+ override async open(options?: PresentOptions): Promise<void> {
242
+ await this._prepare()
243
+ super.open(options)
244
+ }
245
+
246
+ /** @internal Presentable prepare hook: camera permission + warm render + AR session, all while
247
+ * the previous destination (typically a loading screen) stays visible — the swap to this scene
248
+ * happens only after it resolves. Warm render is headless on every platform (offscreen
249
+ * swapchain), so no GL view is created here — the host mounts the scene view at present time.
250
+ * Rejects on camera denial / AR launch failure, or if another navigation happened while
251
+ * preparing (superseded) — in every rejection case the current destination stays visible. */
252
+ async _prepare(): Promise<void> {
253
+ const epoch = _navEpoch()
254
+ await _requestCameraPermission()
255
+ if (this.useWarmRender) await this.warmRender()
194
256
  await new Promise<void>((res, rej) => _creator.launchAR(this._id, res, rej))
195
- super.open()
257
+ if (_navEpoch() !== epoch) {
258
+ _creator.stopAR()
259
+ throw new Error("ARScene.open() superseded: another destination was opened while preparing")
260
+ }
196
261
  }
197
262
 
198
263
  override close(): void {
@@ -212,9 +277,9 @@ export class ARScene<T extends ARMode = "default"> extends Scene {
212
277
  * `open()` rejects on hosts without a VR runtime ("VR is not supported on this device").
213
278
  */
214
279
  export class VRScene extends Scene {
215
- override async open(): Promise<void> {
280
+ override async open(options?: PresentOptions): Promise<void> {
216
281
  await new Promise<void>((res, rej) => _creator.launchVR(this._id, res, rej))
217
- super.open()
282
+ super.open(options)
218
283
  }
219
284
 
220
285
  override close(): void {
@@ -25,18 +25,19 @@ export { signal, computed, effect, type Signal, type Computed } from "./core/sig
25
25
  export { Canvas, Bitmap } from "./canvas/Canvas"
26
26
 
27
27
  // ---- platform runtime (injected globals; the host provides the lower-level _creator* bridges) ----
28
- export { toast, SvgSource } from "./runtime/misc"
28
+ export { toast, openURL, SvgSource } from "./runtime/misc"
29
+ export { app, type AppState } from "./runtime/app"
30
+ export { clipboard } from "./runtime/clipboard"
29
31
  export { date, type DateValue, type DateInput, type Unit, type Locale } from "./runtime/datetime"
30
- export { fetch, fetchLocal, FormData, File } from "./runtime/fetch"
32
+ export { fetch, fetchLocal, FormData, File, type FetchResponse } from "./runtime/fetch"
31
33
  export { localStorage } from "./runtime/storage"
32
- export { device } from "./runtime/device"
34
+ export { device, type HapticStyle, type MotionOptions } from "./runtime/device"
33
35
  export { Input } from "./runtime/input"
34
36
  export { WebSocket } from "./runtime/net"
35
37
  export { AudioPlayer, VideoPlayer } from "./runtime/media"
36
- export { CameraViewer } from "./runtime/camera"
37
38
  export { openFilePicker } from "./runtime/files"
38
39
  export { share } from "./runtime/share"
39
- export { ClickEvent, TouchStartEvent } from "./runtime/touch"
40
+ export { ClickEvent, TouchStartEvent, LongPressEvent } from "./runtime/touch"
40
41
 
41
42
  // ---- 2D engine (creator-2d) ----
42
43
  export { Scene2D } from "./g2/Scene2D"
@@ -53,7 +54,7 @@ export { Texture2D } from "./g2/Texture2D"
53
54
 
54
55
  // ---- UI (creator-ui) ----
55
56
  export {
56
- UIScreen, type UIScreenStyle,
57
+ UIScreen, type UIScreenStyle, type UIScrollableScreen,
57
58
  UIRow, UIColumn, UIBox, type UIContainerStyle,
58
59
  UIText, type UITextStyle,
59
60
  UIButton, type UIButtonStyle,
@@ -66,9 +67,14 @@ export {
66
67
  UISpacer, type UISpacerStyle,
67
68
  UIVirtualizedList,
68
69
  Router,
70
+ NativeView, type UINativeViewStyle,
69
71
  registerFont,
70
72
  __uiMap,
71
73
  } from "./ui/UI"
74
+ // The Presentable navigation contract (docs/navigation-presentable-plan.md). `Presentable` is
75
+ // interface + runtime companion (declaration merging): `Presentable.current` = what's on screen.
76
+ export { Presentable } from "./ui/UI"
77
+ export type { PresentOptions, Transition, TransitionName, TransitionSpec, TransitionTransform } from "./ui/UI"
72
78
  // The base node type + the union a container accepts as children — documented as the signature of
73
79
  // UIColumn/UIRow/append/etc., so they must be global (type-only; erased at runtime).
74
80
  export type { UINode, UINodeChild } from "./ui/UINode"
@@ -81,8 +87,10 @@ export { animate, animateMat4, stopAnimation, pauseAnimation, resumeAnimation }
81
87
  export { cubicBezier } from "./animate/bezier"
82
88
  export { easeIn, easeOut, easeInOut } from "./animate/easings"
83
89
 
84
- // ---- plugins (optional, host-provided capabilities — see src/plugins/) ----
90
+ // ---- plugins (optional, host-provided capabilities — typed NativeView/service wrappers, see src/plugins/) ----
85
91
  export { QRScanner } from "./plugins/qr"
92
+ export { CameraView, type CameraFacing } from "./plugins/camera"
93
+ export { Geolocation, type GeoPosition, type GeoOptions, type GeoWatch } from "./plugins/geolocation"
86
94
 
87
95
  // ---- scenes as data (.scene.ts files — see src/scene/defineScene.ts) ----
88
96
  export { defineScene, use, ref, make, SceneHandle } from "./scene/defineScene"
@@ -0,0 +1,81 @@
1
+ // CameraView — the device camera (live preview + still capture) as a Presentable. A typed wrapper
2
+ // over the `NativeView("camera")` capability (docs/navigation-presentable-plan.md): open it
3
+ // fullscreen (`await camera.open()`), push it (`Router.push(camera)`), or embed it among a
4
+ // screen's children as a live preview node. `takePhoto()` returns a `File` (a host buffer
5
+ // handle) — use it as a texture (`Texture2D.load(file)`), upload it (`FormData`), or `share` it.
6
+ //
7
+ // Host-OPTIONAL: it exists where the host registers a "camera" view (Android; web can via
8
+ // `_creatorUI._registerView`) — gate on `CameraView.isSupported`. Opening prepares first (camera
9
+ // permission) while the current destination stays visible — the same contract as ARScene/QRScanner.
10
+ //
11
+ // The pre-Presentable `_creatorUtils.camera*` bridges are gone from the ABI (2026-07-15): hosts
12
+ // keep their implementations only to serve previously compiled bundles.
13
+
14
+ import { File } from "../runtime/fetch"
15
+ import { _navEpoch, type PresentOptions } from "../ui/presentable"
16
+ import { NativeView, NativeViewElement } from "../ui/NativeView"
17
+ import { _requestCameraPermission } from "./permission"
18
+
19
+ export type CameraFacing = "front" | "back"
20
+
21
+ export interface CameraView extends NativeView {
22
+ /** Request camera permission, then present the preview. The current destination stays visible
23
+ * until the camera is ready; rejects if permission is denied, the host has no camera, or
24
+ * another navigation superseded this one. */
25
+ open(options?: PresentOptions): Promise<void>
26
+ /** Capture a still frame as an image `File` (host-encoded). */
27
+ takePhoto(): Promise<File>
28
+ /** Switch between the front and back camera while the preview is live. */
29
+ setFacingMode(facingMode: CameraFacing): Promise<void>
30
+ }
31
+
32
+ class CameraViewElement extends NativeViewElement {
33
+ constructor(options: { facingMode?: CameraFacing } = {}) {
34
+ super("camera", { facingMode: options.facingMode ?? "back" })
35
+ }
36
+
37
+ /** @internal prepare-then-present: permission before the swap (see Presentable._prepare). */
38
+ async _prepare(): Promise<void> {
39
+ const epoch = _navEpoch()
40
+ await _requestCameraPermission()
41
+ if (_navEpoch() !== epoch) {
42
+ throw new Error("CameraView.open() superseded: another destination was opened while preparing")
43
+ }
44
+ }
45
+
46
+ override async open(options?: PresentOptions): Promise<void> {
47
+ // Fail before prompting for the camera — see QRScanner.
48
+ if (!NativeView.isSupported("camera")) {
49
+ throw new Error("CameraView isn't available on this host — it needs a registered \"camera\" view.")
50
+ }
51
+ await this._prepare()
52
+ super.open(options)
53
+ }
54
+
55
+ async takePhoto(): Promise<File> {
56
+ const photo = await this.call("takePhoto")
57
+ return new File(photo.systemId, photo.name, photo.size)
58
+ }
59
+
60
+ async setFacingMode(facingMode: CameraFacing): Promise<void> {
61
+ await this.call("setFacingMode", facingMode)
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Create a camera view (default: the back camera). `CameraView.isSupported` reports whether this
67
+ * host has a camera capability — check it before offering the feature.
68
+ */
69
+ // PURE IIFE so an app that never uses the camera tree-shakes the whole plugin away (see NativeView).
70
+ export const CameraView: {
71
+ (options?: { facingMode?: CameraFacing }): CameraView
72
+ /** Whether this host registered a "camera" view. */
73
+ readonly isSupported: boolean
74
+ } = /*#__PURE__*/ (() => {
75
+ const factory = (options?: { facingMode?: CameraFacing }): CameraView =>
76
+ new CameraViewElement(options) as unknown as CameraView
77
+ Object.defineProperty(factory, "isSupported", {
78
+ get: (): boolean => NativeView.isSupported("camera"),
79
+ })
80
+ return factory as any
81
+ })()
@@ -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()