lecodes-cli 0.10.2 → 0.10.3
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/dist/index.js +1 -1
- package/package.json +1 -1
- package/runtime/sdk/inject.ts +1 -0
- package/runtime/sdk/plugins/push.ts +132 -0
- package/runtime/sdk/runtime/app.ts +13 -0
- package/runtime/sdk-types.json +1 -1
package/dist/index.js
CHANGED
package/package.json
CHANGED
package/runtime/sdk/inject.ts
CHANGED
|
@@ -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)
|