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.
- package/dist/global.d.ts +11 -0
- package/dist/types/gl/Scene.d.ts +0 -11
- package/dist/types/gl/postProcessing.d.ts +31 -2
- package/dist/types/inject.d.ts +3 -2
- package/dist/types/plugin.d.ts +3 -0
- package/dist/types/plugins/gen/push/sdk/push.d.ts +10 -3
- package/dist/types/plugins/gen/push/sdk/push.gen.d.ts +11 -3
- package/dist/types/runtime/device.d.ts +7 -19
- package/dist/types/runtime/pushSeam.d.ts +23 -0
- package/dist/types/runtime/rpc.d.ts +15 -1
- package/dist/types/server/auth/models.d.ts +13 -1
- package/dist/types/server/auth/types.d.ts +2 -0
- package/dist/types/server/inject.d.ts +1 -0
- package/dist/types/server/pushChannel.d.ts +79 -0
- package/dist/types/ui/UINode.d.ts +1 -1
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/src/animate/tween/Timeline.ts +175 -175
- package/src/bridges/gl.d.ts +5 -13
- package/src/compile/serverSplit.ts +29 -19
- package/src/compile/serverTypes.ts +1 -1
- package/src/gl/CameraPlace.ts +52 -52
- package/src/gl/Light.ts +257 -257
- package/src/gl/Mesh.ts +120 -120
- package/src/gl/Scene.ts +0 -13
- package/src/gl/animation/DynamicBone.ts +486 -486
- package/src/gl/postProcessing.ts +52 -9
- package/src/gl/scenarios.ts +291 -291
- package/src/inject.ts +3 -2
- package/src/plugin.ts +3 -0
- package/src/plugins/gen/push/contract.d.ts +24 -2
- package/src/plugins/gen/push/sdk/push.gen.ts +17 -5
- package/src/plugins/gen/push/sdk/push.ts +159 -55
- package/src/runtime/device.ts +8 -37
- package/src/runtime/pushSeam.ts +22 -0
- package/src/runtime/rpc.ts +35 -1
- package/src/server/auth/host.ts +40 -9
- package/src/server/auth/models.ts +5 -1
- package/src/server/auth/types.ts +2 -0
- package/src/server/host.ts +6 -0
- package/src/server/inject.ts +3 -2
- package/src/server/push/host.ts +143 -0
- package/src/server/pushChannel.ts +121 -0
- package/src/server/runtime.ts +20 -8
- package/src/ui/UIInput.ts +2 -2
- package/src/ui/UINode.ts +1 -1
- 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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
//
|
|
8
|
-
//
|
|
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
|
|
12
|
-
// -
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
-
//
|
|
19
|
-
//
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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 (
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
return (await service().getLaunch()) ?? null
|
|
162
|
+
addEventListener(event: PushEvent, callback: Handler): void {
|
|
163
|
+
listen(event, "", callback)
|
|
89
164
|
},
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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 =>
|
|
171
|
+
get: (): boolean => supported(),
|
|
103
172
|
})
|
|
104
|
-
|
|
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 }
|
package/src/runtime/device.ts
CHANGED
|
@@ -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
|
|
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
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
* the
|
|
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 }
|
package/src/runtime/rpc.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/server/auth/host.ts
CHANGED
|
@@ -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
|
|
169
|
-
const
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|