lecodes-cli 2.4.1 → 2.4.3

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 (54) hide show
  1. package/dist/index.js +36134 -11203
  2. package/package.json +10 -9
  3. package/runtime/sdk-types.json +1 -1
  4. package/runtime/web/VERSION.json +3 -3
  5. package/runtime/web/assets/ar-D6oDL4pw.js +1 -0
  6. package/runtime/web/assets/ar-mVXX3kRQ.js +2 -0
  7. package/runtime/web/assets/qrAnchorWorker-Cb5E8cF-.js +1 -0
  8. package/runtime/web/assets/qr_anchor_wasm_bg-D_ePkCCu.wasm +0 -0
  9. package/runtime/web/index.html +1 -0
  10. package/runtime/web/player.js +12 -12
  11. package/runtime/web-lite/assets/{index-B2zEJlWS.js → index-oPsDD47E.js} +1 -1
  12. package/runtime/web-lite/assets/scene3d-BAl_QQyi.js +1105 -0
  13. package/runtime/web-lite/features.json +2 -2
  14. package/runtime/web-lite/index.html +2 -1
  15. package/runtime/web-lite/lite.js +9 -9
  16. package/runtime/web-lite/plugins/ar/_virtual_lecodes-half-resources-CNYmVR88.js +1 -0
  17. package/runtime/web-lite/plugins/ar/assets/qrAnchorWorker-DcxVKAqo.js +1 -0
  18. package/runtime/web-lite/plugins/ar/assets/qr_anchor_wasm_bg-D_ePkCCu.wasm +0 -0
  19. package/runtime/web-lite/plugins/ar/index-C2uuuLhz.js +2 -0
  20. package/runtime/web-lite/plugins.json +1 -1
  21. package/src/commands/app/android.ts +68 -19
  22. package/src/commands/app/index.ts +84 -17
  23. package/src/commands/app/shared.ts +157 -24
  24. package/src/commands/app/splash.ts +251 -0
  25. package/src/commands/app/templates/android.ts +148 -25
  26. package/src/commands/app/templates/ios.ts +123 -12
  27. package/src/commands/init/index.ts +195 -195
  28. package/src/commands/plugin.ts +3 -3
  29. package/src/commands/push.ts +2 -2
  30. package/src/commands/shared.ts +79 -79
  31. package/src/commands/thumbs.ts +185 -185
  32. package/src/commands/web.ts +12 -5
  33. package/src/compile/collect.ts +2 -3
  34. package/src/compile/collectLocal.ts +13 -2
  35. package/src/compile/designCompile.ts +2 -3
  36. package/src/compile/projectCompile.ts +3 -0
  37. package/src/compile/screenEntry.ts +123 -123
  38. package/src/dev/devServer.ts +63 -7
  39. package/src/dev/https.ts +50 -0
  40. package/src/dev/serve.ts +15 -9
  41. package/src/dev/webRunner.ts +11 -3
  42. package/src/platform/serverDiff.ts +2 -3
  43. package/src/plugin/emitPackage.ts +18 -5
  44. package/src/plugin/emitWeb.ts +17 -8
  45. package/src/plugin/firstParty.ts +30 -0
  46. package/src/plugin/generate.ts +52 -24
  47. package/src/plugin/manifest.ts +66 -7
  48. package/src/project/ignore.ts +164 -164
  49. package/src/project/localFiles.ts +44 -7
  50. package/src/web/build.ts +33 -9
  51. package/src/web/halves.ts +76 -0
  52. package/runtime/web/assets/ar-CHvaraG4.js +0 -1
  53. package/runtime/web-lite/assets/scene3d-Bew8g7Ws.js +0 -1081
  54. package/runtime/web-lite/plugins/ar/index-CHvaraG4.js +0 -1
@@ -1,3 +1,5 @@
1
+ import type { AppLink } from "../shared"
2
+
1
3
  /* Templates for `lecodes app init android` / `lecodes app sync` — the Android mirror of
2
4
  * ios.ts, distilled from the LetaryCreatorViewer host (lecodes-android :app). The
3
5
  * same three ownership tiers: app/ is USER-owned after generation (init writes once, sync
@@ -155,6 +157,75 @@ export const runtimeManifestTemplate = (updateUrl: string | undefined): string =
155
157
  </manifest>
156
158
  `
157
159
 
160
+ /** The window setup every shell's theme inherits (what Theme.LeCodesApp carried before the splash):
161
+ * edge-to-edge bars, no title, and the window background — black, or app.json "splash"'s
162
+ * layer-list. */
163
+ const runtimeThemeItems = (windowBackground: string): string => `
164
+ <item name="android:windowTranslucentNavigation">true</item>
165
+ <item name="android:statusBarColor">@android:color/transparent</item>
166
+ <item name="android:windowNoTitle">true</item>
167
+ <item name="android:windowTranslucentStatus">true</item>
168
+ <item name="android:windowBackground">${windowBackground}</item>`
169
+
170
+ /** What app.json "splash" resolved to, as the runtime module's resources need it (`size` = the side
171
+ * of the square the image is fitted into, dp). */
172
+ export interface AndroidSplashResources { backgroundHex: string, image: boolean, size: number }
173
+
174
+ /** The Android 12+ splash-icon canvas (an icon without an icon background), dp — the system shows
175
+ * its inner 192 dp circle. The image sits on it inset by (canvas − size) / 2. */
176
+ export const SPLASH_ICON_CANVAS_DP = 288
177
+
178
+ /** lecodes-runtime/src/main/res/values/lecodes_splash.xml — CLI-owned, written by init and every
179
+ * sync: Theme.LeCodesRuntime, the parent of the app's user-owned Theme.LeCodesApp (themes.xml).
180
+ * Without "splash" its window is black, as a shell's always was; with it, the colour and the
181
+ * layer-list lecodes_splash_window (API 23–30 show the splash as the window background). The
182
+ * parent needs appcompat on the module's classpath: SDK_RUNTIME_DEPS declares it. */
183
+ export const runtimeThemeTemplate = (splash?: AndroidSplashResources): string => `<?xml version="1.0" encoding="utf-8"?>
184
+ <!-- Generated by \`lecodes app sync\` — regenerated on every sync, do not edit. Theme.LeCodesRuntime is
185
+ the parent of the app's Theme.LeCodesApp (app/src/main/res/values/themes.xml); app.json "splash"
186
+ draws its window. -->
187
+ <resources>${splash ? `
188
+ <color name="lecodes_splash_background">${splash.backgroundHex}</color>` : ""}
189
+ <style name="Theme.LeCodesRuntime" parent="Theme.AppCompat.Light.NoActionBar">${runtimeThemeItems(splash ? "@drawable/lecodes_splash_window" : "@android:color/black")}
190
+ </style>
191
+ </resources>
192
+ `
193
+
194
+ /** lecodes-runtime/src/main/res/values-v31/lecodes_splash.xml — only with app.json "splash": the
195
+ * theme's Android 12+ twin (a v31 style replaces the base one whole, hence every item again) adding
196
+ * the system splash — the colour, and as the icon the image inset (288 − size) / 2 dp on each side
197
+ * (the 288 dp icon canvas with the image at its size, 160 dp by default) or nothing (transparent: the system would draw the launcher
198
+ * icon otherwise). */
199
+ export const runtimeThemeV31Template = (splash: AndroidSplashResources): string => `<?xml version="1.0" encoding="utf-8"?>
200
+ <!-- Generated by \`lecodes app sync\` from app.json "splash" — regenerated on every sync, do not edit. -->
201
+ <resources>
202
+ <style name="Theme.LeCodesRuntime" parent="Theme.AppCompat.Light.NoActionBar">${runtimeThemeItems("@drawable/lecodes_splash_window")}
203
+ <item name="android:windowSplashScreenBackground">@color/lecodes_splash_background</item>
204
+ <item name="android:windowSplashScreenAnimatedIcon">${splash.image ? "@drawable/lecodes_splash_icon" : "@android:color/transparent"}</item>
205
+ </style>
206
+ </resources>
207
+ `
208
+
209
+ /** lecodes-runtime/src/main/res/drawable/lecodes_splash_window.xml — the splash as a window
210
+ * background: the colour, and the image centred at size × size dp (drawable-*dpi/lecodes_splash.png). */
211
+ export const splashWindowTemplate = (splash: AndroidSplashResources): string => `<?xml version="1.0" encoding="utf-8"?>
212
+ <!-- Generated by \`lecodes app sync\` from app.json "splash" — regenerated on every sync, do not edit. -->
213
+ <layer-list xmlns:android="http://schemas.android.com/apk/res/android" android:opacity="opaque">
214
+ <item android:drawable="@color/lecodes_splash_background" />${splash.image ? `
215
+ <item android:width="${splash.size}dp" android:height="${splash.size}dp" android:gravity="center" android:drawable="@drawable/lecodes_splash" />` : ""}
216
+ </layer-list>
217
+ `
218
+
219
+ /** lecodes-runtime/src/main/res/drawable/lecodes_splash_icon.xml — Android 12+'s splash icon: the
220
+ * size dp image inset to the 288 dp canvas the system draws (and masks to a 192 dp circle). The
221
+ * inset is rounded down — an odd size leaves the image half a dp larger, which nobody sees. */
222
+ export const splashIconTemplate = (size: number): string => `<?xml version="1.0" encoding="utf-8"?>
223
+ <!-- Generated by \`lecodes app sync\` from app.json "splash" — regenerated on every sync, do not edit. -->
224
+ <inset xmlns:android="http://schemas.android.com/apk/res/android"
225
+ android:drawable="@drawable/lecodes_splash"
226
+ android:inset="${Math.floor((SPLASH_ICON_CANVAS_DP - size) / 2)}dp" />
227
+ `
228
+
158
229
  /** lecodes-runtime/src/main/kotlin/io/letary/lecodes/runtime/LeCodesRuntime.kt — the generated
159
230
  * registration + host-hook surface. Plugins are called by FQN (no import collisions); the
160
231
  * activity hooks forward to the SDK's LeCodesAppHooks, which NAMES NO PLUGIN — a plugin that
@@ -259,7 +330,8 @@ lecodes app build android # signed release .apk + .aab → ../dist/
259
330
  | \`app/\` (sources, manifest, res, build.gradle.kts, proguard-rules.pro) | **Yours** after init — sync never rewrites them (it only restamps the \`versionName\`/\`versionCode\` and \`isMinifyEnabled\`/\`isShrinkResources\` values, manages \`// lecodes:plugin\`-tagged lines in the plugins block, and — when \`app.json\` sets \`"icon"\` — the icon files below) |
260
331
  | \`app/src/main/assets/\` | CLI-owned — wiped and regenerated by every sync (the compiled \`app.js\` + its \`// preload:\` assets) |
261
332
  | \`res/mipmap-*/ic_launcher*\`, \`res/values/lecodes_icon.xml\` | CLI-owned **only when** \`app.json\` sets \`"icon"\` — regenerated by every sync (plus a value-only \`android:icon\` restamp in the manifest); without the key sync never touches icons |
262
- | \`settings.gradle.kts\`, \`build.gradle.kts\` (root), \`gradle.properties\`, \`lecodes-runtime/\`, \`plugins/\` | CLI-owned — regenerated by every sync |
333
+ | \`app/src/main/res/values/themes.xml\` | **Yours** — its \`Theme.LeCodesApp\` must keep \`parent="Theme.LeCodesRuntime"\` (the window setup and the splash, see "Splash screen") |
334
+ | \`settings.gradle.kts\`, \`build.gradle.kts\` (root), \`gradle.properties\`, \`lecodes-runtime/\`, \`plugins/\` | CLI-owned — regenerated by every sync (\`lecodes-runtime/src/main/res/\`: \`Theme.LeCodesRuntime\` and, when \`app.json\` sets \`"splash"\`, the splash resources) |
263
335
  | \`gradle/\`, \`gradlew\`, \`gradlew.bat\` | Written once at init |
264
336
 
265
337
  ## The SDK
@@ -346,23 +418,50 @@ Prefer hand-made icons? Leave \`"icon"\` unset and use Android Studio (right-cli
346
418
  New → Image Asset) — sync never touches icon files then. \`drawable/ic_launcher.xml\` is only
347
419
  the init-time placeholder.
348
420
 
421
+ ## Splash screen
422
+
423
+ \`"splash": { "background": "#RRGGBB", "image": "./splash.png", "size": 160 }\` in \`../app.json\`
424
+ (every key optional) makes every sync render the splash into \`lecodes-runtime/src/main/res/\`: the
425
+ colour (\`background\`, default \`"iconBackground"\`, default white) with the image fitted centred into
426
+ a \`size\`×\`size\` dp square (160 by default) — never at its pixel size — from
427
+ \`drawable-mdpi…xxxhdpi/lecodes_splash.png\` (size × 1/1.5/2/3/4 px: 160…640 at the default) of the one
428
+ master. Android 12+ shows it as the system splash (\`windowSplashScreenBackground\` +
429
+ \`windowSplashScreenAnimatedIcon\`: the image on the 288 dp icon canvas, inset (288 − size) / 2 dp,
430
+ of which the system shows only the inner 192 dp circle — keep the mark inside one; a \`size\` above
431
+ 192 is cut there, sync warns, and 288 is the most it takes), API 23–30 as the window background. Use
432
+ a square mark of at least 3 × size px (a non-square one is fitted into the square, a smaller one is
433
+ upscaled). No image = the colour
434
+ alone (and no icon on Android 12+, not the launcher one). The app holds the splash until its first
435
+ frame — through the first-launch wait of an OTA shell too.
436
+
437
+ It reaches the app through the theme: \`Theme.LeCodesApp\` in \`app/src/main/res/values/themes.xml\`
438
+ (yours) must say \`parent="Theme.LeCodesRuntime"\` and set no \`android:windowBackground\` of its
439
+ own. A shell generated before the key existed does not — \`lecodes app sync\` and \`lecodes app
440
+ doctor\` print the edit. Without the key the runtime theme keeps the black window shells always had.
441
+
349
442
  ## Notes
350
443
 
351
444
 
352
445
  ## Over-the-air updates
353
446
 
354
- Native, before the JS engine: \`LeCodesShell.boot(engine, context)\` in \`AppViewModel\` asks the
355
- source for newer code (a conditional GET; offline is a no-op), stores it and runs it on the NEXT
356
- launch — the current launch runs the newest LOCAL bundle. Configured in \`../app.json\`:
357
- \`"update": true\` = the project's live bundle on the platform (a pushed project), \`"update":
358
- { "url": "https://…/app.js" }\` = your own hosting, \`false\` = off. Sync writes the URL into
447
+ Native, before the JS engine: \`LeCodesShell.boot(engine, activity)\` in \`AppViewModel\` takes the
448
+ newest LOCAL bundle, then asks the source for newer code (a conditional GET; offline is a no-op)
449
+ and stores it. The FIRST launch of an installed binary waits for that answer (5 s at most, the
450
+ splash held on screen) and runs the newest bundle at once, so a fresh install from the store never
451
+ starts on the older code it shipped with; every later launch runs the local bundle at once and a
452
+ download applies on the NEXT launch. Configured in \`../app.json\`: \`"update": true\` = the
453
+ project's live bundle on the platform (a pushed project), \`"update": { "url": "https://…/app.js" }\`
454
+ = your own hosting, \`false\` = off. Sync writes the URL into
359
455
  \`lecodes-runtime/src/main/AndroidManifest.xml\` (meta-data \`io.letary.lecodes.UPDATE_URL\`). A
360
- download runs only when it is of this shell's SDK major and not older than the bundle running; a
361
- store update always wins over a downloaded bundle. A shell generated before OTA existed reads
362
- \`assets/app.js\` itself — replace that with:
456
+ download runs only when it is of this shell's SDK major and not older than the bundle running. A
457
+ store update keeps a downloaded bundle only when it is newer code than the one the update ships
458
+ (by compile date); otherwise the store build wins. A shell generated before OTA existed reads
459
+ \`assets/app.js\` itself, and one generated before the first-launch wait calls
460
+ \`LeCodesShell.boot(engine, getApplication<Application>())\` (which never waits) — replace either
461
+ with:
363
462
 
364
463
  \`\`\`kotlin
365
- if (!LeCodesShell.boot(engine, getApplication<Application>())) Log.e("LeCodes", "bundled app.js is missing — run \`lecodes app sync\`")
464
+ if (!LeCodesShell.boot(engine, activity)) Log.e("LeCodes", "bundled app.js is missing — run \`lecodes app sync\`")
366
465
  \`\`\`
367
466
  - Push: the notification service needs \`app/google-services.json\` from the le.codes Firebase
368
467
  project; \`lecodes app sync\` applies the google-services Gradle plugin only when that file
@@ -467,7 +566,31 @@ export const urlSchemeFilter = (urlScheme?: string): string => !urlScheme ? "" :
467
566
  </intent-filter>
468
567
  <!-- /lecodes:urlScheme -->`
469
568
 
470
- export const appManifestTemplate = (urlScheme?: string): string => `<?xml version="1.0" encoding="utf-8"?>
569
+ /** The verified-link intent-filters of app.json "appLinks" (`restampManifestAppLinks`): one
570
+ * `autoVerify` filter per host — the `<data>` elements of one filter cross-multiply, so a host's
571
+ * paths stay in its own filter; a root link (no path) takes the whole host. Empty without links. */
572
+ export const appLinksFilter = (links: AppLink[]): string => {
573
+ if (links.length === 0) return ""
574
+ const hosts = [...new Set(links.map(l => l.host))]
575
+ const filters = hosts.map(host => {
576
+ const paths = links.filter(l => l.host === host).map(l => l.path)
577
+ const prefixes = paths.includes("") ? [] : paths
578
+ return `
579
+ <intent-filter android:autoVerify="true">
580
+ <action android:name="android.intent.action.VIEW" />
581
+ <category android:name="android.intent.category.DEFAULT" />
582
+ <category android:name="android.intent.category.BROWSABLE" />
583
+ <data android:scheme="https" />
584
+ <data android:host="${host}" />${prefixes.map(p => `
585
+ <data android:pathPrefix="${p}" />`).join("")}
586
+ </intent-filter>`
587
+ }).join("")
588
+ return `
589
+ <!-- lecodes:appLinks — app.json "appLinks": the verified https links the app opens on (the "url" app event); each host serves /.well-known/assetlinks.json -->${filters}
590
+ <!-- /lecodes:appLinks -->`
591
+ }
592
+
593
+ export const appManifestTemplate = (urlScheme?: string, appLinks: AppLink[] = []): string => `<?xml version="1.0" encoding="utf-8"?>
471
594
  <!-- Generated by \`lecodes app init\` — yours to edit. Sync leaves it alone, except that with
472
595
  app.json "icon" set it restamps the android:icon VALUE to the generated @mipmap set.
473
596
  Plugin permissions/services arrive through the plugin modules' own manifests via the AGP
@@ -492,7 +615,7 @@ export const appManifestTemplate = (urlScheme?: string): string => `<?xml versio
492
615
  <intent-filter>
493
616
  <action android:name="android.intent.action.MAIN" />
494
617
  <category android:name="android.intent.category.LAUNCHER" />
495
- </intent-filter>${urlSchemeFilter(urlScheme)}
618
+ </intent-filter>${urlSchemeFilter(urlScheme)}${appLinksFilter(appLinks)}
496
619
  </activity>
497
620
  </application>
498
621
 
@@ -616,12 +739,14 @@ class AppViewModel(application: Application) : AndroidViewModel(application) {
616
739
  fun onActivityCreate(activity: Activity) {
617
740
  engine.attach(activity)
618
741
  if (!isCreated) {
619
- // OTA (native, no JS involved): LeCodesShell.boot starts the check FIRST — even a bundle
620
- // that crashes on boot can never block its own fix — then runs the newest local bundle
621
- // (a downloaded update, or the embedded assets/app.js). New code applies on the next
622
- // launch. Configured via app.json "update" (→ the runtime module's manifest meta-data);
623
- // offline: no-op.
624
- if (!LeCodesShell.boot(engine, getApplication<Application>())) {
742
+ // OTA (native, no JS involved): LeCodesShell.boot takes the newest local bundle (a
743
+ // downloaded update, or the embedded assets/app.js) and starts the check BEFORE it runs
744
+ // — even a bundle that crashes on boot can never block its own fix. The first launch of
745
+ // this binary waits for the check (5 s at most; the splash stays, the first frame is
746
+ // held) and runs the newest bundle; later launches run at once and new code applies on
747
+ // the next launch. Configured via app.json "update" (→ the runtime module's manifest
748
+ // meta-data); offline: no wait.
749
+ if (!LeCodesShell.boot(engine, activity)) {
625
750
  android.util.Log.e("LeCodes", "bundled app.js is missing — run \`lecodes app sync\`")
626
751
  }
627
752
  isCreated = true
@@ -631,13 +756,11 @@ class AppViewModel(application: Application) : AndroidViewModel(application) {
631
756
  `
632
757
 
633
758
  export const themesXmlTemplate = (): string => `<resources>
634
- <style name="Theme.LeCodesApp" parent="Theme.AppCompat.Light.NoActionBar">
635
- <item name="android:windowTranslucentNavigation">true</item>
636
- <item name="android:statusBarColor">@android:color/transparent</item>
637
- <item name="android:windowNoTitle">true</item>
638
- <item name="android:windowTranslucentStatus">true</item>
639
- <item name="android:windowBackground">@color/black</item>
640
- </style>
759
+ <!-- The app's theme. Its parent, Theme.LeCodesRuntime (the CLI-owned lecodes-runtime module),
760
+ carries the window setup and app.json "splash" — the launch window and Android 12+'s
761
+ system splash; keep that parent and set no android:windowBackground here, or the splash
762
+ is lost. Your own items go inside. -->
763
+ <style name="Theme.LeCodesApp" parent="Theme.LeCodesRuntime" />
641
764
  </resources>
642
765
  `
643
766
 
@@ -636,6 +636,69 @@ export const LAUNCH_STORYBOARD = `<?xml version="1.0" encoding="UTF-8" standalon
636
636
  </document>
637
637
  `
638
638
 
639
+ /** App/Base.lproj/LaunchScreen.storyboard as sync writes it when app.json sets "splash" (the file
640
+ * is CLI-owned then; without the key it stays the person's, LAUNCH_STORYBOARD of init): the view's
641
+ * colour is the colorset LeCodesSplashBackground, the image — when there is one — a size×size pt
642
+ * UIImageView (aspect fit; "splash.size", 160 by default) of the imageset LeCodesSplash centred on
643
+ * the view. Both live in Assets.xcassets, which
644
+ * the project already compiles, so no pbxproj edit. `rgb` (0xRRGGBB) only feeds the storyboard's
645
+ * resource cache (Interface Builder's preview); the app reads the colorset. LeCodesShellViewController
646
+ * mounts this same storyboard over its container until the first frame. */
647
+ export const splashStoryboardTemplate = (image: boolean, rgb: number, size = 160): string => {
648
+ const channel = (shift: number) => String(((rgb >> shift) & 0xff) / 255)
649
+ // The frame is Interface Builder's preview on its 375×667 canvas; the constraints place the view.
650
+ const x = (375 - size) / 2, y = (667 - size) / 2
651
+ const imageView = image ? `
652
+ <subviews>
653
+ <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="LeCodesSplash" translatesAutoresizingMaskIntoConstraints="NO" id="LcS-im-001">
654
+ <rect key="frame" x="${x}" y="${y}" width="${size}" height="${size}"/>
655
+ <constraints>
656
+ <constraint firstAttribute="width" constant="${size}" id="LcS-cw-001"/>
657
+ <constraint firstAttribute="height" constant="${size}" id="LcS-ch-001"/>
658
+ </constraints>
659
+ </imageView>
660
+ </subviews>` : ""
661
+ const centre = image ? `
662
+ <constraints>
663
+ <constraint firstItem="LcS-im-001" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="LcS-cx-001"/>
664
+ <constraint firstItem="LcS-im-001" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="LcS-cy-001"/>
665
+ </constraints>` : ""
666
+ return `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
667
+ <!-- Generated by \`lecodes app sync\` from app.json "splash" — regenerated on every sync, do not edit. -->
668
+ <document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
669
+ <dependencies>
670
+ <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
671
+ <capability name="Named colors" minToolsVersion="9.0"/>
672
+ <capability name="Safe area layout guides" minToolsVersion="9.0"/>
673
+ <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
674
+ </dependencies>
675
+ <scenes>
676
+ <!--View Controller-->
677
+ <scene sceneID="EHf-IW-A2E">
678
+ <objects>
679
+ <viewController id="01J-lp-oVM" sceneMemberID="viewController">
680
+ <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
681
+ <rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
682
+ <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>${imageView}
683
+ <viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
684
+ <color key="backgroundColor" name="LeCodesSplashBackground"/>${centre}
685
+ </view>
686
+ </viewController>
687
+ <placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
688
+ </objects>
689
+ <point key="canvasLocation" x="53" y="375"/>
690
+ </scene>
691
+ </scenes>
692
+ <resources>${image ? `
693
+ <image name="LeCodesSplash" width="${size}" height="${size}"/>` : ""}
694
+ <namedColor name="LeCodesSplashBackground">
695
+ <color red="${channel(16)}" green="${channel(8)}" blue="${channel(0)}" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
696
+ </namedColor>
697
+ </resources>
698
+ </document>
699
+ `
700
+ }
701
+
639
702
  export const ASSETS_ROOT_JSON = `{
640
703
  "info" : {
641
704
  "author" : "xcode",
@@ -703,8 +766,16 @@ export interface RuntimePlugin {
703
766
  id: string
704
767
  /** Swift packages the target links (manifest ios.packages). */
705
768
  packages?: IosPackage[]
769
+ /** The binary targets the target links (manifest ios.binaries), by name: the xcframeworks sync
770
+ * copies to `runtimeBinaryPath(id, name)`. */
771
+ binaries?: string[]
706
772
  }
707
773
 
774
+ /** Where sync copies a plugin's prebuilt xcframework (manifest ios.binaries), relative to the
775
+ * LeCodesRuntime package: inside it, as SwiftPM takes a binary target's path, beside the vendored
776
+ * sources and with the same lifecycle (replaced on every sync, removed with the plugin). */
777
+ export const runtimeBinaryPath = (id: string, name: string): string => `Binaries/${id}/${name}.xcframework`
778
+
708
779
  /** The SDK's engine variants — one tag of lecodes-ios-sdk per variant (`<version>` = full,
709
780
  * `<version>-3d`, `-2d`, `-core`): core = UI only, 2d = + the 2D engine, 3d = + Filament with Jolt,
710
781
  * full = everything. The Swift sources are the same; the engine binary differs. */
@@ -722,8 +793,9 @@ export const sdkProducts = (variant: SdkVariant): string[] =>
722
793
  /** LeCodesRuntime/Package.swift — the CLI-owned dependency graph: the SDK (one tag per
723
794
  * variant, pinned with `.exact(…)`: the variant is the engine binary the tag's manifest names,
724
795
  * so SwiftPM must never drift on its own — moving the version is the CLI's job, `lecodes app
725
- * update` or app.json ios.sdk) + one vendored target per installed plugin. The SDK's assets
726
- * (the 3D variants' shader archives) ride inside the package. */
796
+ * update` or app.json ios.sdk) + one vendored target per installed plugin, + a binary target per
797
+ * xcframework a plugin carries (its copy under Binaries/<id>/). The SDK's assets (the 3D variants'
798
+ * shader archives) ride inside the package. */
727
799
  export const runtimePackageTemplate = (sdkVersion: string, variant: SdkVariant, plugins: RuntimePlugin[], sdkPath?: string): string => {
728
800
  const sdkDep = (name: string) => `.product(name: "${name}", package: "lecodes-ios-sdk")`
729
801
  // LECODES_IOS_SDK_PATH: the SDK-development loop — a package tree on disk (release-ios-sdk.sh
@@ -746,18 +818,31 @@ export const runtimePackageTemplate = (sdkVersion: string, variant: SdkVariant,
746
818
  }
747
819
  const packageDecls = [...packages.values()].map(pkg =>
748
820
  `\n .package(url: "${pkg.url}", ${pkg.exact ? `exact: "${pkg.exact}"` : `from: "${pkg.from}"`}),`).join("")
821
+ // The plugins' prebuilt xcframeworks (manifest ios.binaries): a binary target each, by its copy's
822
+ // path. A name is one target of the package — two plugins carrying one alike cannot both link.
823
+ const targetNames = new Map<string, string>([["LeCodesRuntime", "the runtime's own target"], ...plugins.map(p => [p.module, `the module of "${p.id}"`] as [string, string])])
824
+ for (const p of plugins) {
825
+ for (const name of p.binaries ?? []) {
826
+ const prior = targetNames.get(name)
827
+ if (prior) throw new Error(`Plugin "${p.id}" carries the binary ${name} and ${name} is ${prior} — one name is one target of the shell's package.`)
828
+ targetNames.set(name, `a binary of "${p.id}"`)
829
+ }
830
+ }
749
831
  const pluginTargets = plugins.map(p => {
750
832
  const deps = [
751
833
  sdkDep("LeCodes"),
752
834
  ...(p.packages ?? []).flatMap(pkg => pkg.products.map(name => `.product(name: "${name}", package: "${swiftPackageName(pkg.url)}")`)),
835
+ ...(p.binaries ?? []).map(name => `"${name}"`),
753
836
  ]
837
+ const binaries = (p.binaries ?? []).map(name => `
838
+ .binaryTarget(name: "${name}", path: "${runtimeBinaryPath(p.id, name)}"),`).join("")
754
839
  return `
755
840
  // plugin: ${p.id} (vendored — see plugins.lock.json)
756
841
  .target(
757
842
  name: "${p.module}",
758
843
  dependencies: [${deps.join(", ")}],
759
844
  path: "Sources/${p.module}"
760
- ),`
845
+ ),${binaries}`
761
846
  }).join("")
762
847
  const runtimeDeps = [...sdkProducts(variant).map(sdkDep), ...plugins.map(p => `"${p.module}"`)]
763
848
  return `// swift-tools-version:5.9
@@ -877,7 +962,7 @@ Yours — \`lecodes app sync\` never touches these:
877
962
  ≥1024px image and every sync renders AppIcon.png from it (flattened over \`"iconBackground"\`,
878
963
  default white — App Store icons must be opaque). Or leave the key unset and drop a
879
964
  1024×1024 PNG into AppIcon.appiconset/ yourself — sync never touches it then.
880
- - \`App/Base.lproj/LaunchScreen.storyboard\`
965
+ - \`App/Base.lproj/LaunchScreen.storyboard\` — unless \`app.json\` sets \`"splash"\` (below)
881
966
 
882
967
  CLI-owned — regenerated on every sync, hand edits are overwritten:
883
968
 
@@ -888,10 +973,18 @@ CLI-owned — regenerated on every sync, hand edits are overwritten:
888
973
  - \`App/App.entitlements\` — the capabilities the installed plugins declare (e.g. push)
889
974
  - \`LeCodesRuntime/\` — local Swift package: the lecodes-ios-sdk dependency (one tag per engine
890
975
  variant), vendored plugin sources (Sources/<Module>/), and LeCodesAppViewController — the SDK's
891
- shell controller with this project's plugins registered (App/ViewController.swift subclasses it)
976
+ shell controller with this project's plugins registered (App/ViewController.swift subclasses it).
977
+ A plugin's prebuilt xcframeworks (its manifest's \`ios.binaries\`) are copied to
978
+ Binaries/<plugin id>/: binary targets its vendored target links
892
979
  - the version values inside \`App.xcodeproj/project.pbxproj\` (from app.json)
893
980
  - this README
894
981
 
982
+ CLI-owned **only when** \`../app.json\` sets \`"splash"\` — regenerated on every sync then;
983
+ without the key sync never touches them (see "Splash screen"):
984
+
985
+ - \`App/Base.lproj/LaunchScreen.storyboard\`
986
+ - \`App/Assets.xcassets/LeCodesSplashBackground.colorset/\`, \`App/Assets.xcassets/LeCodesSplash.imageset/\`
987
+
895
988
  ## SDK variant
896
989
 
897
990
  Sync pins the SDK tag of the smallest engine variant that covers what the compiled bundle actually
@@ -900,30 +993,48 @@ physics, ~10 MB heavier), or \`full\` (everything) — \`<version>-core\`, \`-2d
900
993
  bare \`<version>\` for full. Adding your first 3D scene or 2D game to the project switches it
901
994
  automatically on the next sync; set \`ios.variant\` in \`../app.json\` to pin one instead.
902
995
 
996
+ ## Splash screen
997
+
998
+ \`"splash": { "background": "#RRGGBB", "image": "./splash.png", "size": 160 }\` in \`../app.json\`
999
+ (every key optional) makes every sync write the launch screen: a full-screen view of the colour
1000
+ (\`background\`, default \`"iconBackground"\`, default white) with the image fitted centred into a
1001
+ \`size\`×\`size\` pt square (160 by default) — never at its pixel size — from @1x/@2x/@3x renderings
1002
+ (size × 1/2/3 px: 160/320/480 at the default) of the one master. Use a square mark of at least
1003
+ 3 × size px (a non-square one is fitted into the square, a smaller one is upscaled); the same key
1004
+ draws the Android splash, which crops the mark to a circle on Android 12+ — above a \`size\` of 192
1005
+ its corners are cut there (sync warns; 288 is the most it takes). No image = the colour alone. The app holds this screen until its first frame — through the
1006
+ first-launch wait of an OTA shell too — so nothing flashes between the two. iOS caches launch
1007
+ screens: after a change, delete the app from the device (or restart the device) to see the new one.
1008
+
903
1009
  ## Over-the-air updates
904
1010
 
905
1011
  With \`"update": true\` in \`../app.json\` (server-backed projects; or
906
1012
  \`"update": {"url": "https://…/app.js"}\` for any static hosting), the app checks that URL
907
1013
  at launch — natively, before any JS runs, so a broken bundle can never block its own fix.
908
- Unchanged code costs a ~0-byte 304; new code downloads in the background and runs on the
909
- NEXT launch. Offline it's a silent no-op — the app always launches from a local bundle.
910
- An App Store update always beats a cached OTA bundle. JS only: adding/removing native
911
- plugins still needs a store release.
1014
+ Unchanged code costs a ~0-byte 304. The FIRST launch of an installed build waits for the answer
1015
+ (5 s at most, the launch screen held on screen) and runs the newest bundle at once, so a fresh
1016
+ install from the App Store never starts on the older code it shipped with; every later launch
1017
+ runs the local bundle at once, new code downloads in the background and runs on the NEXT launch.
1018
+ Offline it's a silent no-op with no wait — the app always launches from a local bundle. An App
1019
+ Store update keeps a downloaded bundle only when it is newer code than the one the update ships
1020
+ (by compile date); otherwise the store build wins. JS only: adding/removing native plugins still
1021
+ needs a store release.
912
1022
 
913
1023
  Shipping an update is just \`lecodes push --publish\` — no rebuild, no store review:
914
1024
  running apps download the new code on their next launch and run it on the launch after.
915
1025
 
916
1026
  Shells generated before OTA support have a \`ViewController.swift\` that reads app.js from
917
1027
  \`Bundle.main\` directly, so downloaded updates would never run — and since \`App/*.swift\`
918
- is yours, sync won't rewrite it (\`lecodes app doctor\` flags this). Replace the
919
- \`Bundle.main\` load with:
1028
+ is yours, sync won't rewrite it (\`lecodes app doctor\` flags this). Make it subclass
1029
+ \`LeCodesAppViewController\` (the boot above, the first-launch wait included), or replace the
1030
+ \`Bundle.main\` load with (no wait):
920
1031
 
921
1032
  \`\`\`swift
922
- LeCodesUpdater.checkForUpdate()
923
1033
  guard let code = LeCodesUpdater.activeBundle() else {
924
1034
  print("LeCodes: bundled app.js is missing — run \`lecodes app sync\`")
925
1035
  return
926
1036
  }
1037
+ LeCodesUpdater.checkForUpdate()
927
1038
  engine.run(code)
928
1039
  \`\`\`
929
1040