lecodes-cli 2.2.8 → 2.2.10

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 (41) hide show
  1. package/dist/index.js +7579 -14521
  2. package/package.json +8 -8
  3. package/runtime/materials/background.mat +52 -0
  4. package/runtime/sdk-types.json +1 -1
  5. package/runtime/web/VERSION.json +3 -3
  6. package/runtime/web/assets/ar-CHvaraG4.js +1 -0
  7. package/runtime/web/assets/audio-recorder-Bt-Caym7.js +1 -0
  8. package/runtime/web/assets/materials-BzTsWTWO.bin +0 -0
  9. package/runtime/web/assets/speech-DzDPVK_j.js +1 -0
  10. package/runtime/web/player.js +17 -17
  11. package/runtime/web-lite/assets/{index-BttBnvIf.js → index-By07xWGA.js} +1 -1
  12. package/runtime/web-lite/assets/{scene3d-D8LdhX0V.js → scene3d-D5R7Vq3f.js} +58 -58
  13. package/runtime/web-lite/features.json +2 -11
  14. package/runtime/web-lite/lite.js +7 -7
  15. package/runtime/web-lite/plugins/ar/index-CHvaraG4.js +1 -0
  16. package/runtime/web-lite/plugins/audio-recorder/index-Bt-Caym7.js +1 -0
  17. package/runtime/web-lite/plugins/speech/index-DzDPVK_j.js +1 -0
  18. package/runtime/web-lite/plugins.json +3 -0
  19. package/src/commands/app/shared.ts +1 -2
  20. package/src/commands/app/templates/ios.ts +14 -15
  21. package/src/commands/init/index.ts +195 -195
  22. package/src/commands/plugin.ts +8 -2
  23. package/src/commands/render.ts +15 -0
  24. package/src/commands/shared.ts +79 -79
  25. package/src/commands/test.ts +2 -1
  26. package/src/commands/thumbs.ts +185 -185
  27. package/src/compile/screenEntry.ts +123 -123
  28. package/src/declarations/lecodes-headless.d.ts +2 -0
  29. package/src/dev/backendWorker.ts +3 -3
  30. package/src/plugin/contract.ts +29 -12
  31. package/src/plugin/emitKotlin.ts +33 -8
  32. package/src/plugin/emitPackage.ts +98 -0
  33. package/src/plugin/emitSdk.ts +23 -9
  34. package/src/plugin/emitSwift.ts +33 -8
  35. package/src/plugin/emitWeb.ts +57 -1
  36. package/src/plugin/generate.ts +72 -16
  37. package/src/plugin/lower.ts +42 -6
  38. package/src/plugin/permissions.ts +52 -0
  39. package/src/project/ignore.ts +164 -164
  40. package/runtime/web/assets/materials-uri4FvPE.bin +0 -0
  41. package/runtime/web-lite/assets/ar-BOdQ-wV4.js +0 -1
@@ -12,8 +12,9 @@
12
12
  //
13
13
  // The emitter knows the wire's FORMAT in one place each: `_call` (lecodes-sdk/plugin) and the
14
14
  // decoders below. A change of the wire is a change there, never in a plugin. The wire carries
15
- // VALUES: what an app passes and gets is what crosses, a Uint8Array included — only a File is
16
- // something else on it (the handle of a host buffer).
15
+ // VALUES: what an app passes and gets is what crosses, a Uint8Array included — only a File, an
16
+ // Entity and a Scene3D are something else on it (the handle of a host buffer; the id of a 3D node,
17
+ // of a 3D scene).
17
18
 
18
19
  import { CONTRACT_MODULE, type Channel, type Contract, type Decl, type Field, type Type } from "./contract"
19
20
 
@@ -53,6 +54,8 @@ export const emitSdk = (contract: Contract, channel: Channel, opts: SdkEmitOptio
53
54
  case "bytes": return "Uint8Array"
54
55
  case "file": usedSdkTypes.add("File"); return "File"
55
56
  case "json": usedSdkTypes.add("Json"); return "Json"
57
+ case "entity": usedSdkTypes.add("Entity"); return "Entity"
58
+ case "scene3d": usedSdkTypes.add("Scene3D"); return "Scene3D"
56
59
  case "ref": usedTypes.add(t.name); return t.name
57
60
  case "literals": return t.values.map(v => JSON.stringify(v)).join(" | ")
58
61
  case "object": return t.fields.length ? `{ ${t.fields.map(fieldText).join(", ")} }` : "{}"
@@ -67,12 +70,12 @@ export const emitSdk = (contract: Contract, channel: Channel, opts: SdkEmitOptio
67
70
  const fieldText = (f: Field): string => `${f.name}${f.optional ? "?" : ""}: ${typeText(f.type)}`
68
71
 
69
72
  // ---- what crosses as something else ---------------------------------------------------------------
70
- // Only a `File`: it crosses as the handle of a host buffer. Everything else — a Uint8Array too — is
71
- // on the wire what it is for the app.
72
- /** Does a value of this type hold a `File` anywhere? */
73
+ // A `File` crosses as the handle of a host buffer, an `Entity` / a `Scene3D` as the id of the app's
74
+ // 3D node / scene. Everything else — a Uint8Array too — is on the wire what it is for the app.
75
+ /** Does a value of this type hold a `File`, an `Entity` or a `Scene3D` anywhere? */
73
76
  const carriesBuffer = (t: Type, seen = new Set<string>()): boolean => {
74
77
  switch (t.kind) {
75
- case "file": return true
78
+ case "file": case "entity": case "scene3d": return true
76
79
  case "ref": {
77
80
  if (seen.has(t.name)) return false
78
81
  seen.add(t.name)
@@ -92,6 +95,8 @@ export const emitSdk = (contract: Contract, channel: Channel, opts: SdkEmitOptio
92
95
  if (!carriesBuffer(t)) return undefined
93
96
  switch (t.kind) {
94
97
  case "file": usedSdk.add("_toFile"); return `_toFile(${v})`
98
+ case "entity": usedSdk.add("_toEntity"); return `_toEntity(${v})`
99
+ case "scene3d": throw new EmitError(`${where}: a Scene3D crosses from the app to the host only`)
95
100
  case "nullable": return `${v} == null ? null : ${decode(t.of, v, where)}`
96
101
  case "list": return `${v}.map((x: any) => ${decode(t.of, "x", where)})`
97
102
  case "map": return `Object.fromEntries(Object.entries(${v}).map(([k, x]: [string, any]) => [k, ${decode(t.of, "x", where)}]))`
@@ -123,6 +128,8 @@ export const emitSdk = (contract: Contract, channel: Channel, opts: SdkEmitOptio
123
128
  if (!carriesBuffer(t)) return undefined
124
129
  switch (t.kind) {
125
130
  case "file": usedSdk.add("_fromFile"); return `_fromFile(${v})`
131
+ case "entity": usedSdk.add("_fromEntity"); return `_fromEntity(${v})`
132
+ case "scene3d": usedSdk.add("_fromScene3D"); return `_fromScene3D(${v})`
126
133
  case "nullable": return `${v} == null ? null : ${encode(t.of, v, where)}`
127
134
  case "list": return `${v}.map((x: any) => ${encode(t.of, "x", where)})`
128
135
  case "map": return `Object.fromEntries(Object.entries(${v}).map(([k, x]: [string, any]) => [k, ${encode(t.of, "x", where)}]))`
@@ -160,8 +167,11 @@ export const emitSdk = (contract: Contract, channel: Channel, opts: SdkEmitOptio
160
167
  const targetType = `${base}<${eventsType ?? "{}"}>`
161
168
 
162
169
  // An event whose payload holds a File is read before the app's listener sees it.
163
- const readers = channel.events.filter(e => carriesBuffer(e.payload))
164
- .map(e => ` ${/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(e.name) ? e.name : JSON.stringify(e.name)}: (w: any) => ${decode(e.payload, "w", `${iface}: the event "${e.name}"`)},`)
170
+ // (an object literal in parentheses: an arrow's body that opens a brace is a block)
171
+ const readers = channel.events.filter(e => carriesBuffer(e.payload)).map(e => {
172
+ const read = decode(e.payload, "w", `${iface}: the event "${e.name}"`)!
173
+ return ` ${/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(e.name) ? e.name : JSON.stringify(e.name)}: (w: any) => ${read.startsWith("{") ? `(${read})` : read},`
174
+ })
165
175
  if (readers.length) usedSdkTypes.add("EventReaders")
166
176
 
167
177
  const wire: string[] = []
@@ -183,6 +193,7 @@ export const emitSdk = (contract: Contract, channel: Channel, opts: SdkEmitOptio
183
193
  : decode(m.result, "w", `${where}()`) ?? (m.result.kind === "nullable" ? "w ?? null" : undefined)
184
194
  const tags = [
185
195
  ...(m.since > 1 ? [`Contract version ${m.since}: an older half rejects the call — ask \`supports(${JSON.stringify(m.name)})\`.`] : []),
196
+ ...(m.permissions.length ? [`Needs the ${m.permissions.map(k => `"${k}"`).join(" and ")} permission${m.permissions.length > 1 ? "s" : ""}: the host asks before the call, "denied" when refused.`] : []),
186
197
  ...(m.rejects.length ? [`Rejects with ${m.rejects.map(r => `"${r}"`).join(" / ")}.`] : []),
187
198
  ]
188
199
  const doc = docBlock(m.doc, tags)
@@ -212,10 +223,13 @@ export type Method = ${methodNames}
212
223
  ].join("")
213
224
 
214
225
  const paramsArg = `params: ${paramsType ?? "{}"} = {}`
226
+ // Params that hold a File, an Entity or a Scene3D cross as their handles / ids, as an argument does:
227
+ // the channel serializes them as they are handed over.
228
+ const sentParams = paramsType ? encode({ kind: "ref", name: paramsType }, "params", `${iface}: its params`) : undefined
215
229
  const klass = [
216
230
  docBlock(channel.doc || `The ${channel.kind} "${channel.name}" with the wire's methods as its own.`),
217
231
  `export class ${iface}Base extends ${targetType} {\n`,
218
- indent(`constructor(${paramsArg}) { super(${JSON.stringify(iface)}, NAME, params${readers.length ? ", READERS" : ""}) }`),
232
+ indent(`constructor(${paramsArg}) { super(${JSON.stringify(iface)}, NAME, ${sentParams ?? "params"}${readers.length ? ", READERS" : ""}) }`),
219
233
  members.length ? "\n\n" + indent(members.join("\n")) : "",
220
234
  `\n}`,
221
235
  ].join("")
@@ -6,8 +6,8 @@
6
6
  // - `<Name>Plugin`: the PROTOCOL the plugin implements — one method per call, a typed `Reply`;
7
7
  // - `<Name>Channel.register(in:_:)` + the glue that reads a call's arguments, refuses a malformed
8
8
  // one and hands the call over.
9
- // It stands on the host SDK's PluginChannel.swift (Reply, PluginFile) and on the wire's two ends
10
- // (WireIn, WireOut). The wire carries VALUES, and the codecs read a struct straight out of the
9
+ // It stands on the host SDK's PluginChannel.swift (Reply, PluginFile), its gl facade's Entity / Scene3D
10
+ // (scene/GL.swift) and on the wire's two ends (WireIn, WireOut). The wire carries VALUES, and the codecs read a struct straight out of the
11
11
  // runtime's value and write one straight into it — no tree of Foundation's objects in between. A
12
12
  // Uint8Array is a `Data`, a File the handle of a host buffer; a `Json` member alone is Foundation's
13
13
  // objects, because JSON is what a half does with it.
@@ -46,6 +46,8 @@ export const swiftType = (t: L): string => {
46
46
  case "string": return "String"
47
47
  case "json": return "Any"
48
48
  case "file": return "PluginFile"
49
+ case "entity": return "Entity"
50
+ case "scene3d": return "Scene3D"
49
51
  case "bytes": return "Data"
50
52
  case "void": return "Void"
51
53
  case "enum": case "struct": case "union": return t.name
@@ -68,6 +70,8 @@ const decode = (t: L, e: string, depth = 0): string[] => {
68
70
  case "json": return [`${e}.json`]
69
71
  case "bytes": return [`${e}.bytes`]
70
72
  case "file": return [`${e}.file`]
73
+ case "entity": return [`${e}.entity`]
74
+ case "scene3d": return [`${e}.scene3D`]
71
75
  case "enum": case "struct": case "union": return [`${t.name}(wire: ${e})`]
72
76
  // Closures are ARGUMENTS, never trailing: a trailing one does not parse inside a `guard` condition.
73
77
  case "list": return block(`${e}.list({ ${v} in`, decode(t.of, v, depth + 1), `})`)
@@ -100,7 +104,8 @@ const encode = (t: L, e: string, w: string, depth = 0): string[] => {
100
104
  case "json": return [`${w}.json(${e})`]
101
105
  case "bytes": return [`${w}.bytes(${e})`]
102
106
  case "enum": return [`${w}.string(${e}.rawValue)`]
103
- case "file": case "struct": case "union": return [`${e}.write(to: ${w})`]
107
+ case "file": case "entity": case "struct": case "union": return [`${e}.write(to: ${w})`]
108
+ case "scene3d": throw new EmitError("a Scene3D crosses from the app to the host only")
104
109
  case "list": return [`${w}.beginArray(${e}.count)`, ...block(`for ${v} in ${e} {`, encode(t.of, v, w, depth + 1), `}`), `${w}.end()`]
105
110
  case "map": return [`${w}.beginObject(${e}.count)`, `for (k${depth}, ${v}) in ${e} {`, ...indent([`${w}.name(k${depth})`, ...encode(t.of, v, w, depth + 1)]), `}`, `${w}.end()`]
106
111
  case "tuple": return [`${w}.beginArray(${t.of.length})`, ...t.of.flatMap((m, i) => encode(m, `${e}.${i}`, w, depth + 1)), `${w}.end()`]
@@ -242,7 +247,11 @@ const emitChannel = (ch: LChannel): string => {
242
247
  // ---- the protocol ----
243
248
  const signatures = ch.methods.map(m => {
244
249
  const args = [...m.args.map(a => `_ ${ident(a.name)}: ${swiftType(a.type)}`), `_ reply: Reply<${swiftType(m.result)}, ${code}>`]
245
- const tags = [m.doc, m.rejects.length ? `Rejects with ${m.rejects.map(r => `.${r}`).join(" / ")}.` : ""].filter(Boolean).join("\n")
250
+ const tags = [
251
+ m.doc,
252
+ m.permissions.length ? `Needs the ${m.permissions.join(" and ")} permission${m.permissions.length > 1 ? "s" : ""}: asked by the glue before the call, .denied when refused.` : "",
253
+ m.rejects.length ? `Rejects with ${m.rejects.map(r => `.${r}`).join(" / ")}.` : "",
254
+ ].filter(Boolean).join("\n")
246
255
  return `${doc(tags, " ")} func ${ident(m.name)}(${args.join(", ")})`
247
256
  })
248
257
  out.push([
@@ -258,6 +267,7 @@ const emitChannel = (ch: LChannel): string => {
258
267
  ].join(""))
259
268
 
260
269
  // ---- registration ----
270
+ const gated = ch.methods.some(m => m.permissions.length > 0)
261
271
  out.push([
262
272
  `public enum ${iface}Channel {\n`,
263
273
  ` /// What the halves register under.\n public static let name = ${JSON.stringify(ch.name)}\n`,
@@ -265,7 +275,7 @@ const emitChannel = (ch: LChannel): string => {
265
275
  ` /// Register the ${ch.kind}: \`make\` builds ${isView ? "one instance" : "one session"} from its params. ${isView ? "" : "It must be cheap — no prompt,\n /// no I/O: that belongs to the first call that needs it."}\n`.replace(/ \n/, "\n"),
266
276
  ` public static func register(in engine: LeCodesEngine, _ make: @escaping (${ch.params}, ${iface}Events) -> ${iface}Plugin) {\n`,
267
277
  ` engine.register${isView ? "View" : "Service"}(name, version: Int32(version)) { params, channel in\n`,
268
- ` ${iface}Glue(make(WireIn.of(params, { ${ch.params}(wire: $0) }) ?? ${ch.params}(), ${iface}Events(channel: channel)))\n`,
278
+ ` ${iface}Glue(make(WireIn.of(params, { ${ch.params}(wire: $0) }) ?? ${ch.params}(), ${iface}Events(channel: channel))${gated ? ", engine.permissions" : ""})\n`,
269
279
  ` }\n }\n}\n`,
270
280
  ].join(""))
271
281
 
@@ -294,14 +304,29 @@ const emitChannel = (ch: LChannel): string => {
294
304
  // Nothing is written for nil: the app's promise resolves with `undefined`.
295
305
  ? block(`${head} { v, w in`, block(`if let v1 = v {`, encode(m.result.of, "v1", "w", 2), `}`), `}`)
296
306
  : block(`${head} { v, w in`, encode(m.result, "v", "w", 1), `}`)
297
- lines.push(...wrap(`plugin.${ident(m.name)}(${m.args.map((_, i) => `a${i}, `).join("")}`, reply, `)`))
307
+ if (m.permissions.length) {
308
+ // The gate: the host's permission service, then the plugin — or `.denied` without it.
309
+ lines.push(...wrap(`let reply = `, reply, ``))
310
+ lines.push(
311
+ `permissions.ensure([${m.permissions.map(k => `.${k}`).join(", ")}]) { [plugin] granted in`,
312
+ ` guard granted else { return reply.reject(.denied) }`,
313
+ ` plugin.${ident(m.name)}(${m.args.map((_, i) => `a${i}, `).join("")}reply)`,
314
+ `}`,
315
+ )
316
+ } else {
317
+ lines.push(...wrap(`plugin.${ident(m.name)}(${m.args.map((_, i) => `a${i}, `).join("")}`, reply, `)`))
318
+ }
298
319
  return ` case ${JSON.stringify(m.name)}:\n${text(lines, " ")}`
299
320
  })
300
321
  out.push([
301
322
  `/// Reads a call off the wire and hands it to the plugin; a malformed call never reaches it.\n`,
302
323
  `final class ${iface}Glue: ${isView ? "WireNativeViewInstance" : "WireServiceInstance"} {\n`,
303
- ` private let plugin: ${iface}Plugin\n\n`,
304
- ` init(_ plugin: ${iface}Plugin) { self.plugin = plugin }\n\n`,
324
+ ` private let plugin: ${iface}Plugin\n`,
325
+ gated ? ` /// The host's permission service: what a method tagged \`@permission\` is gated by.\n private let permissions: Permissions\n` : "",
326
+ `\n`,
327
+ gated
328
+ ? ` init(_ plugin: ${iface}Plugin, _ permissions: Permissions) {\n self.plugin = plugin\n self.permissions = permissions\n }\n\n`
329
+ : ` init(_ plugin: ${iface}Plugin) { self.plugin = plugin }\n\n`,
305
330
  isView ? ` var view: UIView { plugin.view }\n\n` : "",
306
331
  ` func call(_ method: String, wire args: WireIn, _ settle: ChannelSettle) {\n switch method {\n`,
307
332
  cases.length ? cases.join("\n") + "\n" : "",
@@ -16,7 +16,7 @@
16
16
  // take the same half. What the host is asked for is a SHAPE (`PluginHost`), declared in the file.
17
17
 
18
18
  import { EmitError } from "./emitSdk"
19
- import { waysOf, type L, type LChannel, type LDecl, type LField, type Lowered, type Ways } from "./lower"
19
+ import { reachesKind, waysOf, type L, type LChannel, type LDecl, type LField, type Lowered, type Ways } from "./lower"
20
20
 
21
21
  const str = (value: string): string => JSON.stringify(value)
22
22
  const key = (name: string): string => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : str(name)
@@ -37,6 +37,8 @@ export const webType = (t: L): string => {
37
37
  case "string": return "string"
38
38
  case "json": return "Json"
39
39
  case "file": return "PluginFile"
40
+ case "entity": return "Entity"
41
+ case "scene3d": return "Scene3D"
40
42
  case "bytes": return "Uint8Array"
41
43
  case "void": return "void"
42
44
  case "enum": case "struct": case "union": return t.name
@@ -62,6 +64,7 @@ const decode = (t: L, e: string, depth = 0): string => {
62
64
  case "json": return `wire.json(${e})`
63
65
  case "bytes": return `wire.bytes(${e})`
64
66
  case "file": return `readFile(${e})`
67
+ case "entity": case "scene3d": return `readId(${e})`
65
68
  case "enum": case "struct": case "union": return `read${t.name}(${e})`
66
69
  case "list": return `wire.list(${e}, (${v}) => ${decode(t.of, v, depth + 1)})`
67
70
  case "map": return `wire.map(${e}, (${v}) => ${decode(t.of, v, depth + 1)})`
@@ -289,6 +292,9 @@ export const emitWeb = (lowered: Lowered, opts: WebEmitOptions): string => {
289
292
  const uses = (name: string): boolean => new RegExp(`\\b${name}\\b`).test(body)
290
293
  const views = lowered.channels.some(c => c.kind === "view")
291
294
  const services = lowered.channels.some(c => c.kind === "service")
295
+ // A contract that hands the half the app's 3D nodes / scenes is one whose half drives them: through
296
+ // the host's gl facade, which the host then must have.
297
+ const drives3d = reachesKind(lowered, "entity") || reachesKind(lowered, "scene3d")
292
298
 
293
299
  return [
294
300
  `// GENERATED by \`lecodes plugin gen\` from contract.d.ts — do not edit.\n`,
@@ -302,7 +308,9 @@ export const emitWeb = (lowered: Lowered, opts: WebEmitOptions): string => {
302
308
  services ? ` registerService(name: string, factory: (params: unknown, channel: PluginChannel) => {\n call?(method: string, args: unknown[]): unknown, close?(): void,\n }, version: number): void\n` : "",
303
309
  uses("writeFile") ? ` /** Store bytes in the host's buffer table: the handle a File crosses as. */\n putBuffer(bytes: ArrayBuffer): number\n` : "",
304
310
  uses("readFile") ? ` /** The bytes of a buffer of the host's table — what the handle of a File the app sent names. */\n getBuffer(systemId: number): ArrayBuffer | Uint8Array | null | undefined\n` : "",
311
+ drives3d ? ` /** The 3D engine as the half drives it: the app's entities and scenes by their ids. */\n gl: PluginGL\n` : "",
305
312
  `}\n\n`,
313
+ drives3d ? PLUGIN_GL : "",
306
314
  uses("PluginFile") ? [
307
315
  `/** A \`File\` of the contract. One a half hands to the app: its bytes go into the host's buffer\n * table, the app gets the handle (a texture source, an upload, a share). One the app hands to a\n * half: the bytes of the buffer its handle names. */\n`,
308
316
  `export interface PluginFile { data: ArrayBuffer | Uint8Array, name: string }\n\n`,
@@ -313,8 +321,11 @@ export const emitWeb = (lowered: Lowered, opts: WebEmitOptions): string => {
313
321
  ` return { systemId: host.putBuffer(bytes), name: file.name, size: bytes.byteLength }\n}\n\n`,
314
322
  ].join("") : "",
315
323
  uses("Json") ? `/** A value the generator passes through untyped. */\nexport type Json = null | boolean | number | string | Json[] | { [key: string]: Json }\n\n` : "",
324
+ reachesKind(lowered, "entity") ? `/** A 3D node of the app (\`Entity\` of the contract): its id, what the host's gl facade (\`host.gl\`) takes. */\nexport type Entity = number\n\n` : "",
325
+ reachesKind(lowered, "scene3d") ? `/** A 3D scene of the app (\`Scene3D\` of the contract): its id, what the host's gl facade (\`host.gl\`) takes. */\nexport type Scene3D = number\n\n` : "",
316
326
  uses("wire") || uses("readFile") ? WIRE : "",
317
327
  uses("readFile") ? READ_FILE : "",
328
+ uses("readId") ? READ_ID : "",
318
329
  body.replaceAll("__BUFFERS__", uses("readFile") ? ` buffers = (systemId) => host.getBuffer(systemId)\n` : ""),
319
330
  ].join("")
320
331
  }
@@ -334,6 +345,51 @@ const readFile = (v: unknown): PluginFile | undefined => {
334
345
 
335
346
  `
336
347
 
348
+ /** The host's gl facade as a half sees it — a SHAPE of the web hosts' own (lecodes-web-shared's
349
+ * GLFacade), the twin of `engine.gl` in Swift and Kotlin. */
350
+ const PLUGIN_GL = `/** The host's gl facade (\`host.gl\`): what a half drives the 3D engine through — the app's entities
351
+ * and scenes by their ids, a scene's projection and background, a hook before each frame, a
352
+ * readback. Never creates or frees an entity or a scene. Every call is a no-op on a page without
353
+ * the 3D engine. */
354
+ export interface PluginGL {
355
+ /** The entity's world matrix (16 numbers, column-major). */
356
+ setMatrix(entity: number, m16: ArrayLike<number>): void
357
+ setVisible(entity: number, visible: boolean): void
358
+ /** The pose of the scene's camera (its world matrix). */
359
+ setCameraMatrix(scene: number, m16: ArrayLike<number>): void
360
+ /** A projection the half computes (column-major, OpenGL clip conventions), with the near / far it
361
+ * was built with, in place of the scene's fov projection. */
362
+ setProjection(scene: number, m16: ArrayLike<number>, near: number, far: number): void
363
+ /** Back to the scene's own (fov) projection. */
364
+ clearProjection(scene: number): void
365
+ /** The scene's background: a playing video behind the scene, through \`uv\` — the affine map from
366
+ * the viewport's normalized coordinates (0,0 top-left) to the video's, row-major
367
+ * [a, b, tx, c, d, ty] (identity by default). null = none. */
368
+ setBackground(scene: number, video: HTMLVideoElement | null, uv?: ArrayLike<number>): void
369
+ /** The web has no external textures: a video is handed to setBackground as it is. Always null. */
370
+ createExternalTexture(): null
371
+ /** \`body\` before each frame (the page's animation frame), until the returned function is called —
372
+ * or the world swap. */
373
+ onFrame(body: () => void): () => void
374
+ /** The composite of the scene's next frame — the 3D picture, not the UI over it: RGBA8, top row
375
+ * first, width, height; null (0 × 0) when the scene is not the one presented. */
376
+ readPixels(scene: number, then: (rgba: Uint8Array | null, width: number, height: number) => void): void
377
+ /** The scene's view: the size of the canvas it draws on in CSS px (the space its touches arrive in;
378
+ * not the window's — a player inside a device frame is smaller than its page; 0 × 0 while no scene
379
+ * is on show) and the display's turn (\`screen.orientation.angle\`: 0, 90 = turned
380
+ * counter-clockwise, 180, 270). Read it each frame (\`onFrame\`) and follow a change. */
381
+ viewport(scene: number): { width: number, height: number, rotation: 0 | 90 | 180 | 270 }
382
+ }
383
+
384
+ `
385
+
386
+ /** The reader of an Entity / a Scene3D the app sent: the id of its 3D node / scene. */
387
+ const READ_ID = `/** An Entity / a Scene3D the app sent: its id, a whole number in u32's range. */
388
+ const readId = (v: unknown): number | undefined =>
389
+ typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 4294967295 ? v : undefined
390
+
391
+ `
392
+
337
393
  /** The readers of the wire's values: undefined = absent, null, or not that type. */
338
394
  const WIRE = `// The readers of the wire's values: undefined = absent, null, or not that type. On the web the wire
339
395
  // carries the app's OWN values (the host is JavaScript like the app). What is read of the contract is
@@ -2,7 +2,8 @@
2
2
  // with a `contract.d.ts`; the path is one plugin, or a repo that keeps them in `plugins/<id>/`.
3
3
  // Generated files are written beside the hand-written ones of the place they travel to
4
4
  // (`sdk/<id>.gen.ts`, `ios/<Name>.gen.swift`, `android/…/<Name>.gen.kt`, `web/<id>.gen.ts`) and are
5
- // committed — `check` is what CI runs.
5
+ // committed — `check` is what CI runs. A REPO of plugins also gets its `Package.swift` (the iOS
6
+ // halves as one SwiftPM package, emitPackage.ts) from the manifests.
6
7
 
7
8
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"
8
9
  import { createRequire } from "node:module"
@@ -15,7 +16,9 @@ import { EmitError, emitSdk } from "./emitSdk"
15
16
  import { emitSwift } from "./emitSwift"
16
17
  import { emitWeb } from "./emitWeb"
17
18
  import { lower } from "./lower"
18
- import { ManifestError, readManifestIfAny } from "./manifest"
19
+ import { MANIFEST_FILE, ManifestError, readManifestIfAny, type PluginManifest } from "./manifest"
20
+ import { missingDeclarations, usesPermissions, type PermissionKind } from "./permissions"
21
+ import { DEFAULT_SDK_PATH, PACKAGE_FILE, PackageError, emitPackageSwift } from "./emitPackage"
19
22
 
20
23
  // A path as the report shows it: relative to `from`, with `/` on every platform (a report is
21
24
  // compared and pasted; `\` is Windows' own).
@@ -52,12 +55,13 @@ export const loadTypescript = (dir: string): typeof tsModule => {
52
55
  export type Generated = { file: string, text: string }
53
56
 
54
57
  /** Everything one plugin's contract generates, as `path → text`. Throws ContractError / EmitError. */
55
- export const generatePlugin = (ts: typeof tsModule, plugin: PluginDir): { contract: Contract, files: Generated[] } => {
58
+ export const generatePlugin = (ts: typeof tsModule, plugin: PluginDir): { contract: Contract, manifest: PluginManifest | null, files: Generated[] } => {
56
59
  const file = join(plugin.dir, CONTRACT_FILE)
57
60
  const contract = readContract(ts, file, readFileSync(file, "utf8"))
58
61
  // The manifest is held to the folders the plugin has: a half the manifest does not name is built
59
62
  // into no host.
60
- readManifestIfAny(plugin.dir, plugin.id)
63
+ const manifest = readManifestIfAny(plugin.dir, plugin.id)
64
+ checkPermissions(plugin, contract, manifest)
61
65
  const handWritten = existsSync(join(plugin.dir, "sdk", `${plugin.id}.ts`))
62
66
  const single = contract.channels.length === 1
63
67
  const files: Generated[] = contract.channels.map(channel => ({
@@ -81,7 +85,26 @@ export const generatePlugin = (ts: typeof tsModule, plugin: PluginDir): { contra
81
85
  const foreign = hostImports(join(plugin.dir, "web"))
82
86
  if (foreign.length) throw new EmitError(`web/ imports the host — a web half depends on its generated file, the document it is handed and the browser:\n${foreign.map(f => ` ${f}`).join("\n")}`)
83
87
  }
84
- return { contract, files }
88
+ return { contract, manifest, files }
89
+ }
90
+
91
+ /** The declarations the contract's `@permission` kinds need, held against the plugin's manifests:
92
+ * the usage strings of ios.infoPlist (a missing one crashes the app at the prompt) and the
93
+ * `<uses-permission>` elements of android/src/main/AndroidManifest.xml (a missing one is a
94
+ * silent refusal). Checked where the plugin has the half; a plugin without a manifest (a project's
95
+ * own, the shell declares for it) is not held. */
96
+ const checkPermissions = (plugin: PluginDir, contract: Contract, manifest: PluginManifest | null): void => {
97
+ if (!manifest) return
98
+ const kinds: PermissionKind[] = []
99
+ for (const ch of contract.channels) for (const m of ch.methods) for (const k of m.permissions) if (!kinds.includes(k)) kinds.push(k)
100
+ if (kinds.length === 0) return
101
+ const androidManifest = join(plugin.dir, "android", "src", "main", "AndroidManifest.xml")
102
+ const androidNames = manifest.android ? (existsSync(androidManifest) ? usesPermissions(readFileSync(androidManifest, "utf8")) : []) : null
103
+ const missing = missingDeclarations(kinds, manifest.ios ? manifest.ios.infoPlist ?? {} : null, androidNames)
104
+ const problems: string[] = []
105
+ if (missing.infoPlist.length) problems.push(`ios.infoPlist: the contract's @permission needs ${missing.infoPlist.join(", ")} — the usage string the dialog shows`)
106
+ if (missing.android.length) problems.push(`android/src/main/AndroidManifest.xml: the contract's @permission needs <uses-permission android:name="…"/> for ${missing.android.join(", ")}`)
107
+ if (problems.length) throw new ManifestError(join(plugin.dir, MANIFEST_FILE), problems)
85
108
  }
86
109
 
87
110
  /** The imports of a web half that name a LeCodes package (`lecodes-sdk`, `lecodes-web-shared`, …), as
@@ -121,8 +144,26 @@ export const kotlinPackage = (android: string): { dir: string, name: string } =>
121
144
  return { dir, name: relative(root, dir).split(/[\\/]/).join(".") }
122
145
  }
123
146
 
147
+ /** The SDK package's path the repo names for its Package.swift: `lecodes.iosSdkPath` of the repo's
148
+ * package.json (the first-party repo, a submodule of core, says `../apple/lecodes`); else the default. */
149
+ export const repoSdkPath = (root: string): string => {
150
+ const file = join(root, "package.json")
151
+ if (!existsSync(file)) return DEFAULT_SDK_PATH
152
+ try {
153
+ const pkg = JSON.parse(readFileSync(file, "utf8")) as { lecodes?: { iosSdkPath?: unknown } }
154
+ const path = pkg.lecodes?.iosSdkPath
155
+ if (path !== undefined && typeof path !== "string") throw new PackageError(`package.json lecodes.iosSdkPath: a path, as a string`)
156
+ return path ?? DEFAULT_SDK_PATH
157
+ } catch (e) {
158
+ if (e instanceof PackageError) throw e
159
+ throw new PackageError(`package.json does not parse: ${e instanceof Error ? e.message : String(e)}`)
160
+ }
161
+ }
162
+
124
163
  export type GenReport = {
125
164
  plugins: { id: string, contract: Contract, written: string[], stale: string[], fresh: string[] }[]
165
+ /** The repo's own files (its Package.swift), when the path is a repo of plugins. */
166
+ repo: { written: string[], stale: string[], fresh: string[] }
126
167
  /** Contracts that did not read or emit, as printable text. */
127
168
  failures: string[]
128
169
  }
@@ -134,21 +175,24 @@ export const generate = (path: string, opts: { check?: boolean } = {}): GenRepor
134
175
  if (plugins.length === 0) throw new CliError(`No plugin under ${resolve(path)}: a plugin is a directory with a ${CONTRACT_FILE} (one, or plugins/<id>/ of a repo).`)
135
176
  const ts = loadTypescript(plugins[0]!.dir)
136
177
  const root = resolve(path)
137
- const report: GenReport = { plugins: [], failures: [] }
178
+ const report: GenReport = { plugins: [], repo: { written: [], stale: [], fresh: [] }, failures: [] }
179
+ const place = (f: Generated, entry: { written: string[], stale: string[], fresh: string[] }): void => {
180
+ const name = shown(root, f.file) || basename(f.file)
181
+ const current = existsSync(f.file) ? readFileSync(f.file, "utf8") : null
182
+ if (current === f.text) { entry.fresh.push(name); return }
183
+ if (opts.check) { entry.stale.push(name); return }
184
+ mkdirSync(join(f.file, ".."), { recursive: true })
185
+ writeFileSync(f.file, f.text)
186
+ entry.written.push(name)
187
+ }
188
+ const manifests: { id: string, manifest: PluginManifest }[] = []
138
189
 
139
190
  for (const plugin of plugins) {
140
191
  try {
141
- const { contract, files } = generatePlugin(ts, plugin)
192
+ const { contract, manifest, files } = generatePlugin(ts, plugin)
193
+ if (manifest) manifests.push({ id: plugin.id, manifest })
142
194
  const entry = { id: plugin.id, contract, written: [] as string[], stale: [] as string[], fresh: [] as string[] }
143
- for (const f of files) {
144
- const name = shown(root, f.file) || basename(f.file)
145
- const current = existsSync(f.file) ? readFileSync(f.file, "utf8") : null
146
- if (current === f.text) { entry.fresh.push(name); continue }
147
- if (opts.check) { entry.stale.push(name); continue }
148
- mkdirSync(join(f.file, ".."), { recursive: true })
149
- writeFileSync(f.file, f.text)
150
- entry.written.push(name)
151
- }
195
+ for (const f of files) place(f, entry)
152
196
  report.plugins.push(entry)
153
197
  } catch (e) {
154
198
  if (e instanceof ContractError) report.failures.push(e.problems.map(p => `${shown(root, e.file) || e.file}:${p.line}: ${p.message}`).join("\n"))
@@ -157,5 +201,17 @@ export const generate = (path: string, opts: { check?: boolean } = {}): GenRepor
157
201
  else throw e
158
202
  }
159
203
  }
204
+
205
+ // A repo of plugins (not one plugin): its Package.swift, from every manifest that read. Written
206
+ // only when every contract made it — a repo with a broken plugin keeps the package it has.
207
+ const isRepo = !existsSync(join(root, CONTRACT_FILE))
208
+ if (isRepo && report.failures.length === 0 && manifests.some(m => m.manifest.ios)) {
209
+ try {
210
+ place({ file: join(root, PACKAGE_FILE), text: emitPackageSwift(manifests, repoSdkPath(root)) }, report.repo)
211
+ } catch (e) {
212
+ if (e instanceof PackageError) report.failures.push(`${PACKAGE_FILE}: ${e.message}`)
213
+ else throw e
214
+ }
215
+ }
160
216
  return report
161
217
  }
@@ -10,8 +10,9 @@
10
10
 
11
11
  import type { Channel, Contract, Decl, Field, Type } from "./contract"
12
12
  import { EmitError } from "./emitSdk"
13
+ import type { PermissionKind } from "./permissions"
13
14
 
14
- export type Scalar = "i32" | "f64" | "boolean" | "string" | "json" | "file" | "bytes" | "void"
15
+ export type Scalar = "i32" | "f64" | "boolean" | "string" | "json" | "file" | "bytes" | "entity" | "scene3d" | "void"
15
16
 
16
17
  export type L =
17
18
  | { kind: Scalar }
@@ -36,7 +37,7 @@ export type LDecl =
36
37
  /** `omit`: the argument may be left out (`name?:`); a nullable one (`T | null`) is always named. */
37
38
  export type LArg = { name: string, type: L, omit: boolean }
38
39
 
39
- export type LMethod = { name: string, args: LArg[], result: L, since: number, rejects: string[], doc: string }
40
+ export type LMethod = { name: string, args: LArg[], result: L, since: number, rejects: string[], permissions: PermissionKind[], doc: string }
40
41
 
41
42
  export type LEvent = {
42
43
  name: string
@@ -70,7 +71,7 @@ const upper = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
70
71
  * tells them apart: by what the value at hand IS, before it reads it. */
71
72
  export const wireClass = (t: L): string => {
72
73
  switch (t.kind) {
73
- case "i32": case "f64": return "number"
74
+ case "i32": case "f64": case "entity": case "scene3d": return "number"
74
75
  case "string": case "enum": return "string"
75
76
  case "boolean": return "boolean"
76
77
  case "struct": case "map": return "object"
@@ -116,7 +117,7 @@ export const lower = (contract: Contract): Lowered => {
116
117
  const members = t.of.map((m, i) => type(m, `${name}${upper(memberLabel(m, i))}`, where))
117
118
  const seen = new Map<string, L>()
118
119
  for (const m of members) {
119
- if (m.kind === "json" || m.kind === "file" || m.kind === "bytes" || m.kind === "optional" || m.kind === "union") {
120
+ if (m.kind === "json" || m.kind === "file" || m.kind === "bytes" || m.kind === "entity" || m.kind === "scene3d" || m.kind === "optional" || m.kind === "union") {
120
121
  throw new EmitError(`${where}: a union holds scalars, enums, structs, lists — not ${m.kind === "optional" ? "a nullable member" : m.kind}`)
121
122
  }
122
123
  const c = wireClass(m)
@@ -171,7 +172,7 @@ export const lower = (contract: Contract): Lowered => {
171
172
  return { name: a.name, type: a.optional && t.kind !== "optional" ? { kind: "optional", of: t } : t, omit: a.optional }
172
173
  }),
173
174
  result: m.result.kind === "void" ? { kind: "void" } : type(m.result, `${iface}${upper(m.name)}Result`, `${where}()`),
174
- since: m.since, rejects: m.rejects, doc: m.doc,
175
+ since: m.since, rejects: m.rejects, permissions: m.permissions, doc: m.doc,
175
176
  }
176
177
  })
177
178
 
@@ -190,9 +191,44 @@ export const lower = (contract: Contract): Lowered => {
190
191
 
191
192
  // Only what a channel reaches is declared: a struct of the contract nothing names is not a type
192
193
  // a half ever sees.
193
- return { decls: [...decls.values()], channels }
194
+ const lowered: Lowered = { decls: [...decls.values()], channels }
195
+ // A Scene3D crosses from the app to the host only: a half is handed the scene it drives, it never
196
+ // names one back (the SDK has no scene to resolve an id to).
197
+ const sends = (t: L): boolean => usesKind(lowered, t, "scene3d")
198
+ for (const ch of channels) {
199
+ for (const m of ch.methods) if (sends(m.result)) throw new EmitError(`${ch.iface}.${m.name}(): a Scene3D crosses from the app to the host only — a result cannot hold one`)
200
+ for (const e of ch.events) {
201
+ if (sends(e.payload) || e.inline?.some(f => sends(f.type))) throw new EmitError(`${ch.iface} event ${e.name}: a Scene3D crosses from the app to the host only — an event cannot hold one`)
202
+ }
203
+ }
204
+ return lowered
194
205
  }
195
206
 
207
+ /** Does a value of type `t` hold a `kind` anywhere (through the declarations it names)? */
208
+ export const usesKind = (lowered: Lowered, t: L, kind: Scalar, seen = new Set<string>()): boolean => {
209
+ switch (t.kind) {
210
+ case "list": case "map": case "optional": return usesKind(lowered, t.of, kind, seen)
211
+ case "tuple": return t.of.some(m => usesKind(lowered, m, kind, seen))
212
+ case "struct": case "union": {
213
+ if (seen.has(t.name)) return false
214
+ seen.add(t.name)
215
+ const d = lowered.decls.find(x => x.name === t.name)
216
+ if (d?.kind === "struct") return d.fields.some(f => usesKind(lowered, f.type, kind, seen))
217
+ if (d?.kind === "union") return d.members.some(m => usesKind(lowered, m, kind, seen))
218
+ return false
219
+ }
220
+ case "enum": return false
221
+ default: return t.kind === kind
222
+ }
223
+ }
224
+
225
+ /** Does the contract reach a `kind` at all (an argument, a param, a result, an event)? */
226
+ export const reachesKind = (lowered: Lowered, kind: Scalar): boolean =>
227
+ lowered.channels.some(ch =>
228
+ usesKind(lowered, { kind: "struct", name: ch.params }, kind) ||
229
+ ch.methods.some(m => usesKind(lowered, m.result, kind) || m.args.some(a => usesKind(lowered, a.type, kind))) ||
230
+ ch.events.some(e => usesKind(lowered, e.payload, kind) || !!e.inline?.some(f => usesKind(lowered, f.type, kind))))
231
+
196
232
  /** Which way a declaration crosses: the app SENDS it (an argument, a param) → it is read here; the
197
233
  * plugin sends it (a result, an event) → it is written here. Only the codec a way needs is
198
234
  * emitted by a native emitter — a struct that carries a File is written, never read. */
@@ -0,0 +1,52 @@
1
+ // `@permission <kind…>` on a method of a contract: the OS permissions the call needs. The generated
2
+ // native glue asks the host's one permission service (`engine.permissions`) before the call and
3
+ // rejects with `denied` when one was refused — the plugin's code never sees a dialog; the web half
4
+ // asks nothing (the browser's API prompts itself). A kind's needs on each platform are known here,
5
+ // so `lecodes plugin gen` can hold the plugin's manifests to them: the Info.plist usage strings the
6
+ // manifest declares (ios.infoPlist) and the `<uses-permission>` elements of the Android manifest.
7
+ //
8
+ // The kinds are the SDK's (`permissions.query / request` in lecodes-sdk: sdk/src/runtime/permissions.ts).
9
+
10
+ export const PERMISSION_KINDS = ["camera", "microphone", "location", "speech"] as const
11
+ export type PermissionKind = (typeof PERMISSION_KINDS)[number]
12
+
13
+ export const isPermissionKind = (s: string): s is PermissionKind => (PERMISSION_KINDS as readonly string[]).includes(s)
14
+
15
+ /** What a kind needs declared: Info.plist keys (every one), Android permissions (every group has
16
+ * one of its names in the manifest — location takes FINE or COARSE). */
17
+ export const PERMISSION_NEEDS: Record<PermissionKind, { infoPlist: string[], android: string[][] }> = {
18
+ camera: { infoPlist: ["NSCameraUsageDescription"], android: [["android.permission.CAMERA"]] },
19
+ microphone: { infoPlist: ["NSMicrophoneUsageDescription"], android: [["android.permission.RECORD_AUDIO"]] },
20
+ location: {
21
+ infoPlist: ["NSLocationWhenInUseUsageDescription"],
22
+ android: [["android.permission.ACCESS_FINE_LOCATION", "android.permission.ACCESS_COARSE_LOCATION"]],
23
+ },
24
+ // Speech recognition: its own purpose string on iOS, the microphone's permission on Android. A
25
+ // method that recognizes live speech tags `microphone` beside it (the audio is the microphone's).
26
+ speech: { infoPlist: ["NSSpeechRecognitionUsageDescription"], android: [["android.permission.RECORD_AUDIO"]] },
27
+ }
28
+
29
+ /** The `android:name`s of the `<uses-permission>` elements of an AndroidManifest.xml. */
30
+ export const usesPermissions = (xml: string): string[] =>
31
+ [...xml.matchAll(/<uses-permission(?:-sdk-23)?\b[^>]*\bandroid:name\s*=\s*"([^"]+)"/g)].map(m => m[1]!)
32
+
33
+ /** The declarations the kinds need and the manifests lack: `infoPlist` keys missing from the iOS
34
+ * manifest's ios.infoPlist (null = no iOS half), `android` permission groups with no name in the
35
+ * Android manifest (null = no Android half). Each entry names the kind that needs it. */
36
+ export const missingDeclarations = (
37
+ kinds: PermissionKind[],
38
+ infoPlist: Record<string, string> | null,
39
+ androidNames: string[] | null,
40
+ ): { infoPlist: string[], android: string[] } => {
41
+ const out = { infoPlist: [] as string[], android: [] as string[] }
42
+ for (const kind of kinds) {
43
+ const needs = PERMISSION_NEEDS[kind]
44
+ if (infoPlist) for (const key of needs.infoPlist) {
45
+ if (!(key in infoPlist)) out.infoPlist.push(`${key} (${kind})`)
46
+ }
47
+ if (androidNames) for (const group of needs.android) {
48
+ if (!group.some(name => androidNames.includes(name))) out.android.push(`${group.join(" or ")} (${kind})`)
49
+ }
50
+ }
51
+ return { infoPlist: [...new Set(out.infoPlist)], android: [...new Set(out.android)] }
52
+ }