lecodes-cli 2.2.9 → 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.
- package/dist/index.js +7470 -14528
- package/package.json +8 -8
- package/runtime/materials/background.mat +52 -0
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +3 -3
- package/runtime/web/assets/ar-CHvaraG4.js +1 -0
- package/runtime/web/assets/audio-recorder-Bt-Caym7.js +1 -0
- package/runtime/web/assets/materials-BzTsWTWO.bin +0 -0
- package/runtime/web/assets/speech-DzDPVK_j.js +1 -0
- package/runtime/web/player.js +17 -17
- package/runtime/web-lite/assets/{index-BttBnvIf.js → index-By07xWGA.js} +1 -1
- package/runtime/web-lite/assets/{scene3d-D8LdhX0V.js → scene3d-D5R7Vq3f.js} +58 -58
- package/runtime/web-lite/features.json +2 -11
- package/runtime/web-lite/lite.js +7 -7
- package/runtime/web-lite/plugins/ar/index-CHvaraG4.js +1 -0
- package/runtime/web-lite/plugins/audio-recorder/index-Bt-Caym7.js +1 -0
- package/runtime/web-lite/plugins/speech/index-DzDPVK_j.js +1 -0
- package/runtime/web-lite/plugins.json +3 -0
- package/src/commands/app/templates/ios.ts +14 -15
- package/src/commands/init/index.ts +195 -195
- package/src/commands/render.ts +15 -0
- package/src/commands/shared.ts +79 -79
- package/src/commands/test.ts +2 -1
- package/src/commands/thumbs.ts +185 -185
- package/src/compile/screenEntry.ts +123 -123
- package/src/declarations/lecodes-headless.d.ts +2 -0
- package/src/dev/backendWorker.ts +3 -3
- package/src/plugin/contract.ts +29 -12
- package/src/plugin/emitKotlin.ts +33 -8
- package/src/plugin/emitSdk.ts +23 -9
- package/src/plugin/emitSwift.ts +33 -8
- package/src/plugin/emitWeb.ts +57 -1
- package/src/plugin/generate.ts +22 -1
- package/src/plugin/lower.ts +42 -6
- package/src/plugin/permissions.ts +52 -0
- package/src/project/ignore.ts +164 -164
- package/runtime/web/assets/materials-uri4FvPE.bin +0 -0
- package/runtime/web-lite/assets/ar-BOdQ-wV4.js +0 -1
package/src/plugin/emitSwift.ts
CHANGED
|
@@ -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)
|
|
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 = [
|
|
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
|
-
|
|
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
|
|
304
|
-
`
|
|
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" : "",
|
package/src/plugin/emitWeb.ts
CHANGED
|
@@ -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
|
package/src/plugin/generate.ts
CHANGED
|
@@ -16,7 +16,8 @@ import { EmitError, emitSdk } from "./emitSdk"
|
|
|
16
16
|
import { emitSwift } from "./emitSwift"
|
|
17
17
|
import { emitWeb } from "./emitWeb"
|
|
18
18
|
import { lower } from "./lower"
|
|
19
|
-
import { ManifestError, readManifestIfAny, type PluginManifest } from "./manifest"
|
|
19
|
+
import { MANIFEST_FILE, ManifestError, readManifestIfAny, type PluginManifest } from "./manifest"
|
|
20
|
+
import { missingDeclarations, usesPermissions, type PermissionKind } from "./permissions"
|
|
20
21
|
import { DEFAULT_SDK_PATH, PACKAGE_FILE, PackageError, emitPackageSwift } from "./emitPackage"
|
|
21
22
|
|
|
22
23
|
// A path as the report shows it: relative to `from`, with `/` on every platform (a report is
|
|
@@ -60,6 +61,7 @@ export const generatePlugin = (ts: typeof tsModule, plugin: PluginDir): { contra
|
|
|
60
61
|
// The manifest is held to the folders the plugin has: a half the manifest does not name is built
|
|
61
62
|
// into no host.
|
|
62
63
|
const manifest = readManifestIfAny(plugin.dir, plugin.id)
|
|
64
|
+
checkPermissions(plugin, contract, manifest)
|
|
63
65
|
const handWritten = existsSync(join(plugin.dir, "sdk", `${plugin.id}.ts`))
|
|
64
66
|
const single = contract.channels.length === 1
|
|
65
67
|
const files: Generated[] = contract.channels.map(channel => ({
|
|
@@ -86,6 +88,25 @@ export const generatePlugin = (ts: typeof tsModule, plugin: PluginDir): { contra
|
|
|
86
88
|
return { contract, manifest, files }
|
|
87
89
|
}
|
|
88
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)
|
|
108
|
+
}
|
|
109
|
+
|
|
89
110
|
/** The imports of a web half that name a LeCodes package (`lecodes-sdk`, `lecodes-web-shared`, …), as
|
|
90
111
|
* `file:line: the import`. A half that imports its host runs on that host only. */
|
|
91
112
|
export const hostImports = (web: string): string[] => {
|
package/src/plugin/lower.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|