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
|
@@ -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>
|
package/src/dev/backendWorker.ts
CHANGED
|
@@ -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,
|
|
21
|
-
*
|
|
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 = {
|
package/src/plugin/contract.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 @
|
|
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) : [],
|
package/src/plugin/emitKotlin.ts
CHANGED
|
@@ -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)
|
|
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 = [
|
|
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(
|
|
273
|
-
: ` Services.Instance(onCall = { call -> ${iface}Glue.call(
|
|
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
|
-
|
|
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"],
|
package/src/plugin/emitSdk.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
|
|
164
|
-
|
|
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("")
|