anseronui 0.0.1 → 0.0.2

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/README.md CHANGED
@@ -38,26 +38,6 @@ npm run android
38
38
 
39
39
  `setup` 使用随 npm 包提供的编译器源码准备构建环境,首次会安装依赖,无需 Git。升级 AnseronUI 后需重新执行 `npm run setup`。
40
40
 
41
- <details>
42
- <summary>尚未发布时:使用仓库生成本地 npm 包体验</summary>
43
-
44
- 从框架仓库根目录执行以下命令。生成后的应用使用与 npm 发布包相同的工程结构和开发命令:
45
-
46
- ```sh
47
- npm ci --ignore-scripts
48
- npm pack --pack-destination /tmp
49
- node scripts/anseronui.mjs create ../awesomeAnseron --application-id com.example.awesomeanseron --package /tmp/anseronui-0.1.0.tgz
50
- cd ../awesomeAnseron
51
- npm install
52
- npm run setup
53
- npm run ios
54
- # 或运行 Android:npm run android
55
- ```
56
-
57
- 保留本地 tarball,供后续重新安装依赖使用;不要覆盖已有 App 目录。
58
-
59
- </details>
60
-
61
41
  ### iOS
62
42
 
63
43
  在应用目录执行 `npm run ios`,CLI 会构建应用、安装到模拟器并启动。默认使用唯一已启动模拟器;没有已启动的模拟器时,会选择并启动一个可用 iPhone。存在多个候选时,指定设备名称或 UUID:
@@ -211,3 +191,135 @@ AnseronUI 支持把共享页面嵌入已有宿主。iOS 和 Android 使用 `Anse
211
191
  AnseronUI 自有代码采用 [MIT 协议](LICENSE),允许商业使用、修改和再分发,分发时需保留版权及许可声明。
212
192
 
213
193
  [Flexily](https://github.com/beorn/flexily) 保留上游 MIT 协议;[scriptc](https://github.com/vercel-labs/scriptc) 及本仓库对其的 async embedding 适配继续使用 Apache-2.0。分发 SDK 或包含第三方 runtime 的 App 时,还需保留适用的许可证与声明,详见[第三方声明](THIRD_PARTY_NOTICES.md)。
194
+
195
+ ## 安装 npm 原生插件
196
+
197
+ 独立 App(通过 `anseronui create` 创建)支持从 npm 自动接入原生模块、自定义原生组件和 Web 适配。安装到应用的生产依赖,然后重新构建:
198
+
199
+ ```bash
200
+ npm install @your-scope/anseronui-device
201
+ npm run check
202
+ npm run ios
203
+ # 或 npm run android / npm run build:web
204
+ ```
205
+
206
+ 上面的包名是占位示例。构建会扫描已安装的 `dependencies`、`optionalDependencies` 及其传递依赖,读取包的 `anseronui` 声明,生成注册表,将插件源码、平台依赖和权限配置加入工程。无需在 App 入口逐个手写注册;`devDependencies` 和仅声明为 peer 的包不参与扫描。`npm run check` 在安装依赖后会列出发现的插件。新增、升级或卸载插件后必须重新构建,`--no-build` 仅运行已有 App。
207
+
208
+ 插件生成文件位于应用的 `.anseronui/plugins/`,每次 setup/build 都会重新生成,勿手动修改。插件缺少当前平台适配时,构建会提前报错;插件之间重复的模块/组件名在宿主创建注册表时报错,框架保留模块名也不能覆盖。
209
+
210
+ ## 发布带原生能力的 npm 包
211
+
212
+ 可以把 TS API、Swift/Kotlin 原生实现和 Web 适配放在同一个 npm 包中。完整可复制示例位于 [`examples/native-plugin`](examples/native-plugin),它提供 `useDevice().info()`,通过现有异步 Module 接口读取设备信息。发布自己的插件时,替换示例包名、工厂类型和模块名。
213
+
214
+ 推荐目录:
215
+
216
+ ```text
217
+ anseronui-device/
218
+ ├── package.json
219
+ ├── src/index.ts # 共享 TS API,参与 AOT 编译
220
+ ├── ios/DevicePlugin.swift
221
+ ├── android/DevicePlugin.kt
222
+ └── web/index.ts # 浏览器适配,与共享 Core 分开编译
223
+ ```
224
+
225
+ 在 `package.json` 中声明发布文件、框架 peer 依赖和插件清单:
226
+
227
+ ```json
228
+ {
229
+ "name": "@your-scope/anseronui-device",
230
+ "version": "0.1.0",
231
+ "type": "module",
232
+ "exports": { ".": "./src/index.ts" },
233
+ "files": ["src", "ios", "android", "web"],
234
+ "peerDependencies": { "anseronui": "^0.0.1" },
235
+ "anseronui": {
236
+ "schemaVersion": 1,
237
+ "typescript": "src/index.ts",
238
+ "ios": {
239
+ "sources": ["ios"],
240
+ "factory": "AnseronDevicePlugin",
241
+ "frameworks": ["UIKit"]
242
+ },
243
+ "android": {
244
+ "sources": ["android"],
245
+ "factory": "org.anseronui.device.DevicePlugin"
246
+ },
247
+ "web": { "entry": "web/index.ts" }
248
+ }
249
+ }
250
+ ```
251
+
252
+ 所有源码路径相对于插件包根目录,必须包含在 npm 发布包内,不能越出包目录。`sources` 是目录列表:iOS 递归纳入 `.swift` 文件,Android 纳入 Kotlin/Java 源码目录。Swift 工厂类型需使用简单标识符,Android 工厂使用完整类名;为类型加上自己的命名前缀,避免不同插件冲突。
253
+
254
+ 三端工厂约定如下,原生模块和组件都通过返回的注册表声明:
255
+
256
+ | 平台 | 工厂入口 | 返回值 |
257
+ | --- | --- | --- |
258
+ | iOS | `@MainActor` 类型的 `static func createExtensions()` | `IOSPlatformExtensions` |
259
+ | Android | Kotlin `object` 的 `fun createExtensions(context: Context)` | `AndroidPlatformExtensions` |
260
+ | Web | `web.entry` 导出的 `createExtensions()` | `PlatformExtensions` |
261
+
262
+ iOS 插件 Swift 源码与渲染器一起编译进 App target,直接使用 `IOSPlatformExtensions` 等类型,不需要 `import AnseronUIRenderIOS`。Android 使用 `org.anseronui.render` 中的公开类型;工厂接收当前宿主 Context,可由此使用 Activity 能力。Web 的 `createExtensions()` 同步返回 `modules` / `components`,其中模块处理器可以异步执行。每次创建宿主都会调用工厂,请将监听器和资源释放接到现有取消函数或组件 `dispose()` 上。具体 Module、组件、JSON 数据与生命周期约束见[原生扩展](docs/native-extensions.md)。
263
+
264
+ 共享 TS 入口可以这样包装平台模块,供业务组件使用:
265
+
266
+ ```ts
267
+ import { useServices } from 'anseronui/ui';
268
+
269
+ export interface DeviceInfo { platform: string; version: string }
270
+
271
+ export function useDevice() {
272
+ const services = useServices();
273
+ return {
274
+ info(): Promise<DeviceInfo> {
275
+ return services.invoke<DeviceInfo>('example.device', 'info');
276
+ },
277
+ };
278
+ }
279
+ ```
280
+
281
+ 用户在函数组件内调用 `useDevice()`,再在事件或 effect 中调用 `device.info()`。共享 TS 入口必须是 `.ts` 源码,内部可通过相对路径引用同一源码目录下的 TS/TSX 文件;当前自动映射插件包的主入口,不支持包子路径 exports。仍需遵守 [TS/TSX 编写限制](docs/typescript-limitations.md),插件声明不会让任意 Node.js、React Native 或浏览器 API 自动获得跨端支持。
282
+
283
+ 需要权限或 Android Maven 依赖时,可在对应平台声明中增加以下字段(仅按插件实际需要声明):
284
+
285
+ ```json
286
+ {
287
+ "ios": {
288
+ "sources": ["ios"],
289
+ "factory": "AnseronCameraPlugin",
290
+ "frameworks": ["AVFoundation"],
291
+ "infoPlist": { "NSCameraUsageDescription": "用于扫描二维码" }
292
+ },
293
+ "android": {
294
+ "sources": ["android"],
295
+ "factory": "com.example.camera.CameraPlugin",
296
+ "dependencies": ["androidx.core:core-ktx:1.15.0"],
297
+ "permissions": ["android.permission.CAMERA"]
298
+ }
299
+ }
300
+ ```
301
+
302
+ `ios.infoPlist` 当前只接受 `NS…UsageDescription` 字符串。构建使用合并后的生成 plist,保留应用自己的 `platforms/ios/Info.plist`;应用中的同名说明优先,不同插件的默认说明不一致时会报错。Android 权限自动进入 Debug/Release 合并清单;危险权限的运行时请求仍由插件实现。Maven 依赖使用固定 `group:artifact:version` 坐标,从工程现有仓库解析。
303
+
304
+ 当前版本自动接入的范围是 Swift 源码、Kotlin/Java 源码、iOS 系统 framework、Android Maven 依赖、权限和 Web 适配。CocoaPods、Swift Package 依赖、二进制 SDK、原生资源、entitlements、Android 自定义清单组件等仍需宿主额外配置;React Native 插件需要按本协议适配。旧版生成工程需对照新模板接入生成注册表、iOS 工程生成脚本和 Android Gradle 配置;已有原生工程的 `integrate` 模式继续由宿主管理平台构建和注册。
305
+
306
+ 发布前先在插件目录检查真实发布内容,再在测试 App 中安装本地压缩包,验证目标平台:
307
+
308
+ ```bash
309
+ # 插件目录
310
+ npm pack --dry-run
311
+ npm pack
312
+
313
+ # 测试 App 目录;替换为 npm pack 输出文件的绝对路径
314
+ npm install /absolute/path/your-scope-anseronui-device-0.1.0.tgz
315
+ npm run check
316
+ npm run typecheck
317
+ npm run ios
318
+ npm run android
319
+ npm run build:web
320
+
321
+ # 验证通过后,回到插件目录发布公开的 scoped 包
322
+ npm publish --access public
323
+ ```
324
+
325
+ 提交锁文件并验证所有声明支持的平台。插件包的 `files` 必须包含 TS 源码和原生实现,不能只发布编译后的 JS 或 `.d.ts`。框架本身尚未发布到 npm 时,测试 App 应先按本文的本地 npm 包流程安装 `anseronui`,再安装插件。
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anseronui-sdk",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "type": "module",
5
5
  "license": "MIT"
6
6
  }
@@ -221,13 +221,20 @@ class AndroidRenderer(val root: FrameLayout, private val extensions: AndroidPlat
221
221
 
222
222
  fun viewFor(nodeId: Long): View? = nodes[nodeId]?.view
223
223
 
224
+ private fun systemButton() = Button(root.context).apply {
225
+ setAllCaps(false)
226
+ minWidth = 0
227
+ minHeight = 0
228
+ setPadding((12 * density).roundToInt(), (6 * density).roundToInt(), (12 * density).roundToInt(), (6 * density).roundToInt())
229
+ }
230
+
224
231
  /** Call on the UI thread; real controls measure their own text and padding. */
225
232
  fun measure(kind: String, text: String, fontSize: Float, maxWidth: Float, props: JSONObject? = null): TextSize {
226
233
  require(Looper.myLooper() == Looper.getMainLooper()) { "Measurement must run on the UI thread" }
227
234
  require(fontSize.isFinite() && fontSize >= 0 && maxWidth.isFinite() && maxWidth >= 0)
228
235
  val view: TextView = when (kind) {
229
236
  "Text", "RichText" -> TextView(root.context)
230
- "Button" -> Button(root.context)
237
+ "Button" -> systemButton()
231
238
  "TextInput" -> EditText(root.context)
232
239
  else -> error("Unknown text kind")
233
240
  }
@@ -282,7 +289,7 @@ class AndroidRenderer(val root: FrameLayout, private val extensions: AndroidPlat
282
289
  "View" -> { view = FrameLayout(root.context); content = view }
283
290
  "Text" -> { view = TextView(root.context); content = null }
284
291
  "RichText" -> { view = TextView(root.context); content = null }
285
- "Button" -> { view = Button(root.context); content = null }
292
+ "Button" -> { view = systemButton(); content = null }
286
293
  "TextInput" -> { view = EditText(root.context); content = null }
287
294
  "ScrollView" -> {
288
295
  val scroll = TouchScrollView().apply { isFillViewport = true }
@@ -444,7 +451,7 @@ class AndroidRenderer(val root: FrameLayout, private val extensions: AndroidPlat
444
451
  background.setStroke((props.optDouble("borderWidth", 0.0) * density).roundToInt(), parseColor(props.optString("borderColor", ""), Color.TRANSPARENT))
445
452
  if (view is TextView) {
446
453
  view.textSize = props.optDouble("fontSize", 16.0).toFloat()
447
- view.setTextColor(parseColor(props.optString("color", ""), Color.BLACK))
454
+ view.setTextColor(parseColor(props.optString("color", ""), if (record.kind == "Button") Color.rgb(0, 122, 255) else Color.BLACK))
448
455
  view.typeface = android.graphics.Typeface.create(view.typeface, if (props.optString("fontWeight") == "bold") android.graphics.Typeface.BOLD else android.graphics.Typeface.NORMAL)
449
456
  if (props.optString("fontStyle") == "italic") view.setTypeface(view.typeface, android.graphics.Typeface.ITALIC)
450
457
  view.gravity = when (props.optString("textAlign")) { "center" -> android.view.Gravity.CENTER_HORIZONTAL; "right" -> android.view.Gravity.RIGHT; else -> android.view.Gravity.LEFT }
@@ -490,9 +497,12 @@ class AndroidRenderer(val root: FrameLayout, private val extensions: AndroidPlat
490
497
  view.movementMethod = LinkMovementMethod.getInstance()
491
498
  }
492
499
  "Button" -> {
500
+ view.setAllCaps(false)
493
501
  view.text = props.optString("title", "")
494
- val normal = parseColor(props.optString("color", ""), Color.BLACK)
495
- val disabled = Color.argb((Color.alpha(normal) * 0.45f).roundToInt(), Color.red(normal), Color.green(normal), Color.blue(normal))
502
+ val normal = parseColor(props.optString("color", ""), Color.rgb(0, 122, 255))
503
+ val disabled = if (props.has("color")) {
504
+ Color.argb((Color.alpha(normal) * 0.4f).roundToInt(), Color.red(normal), Color.green(normal), Color.blue(normal))
505
+ } else Color.rgb(142, 142, 147)
496
506
  view.setTextColor(ColorStateList(
497
507
  arrayOf(intArrayOf(-android.R.attr.state_enabled), intArrayOf()),
498
508
  intArrayOf(disabled, normal)
@@ -191,7 +191,14 @@ export class DOMRenderer {
191
191
  video.addEventListener("loadeddata", () => record.enabled.has("firstFrameDidDisplay") && this.onEvent({ type: "NativeEvent", nodeId: command.nodeId, revision: this.revision, event: "firstFrameDidDisplay" }));
192
192
  video.addEventListener("timeupdate", () => record.enabled.has("playTimeDidChanged") && this.onEvent({ type: "NativeEvent", nodeId: command.nodeId, revision: this.revision, event: "playTimeDidChanged", x: Math.round(video.currentTime * 1000), duration: Number.isFinite(video.duration) ? Math.round(video.duration * 1000) : 0 }));
193
193
  }
194
- if (command.kind === "Button") element.setAttribute("type", "button");
194
+ if (command.kind === "Button") {
195
+ element.setAttribute("type", "button");
196
+ Object.assign(element.style, {
197
+ appearance: "none", boxSizing: "border-box", border: "0", borderRadius: "0",
198
+ background: "transparent", padding: "6px 12px", color: "#007aff",
199
+ font: "16px system-ui, -apple-system, BlinkMacSystemFont, sans-serif", cursor: "pointer",
200
+ });
201
+ }
195
202
  const record: NodeRecord = { element, animation: new NodeAnimation(values => paintElement(element, values)), native, kind: command.kind, enabled: new Set(), composing: false, inputRevision: 0, pendingInputRevision: 0, pendingValue: null };
196
203
  this.records.set(command.nodeId, record);
197
204
  element.dataset.tsnNode = String(command.nodeId);
@@ -341,8 +348,8 @@ export class DOMRenderer {
341
348
  const visual = { ...visualDefaults, ...colorValues(String(props.backgroundColor ?? "transparent")) };
342
349
  for (const key of ["opacity", "translateX", "translateY", "scaleX", "scaleY", "rotate", "borderRadius"]) if (typeof props[key] === "number") visual[key] = props[key] as number;
343
350
  record.animation.stage(visual);
344
- element.style.color = String(props.color ?? "");
345
- element.style.fontSize = props.fontSize === undefined ? "" : props.fontSize + "px";
351
+ element.style.color = String(props.color ?? (record.kind === "Button" ? (props.disabled === true ? "#8e8e93" : "#007aff") : ""));
352
+ element.style.fontSize = props.fontSize === undefined ? (record.kind === "Button" ? "16px" : "") : props.fontSize + "px";
346
353
  element.style.fontWeight = String(props.fontWeight ?? "");
347
354
  element.style.fontStyle = String(props.fontStyle ?? "");
348
355
  element.style.textAlign = String(props.textAlign ?? "");
@@ -7,6 +7,8 @@ import { fileURLToPath } from "node:url";
7
7
  import { parseArgs } from "node:util";
8
8
  import { createStandalone, buildStandalone, runStandalone, setupStandalone, typecheckStandalone, devStandalone } from "./standalone-app.mjs";
9
9
 
10
+ import { discoverNativePlugins } from "./native-plugins.mjs";
11
+
10
12
  const frameworkRoot = resolve(fileURLToPath(new URL("../", import.meta.url)));
11
13
  const requestedCommand = process.argv[2] ?? "help";
12
14
  const command = ["--help", "-h"].includes(requestedCommand) ? "help" : ["--version", "-v"].includes(requestedCommand) ? "version" : requestedCommand;
@@ -77,6 +79,10 @@ function checkApp() {
77
79
  const sourceCandidates = config.schemaVersion === 2 ? [resolve(dirname(configPath), page.source)] : [resolve(frameworkRoot, page.source), resolve(dirname(configPath), page.source)];
78
80
  if (!sourceCandidates.some(existsSync)) throw new Error(`Page source not found: ${page.source}`);
79
81
  }
82
+ if (config.app && existsSync(join(dirname(configPath), "node_modules"))) {
83
+ const plugins = discoverNativePlugins(dirname(configPath));
84
+ console.log(`Native plugins: ${plugins.map(plugin => plugin.name).join(", ") || "(none)"}`);
85
+ }
80
86
  console.log(`AnseronUI config OK: ${configPath}`);
81
87
  console.log(`Pages: ${config.pages.map(page => page.source).join(", ")}`);
82
88
  }
@@ -0,0 +1,143 @@
1
+ import { existsSync, readFileSync, realpathSync, readdirSync, mkdirSync, writeFileSync, renameSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, join, resolve, relative, sep, isAbsolute } from 'node:path';
4
+
5
+ const read = file => JSON.parse(readFileSync(file, 'utf8'));
6
+ const posix = path => path.split(sep).join('/');
7
+ const identifier = /^[A-Za-z_][A-Za-z0-9_]*$/;
8
+ const qualified = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
9
+ const packageName = /^(?:@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*$/;
10
+ function check(condition, message) { if (!condition) throw new Error(`Native plugin: ${message}`); }
11
+ function fields(value, allowed, label) {
12
+ check(value && typeof value === 'object' && !Array.isArray(value), `${label} must be an object`);
13
+ for (const key of Object.keys(value)) check(allowed.includes(key), `${label}: unknown field ${key}`);
14
+ }
15
+ function strings(value, label, pattern) {
16
+ check(Array.isArray(value) && value.every(item => typeof item === 'string' && (!pattern || pattern.test(item))), `${label} must be an array of valid strings`);
17
+ return value;
18
+ }
19
+ function inside(root, path) {
20
+ check(typeof path === 'string' && path.length && !isAbsolute(path), `expected package-relative path: ${path}`);
21
+ const result = realpathSync(resolve(root, path));
22
+ const rel = relative(root, result);
23
+ check(rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel), `path escapes package: ${path}`);
24
+ return result;
25
+ }
26
+ function sourceFiles(root, paths, extensions) {
27
+ const files = new Set();
28
+ function walk(path) {
29
+ const entries = readdirSync(path, { withFileTypes: true });
30
+ for (const entry of entries) {
31
+ check(!entry.isSymbolicLink(), `source symlinks are unsupported: ${join(path, entry.name)}`);
32
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
33
+ const next = join(path, entry.name);
34
+ if (entry.isDirectory()) walk(next);
35
+ else if (extensions.some(ext => entry.name.endsWith(ext))) files.add(next);
36
+ }
37
+ }
38
+ for (const path of strings(paths, 'sources')) walk(inside(root, path));
39
+ check(files.size, `no ${extensions.join('/')} sources found in ${root}`);
40
+ return [...files].sort();
41
+ }
42
+ function locate(from, name) {
43
+ check(packageName.test(name), `invalid dependency name ${name}`);
44
+ const resolver = createRequire(join(from, 'package.json'));
45
+ for (const directory of resolver.resolve.paths(name) ?? []) {
46
+ const file = join(directory, name, 'package.json');
47
+ if (existsSync(file)) return realpathSync(file);
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /** Discover installed production dependencies, including transitive and scoped packages. */
53
+ export function discoverNativePlugins(app) {
54
+ const seen = new Set(); const plugins = new Map();
55
+ function visit(file) {
56
+ file = realpathSync(file);
57
+ if (seen.has(file)) return;
58
+ seen.add(file);
59
+ const pkg = read(file); const root = dirname(file); const spec = pkg.anseronui;
60
+ if (spec !== undefined) {
61
+ check(packageName.test(pkg.name), `invalid package name ${pkg.name}`);
62
+ check(!plugins.has(pkg.name), `multiple installed versions of ${pkg.name}`);
63
+ fields(spec, ['schemaVersion', 'typescript', 'ios', 'android', 'web'], pkg.name);
64
+ check(spec.schemaVersion === 1, `${pkg.name}: schemaVersion must be 1`);
65
+ const entry = inside(root, spec.typescript);
66
+ check(entry.endsWith('.ts') && !entry.endsWith('.d.ts'), `${pkg.name}: typescript must point to a .ts source entry`);
67
+ const plugin = { name: pkg.name, root, entry };
68
+ if (spec.ios !== undefined) {
69
+ fields(spec.ios, ['sources', 'factory', 'frameworks', 'infoPlist'], `${pkg.name}.ios`);
70
+ check(typeof spec.ios.factory === 'string' && identifier.test(spec.ios.factory), `${pkg.name}: invalid Swift factory type`);
71
+ const frameworks = strings(spec.ios.frameworks ?? [], 'ios.frameworks', identifier);
72
+ const infoPlist = spec.ios.infoPlist ?? {};
73
+ fields(infoPlist, Object.keys(infoPlist), 'ios.infoPlist');
74
+ for (const [key, value] of Object.entries(infoPlist)) check(/^NS[A-Za-z0-9]+UsageDescription$/.test(key) && typeof value === 'string' && value.trim(), `invalid iOS usage description ${key}`);
75
+ plugin.ios = { sources: sourceFiles(root, spec.ios.sources, ['.swift']), factory: spec.ios.factory, frameworks, infoPlist };
76
+ }
77
+ if (spec.android !== undefined) {
78
+ fields(spec.android, ['sources', 'factory', 'dependencies', 'permissions'], `${pkg.name}.android`);
79
+ check(typeof spec.android.factory === 'string' && qualified.test(spec.android.factory), `${pkg.name}: invalid Kotlin factory type`);
80
+ plugin.android = {
81
+ sources: strings(spec.android.sources, 'android.sources').map(path => inside(root, path)),
82
+ factory: spec.android.factory,
83
+ dependencies: strings(spec.android.dependencies ?? [], 'android.dependencies', /^[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+:[A-Za-z0-9_.+-]+$/),
84
+ permissions: strings(spec.android.permissions ?? [], 'android.permissions', qualified),
85
+ };
86
+ sourceFiles(root, spec.android.sources, ['.kt', '.java']);
87
+ }
88
+ if (spec.web !== undefined) {
89
+ fields(spec.web, ['entry'], `${pkg.name}.web`);
90
+ plugin.web = { entry: inside(root, spec.web.entry) };
91
+ }
92
+ check(plugin.ios || plugin.android || plugin.web, `${pkg.name}: declare at least one platform`);
93
+ plugins.set(pkg.name, plugin);
94
+ }
95
+ const optional = pkg.optionalDependencies ?? {};
96
+ for (const name of Object.keys({ ...pkg.dependencies, ...optional }).sort()) {
97
+ const dependency = locate(root, name);
98
+ if (!dependency) { check(name in optional, `missing dependency ${name}; run npm install`); continue; }
99
+ visit(dependency);
100
+ }
101
+ }
102
+ visit(join(app, 'package.json'));
103
+ return [...plugins.values()].sort((a, b) => a.name.localeCompare(b.name, 'en'));
104
+ }
105
+
106
+ /** Generated outputs belong to the app and are replaced on each setup/build. */
107
+ export function generateNativePlugins(app, plugins, target) {
108
+ const platform = target?.startsWith('ios') ? 'ios' : target;
109
+ if (platform) for (const plugin of plugins) check(plugin[platform], `${plugin.name} does not support ${platform}`);
110
+ const directory = join(app, '.anseronui/plugins');
111
+ // Validate before replacing any previous output.
112
+ const infoPlist = {};
113
+ for (const plugin of plugins) for (const [key, value] of Object.entries(plugin.ios?.infoPlist ?? {})) {
114
+ check(!(key in infoPlist) || infoPlist[key] === value, `conflicting iOS usage description ${key}`);
115
+ infoPlist[key] = value;
116
+ }
117
+ mkdirSync(directory, { recursive: true });
118
+ const write = (file, text) => {
119
+ const destination = join(directory, file);
120
+ if (existsSync(destination) && readFileSync(destination, 'utf8') === text) return;
121
+ const temporary = destination + `.${process.pid}.tmp`;
122
+ writeFileSync(temporary, text);
123
+ renameSync(temporary, destination);
124
+ };
125
+ const ios = plugins.filter(plugin => plugin.ios);
126
+ write('ios.json', JSON.stringify({ sources: ios.flatMap(plugin => plugin.ios.sources), frameworks: [...new Set(ios.flatMap(plugin => plugin.ios.frameworks))], infoPlist }, null, 2));
127
+ write('GeneratedPlugins.swift', `// Generated by AnseronUI. Do not edit.\nimport Foundation\n@MainActor enum AnseronUIPlugins {\n static func makeExtensions() -> IOSPlatformExtensions {\n let result = IOSPlatformExtensions()\n${ios.map(plugin => ` do {\n let next = ${plugin.ios.factory}.createExtensions()\n for (name, handler) in next.modules {\n precondition(!["http", "timer", "__view", "router"].contains(name) && result.modules[name] == nil, "Duplicate or reserved native module: \\(name)")\n result.modules[name] = handler\n }\n for (name, factory) in next.components {\n precondition(result.components[name] == nil, "Duplicate native component: \\(name)")\n result.components[name] = factory\n }\n }`).join('\n')}\n return result\n }\n}\n`);
128
+ const android = plugins.filter(plugin => plugin.android);
129
+ write('GeneratedPlugins.kt', `// Generated by AnseronUI. Do not edit.\npackage org.anseronui.generated\nimport android.content.Context\nimport org.anseronui.render.*\nobject AnseronUIPlugins {\n fun makeExtensions(context: Context): AndroidPlatformExtensions {\n val modules = mutableMapOf<String, NativeModuleHandler>()\n val components = mutableMapOf<String, NativeComponentFactory>()\n${android.map(plugin => ` run {\n val next = ${plugin.android.factory}.createExtensions(context)\n for ((name, handler) in next.modules) {\n require(name !in listOf("http", "timer", "__view", "router") && name !in modules) { "Duplicate or reserved native module: $name" }\n modules[name] = handler\n }\n for ((name, factory) in next.components) {\n require(name !in components) { "Duplicate native component: $name" }\n components[name] = factory\n }\n }`).join('\n')}\n return AndroidPlatformExtensions(modules, components)\n }\n}\n`);
130
+ // Gradle consumes JSON instead of executable snippets from package metadata.
131
+ write('android.json', JSON.stringify({ sources: android.flatMap(plugin => plugin.android.sources), dependencies: [...new Set(android.flatMap(plugin => plugin.android.dependencies))], permissions: [...new Set(android.flatMap(plugin => plugin.android.permissions))] }, null, 2));
132
+ const typeImport = read(join(app, 'anseronui.config.json')).schemaVersion === 2 ? 'anseronui/render-web' : '@anseronui/render-web';
133
+ const web = plugins.filter(plugin => plugin.web);
134
+ write('web.ts', `${web.map((plugin, i) => `import { createExtensions as plugin${i} } from ${JSON.stringify('./' + posix(relative(directory, plugin.web.entry)).replace(/\.ts$/, ''))};`).join('\n')}\nimport type { PlatformExtensions } from '${typeImport}';\nexport function createPluginExtensions(): PlatformExtensions {\n const result: PlatformExtensions = { modules: Object.create(null), components: Object.create(null) };\n const plugins: PlatformExtensions[] = [${web.map((_, i) => `plugin${i}()`).join(', ')}];\n for (const next of plugins) {\n for (const [name, handler] of Object.entries(next.modules ?? {})) {\n if (['http', 'timer', '__view', 'router'].includes(name) || Object.hasOwn(result.modules!, name)) throw new Error('Duplicate or reserved native module: ' + name);\n result.modules![name] = handler;\n }\n for (const [name, factory] of Object.entries(next.components ?? {})) {\n if (Object.hasOwn(result.components!, name)) throw new Error('Duplicate native component: ' + name);\n result.components![name] = factory;\n }\n }\n return result;\n}\n`);
135
+ return directory;
136
+ }
137
+
138
+ export function pluginBuildConfig(config, framework, plugins) {
139
+ const result = structuredClone(config);
140
+ result.npmPlugins = plugins.map(plugin => ({ name: plugin.name, entry: posix(relative(framework, plugin.entry)) }));
141
+ result.sourceDirs = [...new Set([...result.sourceDirs, ...plugins.map(plugin => posix(relative(framework, dirname(plugin.entry))))])];
142
+ return result;
143
+ }
@@ -14,7 +14,7 @@ const files = [
14
14
  'packages/flexily/LICENSE', 'packages/render-ios/Package.swift',
15
15
  'packages/render-android/src/main/AndroidManifest.xml',
16
16
  'internal/toolchain/build-library.mjs', 'internal/toolchain/async-embedding.patch', 'internal/toolchain/scriptc.vendor.json',
17
- ...['anseronui', 'standalone-app', 'sdk-files', 'prepare-compiler', 'prepare-tools',
17
+ ...['anseronui', 'standalone-app', 'native-plugins', 'sdk-files', 'prepare-compiler', 'prepare-tools',
18
18
  'build-core', 'build-app', 'stage-core', 'core-inputs', 'lower-timers'].map(name => `scripts/${name}.mjs`),
19
19
  ];
20
20
  export const toolchainHost = `${process.platform}-${process.arch}${process.platform === 'linux' ? (process.report.getReport().header.glibcVersionRuntime ? '-gnu' : '-musl') : process.platform === 'win32' ? '-msvc' : ''}`;
@@ -28,7 +28,7 @@ export function sdkFiles(root) {
28
28
  const result = [...files, ...vendor.sdkFiles.map(path => `internal/toolchain/scriptc/${path}`)];
29
29
  function walk(path) {
30
30
  for (const entry of readdirSync(join(root, path), { withFileTypes: true })) {
31
- if (['AGENTS.md', '.DS_Store', 'node_modules', 'test', 'tests'].includes(entry.name)) continue;
31
+ if (['AGENTS.md', '.DS_Store', '__pycache__', 'node_modules', 'test', 'tests'].includes(entry.name)) continue;
32
32
  const next = `${path}/${entry.name}`;
33
33
  if (entry.isDirectory()) walk(next); else result.push(next);
34
34
  }
@@ -29,6 +29,11 @@ const aliases = new Map([
29
29
  ]);
30
30
  function stagedPath(path) {
31
31
  const absolute = resolve(root, path);
32
+ const plugin = (config.npmPlugins ?? []).find(plugin => {
33
+ const base = dirname(resolve(root, plugin.entry));
34
+ return absolute === base || absolute.startsWith(base + '/');
35
+ });
36
+ if (plugin) return join('__npm_plugins__', createHash('sha1').update(plugin.name).digest('hex').slice(0, 12), relative(dirname(resolve(root, plugin.entry)), absolute));
32
37
  const projectPath = relative(root, absolute);
33
38
  if (!projectPath.startsWith('..')) return projectPath;
34
39
  const candidates = (config.sourceDirs ?? []).map(dir => resolve(root, dir))
@@ -38,6 +43,7 @@ function stagedPath(path) {
38
43
  const identity = createHash('sha1').update(base).digest('hex').slice(0, 12);
39
44
  return join('__external__', identity, relative(base, absolute));
40
45
  }
46
+ for (const plugin of config.npmPlugins ?? []) aliases.set(plugin.name, stagedPath(plugin.entry));
41
47
  async function copy(path, outputPath = stagedPath(path)) {
42
48
  const absolute = resolve(root, path);
43
49
  for (const item of await readdir(absolute,{withFileTypes:true})) {
@@ -6,7 +6,8 @@ import { spawn, spawnSync } from 'node:child_process';
6
6
  import { setTimeout as delay } from 'node:timers/promises';
7
7
  import { createHash } from 'node:crypto';
8
8
  import { createRequire } from 'node:module';
9
- import { copySDK, sdkFingerprint, toolchainHost } from './sdk-files.mjs';
9
+ import { copySDK, sdkFingerprint, toolchainHost, sdkAliases } from './sdk-files.mjs';
10
+ import { discoverNativePlugins, generateNativePlugins, pluginBuildConfig } from './native-plugins.mjs';
10
11
 
11
12
  const framework = fileURLToPath(new URL('../', import.meta.url));
12
13
  const templates = fileURLToPath(new URL('./templates/app/', import.meta.url));
@@ -77,6 +78,19 @@ export function createStandalone(targetPath, { name, applicationId, force = fals
77
78
  writeFileSync(path, contents);
78
79
  }
79
80
  }
81
+ if (localFramework) {
82
+ // npm plugins use the public package name even while developing against a checkout.
83
+ const aliases = { anseronui: resolve(framework, 'packages/ui/src/index.ts'),
84
+ ...Object.fromEntries(Object.entries(sdkAliases).map(([name, path]) => [name.replace('@anseronui/', 'anseronui/'), resolve(framework, path)])) };
85
+ const tsconfigPath = join(target, 'tsconfig.json');
86
+ const tsconfig = json(tsconfigPath);
87
+ tsconfig.compilerOptions.paths = Object.fromEntries(Object.entries(aliases).map(([name, path]) => [name, [path]]));
88
+ write(tsconfigPath, JSON.stringify(tsconfig, null, 2) + '\n');
89
+ const vitePath = join(target, 'web/vite.config.ts');
90
+ // Use exact RegExp aliases so the bare package alias cannot consume its subpaths.
91
+ const viteAliases = Object.entries(aliases).map(([name, path]) => `{ find: new RegExp(${JSON.stringify('^' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$')}), replacement: ${JSON.stringify(path)} }`).join(', ');
92
+ write(vitePath, readFileSync(vitePath, 'utf8').replace('defineConfig({', `defineConfig({ resolve: { alias: [${viteAliases}] },`));
93
+ }
80
94
  chmodSync(join(target, 'platforms/android/gradlew'), 0o755);
81
95
  write(join(target, 'anseronui.config.json'), JSON.stringify(config, null, 2) + '\n');
82
96
  const cli = posix(relative(target, join(framework, 'scripts/anseronui.mjs')));
@@ -95,7 +109,8 @@ export function createStandalone(targetPath, { name, applicationId, force = fals
95
109
  write(join(target, 'platforms/android/settings.gradle'), `pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }\ndependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS); repositories { google(); mavenCentral() } }\nrootProject.name = '${displayName}'\ninclude ':app', ':render'\n`);
96
110
  const renderer = posix(relative(join(target, 'platforms/android'), resolve(target, config.app.framework, 'packages/render-android')));
97
111
  write(join(target, 'platforms/android/render/build.gradle'), `plugins { id 'com.android.library'; id 'org.jetbrains.kotlin.android' }\nandroid {\n namespace 'org.anseronui.render'\n compileSdk 35\n defaultConfig { minSdk 26 }\n compileOptions { sourceCompatibility JavaVersion.VERSION_17; targetCompatibility JavaVersion.VERSION_17 }\n kotlinOptions { jvmTarget = '17' }\n sourceSets.main {\n java.srcDirs = [rootProject.file(${JSON.stringify(renderer + '/src/main/java')})]\n manifest.srcFile rootProject.file(${JSON.stringify(renderer + '/src/main/AndroidManifest.xml')})\n jniLibs.srcDirs = [rootProject.file('../../.verification/android-jni')]\n }\n}\n`);
98
- write(join(target, 'README.md'), `# ${displayName}\n\nAnseronUI 跨平台应用,应用标识 \`${identifier}\`。\n\n\`npm install\` → \`npm run setup\` → \`npm run ios\` / \`npm run android\`。\n\nsetup 使用 npm 包内的固定版本编译器源码构建工具链,无需 Git。iOS 需要 macOS、Xcode、模拟器 runtime 和 Python 3。Android 需要 SDK 35、NDK、JDK 17–22 及 arm64-v8a 设备或模拟器。\n\n指定设备:\`npm run ios -- --device <UUID或名称>\`、\`npm run android -- --device <serial>\`,或 \`npm run android -- --avd <AVD名称>\`。\`--no-build\` 复用已有产物,\`--dry-run\` 预览操作。\n\n\`npm run check\` 检查配置;\`npm run typecheck\` 检查 TSX。\`npm run build:ios\` / \`npm run build:android\` 只构建;\`build:ios-device\` 生成未签名真机产物,需自行配置 Xcode 签名。\n\nWeb:\`npm run build:web\`,然后 \`npm run dev:web\`。修改 TSX 后需重新编译 Wasm。\n\n页面位于 \`src/pages\`;页面清单位于 \`anseronui.config.json\`。${localFramework ? '本地源码模式:页面路径相对于框架目录。' : '配置中的页面、源码及 Web 路径相对于本工程,可整体移动工程。npm 依赖固定版本,提交 package-lock.json 以锁定依赖。SDK 与编译器缓存位于 .anseronui/,构建产物位于 .verification/、platforms/android/app/build/ 和 dist/web/。无需框架源码仓库。'}\n\n三端仅显示 TSX 页面,系统栏背景跟随页面根节点 backgroundColor。\n`);
112
+ write(join(target, 'README.md'), `# ${displayName}\n\nAnseronUI 跨平台应用,应用标识 \`${identifier}\`。\n\n\`npm install\` → \`npm run setup\` → \`npm run ios\` / \`npm run android\`。\n\nsetup 使用 npm 包内的固定版本编译器源码构建工具链,无需 Git。iOS 需要 macOS、Xcode、模拟器 runtime 和 Python 3。Android 需要 SDK 35、NDK、JDK 17–22 及 arm64-v8a 设备或模拟器。\n\n指定设备:\`npm run ios -- --device <UUID或名称>\`、\`npm run android -- --device <serial>\`,或 \`npm run android -- --avd <AVD名称>\`。\`--no-build\` 复用已有产物,\`--dry-run\` 预览操作。\n\n\`npm run check\` 检查配置;\`npm run typecheck\` 检查 TSX。\`npm run build:ios\` / \`npm run build:android\` 只构建;\`build:ios-device\` 生成未签名真机产物,需自行配置 Xcode 签名。\n\nWeb:\`npm run build:web\`,然后 \`npm run dev:web\`。修改 TSX 后需重新编译 Wasm。\n\n页面位于 \`src/pages\`;页面清单位于 \`anseronui.config.json\`。${localFramework ? '本地源码模式:页面路径相对于框架目录。' : '配置中的页面、源码及 Web 路径相对于本工程,可整体移动工程。npm 依赖固定版本,提交 package-lock.json 以锁定依赖。SDK 与编译器缓存位于 .anseronui/,构建产物位于 .verification/、platforms/android/app/build/ 和 dist/web/。无需框架源码仓库。'}\n\n三端仅显示 TSX 页面,系统栏背景跟随页面根节点 backgroundColor。\n\n原生插件:使用 \`npm install <插件包>\` 安装到生产依赖,\`npm run check\` 查看已发现插件,再重新构建目标平台。构建自动接入包内的原生源码和注册表;生成文件位于 \`.anseronui/plugins/\`,请勿手动修改。插件发布协议见 AnseronUI 框架 README。\n`);
113
+ generateNativePlugins(target, []);
99
114
  console.log(`Created independent AnseronUI app: ${target}\nApplication ID: ${identifier}\nNext: cd ${JSON.stringify(target)}${localFramework ? '' : ' && npm install'} && npm run setup && npm run ios\nAndroid: npm run android`);
100
115
  }
101
116
 
@@ -105,7 +120,12 @@ export function setupStandalone(configPath, { prepareCompiler = false } = {}) {
105
120
  if (!existsSync(join(framework, 'node_modules/.bin/tsc'))) throw new Error('Install framework dependencies first: npm ci --ignore-scripts');
106
121
  const modules = join(ctx.app, 'node_modules');
107
122
  if (!existsSync(modules)) symlinkSync(join(framework, 'node_modules'), modules, 'dir');
108
- return { ...ctx, framework };
123
+ const plugins = discoverNativePlugins(ctx.app);
124
+ generateNativePlugins(ctx.app, plugins);
125
+ const config = pluginBuildConfig(ctx.config, framework, plugins);
126
+ const file = join(ctx.app, '.anseronui/build.config.json');
127
+ write(file, JSON.stringify(config, null, 2) + '\n');
128
+ return { ...ctx, framework, config, file };
109
129
  }
110
130
  // The package is immutable. Each app owns its SDK, compiler sources and AOT staging.
111
131
  if (ctx.config.app.framework !== '.anseronui/sdk') throw new Error('schemaVersion 2 requires app.framework = .anseronui/sdk');
@@ -150,20 +170,23 @@ export function setupStandalone(configPath, { prepareCompiler = false } = {}) {
150
170
  rmSync(link, { recursive: true, force: true });
151
171
  mkdirSync(dirname(link), { recursive: true }); symlinkSync(path, link, 'dir');
152
172
  }
173
+ const plugins = discoverNativePlugins(ctx.app);
174
+ generateNativePlugins(ctx.app, plugins);
153
175
  const config = structuredClone(ctx.config);
154
176
  const sdkPath = path => posix(relative(sdk, resolve(ctx.app, path)));
155
177
  config.sourceDirs = config.sourceDirs.map(sdkPath);
156
178
  config.pages.forEach(page => { page.source = sdkPath(page.source); });
157
179
  config.web = { ...config.web, viteConfig: sdkPath(config.web.viteConfig), publicDir: sdkPath(config.web.publicDir) };
158
180
  const file = join(ctx.app, '.anseronui/build.config.json');
159
- const text = JSON.stringify(config, null, 2) + '\n';
181
+ const buildConfig = pluginBuildConfig(config, sdk, plugins);
182
+ const text = JSON.stringify(buildConfig, null, 2) + '\n';
160
183
  if (!existsSync(file) || readFileSync(file, 'utf8') !== text) write(file, text);
161
184
  const compilerStamp = join(sdk, '.compiler-fingerprint');
162
185
  if (prepareCompiler && (!existsSync(compilerStamp) || readFileSync(compilerStamp, 'utf8') !== fingerprint || !existsSync(join(sdk, 'internal/toolchain/scriptc/packages/compiler/dist/index.js')))) {
163
186
  run(process.execPath, ['scripts/prepare-compiler.mjs'], { cwd: sdk, env: { ...process.env, ANSERONUI_SDK_WORKSPACE: '1' } });
164
187
  write(compilerStamp, fingerprint);
165
188
  }
166
- return { ...ctx, config, file, framework: sdk };
189
+ return { ...ctx, config: buildConfig, file, framework: sdk };
167
190
  }
168
191
  function androidEnvironment() {
169
192
  const sdk = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT ?? join(homedir(), process.platform === 'darwin' ? 'Library/Android/sdk' : 'Android/Sdk');
@@ -190,6 +213,7 @@ export function buildStandalone(configPath, target) {
190
213
  if (target.startsWith('ios') && process.platform !== 'darwin') throw new Error('iOS builds require macOS and Xcode.');
191
214
  const ctx = setupStandalone(configPath);
192
215
  const { app, config, file, framework } = ctx;
216
+ generateNativePlugins(app, discoverNativePlugins(app), target);
193
217
  if (config.schemaVersion === 2 && (!existsSync(join(framework, '.compiler-fingerprint')) || readFileSync(join(framework, '.compiler-fingerprint'), 'utf8') !== readFileSync(join(framework, '.sdk-fingerprint'), 'utf8'))) throw new Error('SDK changed; run npm run setup before building.');
194
218
  if (!existsSync(join(framework, 'internal/toolchain/scriptc/packages/compiler/dist/index.js'))) throw new Error('Run npm run setup from the app first.');
195
219
  const env = target === 'android' ? javaEnvironment(androidEnvironment()) : process.env;
@@ -15,3 +15,16 @@ android {
15
15
  sourceSets.main.assets.srcDirs += rootProject.file('../../assets')
16
16
  }
17
17
  dependencies { implementation project(':render') }
18
+
19
+ // Generated from installed npm plugins by AnseronUI setup/build.
20
+ def pluginDir = rootProject.file('../../.anseronui/plugins')
21
+ def nativePlugins = new groovy.json.JsonSlurper().parse(new File(pluginDir, 'android.json'))
22
+ android.sourceSets.main.java.srcDirs += nativePlugins.sources
23
+ android.sourceSets.main.java.srcDirs += pluginDir
24
+ nativePlugins.dependencies.each { coordinate -> dependencies.add('implementation', coordinate) }
25
+ // An overlay manifest adds permissions without modifying the app's own manifest.
26
+ def permissionsManifest = new File(pluginDir, 'AndroidManifest.xml')
27
+ permissionsManifest.text = '<manifest xmlns:android="http://schemas.android.com/apk/res/android">' +
28
+ nativePlugins.permissions.collect { '<uses-permission android:name="' + it + '" />' }.join('') + '</manifest>'
29
+ android.sourceSets.debug.manifest.srcFile permissionsManifest
30
+ android.sourceSets.release.manifest.srcFile permissionsManifest
@@ -36,7 +36,8 @@ class MainActivity : Activity() {
36
36
  content = AnseronUIRenderView(
37
37
  this,
38
38
  baseUrl = URL("http://127.0.0.1:5175"),
39
- createCore = { NativeCore("__BUILD_ID__") }
39
+ createCore = { NativeCore("__BUILD_ID__") },
40
+ extensions = org.anseronui.generated.AnseronUIPlugins.makeExtensions(this)
40
41
  ).apply {
41
42
  onDiagnostic = { code, message -> android.util.Log.w("__APP_NAME__", "$code: $message") }
42
43
  onFailure = { error -> android.util.Log.e("__APP_NAME__", "Host failed", error) }
@@ -16,6 +16,7 @@ final class AppController: UIViewController {
16
16
  override var preferredStatusBarStyle: UIStatusBarStyle { statusBarStyle }
17
17
  private let content = AnseronUIRenderView(
18
18
  baseURL: URL(string: "http://127.0.0.1:5175"),
19
+ extensions: AnseronUIPlugins.makeExtensions(),
19
20
  createCore: { try NativeCore(bundleBuildId: "__BUILD_ID__") }
20
21
  )
21
22
 
@@ -7,7 +7,7 @@ const app = fileURLToPath(new URL('../', import.meta.url));
7
7
  let configPath = join(app, 'anseronui.config.json');
8
8
  const config = JSON.parse(readFileSync(configPath, 'utf8'));
9
9
  const framework = resolve(app, config.app.framework);
10
- if (config.schemaVersion === 2) configPath = join(app, '.anseronui/build.config.json');
10
+ configPath = join(app, '.anseronui/build.config.json');
11
11
  const { coreFingerprint } = await import(pathToFileURL(join(framework, 'scripts/core-inputs.mjs')));
12
12
  const target = process.env.PLATFORM_NAME === 'iphoneos' ? 'ios-device' : 'ios-simulator';
13
13
  try {
@@ -4,6 +4,7 @@ import json
4
4
  import shutil
5
5
  import shlex
6
6
  import os
7
+ import plistlib
7
8
  root = Path(__file__).resolve().parent.parent
8
9
  config = json.loads((root/'anseronui.config.json').read_text())
9
10
  framework = (root/config['app']['framework']).resolve()
@@ -15,9 +16,17 @@ def add(kind, **values):
15
16
  key = f'{len(objects)+1:024X}'
16
17
  objects[key] = {'isa': kind, **values}
17
18
  return key
19
+ plugin_dir = root/'.anseronui/plugins'
20
+ plugins = json.loads((plugin_dir/'ios.json').read_text())
21
+ with (ios/'Info.plist').open('rb') as file:
22
+ info = plistlib.load(file)
23
+ # App-owned descriptions take precedence over library defaults.
24
+ info = {**plugins['infoPlist'], **info}
25
+ with (plugin_dir/'Info.plist').open('wb') as file:
26
+ plistlib.dump(info, file)
18
27
  files = []
19
28
  build_files = []
20
- for path, filetype in [
29
+ source_inputs = [
21
30
  ('App.swift', 'sourcecode.swift'),
22
31
  (framework_path('packages/render-ios/Sources/AnseronUIRenderIOS/Extensions.swift'), 'sourcecode.swift'),
23
32
  (framework_path('packages/render-ios/Sources/AnseronUIRenderIOS/Renderer.swift'), 'sourcecode.swift'),
@@ -26,7 +35,10 @@ for path, filetype in [
26
35
  (framework_path('packages/render-ios/Sources/AnseronUIRenderIOS/AnseronUIRenderView.swift'), 'sourcecode.swift'),
27
36
  (framework_path('packages/render-ios/Sources/AnseronUIRenderIOS/NativeCore.swift'), 'sourcecode.swift'),
28
37
  (framework_path('packages/core/native/transport.c'), 'sourcecode.c.c'),
29
- ]:
38
+ ]
39
+ source_inputs += [(os.path.relpath(plugin_dir/'GeneratedPlugins.swift', ios), 'sourcecode.swift')]
40
+ source_inputs += [(os.path.relpath(path, ios), 'sourcecode.swift') for path in plugins['sources']]
41
+ for path, filetype in source_inputs:
30
42
  ref = add('PBXFileReference', lastKnownFileType=filetype, path=path, sourceTree='SOURCE_ROOT')
31
43
  files.append(ref)
32
44
  build_files.append(add('PBXBuildFile', fileRef=ref))
@@ -36,7 +48,7 @@ group = add('PBXGroup', children=[*files, products], sourceTree='<group>')
36
48
  sources = add('PBXSourcesBuildPhase', buildActionMask='2147483647', files=build_files, runOnlyForDeploymentPostprocessing='0')
37
49
  settings = {
38
50
  'PRODUCT_NAME': 'App', 'PRODUCT_BUNDLE_IDENTIFIER': config['app']['applicationId'],
39
- 'INFOPLIST_FILE': 'Info.plist', 'SDKROOT': 'iphoneos', 'SUPPORTED_PLATFORMS': 'iphoneos iphonesimulator',
51
+ 'INFOPLIST_FILE': '../../.anseronui/plugins/Info.plist', 'SDKROOT': 'iphoneos', 'SUPPORTED_PLATFORMS': 'iphoneos iphonesimulator',
40
52
  'IPHONEOS_DEPLOYMENT_TARGET': '15.0', 'TARGETED_DEVICE_FAMILY': '1,2',
41
53
  'ARCHS': 'arm64', 'SWIFT_VERSION': '5.0', 'CLANG_ENABLE_MODULES': 'YES', 'CODE_SIGN_STYLE': 'Automatic',
42
54
  'HEADER_SEARCH_PATHS': ['$(inherited)', '$(SRCROOT)/' + framework_path('packages/core/native/include')],
@@ -44,6 +56,7 @@ settings = {
44
56
  'CORE_LIBRARY': '$(SRCROOT)/../../.verification/core-ios-device/entry.lib.a',
45
57
  'CORE_LIBRARY[sdk=iphonesimulator*]': '$(SRCROOT)/../../.verification/core-ios-simulator/entry.lib.a',
46
58
  }
59
+ settings['OTHER_LDFLAGS'] = ['$(inherited)'] + [flag for name in plugins['frameworks'] for flag in ['-framework', name]]
47
60
  configs = [add('XCBuildConfiguration', name=name, buildSettings={**settings, 'SWIFT_OPTIMIZATION_LEVEL': '-Onone' if name=='Debug' else '-O', 'ENABLE_TESTABILITY': 'YES' if name=='Debug' else 'NO'}) for name in ['Debug', 'Release']]
48
61
  config_list = add('XCConfigurationList', buildConfigurations=configs, defaultConfigurationIsVisible='0', defaultConfigurationName='Release')
49
62
  core_ref = add('PBXFileReference', lastKnownFileType='archive.ar', path='$(CORE_LIBRARY)', sourceTree='<absolute>')
@@ -1,5 +1,6 @@
1
1
  import { BrowserHost, WasmRuntime } from "@anseronui/render-web";
2
2
  import "./style.css";
3
+ import { createPluginExtensions } from "../.anseronui/plugins/web";
3
4
 
4
5
  const container = document.querySelector<HTMLElement>("#app")!;
5
6
  const routeStack = ["home"];
@@ -10,7 +11,7 @@ function select(page: string) {
10
11
  await host?.destroy();
11
12
  const root = document.createElement("div"); root.className = "surface"; container.replaceChildren(root);
12
13
  const runtime = await WasmRuntime.load("/core.wasm", undefined, "__BUILD_ID__");
13
- host = new BrowserHost(root, runtime, undefined, { router: async (method, args) => {
14
+ host = new BrowserHost(root, runtime, undefined, { ...createPluginExtensions(), router: async (method, args) => {
14
15
  const target = typeof args === "object" && args !== null && "page" in args ? String((args as { page: unknown }).page) : "";
15
16
  if (method === "push") { if (!target) throw new Error("INVALID_ROUTE"); routeStack.push(target); setTimeout(() => { void select(target); }, 0); return null; }
16
17
  if (method === "replace") { if (!target || routeStack.length === 0) throw new Error("INVALID_ROUTE"); routeStack[routeStack.length - 1] = target; setTimeout(() => { void select(target); }, 0); return null; }
package/package.json CHANGED
@@ -17,10 +17,11 @@
17
17
  "test:layout:upstream": "vitest run --config scripts/flexily.vitest.config.ts",
18
18
  "bench:layout": "vitest bench --run --config scripts/flexily.bench.config.ts",
19
19
  "typecheck:layout": "tsc --noEmit -p packages/layout/tsconfig.json",
20
+ "pretypecheck:framework": "npm run lower:counter",
20
21
  "typecheck:framework": "tsc --noEmit -p tsconfig.framework.json",
21
22
  "test:framework": "vitest run --config scripts/framework.vitest.config.ts",
22
23
  "test:build": "node --test scripts/core-inputs.test.mjs scripts/lower-timers.test.mjs",
23
- "test:tooling": "node --test scripts/anseronui.test.mjs",
24
+ "test:tooling": "node --test scripts/anseronui.test.mjs scripts/native-plugins.test.mjs",
24
25
  "lower:counter": "node --import tsx scripts/lower-tsx.mjs examples/counter/page.tsx .verification/framework/counter.ts",
25
26
  "dev:counter": "vite --config examples/counter/vite.config.ts",
26
27
  "build:counter:web": "vite build --config examples/counter/vite.config.ts",
@@ -58,9 +59,13 @@
58
59
  "@scriptc/runtime-darwin-arm64": "0.4.8",
59
60
  "@scriptc/runtime-wasm32-wasi": "0.4.8"
60
61
  },
61
- "version": "0.0.1",
62
+ "version": "0.0.2",
62
63
  "description": "Create and build AnseronUI apps for iOS, Android and Web from shared TSX pages",
63
64
  "license": "MIT",
65
+ "repository": {
66
+ "type": "git",
67
+ "url": "git+https://github.com/ailuoku6/AnseronUI.git"
68
+ },
64
69
  "files": [
65
70
  "dist/sdk",
66
71
  "THIRD_PARTY_NOTICES.md"