lecodes-sdk 2.2.13 → 2.3.0

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/dist/global.d.ts +11 -0
  2. package/dist/types/gl/Scene.d.ts +0 -11
  3. package/dist/types/gl/postProcessing.d.ts +31 -2
  4. package/dist/types/inject.d.ts +3 -2
  5. package/dist/types/plugin.d.ts +3 -0
  6. package/dist/types/plugins/gen/push/sdk/push.d.ts +10 -3
  7. package/dist/types/plugins/gen/push/sdk/push.gen.d.ts +11 -3
  8. package/dist/types/runtime/device.d.ts +7 -19
  9. package/dist/types/runtime/pushSeam.d.ts +23 -0
  10. package/dist/types/runtime/rpc.d.ts +15 -1
  11. package/dist/types/server/auth/models.d.ts +13 -1
  12. package/dist/types/server/auth/types.d.ts +2 -0
  13. package/dist/types/server/inject.d.ts +1 -0
  14. package/dist/types/server/pushChannel.d.ts +79 -0
  15. package/dist/types/ui/UINode.d.ts +1 -1
  16. package/dist/types/version.d.ts +1 -1
  17. package/dist/types.json +1 -1
  18. package/package.json +1 -1
  19. package/src/animate/tween/Timeline.ts +175 -175
  20. package/src/bridges/gl.d.ts +5 -13
  21. package/src/compile/serverSplit.ts +29 -19
  22. package/src/compile/serverTypes.ts +1 -1
  23. package/src/gl/CameraPlace.ts +52 -52
  24. package/src/gl/Light.ts +257 -257
  25. package/src/gl/Mesh.ts +120 -120
  26. package/src/gl/Scene.ts +0 -13
  27. package/src/gl/animation/DynamicBone.ts +486 -486
  28. package/src/gl/postProcessing.ts +52 -9
  29. package/src/gl/scenarios.ts +291 -291
  30. package/src/inject.ts +3 -2
  31. package/src/plugin.ts +3 -0
  32. package/src/plugins/gen/push/contract.d.ts +24 -2
  33. package/src/plugins/gen/push/sdk/push.gen.ts +17 -5
  34. package/src/plugins/gen/push/sdk/push.ts +159 -55
  35. package/src/runtime/device.ts +8 -37
  36. package/src/runtime/pushSeam.ts +22 -0
  37. package/src/runtime/rpc.ts +35 -1
  38. package/src/server/auth/host.ts +40 -9
  39. package/src/server/auth/models.ts +5 -1
  40. package/src/server/auth/types.ts +2 -0
  41. package/src/server/host.ts +6 -0
  42. package/src/server/inject.ts +3 -2
  43. package/src/server/push/host.ts +143 -0
  44. package/src/server/pushChannel.ts +121 -0
  45. package/src/server/runtime.ts +20 -8
  46. package/src/ui/UIInput.ts +2 -2
  47. package/src/ui/UINode.ts +1 -1
  48. package/src/version.ts +1 -1
@@ -7,14 +7,22 @@
7
7
  // An OPEN SESSION is the delivery pipe of the events. Opening one is free (no prompt, no I/O).
8
8
  // Identity: every call is attributed to the world that makes it (the project's uuid, held by the
9
9
  // host); a world without one rejects "unavailable" BEFORE any permission path.
10
+ //
11
+ // A notification reaches the JS world through this channel ALONE: a tap is the "tap" event (or
12
+ // `getLaunch()` for the one that started the process — the SDK's wrapper replays it into the first
13
+ // "tap" listener), a foreground arrival is "message". Its `url` is the app's to route — it does NOT
14
+ // ride the app's deep-link path (`app.launchUrl`, the "url" event).
10
15
 
11
16
  import type { Json, Service, i32 } from "../../../plugin"
12
17
 
13
18
  /** A delivered notification, as the app sees it. */
14
19
  export interface PushPayload {
20
+ /** The push channel it was sent through — the id of the `pushChannel()` export of the project's
21
+ * server (`<path>#<name>`). Absent = sent outside one (a push key, `lecodes pn send`). */
22
+ channel?: string
15
23
  title: string
16
24
  body?: string
17
- /** A deep link — it also rides the app's own link path (`app.launchUrl`, the "url" event). */
25
+ /** A deep link, for the app to open itself (it does not ride `app.launchUrl`). */
18
26
  url?: string
19
27
  /** The developer's own JSON object, ≤ 2 KB serialized. */
20
28
  data?: { [key: string]: Json }
@@ -26,6 +34,8 @@ export interface PushStatus {
26
34
  permission: "granted" | "denied" | "prompt"
27
35
  /** Whether this device holds a registration (an address) for this app. */
28
36
  registered: boolean
37
+ /** The device's address for this app, when it is registered. @since 2 */
38
+ address?: string
29
39
  }
30
40
 
31
41
  export interface PushRegisterOptions {
@@ -38,10 +48,19 @@ export interface PushRegistration {
38
48
  address: string
39
49
  }
40
50
 
51
+ /** What the app shows itself when a notification arrives while it is in the foreground (the
52
+ * "message" event): the host shows the system banner for every other one. */
53
+ export interface PushHandled {
54
+ /** The push channels with a foreground handler. */
55
+ channels: string[]
56
+ /** Whether notifications sent outside a channel have one. */
57
+ untyped: boolean
58
+ }
59
+
41
60
  export interface PushParams {}
42
61
 
43
62
  export interface PushEvents {
44
- /** Arrived while this app was in the foreground (the host shows no banner). */
63
+ /** Arrived while this app was in the foreground and the app handles it (PushHandled): no banner. */
45
64
  message: PushPayload
46
65
  /** Tapped while the world was alive. */
47
66
  tap: PushPayload
@@ -58,4 +77,7 @@ export interface Push extends Service<"push", PushParams, PushEvents> {
58
77
  unregister(): Promise<void>
59
78
  /** The notification that cold-started this world, or null. Stable across calls. */
60
79
  getLaunch(): Promise<PushPayload | null>
80
+ /** Which notifications the app handles itself in the foreground; the rest get the system banner.
81
+ * Until it is called nothing is handled. @since 2 */
82
+ setMessageHandled(handled: PushHandled): Promise<void>
61
83
  }
@@ -1,15 +1,15 @@
1
1
  // GENERATED by scripts/vendor-plugins.ts from lecodes-plugins/plugins/push/sdk/push.gen.ts — do not edit here.
2
2
  // Change it in the plugin repo and run `bun run vendor:plugins`; `bun run check:plugins` fails when this copy is stale.
3
3
  // GENERATED by `lecodes plugin gen` from contract.d.ts — do not edit.
4
- // push: the service "push", contract version 1.
4
+ // push: the service "push", contract version 2.
5
5
 
6
6
  import { ServiceChannel } from "../../../../plugin"
7
- import type { PushEvents, PushParams, PushPayload, PushRegisterOptions, PushRegistration, PushStatus } from "../contract"
7
+ import type { PushEvents, PushHandled, PushParams, PushPayload, PushRegisterOptions, PushRegistration, PushStatus } from "../contract"
8
8
 
9
9
  /** What the halves register under. */
10
10
  export const NAME = "push"
11
11
  /** The contract's version: the highest `@since` of its members. */
12
- export const VERSION = 1
12
+ export const VERSION = 2
13
13
 
14
14
  // ---- the wire, call by call ----------------------------------------------------------------------
15
15
 
@@ -31,15 +31,23 @@ export const unregister = (service: ServiceChannel<PushEvents>): Promise<void> =
31
31
  export const getLaunch = (service: ServiceChannel<PushEvents>): Promise<PushPayload | null> =>
32
32
  service._call("getLaunch", []).then((w: any) => w ?? null)
33
33
 
34
+ /** Which notifications the app handles itself in the foreground; the rest get the system banner.
35
+ * Until it is called nothing is handled.
36
+ * Contract version 2: an older half rejects the call — ask `supports("setMessageHandled")`. */
37
+ export const setMessageHandled = (service: ServiceChannel<PushEvents>, handled: PushHandled): Promise<void> =>
38
+ service._call("setMessageHandled", [handled], 2)
39
+
34
40
  // ---- what the host's half has --------------------------------------------------------------------
35
41
 
36
42
  /** A method of the contract. */
37
- export type Method = "getStatus" | "register" | "unregister" | "getLaunch"
43
+ export type Method = "getStatus" | "register" | "unregister" | "getLaunch" | "setMessageHandled"
44
+
45
+ const SINCE: { [method: string]: number } = { setMessageHandled: 2 }
38
46
 
39
47
  /** Whether this host has the service "push" — and, given a method, a half new enough for it
40
48
  * (a method added after the first version of the contract is tagged `@since`). */
41
49
  export const supports = (method?: Method): boolean =>
42
- ServiceChannel.supports(NAME) // every method is of version 1
50
+ ServiceChannel.supports(NAME, method === undefined ? 1 : SINCE[method] ?? 1)
43
51
 
44
52
  // ---- the client -----------------------------------------------------------------------------------
45
53
 
@@ -57,4 +65,8 @@ export class PushBase extends ServiceChannel<PushEvents> {
57
65
  unregister(): Promise<void> { return unregister(this) }
58
66
  /** The notification that cold-started this world, or null. Stable across calls. */
59
67
  getLaunch(): Promise<PushPayload | null> { return getLaunch(this) }
68
+ /** Which notifications the app handles itself in the foreground; the rest get the system banner.
69
+ * Until it is called nothing is handled.
70
+ * Contract version 2: an older half rejects the call — ask `supports("setMessageHandled")`. */
71
+ setMessageHandled(handled: PushHandled): Promise<void> { return setMessageHandled(this, handled) }
60
72
  }
@@ -4,102 +4,206 @@
4
4
  // registration with the le.codes relay; the app only ever sees the opaque project-scoped ADDRESS
5
5
  // (never the raw APNs / FCM token) plus payloads.
6
6
  //
7
- // Hand-written over the generated wire because the public API is a module singleton with
8
- // `addEventListener`, and a listener must OPEN the session: events arrive on a live one only.
7
+ // Two readers of this module:
8
+ // - the app, through `Push`: the permission (`register`), the state (`getStatus`), and the
9
+ // notifications sent OUTSIDE a push channel — a push key, `lecodes pn send` — as events;
10
+ // - the SDK's transport and its push-channel proxies, through the runtime `__pushEnable()`
11
+ // installs (lecodes-sdk/plugin `_pushRuntime`): the device's address for the `x-lecodes-push`
12
+ // header, the registration the server asks for, the handlers of each `pushChannel()` export.
13
+ // The compiler puts `__pushEnable()` at the top of every server stub of a project with push
14
+ // channels, so an app that has none never carries this module.
9
15
  //
10
16
  // The app is not running when a push arrives — display is host / OS work. The JS world sees
11
- // exactly three delivery paths:
12
- // - event "message": arrived while this app was foregrounded (the host suppresses the banner)
13
- // - event "tap": tapped while the world was alive (warm)
14
- // - getLaunch(): the notification that cold-started this world (stable pull, or null)
15
- // A payload's `url` additionally rides the app-lifecycle deep-link path (`app.launchUrl` /
16
- // the "url" event), so apps that already route links get notification routing for free.
17
+ // exactly two delivery paths:
18
+ // - "tap": tapped, whether the app was running or the tap started it (the cold start is
19
+ // read with the service's getLaunch once and replayed into the first matching
20
+ // tap handler, AFTER the top-level code — the handler may route);
21
+ // - "message": arrived while this app was in the foreground AND the app handles it (a message
22
+ // handler of its channel, or an untyped one): the host shows no banner for those,
23
+ // and the system banner for every other.
24
+ // A payload's `url` is the app's to route; it does not ride the deep-link path.
17
25
  //
18
- // The OS permission prompt happens inside `register()`; `getStatus()` and `getLaunch()` never
19
- // prompt. On a host without the service every method rejects at once, BEFORE any permission path.
26
+ // A notification is heard ONCE: one sent through a push channel by its channel's handlers, one
27
+ // sent outside a channel by the untyped listeners (`Push.addEventListener`).
28
+ //
29
+ // The OS permission prompt happens inside `register()`; `getStatus()` never prompts. On a host
30
+ // without the service every method rejects at once, BEFORE any permission path.
20
31
 
21
- import { NAME, PushBase } from "./push.gen"
32
+ import { NAME, PushBase, supports } from "./push.gen"
33
+ import { _pushRuntime } from "../../../../plugin"
22
34
  import type { PushEvents, PushPayload, PushRegisterOptions, PushRegistration, PushStatus } from "../contract"
23
35
 
24
36
  export type { PushPayload, PushRegisterOptions, PushStatus }
25
37
 
26
38
  export type PushEvent = keyof PushEvents
39
+ type Handler = (payload: PushPayload) => void
40
+ /** Handlers by push channel id; "" = the untyped ones. */
41
+ type Handlers = Map<string, Set<Handler>>
27
42
 
28
- /**
29
- * Remote push notifications. `Push.isSupported` reports whether this host registered the
30
- * service — check it before offering notification features.
31
- */
32
43
  // PURE IIFE so an app that never uses push tree-shakes the whole plugin away.
33
- export const Push: {
34
- /** Whether this host registered a "push" service. */
35
- readonly isSupported: boolean
36
- /** Permission + registration state. Never prompts. */
37
- getStatus(): Promise<PushStatus>
38
- /** Ask for permission (first time) and register this device → the opaque address your server
39
- * targets. Idempotent — safe to call every launch; rejects "denied" / "unavailable". */
40
- register(options?: PushRegisterOptions): Promise<PushRegistration>
41
- /** Remove this device's registration (the address stops receiving). */
42
- unregister(): Promise<void>
43
- /** The notification that cold-started this app run, or null. Stable across calls. */
44
- getLaunch(): Promise<PushPayload | null>
45
- /** "message" = received while the app is open; "tap" = the user tapped one while it ran. */
46
- addEventListener(event: PushEvent, callback: (payload: PushPayload) => void): void
47
- removeEventListener(event: PushEvent, callback: (payload: PushPayload) => void): void
48
- } = /*#__PURE__*/ (() => {
44
+ const built = /*#__PURE__*/ (() => {
49
45
  let client: PushBase | null = null
50
46
  const service = (): PushBase => client ??= new PushBase()
47
+ const supported = (): boolean => supports()
51
48
 
52
49
  const assertSupported = (): void => {
53
50
  // Fail before the host is touched — a permission dialog for a feature that can't exist
54
51
  // would be worse than the error.
55
- if (!PushBase.supports(NAME)) {
52
+ if (!supported()) {
56
53
  throw new Error('Push isn\'t available on this host — it needs a registered "push" service.')
57
54
  }
58
55
  }
59
56
 
60
- // `on` only records listeners; events need a live session to arrive on. Poke the host with the
61
- // prompt-free getStatus once so a listener-only app (already registered on a previous run)
62
- // still hears warm taps.
63
- let pipeOpened = false
57
+ // ── the address: what the transport tells the server ──
58
+ let address: string | null = null
59
+ const learn = (a: string | null | undefined): void => { if (a) address = a }
60
+
61
+ // ── the handlers ──
62
+ const taps: Handlers = new Map()
63
+ const messages: Handlers = new Map()
64
+ const add = (map: Handlers, key: string, fn: Handler): void => {
65
+ let set = map.get(key)
66
+ if (!set) map.set(key, set = new Set())
67
+ set.add(fn)
68
+ }
69
+ const remove = (map: Handlers, key: string, fn: Handler): void => { map.get(key)?.delete(fn) }
70
+ /** A payload's handlers: its channel's, or the untyped ones when it was sent outside a channel. */
71
+ const handlersOf = (map: Handlers, p: PushPayload): Set<Handler> | undefined => map.get(p.channel ?? "")
72
+ const dispatch = (map: Handlers, p: PushPayload): void => {
73
+ const set = handlersOf(map, p)
74
+ if (set) for (const fn of [...set]) fn(p)
75
+ }
76
+
77
+ // Events arrive on a live session only; a handler opens the pipe. The prompt-free getStatus is the
78
+ // opening call, and it tells the address of an earlier registration.
79
+ let piped = false
64
80
  const ensurePipe = (): void => {
65
- if (pipeOpened || !PushBase.supports(NAME)) return
66
- pipeOpened = true
67
- service().getStatus().catch(() => { pipeOpened = false })
81
+ if (piped || !supported()) return
82
+ piped = true
83
+ const s = service()
84
+ s.on("tap", (p: PushPayload) => dispatch(taps, p))
85
+ s.on("message", (p: PushPayload) => dispatch(messages, p))
86
+ s.getStatus().then(st => learn(st.address), () => {})
87
+ }
88
+
89
+ // The notification that started the process: replayed into the first tap handler that matches it,
90
+ // asynchronously — after the top-level code that attached the handler, so it may route.
91
+ let launch: Promise<PushPayload | null> | null = null
92
+ let launchDelivered = false
93
+ const replayLaunch = (): void => {
94
+ if (launchDelivered || !supported()) return
95
+ launch ??= service().getLaunch().catch(() => null)
96
+ launch.then(p => {
97
+ if (!p || launchDelivered) return
98
+ const set = handlersOf(taps, p)
99
+ if (!set?.size) return // its handler may be attached later: tried again then
100
+ launchDelivered = true
101
+ for (const fn of [...set]) fn(p)
102
+ })
103
+ }
104
+
105
+ // What the host shows no banner for in the foreground: the channels with a message handler.
106
+ const announceHandled = (): void => {
107
+ if (!supports("setMessageHandled")) return
108
+ const channels = [...messages].filter(([key, set]) => key && set.size).map(([key]) => key)
109
+ service().setMessageHandled({ channels, untyped: (messages.get("")?.size ?? 0) > 0 }).catch(() => {})
110
+ }
111
+
112
+ /** One handler of one kind for one channel ("" = untyped); returns its remover. Recording on an
113
+ * unsupported host is a silent no-op (the events never fire), like app / device listeners. */
114
+ const listen = (kind: PushEvent, channel: string, fn: Handler): () => void => {
115
+ const map = kind === "tap" ? taps : messages
116
+ add(map, channel, fn)
117
+ ensurePipe()
118
+ if (kind === "tap") replayLaunch()
119
+ else announceHandled()
120
+ return () => {
121
+ remove(map, channel, fn)
122
+ if (kind === "message") announceHandled()
123
+ }
124
+ }
125
+
126
+ // The server asked this device to register (the envelope's `push: true`): once per run. The prompt
127
+ // appears only when the permission was never asked; a refusal is final (the OS asks no twice).
128
+ let asked = false
129
+ const requestRegister = (): void => {
130
+ if (asked || !supported()) return
131
+ asked = true
132
+ service().getStatus()
133
+ .then(st => {
134
+ learn(st.address)
135
+ if (st.permission === "denied") return
136
+ return service().register({}).then(r => learn(r.address))
137
+ })
138
+ .catch(() => {})
68
139
  }
69
140
 
70
141
  const push = {
71
142
  async getStatus(): Promise<PushStatus> {
72
143
  assertSupported()
73
- return await service().getStatus()
144
+ const st = await service().getStatus()
145
+ learn(st.address)
146
+ return st
74
147
  },
75
148
 
76
149
  async register(options?: PushRegisterOptions): Promise<PushRegistration> {
77
150
  assertSupported()
78
- return await service().register(options ?? {})
151
+ const r = await service().register(options ?? {})
152
+ learn(r.address)
153
+ return r
79
154
  },
80
155
 
81
156
  async unregister(): Promise<void> {
82
157
  assertSupported()
83
158
  await service().unregister()
159
+ address = null
84
160
  },
85
161
 
86
- async getLaunch(): Promise<PushPayload | null> {
87
- assertSupported()
88
- return (await service().getLaunch()) ?? null
162
+ addEventListener(event: PushEvent, callback: Handler): void {
163
+ listen(event, "", callback)
89
164
  },
90
-
91
- addEventListener(event: PushEvent, callback: (payload: PushPayload) => void): void {
92
- // Recording on an unsupported host is a silent no-op (the events just never fire),
93
- // like app / device listeners — only the promise-returning methods reject loudly.
94
- service().on(event, callback)
95
- ensurePipe()
96
- },
97
- removeEventListener(event: PushEvent, callback: (payload: PushPayload) => void): void {
98
- service().off(event, callback)
165
+ removeEventListener(event: PushEvent, callback: Handler): void {
166
+ remove(event === "tap" ? taps : messages, "", callback)
167
+ if (event === "message") announceHandled()
99
168
  },
100
169
  }
101
170
  Object.defineProperty(push, "isSupported", {
102
- get: (): boolean => PushBase.supports(NAME),
171
+ get: (): boolean => supported(),
103
172
  })
104
- return push as any
173
+
174
+ const runtime = {
175
+ address: (): string | null => address,
176
+ requestRegister,
177
+ on: listen,
178
+ }
179
+ return { push, runtime }
105
180
  })()
181
+
182
+ /**
183
+ * Remote push notifications. `Push.isSupported` reports whether this host registered the
184
+ * service — check it before offering notification features.
185
+ *
186
+ * A project with a backend needs none of this: its `pushChannel()` exports are heard with their
187
+ * own `onTap` / `onMessage`, and the device is registered when the server asks (after a sign-in).
188
+ * `Push` is for the rest — a permission asked at a moment of the app's choosing, and the
189
+ * notifications sent outside a channel (a push key, `lecodes pn send`).
190
+ */
191
+ export const Push: {
192
+ /** Whether this host registered a "push" service. */
193
+ readonly isSupported: boolean
194
+ /** Permission + registration state. Never prompts. */
195
+ getStatus(): Promise<PushStatus>
196
+ /** Ask for permission (first time) and register this device → the opaque address your server
197
+ * targets. Idempotent — safe to call every launch; rejects "denied" / "unavailable". */
198
+ register(options?: PushRegisterOptions): Promise<PushRegistration>
199
+ /** Remove this device's registration (the address stops receiving). */
200
+ unregister(): Promise<void>
201
+ /** The notifications sent outside a push channel: "tap" = tapped (the one that started the app
202
+ * too); "message" = arrived while the app is open (the app shows it, the host no banner). */
203
+ addEventListener(event: PushEvent, callback: (payload: PushPayload) => void): void
204
+ removeEventListener(event: PushEvent, callback: (payload: PushPayload) => void): void
205
+ } = built.push as any
206
+
207
+ /** Installs the push runtime for the SDK's transport and its push-channel proxies (lecodes-sdk/plugin
208
+ * `_pushRuntime`). A line of every server stub of a project with push channels — never app code. */
209
+ export const __pushEnable = (): void => { _pushRuntime.current = built.runtime }
@@ -30,11 +30,8 @@ const onResize = (width: number, height: number): void => {
30
30
 
31
31
  // --- device.hdr state ---
32
32
  // The 3D bridge is a free global that a 2D-only bundle never defines — reach it defensively. Hosts
33
- // without the methods are SDR by definition; the look setters are remembered so the getters read
34
- // back what the app asked for.
33
+ // without the methods are SDR by definition.
35
34
  const gl = (): typeof _creator | undefined => (typeof _creator !== "undefined" ? _creator : undefined)
36
- let hdrStrengthLocal = 0.35
37
- let hdrPaperWhiteLocal = 1
38
35
  // `hdrchange` is a poll: the engine quantises the headroom to half-stops, so a change is rare and
39
36
  // a 4 Hz timer (only while someone listens) is cheaper than a per-frame bridge call.
40
37
  const hdrListeners: HdrChangeCallback[] = []
@@ -202,11 +199,13 @@ export const device = {
202
199
  return new Vec3(g.x, -g.y, g.z)
203
200
  },
204
201
  },
205
- /** The display's extended dynamic range — HDR. On a screen with headroom above SDR white (Apple's
206
- * XDR panels; the macOS host today) the 3D surface is float and the engine renders past 1.0:
207
- * 1.0 is the white of the UI and of a diffuse white surface, and the sun, emissives and speculars
208
- * climb up to `headroom` times that. Everywhere else it is SDR: `available` false, `headroom` 1,
209
- * the look setters remembered but invisible.
202
+ /** The display's extended dynamic range — HDR: the FACTS about the display. On the macOS desktop
203
+ * host, in a visible window on a screen with headroom above SDR white (Apple's XDR panels, an HDR
204
+ * external display), the 3D surface is float and the engine renders past 1.0: 1.0 is the white of
205
+ * the UI and of a diffuse white surface, and the sun, emissives and speculars climb up to
206
+ * `headroom` times that. Everywhere else it is SDR (the other hosts, a hidden window, a render to
207
+ * PNG): `available` false, `headroom` 1. The LOOK on such a display — how much of the picture
208
+ * reaches for the peak, where white lands — is the scene's: `scene.postProcessing.hdr`.
210
209
  *
211
210
  * Branch content on `available`, not on `headroom` — the live value ramps up from 1 over the
212
211
  * first seconds after launch and follows the brightness keys. Particle `emissive` is the thing
@@ -230,34 +229,6 @@ export const device = {
230
229
  const v = gl()?.getDisplayHeadroom?.() ?? 1
231
230
  return v > 1 ? v : 1
232
231
  },
233
- /** How much of the picture reaches for the headroom, 0..1 (default 0.35). 0 touches only what
234
- * SDR clipped — faithful to the SDR grade, but a mostly-mid-tone frame then looks flat next to
235
- * the one bright spot; 1 lifts nearly everything above black toward the peak. A sunlit exterior
236
- * takes 1, a dim interior wants less. A host may pin it (`LECODES_HDR_STRENGTH`). */
237
- get strength(): number {
238
- return gl()?.getHdrStrength?.() ?? hdrStrengthLocal
239
- },
240
- set strength(v: number) {
241
- const s = Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 0.35
242
- hdrStrengthLocal = s
243
- gl()?.setHdrStrength?.(s)
244
- },
245
- /** Where white lands, as a multiple of SDR white, 1..8 (default 1) — the "HDR brightness" of a
246
- * console game's calibration screen, where paper white sits at ~200 nits against SDR's ~100.
247
- * It scales the WHOLE picture: at 1 a white wall is as bright as the UI's white, at 2 it is
248
- * twice that, and highlights still climb above it (the engine keeps `headroom / paperWhite` for
249
- * them, clamped at the peak). `strength` decides how much of the picture reaches for the
250
- * peak; this decides where the picture starts. 1.5–2 is the console norm; a game that wants to
251
- * read as "HDR on" rather than "SDR with a brighter sun" wants this over `strength`. A host may
252
- * pin it (`LECODES_HDR_PAPER_WHITE`). */
253
- get paperWhite(): number {
254
- return gl()?.getHdrPaperWhite?.() ?? hdrPaperWhiteLocal
255
- },
256
- set paperWhite(v: number) {
257
- const p = Number.isFinite(v) ? Math.min(8, Math.max(1, v)) : 1
258
- hdrPaperWhiteLocal = p
259
- gl()?.setHdrPaperWhite?.(p)
260
- },
261
232
  },
262
233
  /** Current connectivity — best-effort navigator.onLine semantics: `false` only when the platform
263
234
  * is sure there's no network. `true` on hosts that don't track it. Change events: `"online"` /
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The seam between the app's transport (./rpc.ts) and the push plugin's wrapper (src/plugins/gen/push/sdk/push.ts).
3
+ *
4
+ * A push channel (`pushChannel()` of a *.server.ts file — src/server/pushChannel.ts) is heard in the app through
5
+ * the plugin: its taps and foreground arrivals come off the plugin's service. The transport, for its part, tells
6
+ * the server the device's address (the `x-lecodes-push` header of every call) and registers the device when the
7
+ * server asks for it (the envelope's `push: true`). Neither may import the wrapper: an app without push channels
8
+ * would then carry the plugin. So the wrapper installs itself here — `__pushEnable()`, a line the compiler puts at
9
+ * the top of every server stub of a project with push channels — and the transport reads what is installed.
10
+ */
11
+ export type PushRuntime = {
12
+ /** This device's address for the app, once known (after a registration, or the status of an earlier one). */
13
+ address(): string | null
14
+ /** The server asked this device to register: once per run, prompt-free when the permission was given,
15
+ * nothing past a refusal. */
16
+ requestRegister(): void
17
+ /** Hear the taps ("tap") or the foreground arrivals ("message") of one push channel, by its id. Returns the remover. */
18
+ on(kind: "tap" | "message", channel: string, handler: (payload: any) => void): () => void
19
+ }
20
+
21
+ /** What the wrapper installed; null = the app has no push channels (or the plugin is not in the bundle). */
22
+ export const _pushRuntime: { current: PushRuntime | null } = { current: null }
@@ -8,7 +8,8 @@
8
8
  * export const db = __serverOnly("schema.server.ts#db")
9
9
  *
10
10
  * Wire (implemented by the runner):
11
- * POST <serverUrl>/api/<id> body {"args":[…]}, `authorization: Bearer <session>` when known
11
+ * POST <serverUrl>/api/<id> body {"args":[…]}, `authorization: Bearer <session>` when known,
12
+ * `x-lecodes-push: <address>` once the device registered for notifications
12
13
  * — with a `File` among the arguments: multipart, the field `args` is
13
14
  * that JSON with `{"$file": <i>}` where the file stood, `file<i>` the files
14
15
  * → 200 {"ok":true,"result":…,"session"?:"…"} | {"ok":false,"status":n,"message":"…","session"?:"…"}
@@ -24,6 +25,7 @@ import { localStorage } from "./storage"
24
25
  import { WebSocket } from "./net"
25
26
  import type { ChannelGroup, ClientFrame, ServerFrame } from "./wire"
26
27
  import type { ChannelSubscription } from "../server/channel"
28
+ import { _pushRuntime } from "./pushSeam"
27
29
 
28
30
  /** Rejection value of a failed endpoint call: `status` mirrors the server's `ApiError`
29
31
  * (0 = the request itself failed: no network / no server). */
@@ -69,6 +71,9 @@ export const __rpc = (serverUrl: string, id: string) => {
69
71
  const headers: Record<string, string> = {}
70
72
  const session = getSession(serverUrl)
71
73
  if (session) headers.authorization = `Bearer ${session}`
74
+ // the device's push address, for the session to carry (src/server/auth/host.ts) — what a push channel reaches the user by
75
+ const pushAddress = _pushRuntime.current?.address()
76
+ if (pushAddress) headers["x-lecodes-push"] = pushAddress
72
77
  // a File anywhere among the arguments leaves its place to a marker and travels beside them
73
78
  const files: File[] = []
74
79
  const json = JSON.stringify({ args }, (_key, value) => value instanceof File ? { $file: files.push(value) - 1 } : value)
@@ -88,6 +93,8 @@ export const __rpc = (serverUrl: string, id: string) => {
88
93
  let body: any
89
94
  try { body = res.json() } catch { body = null }
90
95
  if (body && typeof body === "object" && "session" in body) setSession(serverUrl, body.session ?? null)
96
+ // the server asks this device to register for notifications: a signed-in session without an address, and push channels to hear
97
+ if (body && typeof body === "object" && body.push === true) _pushRuntime.current?.requestRegister()
91
98
  if (body && body.ok === true) return body.result
92
99
  if (body && body.ok === false) throw new RpcError(body.status ?? res.status, body.message ?? "")
93
100
  throw new RpcError(res.status, `Endpoint ${id}: unexpected response (HTTP ${res.status})`)
@@ -287,3 +294,30 @@ export const __channel = (serverUrl: string, id: string) => ({
287
294
  throw new RpcError(0, `Channel ${id}: the server publishes, the app subscribes — call a server function that publishes`)
288
295
  },
289
296
  })
297
+
298
+ // ───────────────────────────── push channels ─────────────────────────────
299
+
300
+ /**
301
+ * Build the client proxy of push channel `id` — what a `pushChannel()` export of a `*.server.ts` file is
302
+ * in the app (its type is the server's: src/server/pushChannel.ts). The notifications come off the push
303
+ * plugin, through the runtime its wrapper installed (`__pushEnable()`, the first line of the stubs of a
304
+ * project with push channels — ./pushSeam.ts): `onTap` hears this channel's taps (the one that started
305
+ * the app included), `onMessage` its arrivals while the app is in the foreground. On a host without the
306
+ * plugin a handler is recorded and never called, like the plugin's own listeners.
307
+ */
308
+ export const __pushChannel = (serverUrl: string, id: string) => {
309
+ const listen = (kind: "tap" | "message", handler: unknown): ChannelSubscription => {
310
+ if (typeof handler !== "function") throw new RpcError(0, `Push channel ${id}: ${kind === "tap" ? "onTap" : "onMessage"}(handler) — no handler given`)
311
+ const runtime = _pushRuntime.current
312
+ if (!runtime) throw new RpcError(0, `Push channel ${id}: the push runtime is not installed — the compiler puts __pushEnable() in the stub of a project with push channels`)
313
+ const off = runtime.on(kind, id, handler as (payload: unknown) => void)
314
+ return { close: off }
315
+ }
316
+ return {
317
+ onTap: (handler: unknown): ChannelSubscription => listen("tap", handler),
318
+ onMessage: (handler: unknown): ChannelSubscription => listen("message", handler),
319
+ send(): never {
320
+ throw new RpcError(0, `Push channel ${id}: the server sends, the app listens — call a server function that sends${serverUrl ? "" : " (and compile with serverUrl)"}`)
321
+ },
322
+ }
323
+ }
@@ -12,7 +12,13 @@
12
12
  *
13
13
  * Tokens: 32 random bytes (base64url) held by the client transport; only `sha256(token)` is stored.
14
14
  * A request without a valid token gets a fresh guest session and the token travels back in the
15
- * envelope (`session`); sign-in/out ROTATE the token the same way.
15
+ * envelope (`session`); sign-in/out ROTATE the token the same way (the row stays: what it holds of the
16
+ * device — its push address — survives a sign-out, and a sign-in on the same device finds it there).
17
+ *
18
+ * The push address: the transport sends the device's address at the platform's push relay in the
19
+ * `x-lecodes-push` header of every call (once the device registered); `resolve` keeps the session's
20
+ * `pushAddress` equal to it. That is the whole binding of a device to a user — a push channel's `send`
21
+ * reaches a user through the live sessions that carry an address (../push/host.ts).
16
22
  *
17
23
  * Identities: every sign-in method is an `Identity` row `(provider, subject)` of a user — `email:<address>`
18
24
  * or `login:<name>` (its `hash` = the password). The one rule that keeps passwords honest: proving an
@@ -114,6 +120,17 @@ const parseLogin = (login: unknown): { provider: "email" | "login", subject: str
114
120
 
115
121
  const identityKey = (provider: IdentityProvider, subject: string) => `${provider}:${subject}`
116
122
 
123
+ /** The header the transport tells the device's push address in (src/runtime/rpc.ts). */
124
+ export const PUSH_ADDRESS_HEADER = "x-lecodes-push"
125
+ const PUSH_ADDRESS_RE = /^[A-Za-z0-9_-]{1,128}$/
126
+ /** The address of this request's device, when the header carries a well-formed one. */
127
+ const pushAddressOf = (ctx: RequestContext): string | null => {
128
+ const headers = ctx.headers ?? {}
129
+ const raw = headers[PUSH_ADDRESS_HEADER] ?? Object.entries(headers).find(([k]) => k.toLowerCase() === PUSH_ADDRESS_HEADER)?.[1]
130
+ const value = typeof raw === "string" ? raw.trim() : ""
131
+ return PUSH_ADDRESS_RE.test(value) ? value : null
132
+ }
133
+
117
134
  /** A sliding-window counter per key (in-worker; the persistent limits live on the rows). */
118
135
  const window = (map: Map<string, number[]>, key: string, now: number, span: number): number[] => {
119
136
  const list = (map.get(key) ?? []).filter(x => now - x < span)
@@ -146,7 +163,7 @@ export const createAuthHost = (cfg: AuthHostConfig): AuthHost => {
146
163
  const log = cfg.log ?? ((level, text) => (level === "warn" ? console.warn(text) : console.info(text)))
147
164
 
148
165
  // token → session (60 s). Cleared on every mutation — correctness over reuse.
149
- const sessionCache = new Map<string, { sessionId: number, userId: number | null, at: number }>()
166
+ const sessionCache = new Map<string, { sessionId: number, userId: number | null, pushAddress: string | null, at: number }>()
150
167
  const clearCache = () => { sessionCache.clear() }
151
168
 
152
169
  const requireAuth = () => {
@@ -165,9 +182,10 @@ export const createAuthHost = (cfg: AuthHostConfig): AuthHost => {
165
182
 
166
183
  const mint = async (ctx: RequestContext): Promise<AuthState> => {
167
184
  const token = randomToken()
168
- const { id } = await sessions.insert({ tokenHash: await sha256(token) })
169
- const state: AuthState = { sessionId: id, userId: null, issuedToken: token }
170
- sessionCache.set(token, { sessionId: id, userId: null, at: now() })
185
+ const pushAddress = pushAddressOf(ctx)
186
+ const { id } = await sessions.insert({ tokenHash: await sha256(token), ...(pushAddress ? { pushAddress } : {}) })
187
+ const state: AuthState = { sessionId: id, userId: null, pushAddress, issuedToken: token }
188
+ sessionCache.set(token, { sessionId: id, userId: null, pushAddress, at: now() })
171
189
  ctx.auth = state
172
190
  ctx.sessionToken = token
173
191
  return state
@@ -188,21 +206,34 @@ export const createAuthHost = (cfg: AuthHostConfig): AuthHost => {
188
206
  if (opts.rotate) await rotate(ctx)
189
207
  }
190
208
 
209
+ /** The session's push address follows the header: written when it differs (a registration, a new address
210
+ * after the OS rotated the token) — never cleared by a call without the header: the app may not have
211
+ * asked the plugin yet in this run. */
212
+ const followPushAddress = async (token: string, sessionId: number, held: string | null, told: string | null): Promise<string | null> => {
213
+ if (!told || told === held) return held
214
+ await sessions.update(sessionId, { pushAddress: told })
215
+ const c = sessionCache.get(token)
216
+ if (c) c.pushAddress = told
217
+ return told
218
+ }
219
+
191
220
  const resolve = async (ctx: RequestContext): Promise<void> => {
192
221
  if (!tables) return
193
222
  const token = ctx.sessionToken
194
223
  if (token) {
195
224
  const c = sessionCache.get(token)
196
225
  if (c && now() - c.at < CACHE_TTL_MS) {
197
- ctx.auth = { sessionId: c.sessionId, userId: c.userId }
226
+ const pushAddress = await followPushAddress(token, c.sessionId, c.pushAddress, pushAddressOf(ctx))
227
+ ctx.auth = { sessionId: c.sessionId, userId: c.userId, pushAddress }
198
228
  return
199
229
  }
200
- const row = await sessions.where({ tokenHash: await sha256(token) }).select({ id: true, revoked: true, lastSeenAt: true, user: { id: true } }).first()
230
+ const row = await sessions.where({ tokenHash: await sha256(token) }).select({ id: true, revoked: true, lastSeenAt: true, pushAddress: true, user: { id: true } }).first()
201
231
  if (row && !row.revoked) {
202
232
  const userId: number | null = row.user ? row.user.id : null
203
233
  if (now() - (row.lastSeenAt ?? 0) > SEEN_UPDATE_MS) sessions.update(row.id, { lastSeenAt: now() }).then(undefined, () => {})
204
- sessionCache.set(token, { sessionId: row.id, userId, at: now() })
205
- ctx.auth = { sessionId: row.id, userId }
234
+ sessionCache.set(token, { sessionId: row.id, userId, pushAddress: row.pushAddress ?? null, at: now() })
235
+ const pushAddress = await followPushAddress(token, row.id, row.pushAddress ?? null, pushAddressOf(ctx))
236
+ ctx.auth = { sessionId: row.id, userId, pushAddress }
206
237
  return
207
238
  }
208
239
  }