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.
- package/README.md +1 -0
- package/dist/index.js +920 -636
- package/package.json +9 -4
- package/runtime/scene-harness.json +1 -0
- package/runtime/sdk/compile/assetMacro.ts +4 -3
- package/runtime/sdk/compile/bundler.ts +35 -3
- package/runtime/sdk/compile/compileProject.ts +14 -2
- package/runtime/sdk/compile/libraryImports.ts +47 -0
- package/runtime/sdk/compile/sceneEditor.ts +16 -4
- package/runtime/sdk/core/Aspect.ts +37 -0
- package/runtime/sdk/core/InspectorUI.ts +212 -0
- package/runtime/sdk/core/fields.ts +15 -3
- package/runtime/sdk/g2/Scene2D.ts +51 -5
- package/runtime/sdk/gl/Model.ts +9 -0
- package/runtime/sdk/gl/Node.ts +4 -1
- package/runtime/sdk/gl/Scene.ts +76 -11
- package/runtime/sdk/gl/scenarios.ts +349 -0
- package/runtime/sdk/inject.ts +28 -8
- package/runtime/sdk/kit/UITabs.ts +105 -0
- package/runtime/sdk/plugins/camera.ts +81 -0
- package/runtime/sdk/plugins/geolocation.ts +123 -0
- package/runtime/sdk/plugins/permission.ts +7 -0
- package/runtime/sdk/plugins/qr.ts +61 -24
- package/runtime/sdk/runtime/app.ts +37 -0
- package/runtime/sdk/runtime/appEvents.ts +29 -0
- package/runtime/sdk/runtime/channel.ts +50 -0
- package/runtime/sdk/runtime/clipboard.ts +20 -0
- package/runtime/sdk/runtime/datetime.ts +2 -4
- package/runtime/sdk/runtime/device.ts +137 -4
- package/runtime/sdk/runtime/misc.ts +4 -0
- package/runtime/sdk/runtime/service.ts +82 -0
- package/runtime/sdk/runtime/touch.ts +26 -0
- package/runtime/sdk/scene/defineScene.ts +651 -17
- package/runtime/sdk/scene/editorPlugins.ts +86 -0
- package/runtime/sdk/ui/NativeView.ts +144 -0
- package/runtime/sdk/ui/UI.ts +6 -1
- package/runtime/sdk/ui/UIButton.ts +26 -3
- package/runtime/sdk/ui/UIInput.ts +2 -2
- package/runtime/sdk/ui/UINode.ts +24 -4
- package/runtime/sdk/ui/UIScreen.ts +86 -29
- package/runtime/sdk/ui/UIScreenHost.ts +213 -0
- package/runtime/sdk/ui/UIText.ts +1 -1
- package/runtime/sdk/ui/UIVideo.ts +48 -2
- package/runtime/sdk/ui/UIWidget.ts +34 -1
- package/runtime/sdk/ui/presentable.ts +116 -0
- package/runtime/sdk/ui/router.ts +73 -29
- package/runtime/sdk-types.json +1 -1
- 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 —
|
|
2
|
-
// (
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
constructor(
|
|
15
|
-
|
|
29
|
+
class QRScannerElement extends NativeViewElement {
|
|
30
|
+
constructor() {
|
|
31
|
+
super("qrScanner", {})
|
|
16
32
|
}
|
|
17
33
|
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
/**
|
|
24
|
-
async
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|