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
@@ -1,123 +1,123 @@
1
- import { existsSync, readFileSync, statSync } from "node:fs"
2
- import { isAbsolute, join, relative, resolve } from "node:path"
3
- import { screenFrame } from "../project/designMeta"
4
- import { CliError } from "../cli"
5
- import { toKey } from "../project/paths"
6
- import type { CompileEntry } from "./projectCompile"
7
-
8
- /*
9
- * Rendering ONE screen module instead of the whole app — `lecodes render <file>` (and
10
- * `--desktop`). A design screen (`design/screens/login.ts`) never boots itself: it default-exports
11
- * a `UIScreen`, or a `(state) => UIScreen` function for its states, and the design board / snapshot
12
- * supply the `.open()`. This builds the same virtual entry for the normal project pipeline, so any
13
- * file written that way — inside design/ or not — renders with the full `lecodes render` flag set
14
- * (--png/--clip/--script/--device/…) against the project's real imports and assets.
15
- *
16
- * Kept in step with compile/designCompile.ts, which does the same wrapping per screen for the board.
17
- */
18
-
19
- /** Extensions a screen module may carry (so `render design/screens/login` works too). */
20
- const SCREEN_EXTS = [".ts", ".tsx", ".js", ".jsx", ".mjs"]
21
-
22
- /** Where the generated entry lives — the project root, so its import specifier is the screen's
23
- * own project key ("./design/screens/login"). Leading dot: never a real project file. */
24
- const SCREEN_ENTRY_PATH = "/.screen-entry.ts"
25
-
26
- /** The design folder a bare screen id (`lecodes render login`) is looked up in. */
27
- const DEFAULT_DESIGN_DIR = "design"
28
-
29
- export type ScreenTarget = {
30
- /** The virtual entry module — hand it to the compiler alongside the project's own entries. */
31
- entry: CompileEntry
32
- /** Path of that entry, for `entryOverride`. */
33
- entryPath: string
34
- /** The board frame from the design folder's meta.json, when this IS a design screen. */
35
- frame?: [number, number]
36
- /** How the screen is named in progress notes ("design/screens/login.ts", "login@empty"). */
37
- label: string
38
- }
39
-
40
- /** `lecodes render <file|screen-id>` — the positional a screen render is asked for by. */
41
-
42
- /** Whether the argument names a path at all (vs a bare screen id). */
43
- const hasSeparator = (file: string) => file.includes("/") || file.includes("\\")
44
-
45
- /** Find the screen file: a path (with or without extension), or a bare id resolved against the
46
- * design folder's screens/ (`lecodes render login`, honoring --dir). */
47
- const findScreenFile = (root: string, file: string, dirFlag: string | undefined): string => {
48
- const candidates = [resolve(process.cwd(), file)]
49
- if (!hasSeparator(file)) {
50
- candidates.push(join(root, (dirFlag ?? DEFAULT_DESIGN_DIR).replace(/[/\\]+$/, ""), "screens", file))
51
- }
52
- for (const base of candidates) {
53
- if (existsSync(base) && statSync(base).isFile()) return base
54
- for (const ext of SCREEN_EXTS) if (existsSync(base + ext)) return base + ext
55
- }
56
- throw new CliError(
57
- `No screen file at "${file}".` +
58
- (hasSeparator(file) ? "" : ` Pass a path, or a screen id that exists in ${dirFlag ?? DEFAULT_DESIGN_DIR}/screens/.`))
59
- }
60
-
61
- /** The design folder a screen file belongs to ("<root>/design" for "/design/screens/login.ts"),
62
- * or null when it isn't laid out as a design screen. */
63
- const designDirOf = (root: string, key: string): { dir: string, id: string } | null => {
64
- const m = /^\/(.*)screens\/([^/]+)\.[^./]+$/.exec(key)
65
- if (!m) return null
66
- const dir = join(root, m[1])
67
- return existsSync(join(dir, "meta.json")) ? { dir, id: m[2] } : null
68
- }
69
-
70
- /**
71
- * Resolve `<file>` into the virtual entry that opens its default export.
72
- * `state` picks the branch of a `(state) => UIScreen` screen (ignored by a value export).
73
- */
74
- const resolveScreenTarget = (
75
- root: string, file: string, opts: { state?: string, dir?: string } = {},
76
- ): ScreenTarget => {
77
- const abs = findScreenFile(root, file, opts.dir)
78
- const rel = relative(root, abs)
79
- if (rel.startsWith("..") || isAbsolute(rel)) {
80
- throw new CliError(
81
- `"${file}" is outside the project (${root}) — a screen renders as part of its project, so its ` +
82
- `imports and asset() files resolve from the same tree.`)
83
- }
84
- const key = toKey(rel)
85
-
86
- // A file with no default export compiles to an empty render; say so here instead.
87
- if (!/^\s*export\s+default\b/m.test(readFileSync(abs, "utf8"))) {
88
- throw new CliError(
89
- `${key.slice(1)} has no default export. A screen file ends with ` +
90
- `\`export default UIScreen([...])\` — or \`export default (state) => UIScreen([...])\` for screen states.`)
91
- }
92
-
93
- // Same wrapper the design board compiles (designCompile.ts): a function export is invoked with the
94
- // chosen state (a bare call uses the parameter's default = the canonical state), a value is used
95
- // as-is, and either way the screen is opened so the runtime has something to render.
96
- const invoke = opts.state !== undefined ? `screen(${JSON.stringify(opts.state)})` : "screen()"
97
- const spec = "." + key.replace(/\.[^./]+$/, "")
98
- const design = designDirOf(root, key)
99
- return {
100
- entryPath: SCREEN_ENTRY_PATH,
101
- entry: {
102
- path: SCREEN_ENTRY_PATH,
103
- type: "text",
104
- text: `import screen from ${JSON.stringify(spec)}\n`
105
- + `const __screen = typeof screen === "function" ? ${invoke} : screen\n`
106
- + `__screen.open()\n`,
107
- },
108
- frame: design ? screenFrame(design.dir, design.id) : undefined,
109
- label: (design ? design.id : key.slice(1)) + (opts.state !== undefined ? `@${opts.state}` : ""),
110
- }
111
- }
112
-
113
- /** The screen target named on the command line (`render <file>`), or undefined when no screen was
114
- * named. Rejects the flag combinations that would fight over the entrypoint. */
115
- export type ScreenArgs = { file?: string, state?: string, entry?: string, dir?: string }
116
- export const screenTargetFrom = (root: string, a: ScreenArgs): ScreenTarget | undefined => {
117
- if (a.file === undefined) {
118
- if (a.state !== undefined) throw new CliError("--state picks a screen's state — name the screen file too: lecodes render <file> --state <name>")
119
- return undefined
120
- }
121
- if (a.entry !== undefined) throw new CliError("Use either a screen file or --entry, not both — each names the module to compile from.")
122
- return resolveScreenTarget(root, a.file, { state: a.state, dir: a.dir })
123
- }
1
+ import { existsSync, readFileSync, statSync } from "node:fs"
2
+ import { isAbsolute, join, relative, resolve } from "node:path"
3
+ import { screenFrame } from "../project/designMeta"
4
+ import { CliError } from "../cli"
5
+ import { toKey } from "../project/paths"
6
+ import type { CompileEntry } from "./projectCompile"
7
+
8
+ /*
9
+ * Rendering ONE screen module instead of the whole app — `lecodes render <file>` (and
10
+ * `--desktop`). A design screen (`design/screens/login.ts`) never boots itself: it default-exports
11
+ * a `UIScreen`, or a `(state) => UIScreen` function for its states, and the design board / snapshot
12
+ * supply the `.open()`. This builds the same virtual entry for the normal project pipeline, so any
13
+ * file written that way — inside design/ or not — renders with the full `lecodes render` flag set
14
+ * (--png/--clip/--script/--device/…) against the project's real imports and assets.
15
+ *
16
+ * Kept in step with compile/designCompile.ts, which does the same wrapping per screen for the board.
17
+ */
18
+
19
+ /** Extensions a screen module may carry (so `render design/screens/login` works too). */
20
+ const SCREEN_EXTS = [".ts", ".tsx", ".js", ".jsx", ".mjs"]
21
+
22
+ /** Where the generated entry lives — the project root, so its import specifier is the screen's
23
+ * own project key ("./design/screens/login"). Leading dot: never a real project file. */
24
+ const SCREEN_ENTRY_PATH = "/.screen-entry.ts"
25
+
26
+ /** The design folder a bare screen id (`lecodes render login`) is looked up in. */
27
+ const DEFAULT_DESIGN_DIR = "design"
28
+
29
+ export type ScreenTarget = {
30
+ /** The virtual entry module — hand it to the compiler alongside the project's own entries. */
31
+ entry: CompileEntry
32
+ /** Path of that entry, for `entryOverride`. */
33
+ entryPath: string
34
+ /** The board frame from the design folder's meta.json, when this IS a design screen. */
35
+ frame?: [number, number]
36
+ /** How the screen is named in progress notes ("design/screens/login.ts", "login@empty"). */
37
+ label: string
38
+ }
39
+
40
+ /** `lecodes render <file|screen-id>` — the positional a screen render is asked for by. */
41
+
42
+ /** Whether the argument names a path at all (vs a bare screen id). */
43
+ const hasSeparator = (file: string) => file.includes("/") || file.includes("\\")
44
+
45
+ /** Find the screen file: a path (with or without extension), or a bare id resolved against the
46
+ * design folder's screens/ (`lecodes render login`, honoring --dir). */
47
+ const findScreenFile = (root: string, file: string, dirFlag: string | undefined): string => {
48
+ const candidates = [resolve(process.cwd(), file)]
49
+ if (!hasSeparator(file)) {
50
+ candidates.push(join(root, (dirFlag ?? DEFAULT_DESIGN_DIR).replace(/[/\\]+$/, ""), "screens", file))
51
+ }
52
+ for (const base of candidates) {
53
+ if (existsSync(base) && statSync(base).isFile()) return base
54
+ for (const ext of SCREEN_EXTS) if (existsSync(base + ext)) return base + ext
55
+ }
56
+ throw new CliError(
57
+ `No screen file at "${file}".` +
58
+ (hasSeparator(file) ? "" : ` Pass a path, or a screen id that exists in ${dirFlag ?? DEFAULT_DESIGN_DIR}/screens/.`))
59
+ }
60
+
61
+ /** The design folder a screen file belongs to ("<root>/design" for "/design/screens/login.ts"),
62
+ * or null when it isn't laid out as a design screen. */
63
+ const designDirOf = (root: string, key: string): { dir: string, id: string } | null => {
64
+ const m = /^\/(.*)screens\/([^/]+)\.[^./]+$/.exec(key)
65
+ if (!m) return null
66
+ const dir = join(root, m[1])
67
+ return existsSync(join(dir, "meta.json")) ? { dir, id: m[2] } : null
68
+ }
69
+
70
+ /**
71
+ * Resolve `<file>` into the virtual entry that opens its default export.
72
+ * `state` picks the branch of a `(state) => UIScreen` screen (ignored by a value export).
73
+ */
74
+ const resolveScreenTarget = (
75
+ root: string, file: string, opts: { state?: string, dir?: string } = {},
76
+ ): ScreenTarget => {
77
+ const abs = findScreenFile(root, file, opts.dir)
78
+ const rel = relative(root, abs)
79
+ if (rel.startsWith("..") || isAbsolute(rel)) {
80
+ throw new CliError(
81
+ `"${file}" is outside the project (${root}) — a screen renders as part of its project, so its ` +
82
+ `imports and asset() files resolve from the same tree.`)
83
+ }
84
+ const key = toKey(rel)
85
+
86
+ // A file with no default export compiles to an empty render; say so here instead.
87
+ if (!/^\s*export\s+default\b/m.test(readFileSync(abs, "utf8"))) {
88
+ throw new CliError(
89
+ `${key.slice(1)} has no default export. A screen file ends with ` +
90
+ `\`export default UIScreen([...])\` — or \`export default (state) => UIScreen([...])\` for screen states.`)
91
+ }
92
+
93
+ // Same wrapper the design board compiles (designCompile.ts): a function export is invoked with the
94
+ // chosen state (a bare call uses the parameter's default = the canonical state), a value is used
95
+ // as-is, and either way the screen is opened so the runtime has something to render.
96
+ const invoke = opts.state !== undefined ? `screen(${JSON.stringify(opts.state)})` : "screen()"
97
+ const spec = "." + key.replace(/\.[^./]+$/, "")
98
+ const design = designDirOf(root, key)
99
+ return {
100
+ entryPath: SCREEN_ENTRY_PATH,
101
+ entry: {
102
+ path: SCREEN_ENTRY_PATH,
103
+ type: "text",
104
+ text: `import screen from ${JSON.stringify(spec)}\n`
105
+ + `const __screen = typeof screen === "function" ? ${invoke} : screen\n`
106
+ + `__screen.open()\n`,
107
+ },
108
+ frame: design ? screenFrame(design.dir, design.id) : undefined,
109
+ label: (design ? design.id : key.slice(1)) + (opts.state !== undefined ? `@${opts.state}` : ""),
110
+ }
111
+ }
112
+
113
+ /** The screen target named on the command line (`render <file>`), or undefined when no screen was
114
+ * named. Rejects the flag combinations that would fight over the entrypoint. */
115
+ export type ScreenArgs = { file?: string, state?: string, entry?: string, dir?: string }
116
+ export const screenTargetFrom = (root: string, a: ScreenArgs): ScreenTarget | undefined => {
117
+ if (a.file === undefined) {
118
+ if (a.state !== undefined) throw new CliError("--state picks a screen's state — name the screen file too: lecodes render <file> --state <name>")
119
+ return undefined
120
+ }
121
+ if (a.entry !== undefined) throw new CliError("Use either a screen file or --entry, not both — each names the module to compile from.")
122
+ return resolveScreenTarget(root, a.file, { state: a.state, dir: a.dir })
123
+ }
@@ -27,6 +27,8 @@ declare module "lecodes-headless/headless" {
27
27
  /** Canned `_creatorFetch.fetch` responses (URL pattern → { status?, json?, text? }); they beat
28
28
  * every other fetch path, including waitNetwork. */
29
29
  fixtures?: Fixtures
30
+ /** Native views drawn as a placeholder: view name → the label drawn in its box. */
31
+ nativeViews?: Record<string, string>
30
32
  }
31
33
  export type FetchFixture = { status?: number, json?: unknown, text?: string }
32
34
  export type Fixtures = Record<string, FetchFixture>
@@ -17,9 +17,9 @@ import type { FromWorker, LogLevel, ToWorker } from "./localBackend"
17
17
  * a new process, so nothing of the old code (its timers, its module state) outlives it.
18
18
  *
19
19
  * The database is marcidb's embedded engine (the peer marcidb-embedded), opened here, in the
20
- * directory the parent names. The schema is synced on every load, the way the runner syncs a draft
21
- * slot: what the models no longer have is dropped; a change the engine can't migrate in place moves
22
- * the old database aside and starts an empty one.
20
+ * directory the parent names. The schema is synced on every load, more freely than the runner does
21
+ * (this is the developer's own machine): what the models no longer have is dropped; a change the
22
+ * engine can't migrate in place moves the old database aside and starts an empty one.
23
23
  */
24
24
 
25
25
  type Embedded = {
@@ -11,8 +11,11 @@
11
11
  // `ts` is passed in: the CLI has no TypeScript of its own and loads the plugin repo's.
12
12
 
13
13
  import type tsModule from "typescript"
14
+ import { PERMISSION_KINDS, isPermissionKind, type PermissionKind } from "./permissions"
14
15
 
15
- export type Scalar = "i32" | "f64" | "boolean" | "string" | "bytes" | "file" | "json" | "void"
16
+ /** `entity` / `scene3d`: the app's 3D Node / Scene, on the wire their u32 ids (`Entity` / `Scene3D` of
17
+ * lecodes-sdk/plugin — a half drives them through the host's gl facade). */
18
+ export type Scalar = "i32" | "f64" | "boolean" | "string" | "bytes" | "file" | "json" | "entity" | "scene3d" | "void"
16
19
 
17
20
  export type Type =
18
21
  | { kind: Scalar }
@@ -46,8 +49,10 @@ export type Method = {
46
49
  result: Type
47
50
  /** The contract version that added it (`@since`), 1 when untagged. */
48
51
  since: number
49
- /** The codes the call can fail with (`@rejects`). */
52
+ /** The codes the call can fail with (`@rejects`; `denied` is added for a method with a permission). */
50
53
  rejects: string[]
54
+ /** The OS permissions the call needs (`@permission`): the native glue asks before the call. */
55
+ permissions: PermissionKind[]
51
56
  doc: string
52
57
  line: number
53
58
  }
@@ -91,12 +96,12 @@ export class ContractError extends Error {
91
96
  /** The module a contract imports its vocabulary from, and the names it may take. */
92
97
  export const CONTRACT_MODULE = "lecodes-sdk/plugin"
93
98
  const MARKERS: Record<string, Channel["kind"]> = { View: "view", Service: "service" }
94
- const VOCABULARY: Record<string, Scalar> = { i32: "i32", f64: "f64", File: "file", Json: "json" }
99
+ const VOCABULARY: Record<string, Scalar> = { i32: "i32", f64: "f64", File: "file", Json: "json", Entity: "entity", Scene3D: "scene3d" }
95
100
 
96
101
  /** Names a contract may not declare: the generated code of every language would shadow a global. */
97
102
  const RESERVED = new Set([
98
- "Array", "Boolean", "Date", "Error", "File", "Function", "Json", "Map", "Number", "Object", "Promise", "Record",
99
- "Service", "Set", "String", "Symbol", "Uint8Array", "View",
103
+ "Array", "Boolean", "Date", "Entity", "Error", "File", "Function", "Json", "Map", "Number", "Object", "Promise", "Record",
104
+ "Scene3D", "Service", "Set", "String", "Symbol", "Uint8Array", "View",
100
105
  // What a native half's generated file names: the types of Swift and Kotlin, the host's support types.
101
106
  "Any", "Bool", "Data", "Double", "Int", "Int32", "List", "Pair", "Triple", "Unit", "Void",
102
107
  "NoCode", "PluginFile", "Reply", "Wire",
@@ -112,7 +117,7 @@ const KEYWORDS = new Set([
112
117
  /** Members of the wrapper's bases a method would replace. */
113
118
  const TAKEN = new Set(["call", "on", "off", "open", "close", "destroy", "style", "constructor"])
114
119
 
115
- const TAGS = new Set(["since", "rejects"])
120
+ const TAGS = new Set(["since", "rejects", "permission"])
116
121
 
117
122
  export const readContract = (ts: typeof tsModule, file: string, text: string): Contract => {
118
123
  const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true)
@@ -157,9 +162,10 @@ export const readContract = (ts: typeof tsModule, file: string, text: string): C
157
162
  const docs = ts.getJSDocCommentsAndTags(n).filter(ts.isJSDoc)
158
163
  return docs.map(d => ts.getTextOfJSDocComment(d.comment) ?? "").filter(Boolean).join("\n").trim()
159
164
  }
160
- const tagsOf = (n: tsModule.Node): { since: number, rejects: string[] } => {
165
+ const tagsOf = (n: tsModule.Node): { since: number, rejects: string[], permissions: PermissionKind[] } => {
161
166
  let since = 1
162
167
  const rejects: string[] = []
168
+ const permissions: PermissionKind[] = []
163
169
  for (const tag of ts.getJSDocTags(n)) {
164
170
  const name = tag.tagName.text
165
171
  const value = (ts.getTextOfJSDocComment(tag.comment) ?? "").trim()
@@ -167,6 +173,13 @@ export const readContract = (ts: typeof tsModule, file: string, text: string): C
167
173
  if (name === "since") {
168
174
  if (!/^[1-9]\d*$/.test(value)) fail(tag, `@since takes a contract version: a whole number from 1 (got "${value}")`)
169
175
  else since = Number(value)
176
+ } else if (name === "permission") {
177
+ const kinds = value.split(/[\s,]+/).filter(Boolean)
178
+ if (kinds.length === 0) fail(tag, `@permission names the OS permissions the call needs: ${PERMISSION_KINDS.join(", ")}`)
179
+ for (const kind of kinds) {
180
+ if (!isPermissionKind(kind)) fail(tag, `@permission: "${kind}" is not a permission (one of ${PERMISSION_KINDS.join(", ")})`)
181
+ else if (!permissions.includes(kind)) permissions.push(kind)
182
+ }
170
183
  } else {
171
184
  const codes = value.split(/[\s,]+/).filter(Boolean)
172
185
  if (codes.length === 0) fail(tag, "@rejects lists the codes a call can fail with")
@@ -176,7 +189,7 @@ export const readContract = (ts: typeof tsModule, file: string, text: string): C
176
189
  }
177
190
  }
178
191
  }
179
- return { since, rejects }
192
+ return { since, rejects, permissions }
180
193
  }
181
194
 
182
195
  // ---- types ---------------------------------------------------------------------------------------
@@ -323,8 +336,9 @@ export const readContract = (ts: typeof tsModule, file: string, text: string): C
323
336
  if (m.questionToken) fail(m, `${d.name.text}.${name}: an event is not optional`)
324
337
  if (events.some(e => e.name === name)) fail(m, `${d.name.text}: "${name}" is declared twice`)
325
338
  const payload = m.type.kind === ts.SyntaxKind.VoidKeyword ? VOID : readType(m.type, `${d.name.text}.${name}`)
326
- const { since, rejects } = tagsOf(m)
339
+ const { since, rejects, permissions } = tagsOf(m)
327
340
  if (rejects.length) fail(m, `${d.name.text}.${name}: an event rejects nothing`)
341
+ if (permissions.length) fail(m, `${d.name.text}.${name}: an event needs no permission — the method that starts it does`)
328
342
  events.push({ name, payload, since, doc: docOf(m), line: lineOf(m) })
329
343
  }
330
344
  return events
@@ -358,7 +372,10 @@ export const readContract = (ts: typeof tsModule, file: string, text: string): C
358
372
  if (!promised) fail(m, `${where}: a call crosses to the host and back — it returns Promise<…>`)
359
373
  else if (promised.kind !== ts.SyntaxKind.VoidKeyword) result = readType(promised, `${where}()`)
360
374
 
361
- methods.push({ name, args, result, ...tagsOf(m), doc: docOf(m), line: lineOf(m) })
375
+ const tags = tagsOf(m)
376
+ // A gated call fails with `denied` before the plugin's code: the code is the tag's, not the author's to list.
377
+ if (tags.permissions.length && !tags.rejects.includes("denied")) tags.rejects.unshift("denied")
378
+ methods.push({ name, args, result, ...tags, doc: docOf(m), line: lineOf(m) })
362
379
  }
363
380
  return methods
364
381
  }
@@ -399,8 +416,8 @@ export const readContract = (ts: typeof tsModule, file: string, text: string): C
399
416
  }
400
417
  const events = eventsDecl ? readEvents(eventsDecl) : []
401
418
  const methods = readMethods(d)
402
- const { since, rejects } = tagsOf(d)
403
- if (since !== 1 || rejects.length) fail(d.name, `${iface}: @since and @rejects tag a method or an event, not the channel`)
419
+ const { since, rejects, permissions } = tagsOf(d)
420
+ if (since !== 1 || rejects.length || permissions.length) fail(d.name, `${iface}: @since, @rejects and @permission tag a method (or @since an event), not the channel`)
404
421
  channels.push({
405
422
  kind: marker.kind, name, iface,
406
423
  params: paramsDecl ? readFields(paramsDecl.members, paramsDecl.name.text) : [],
@@ -7,8 +7,8 @@
7
7
  // - `<Name>Plugin`: the INTERFACE the plugin implements — one method per call, a typed `Reply`;
8
8
  // - `<Name>Channel.register(engine, make)` + the glue that reads a call's arguments, refuses a
9
9
  // malformed one and hands the call over.
10
- // It stands on the host SDK's PluginChannel.kt (Reply, PluginFile, Wire) and on the packed form's
11
- // two ends (PackedReader, PackedWriter). The wire carries VALUES, PACKED, and the codecs read a
10
+ // It stands on the host SDK's PluginChannel.kt (Reply, PluginFile, Wire), its gl facade's Entity /
11
+ // Scene3D (scene/GL.kt) and on the packed form's two ends (PackedReader, PackedWriter). The wire carries VALUES, PACKED, and the codecs read a
12
12
  // class straight out of the buffer and write one straight into it — no tree of org.json's objects
13
13
  // in between. A Uint8Array is a ByteArray, a File the handle of a host buffer; a `Json` member alone
14
14
  // is org.json's objects, because JSON is what a half does with it.
@@ -54,6 +54,8 @@ export const kotlinType = (t: L): string => {
54
54
  case "string": return "String"
55
55
  case "json": return "Any"
56
56
  case "file": return "PluginFile"
57
+ case "entity": return "Entity"
58
+ case "scene3d": return "Scene3D"
57
59
  case "bytes": return "ByteArray"
58
60
  case "void": return "Unit"
59
61
  case "enum": case "struct": case "union": return t.name
@@ -78,6 +80,8 @@ const decode = (t: L, depth = 0): string[] => {
78
80
  case "json": return [`r.json()`]
79
81
  case "bytes": return [`r.bytes()`]
80
82
  case "file": return [`Wire.file(r)`]
83
+ case "entity": return [`Entity.read(r)`]
84
+ case "scene3d": return [`Scene3D.read(r)`]
81
85
  case "enum": case "struct": case "union": return [`${t.name}.read(r)`]
82
86
  case "list": return block(`r.list {`, decode(t.of, depth + 1), `}`)
83
87
  case "map": return block(`r.map {`, decode(t.of, depth + 1), `}`)
@@ -105,7 +109,8 @@ const encode = (t: L, e: string, w: string, depth = 0): string[] => {
105
109
  case "json": return [`${w}.json(${e})`]
106
110
  case "bytes": return [`${w}.bytes(${e})`]
107
111
  case "enum": return [`${w}.string(${e}.wire)`]
108
- case "file": case "struct": case "union": return [`${e}.write(${w})`]
112
+ case "file": case "entity": case "struct": case "union": return [`${e}.write(${w})`]
113
+ case "scene3d": throw new EmitError("a Scene3D crosses from the app to the host only")
109
114
  case "list": return [`${w}.beginArray()`, ...block(`for (${v} in ${e}) {`, encode(t.of, v, w, depth + 1), `}`), `${w}.end()`]
110
115
  case "map": return [`${w}.beginObject()`, `for ((k${depth}, ${v}) in ${e}) {`, ...indent([`${w}.key(k${depth})`, ...encode(t.of, v, w, depth + 1)]), `}`, `${w}.end()`]
111
116
  case "tuple": return [`${w}.beginArray()`, ...t.of.flatMap((m, i) => encode(m, `${e}.${TUPLE[i]}`, w, depth + 1)), `${w}.end()`]
@@ -242,7 +247,11 @@ const emitChannel = (ch: LChannel): string => {
242
247
  // ---- the interface ----
243
248
  const signatures = ch.methods.map(m => {
244
249
  const args = [...m.args.map(a => `${ident(a.name)}: ${kotlinType(a.type)}`), `reply: Reply<${kotlinType(m.result)}, ${code}>`]
245
- const tags = [m.doc, m.rejects.length ? `Rejects with ${m.rejects.map(constName).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(constName).join(" / ")}.` : "",
254
+ ].filter(Boolean).join("\n")
246
255
  return `${doc(tags, " ")} fun ${ident(m.name)}(${args.join(", ")})`
247
256
  })
248
257
  out.push([
@@ -258,6 +267,8 @@ const emitChannel = (ch: LChannel): string => {
258
267
  ].join(""))
259
268
 
260
269
  // ---- registration ----
270
+ const gated = ch.methods.some(m => m.permissions.length > 0)
271
+ const glueArgs = gated ? "plugin, engine.permissions, " : "plugin, "
261
272
  out.push([
262
273
  `object ${iface}Channel {\n`,
263
274
  ` /** What the halves register under. */\n const val NAME = ${str(ch.name)}\n\n`,
@@ -269,8 +280,8 @@ const emitChannel = (ch: LChannel): string => {
269
280
  ` engine.register${isView ? "View" : "Service"}(NAME, VERSION) { params, channel ->\n`,
270
281
  ` val plugin = make(${ch.params}.read(Wire.reader(params)) ?: ${ch.params}(), ${iface}Events { event, packed -> channel.emitPacked(event, packed) })\n`,
271
282
  isView
272
- ? ` NativeViews.Instance(plugin.view, onCall = { call -> ${iface}Glue.call(plugin, call.method, call.reader(), call) }, onDestroy = { plugin.destroy() })\n`
273
- : ` Services.Instance(onCall = { call -> ${iface}Glue.call(plugin, call.method, call.reader(), call) }, onClose = { plugin.close() })\n`,
283
+ ? ` NativeViews.Instance(plugin.view, onCall = { call -> ${iface}Glue.call(${glueArgs}call.method, call.reader(), call) }, onDestroy = { plugin.destroy() })\n`
284
+ : ` Services.Instance(onCall = { call -> ${iface}Glue.call(${glueArgs}call.method, call.reader(), call) }, onClose = { plugin.close() })\n`,
274
285
  ` }\n }\n}\n`,
275
286
  ].join(""))
276
287
 
@@ -295,13 +306,24 @@ const emitChannel = (ch: LChannel): string => {
295
306
  // Nothing is written for null: the app's promise resolves with `undefined`.
296
307
  ? block(`${head} { v, w ->`, block(`if (v != null) {`, encode(m.result.of, "v", "w", 2), `}`), `}`)
297
308
  : block(`${head} { v, w ->`, encode(m.result, "v", "w", 1), `}`)
298
- lines.push(...wrap(`plugin.${ident(m.name)}(${m.args.map((_, i) => `a${i}, `).join("")}`, reply, `)`))
309
+ if (m.permissions.length) {
310
+ // The gate: the host's permission service, then the plugin — or DENIED without it.
311
+ lines.push(...wrap(`val reply = `, reply, ``))
312
+ lines.push(
313
+ `permissions.ensure(listOf(${m.permissions.map(k => `Permissions.Kind.${k.toUpperCase()}`).join(", ")})) { granted ->`,
314
+ ` if (!granted) reply.reject(${code}.DENIED)`,
315
+ ` else plugin.${ident(m.name)}(${m.args.map((_, i) => `a${i}, `).join("")}reply)`,
316
+ `}`,
317
+ )
318
+ } else {
319
+ lines.push(...wrap(`plugin.${ident(m.name)}(${m.args.map((_, i) => `a${i}, `).join("")}`, reply, `)`))
320
+ }
299
321
  return ` ${str(m.name)} -> {\n${text(lines, " ")}\n }`
300
322
  })
301
323
  out.push([
302
324
  `/** Reads a call off the wire and hands it to the plugin; a malformed call never reaches it. */\n`,
303
325
  `internal object ${iface}Glue {\n`,
304
- ` fun call(plugin: ${iface}Plugin, method: String, r: PackedReader, settle: ChannelSettle) {\n val n = r.beginArray() // the arguments, in their order\n when (method) {\n`,
326
+ ` fun call(plugin: ${iface}Plugin, ${gated ? "permissions: Permissions, " : ""}method: String, r: PackedReader, settle: ChannelSettle) {\n val n = r.beginArray() // the arguments, in their order\n when (method) {\n`,
305
327
  cases.length ? cases.join("\n") + "\n" : "",
306
328
  ` else -> settle.reject("Unknown method: $method")\n }\n }\n}\n`,
307
329
  ].join(""))
@@ -321,9 +343,12 @@ const IMPORTS: [string, string][] = [
321
343
  ["ChannelSettle", "io.letary.lecodes.services.ChannelSettle"],
322
344
  ["NativeViews", "io.letary.lecodes.services.NativeViews"],
323
345
  ["NoCode", "io.letary.lecodes.services.NoCode"],
346
+ ["Permissions", "io.letary.lecodes.services.Permissions"],
324
347
  ["PluginCode", "io.letary.lecodes.services.PluginCode"],
325
348
  ["PluginEmit", "io.letary.lecodes.services.PluginEmit"],
326
349
  ["PluginFile", "io.letary.lecodes.services.PluginFile"],
350
+ ["Entity", "io.letary.lecodes.scene.Entity"],
351
+ ["Scene3D", "io.letary.lecodes.scene.Scene3D"],
327
352
  ["Reply", "io.letary.lecodes.services.Reply"],
328
353
  ["Services", "io.letary.lecodes.services.Services"],
329
354
  ["Wire", "io.letary.lecodes.services.Wire"],
@@ -0,0 +1,98 @@
1
+ // A plugin repo's `Package.swift`, from the manifests: the plugins with an iOS half as ONE SwiftPM
2
+ // package — a target per plugin (its sources `plugins/<id>/ios`, the packages its manifest names)
3
+ // and a product per plugin. Each plugin is its own MODULE, so two plugins may declare a type of the
4
+ // same name, and an Xcode project links a plugin as a product instead of compiling its folder.
5
+ //
6
+ // The targets depend on the LeCodes SDK as a package on disk: `LECODES_IOS_SDK_PATH`, else the path
7
+ // the repo's package.json names (`lecodes.iosSdkPath`), else the sibling `../lecodes` (a checkout
8
+ // of the published SDK package, or whatever stands there).
9
+ //
10
+ // A shell made by `lecodes app sync` does NOT use this package: it vendors the sources into targets
11
+ // of its own (templates/ios.ts), pinned by plugins.lock.json.
12
+
13
+ import type { PluginManifest } from "./manifest"
14
+ import { pascal } from "./manifest"
15
+
16
+ export class PackageError extends Error {}
17
+
18
+ /** A package's identity from its URL (`https://github.com/stasel/WebRTC` → `WebRTC`): what SwiftPM
19
+ * names it in `.product(name:package:)`. The same rule as the shell's (commands/app/shared.ts). */
20
+ export const swiftPackageName = (url: string): string =>
21
+ (url.replace(/\/+$/, "").split("/").pop() ?? url).replace(/\.git$/, "")
22
+
23
+ /** The Swift module of a plugin's iOS half: the manifest's, or `LeCodes` + the id in PascalCase. */
24
+ export const iosModule = (id: string, manifest: PluginManifest): string => manifest.ios?.module ?? `LeCodes${pascal(id)}`
25
+
26
+ export const PACKAGE_FILE = "Package.swift"
27
+
28
+ /** Where the SDK package stands when neither the environment nor the repo says: a sibling checkout. */
29
+ export const DEFAULT_SDK_PATH = "../lecodes"
30
+
31
+ /** The text of the repo's Package.swift for `plugins` (every manifest; those without an iOS half are
32
+ * left out); `sdkPath` is the repo's own SDK location (package.json `lecodes.iosSdkPath`). Throws
33
+ * PackageError when two plugins pin one package at different versions. */
34
+ export const emitPackageSwift = (plugins: { id: string, manifest: PluginManifest }[], sdkPath: string = DEFAULT_SDK_PATH): string => {
35
+ const ios = plugins.filter(p => p.manifest.ios !== null)
36
+ const packages = new Map<string, { url: string, exact?: string, from?: string, by: string }>()
37
+ for (const p of ios) {
38
+ for (const pkg of p.manifest.ios!.packages ?? []) {
39
+ const prior = packages.get(pkg.url)
40
+ if (prior && (prior.exact !== pkg.exact || prior.from !== pkg.from)) {
41
+ throw new PackageError(`"${p.id}" pins ${pkg.url} at ${pkg.exact ?? `from ${pkg.from}`} but "${prior.by}" pins ${prior.exact ?? `from ${prior.from}`} — one package resolves once`)
42
+ }
43
+ if (!prior) packages.set(pkg.url, { ...pkg, by: p.id })
44
+ }
45
+ }
46
+ const moduleOf = (p: { id: string, manifest: PluginManifest }) => iosModule(p.id, p.manifest)
47
+
48
+ const products = ios.map(p => ` .library(name: "${moduleOf(p)}", targets: ["${moduleOf(p)}"]),`)
49
+ const dependencies = [
50
+ ` .package(name: "lecodes", path: sdk),`,
51
+ ...[...packages.values()].map(pkg => ` .package(url: "${pkg.url}", ${pkg.exact ? `exact: "${pkg.exact}"` : `from: "${pkg.from}"`}),`),
52
+ ]
53
+ const targets = ios.map(p => {
54
+ const deps = [
55
+ `.product(name: "LeCodes", package: "lecodes")`,
56
+ ...(p.manifest.ios!.packages ?? []).flatMap(pkg => pkg.products.map(name => `.product(name: "${name}", package: "${swiftPackageName(pkg.url)}")`)),
57
+ ]
58
+ return [
59
+ ` // ${p.id}`,
60
+ ` .target(`,
61
+ ` name: "${moduleOf(p)}",`,
62
+ ` dependencies: [${deps.join(", ")}],`,
63
+ ` path: "plugins/${p.id}/ios"`,
64
+ ` ),`,
65
+ ].join("\n")
66
+ })
67
+
68
+ return `// swift-tools-version:5.9
69
+ // GENERATED by \`lecodes plugin gen\` from the plugins' manifests (lecodes-plugin.json) — do not edit.
70
+ //
71
+ // The plugins with an iOS half as ONE SwiftPM package: a target per plugin (its sources in
72
+ // plugins/<id>/ios, the packages its manifest names) and a product per plugin. Each plugin is its
73
+ // own module: an Xcode project links a plugin as a product instead of compiling its folder, and two
74
+ // plugins may name a type alike.
75
+ //
76
+ // The SDK is the LeCodes package on disk: LECODES_IOS_SDK_PATH, else the path below (the repo's
77
+ // package.json \`lecodes.iosSdkPath\`, or the sibling ../lecodes). A shell made by \`lecodes app sync\`
78
+ // does not use this package: it vendors the sources into targets of its own, pinned by
79
+ // plugins.lock.json.
80
+ import PackageDescription
81
+
82
+ let sdk = Context.environment["LECODES_IOS_SDK_PATH"] ?? ${JSON.stringify(sdkPath)}
83
+
84
+ let package = Package(
85
+ name: "lecodes-plugins",
86
+ platforms: [.iOS(.v15)],
87
+ products: [
88
+ ${products.join("\n")}
89
+ ],
90
+ dependencies: [
91
+ ${dependencies.join("\n")}
92
+ ],
93
+ targets: [
94
+ ${targets.join("\n")}
95
+ ]
96
+ )
97
+ `
98
+ }