lecodes-cli 0.10.2 → 0.10.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lecodes-cli",
3
- "version": "0.10.2",
3
+ "version": "0.10.4",
4
4
  "dependencies": {
5
5
  "@letary/chisel": "^0.6.0"
6
6
  },
@@ -94,6 +94,7 @@ export { easeIn, easeOut, easeInOut } from "./animate/easings"
94
94
  export { QRScanner } from "./plugins/qr"
95
95
  export { CameraView, type CameraFacing } from "./plugins/camera"
96
96
  export { Geolocation, type GeoPosition, type GeoOptions, type GeoWatch } from "./plugins/geolocation"
97
+ export { Push, type PushPayload, type PushStatus, type PushRegisterOptions, type PushEvent } from "./plugins/push"
97
98
  export { Service } from "./plugins/service"
98
99
 
99
100
  // ---- scenes as data (.scene.ts files — see src/scene/defineScene.ts) ----
@@ -0,0 +1,132 @@
1
+ // Push — remote notifications as a typed service plugin (docs/push-plan.md). A wrapper over
2
+ // the host-registered "push" service, the Geolocation recipe applied to notifications: the
3
+ // host owns the OS token, the permission prompt, and the platform registration with the
4
+ // le.codes backend; the app only ever sees the opaque project-scoped ADDRESS (never the raw
5
+ // APNs/FCM token) plus payloads.
6
+ //
7
+ // Host-OPTIONAL, like every plugin — gate on `Push.isSupported`. The OS permission prompt
8
+ // happens inside `register()` (the first call that needs it, host-side); `getStatus()` and
9
+ // `getLaunch()` never prompt. On hosts without the service every method rejects immediately,
10
+ // BEFORE any permission path.
11
+ //
12
+ // The app is not running when a push arrives — display is host/OS work. The JS world sees
13
+ // exactly three delivery paths:
14
+ // - event "message": arrived while this app was foregrounded (the host suppresses the banner)
15
+ // - event "tap": tapped while the world was alive (warm)
16
+ // - getLaunch(): the notification that cold-started this world (stable pull, or null)
17
+ // A payload's `url` additionally rides the app-lifecycle deep-link path (`app.launchUrl` /
18
+ // the "url" event), so apps that already route links get notification routing for free.
19
+ //
20
+ // Service contract ("push"): calls `getStatus() → PushStatus`, `register({user?}) → {address}`
21
+ // (rejects "denied"/"unavailable"), `unregister()`, `getLaunch() → PushPayload|null`;
22
+ // events `message`/`tap` → PushPayload. An OPEN SESSION is the host's delivery pipe for the
23
+ // events — addEventListener opens one eagerly (cheap by the channel contract: no prompt, no I/O).
24
+
25
+ import { ServiceClient } from "../runtime/service"
26
+
27
+ /** A delivered notification, as the app sees it. */
28
+ export interface PushPayload {
29
+ title: string
30
+ body?: string
31
+ /** Deep link — also delivered through `app.launchUrl` / the app "url" event. */
32
+ url?: string
33
+ /** The developer's own JSON, ≤ 2 KB serialized. */
34
+ data?: Record<string, any>
35
+ badge?: number
36
+ }
37
+
38
+ export interface PushStatus {
39
+ /** OS notification permission for the host app. "prompt" = not asked yet. */
40
+ permission: "granted" | "denied" | "prompt"
41
+ /** Whether this device already holds a registration (an address) for this app. */
42
+ registered: boolean
43
+ }
44
+
45
+ export interface PushRegisterOptions {
46
+ /** Your app's OWN user id ("external user id"): lets your server send to `users: ["u42"]`
47
+ * instead of storing addresses, fanning out to all the user's devices. CLIENT-ASSERTED and
48
+ * unverified — never target sensitive content by user. The registration reflects the latest
49
+ * `register()` call, so registering without `user` clears it (logout). */
50
+ user?: string
51
+ }
52
+
53
+ export type PushEvent = "message" | "tap"
54
+
55
+ /**
56
+ * Remote push notifications. `Push.isSupported` reports whether this host registered the
57
+ * service — check it before offering notification features.
58
+ */
59
+ // PURE IIFE so an app that never uses push tree-shakes the whole plugin away (see Geolocation).
60
+ export const Push: {
61
+ /** Whether this host registered a "push" service. */
62
+ readonly isSupported: boolean
63
+ /** Permission + registration state. Never prompts. */
64
+ getStatus(): Promise<PushStatus>
65
+ /** Ask for permission (first time) and register this device → the opaque address your server
66
+ * targets. Idempotent — safe to call every launch; rejects "denied" / "unavailable". */
67
+ register(options?: PushRegisterOptions): Promise<{ address: string }>
68
+ /** Remove this device's registration (the address stops receiving). */
69
+ unregister(): Promise<void>
70
+ /** The notification that cold-started this app run, or null. Stable across calls. */
71
+ getLaunch(): Promise<PushPayload | null>
72
+ /** "message" = received while the app is open; "tap" = the user tapped one while it ran. */
73
+ addEventListener(event: PushEvent, callback: (payload: PushPayload) => void): void
74
+ removeEventListener(event: PushEvent, callback: (payload: PushPayload) => void): void
75
+ } = /*#__PURE__*/ (() => {
76
+ let client: ServiceClient | null = null
77
+ const service = (): ServiceClient => client ??= new ServiceClient("push")
78
+
79
+ const assertSupported = (): void => {
80
+ // Fail before the host is touched — a permission dialog for a feature that can't exist
81
+ // would be worse than the error (same ordering rule as QRScanner/Geolocation).
82
+ if (!ServiceClient.isSupported("push")) {
83
+ throw new Error('Push isn\'t available on this host — it needs a registered "push" service.')
84
+ }
85
+ }
86
+
87
+ // ServiceClient.on only records listeners; events need a live session to arrive on. Poke the
88
+ // host with the prompt-free getStatus once so a listener-only app (already registered on a
89
+ // previous run) still hears warm taps.
90
+ let pipeOpened = false
91
+ const ensurePipe = (): void => {
92
+ if (pipeOpened || !ServiceClient.isSupported("push")) return
93
+ pipeOpened = true
94
+ service().call("getStatus").catch(() => { pipeOpened = false })
95
+ }
96
+
97
+ const push = {
98
+ async getStatus(): Promise<PushStatus> {
99
+ assertSupported()
100
+ return await service().call("getStatus")
101
+ },
102
+
103
+ async register(options?: PushRegisterOptions): Promise<{ address: string }> {
104
+ assertSupported()
105
+ return await service().call("register", options ?? {})
106
+ },
107
+
108
+ async unregister(): Promise<void> {
109
+ assertSupported()
110
+ await service().call("unregister")
111
+ },
112
+
113
+ async getLaunch(): Promise<PushPayload | null> {
114
+ assertSupported()
115
+ return (await service().call("getLaunch")) ?? null
116
+ },
117
+
118
+ addEventListener(event: PushEvent, callback: (payload: PushPayload) => void): void {
119
+ // Recording on an unsupported host is a silent no-op (the events just never fire),
120
+ // like app/device listeners — only the promise-returning methods reject loudly.
121
+ service().on(event, callback as (data?: any) => void)
122
+ ensurePipe()
123
+ },
124
+ removeEventListener(event: PushEvent, callback: (payload: PushPayload) => void): void {
125
+ service().off(event, callback as (data?: any) => void)
126
+ },
127
+ }
128
+ Object.defineProperty(push, "isSupported", {
129
+ get: (): boolean => ServiceClient.isSupported("push"),
130
+ })
131
+ return push as any
132
+ })()
@@ -40,6 +40,19 @@ export const app = {
40
40
  get keyboardHeight(): number {
41
41
  return _creatorUtils.keyboardHeight?.() ?? 0
42
42
  },
43
+ /** Re-run this app's bundle in a fresh world — the platform's `location.reload()`. Pass `url`
44
+ * to reboot with different launch arguments (the new run reads them via `app.launchUrl`),
45
+ * `null` to reboot with a cleared one, or omit it to replay the current one. No-op on hosts
46
+ * without world control (headless). */
47
+ restart(url?: string | null): void {
48
+ _creatorUtils.restart?.(url)
49
+ },
50
+ /** Leave this app and return to the platform launcher (the LeCodes home screen). No-op where
51
+ * there is no launcher — a standalone-built app ignores it (platforms forbid programmatic
52
+ * exit), so a "back to LeCodes" button simply does nothing outside the LeCodes app. */
53
+ quit(): void {
54
+ _creatorUtils.quit?.()
55
+ },
43
56
  addEventListener<E extends keyof AppEventMap>(event: E, callback: AppEventMap[E]): void {
44
57
  if (event === "keyboard") {
45
58
  keyboardEventsOn(callback as (height: number, duration: number) => void)
@@ -34,17 +34,28 @@ type ChannelCallBridge = (
34
34
  onComplete: (result?: string) => void, onError: (err: string) => void,
35
35
  ) => void
36
36
 
37
- /** @internal one JSON call over a channel bridge; `bridge` may be absent (optional ABI member). */
37
+ /** @internal one JSON call over a channel bridge.
38
+ *
39
+ * Takes the HANDLER OBJECT plus the member name — never a plucked-off function reference. On
40
+ * Apple hosts the bridge members are JSExport-ed Objective-C/Swift methods, and JavaScriptCore
41
+ * binds those to their native receiver: invoking a detached `handler.viewCall(…)` with no `this`
42
+ * fails at runtime with "self type check failed for Objective-C instance method". QuickJS and web
43
+ * hosts are plain functions that ignore `this`, so a detached call works there and the breakage
44
+ * looks iOS/macOS-only. Calling through `owner` keeps the receiver attached on every host.
45
+ *
46
+ * `bridgeName` may be absent from the host object (optional ABI member) — that's the
47
+ * unsupported-host path, not an error. */
38
48
  export const _channelCall = (
39
- bridge: ChannelCallBridge | undefined, unsupportedError: string,
49
+ owner: Record<string, any> | undefined, bridgeName: string, unsupportedError: string,
40
50
  id: number, method: string, args: any[],
41
51
  ): Promise<any> =>
42
52
  new Promise((res, rej) => {
53
+ const bridge = owner?.[bridgeName] as ChannelCallBridge | undefined
43
54
  if (typeof bridge !== "function") {
44
55
  rej(new Error(unsupportedError))
45
56
  return
46
57
  }
47
- bridge(id, method, JSON.stringify(args),
58
+ bridge.call(owner, id, method, JSON.stringify(args),
48
59
  (result?: string) => res(result === undefined || result === "" ? undefined : JSON.parse(result)),
49
60
  (err: string) => rej(new Error(err)))
50
61
  })
@@ -55,7 +55,7 @@ export class ServiceClient {
55
55
  return Promise.reject(e)
56
56
  }
57
57
  return _channelCall(
58
- typeof _creatorUtils !== "undefined" ? _creatorUtils.serviceCall : undefined,
58
+ typeof _creatorUtils !== "undefined" ? _creatorUtils : undefined, "serviceCall",
59
59
  `Service("${this.serviceName}") is not supported on this host (requires _creatorUtils.serviceCall)`,
60
60
  id, method, args)
61
61
  }
@@ -107,7 +107,7 @@ export class NativeViewElement extends Element<"native"> implements Presentable
107
107
 
108
108
  call(method: string, ...args: any[]): Promise<any> {
109
109
  return _channelCall(
110
- typeof _creatorUI !== "undefined" ? _creatorUI.viewCall : undefined,
110
+ typeof _creatorUI !== "undefined" ? _creatorUI : undefined, "viewCall",
111
111
  `NativeView("${this.viewName}") is not supported on this host (requires _creatorUI.viewCall)`,
112
112
  this._viewId, method, args)
113
113
  }
@@ -1,7 +1,13 @@
1
1
  import { Element, type Color, type DrawableStyle, type ElementStyle, type OnLayoutCallback, type BoundingClientRect, type PaddingStyle, type Classes, type Style, type UINodeChild } from "./UINode"
2
2
 
3
3
  // refreshControlColor tints the native pull-to-refresh spinner (pairs with onRefresh).
4
- export type UIVirtualizedListStyle = DrawableStyle & ElementStyle & { refreshControlColor?: Color }
4
+ // keyboardDismissMode: same key and semantics as UIScrollable's — hosts treat a vlist as a scroll
5
+ // container, so it always honored this; only the type was missing. A chat is the reason it exists:
6
+ // "none" keeps the composer's keyboard up no matter how the history is scrolled.
7
+ export type UIVirtualizedListStyle = DrawableStyle & ElementStyle & {
8
+ refreshControlColor?: Color,
9
+ keyboardDismissMode?: "interactive" | "scroll" | "none",
10
+ }
5
11
 
6
12
  export interface VListConfig<T> {
7
13
  /** Стабильный уникальный ключ айтема */
@@ -23,7 +23,8 @@ export interface UIWidget {
23
23
  * (= back to a global overlay). Set it while the widget is hidden. The widget is always its own
24
24
  * overlay root mounted into the owner's page view — it never becomes a child of the owner, so it
25
25
  * stays topmost and its updates never trigger the owner's relayout. Works with a `UIScreen` that
26
- * is a page inside a `UIPager`: the widget mounts into that tab and rides its swipe.
26
+ * is a page inside a `UIPager`: the widget mounts into that tab and rides its swipe. (Web shows
27
+ * and hides it with the owner but does not yet ride the transition.)
27
28
  */
28
29
  attachTo(owner: Presentable | null): this,
29
30