lecodes-cli 0.18.2 → 0.19.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.
Files changed (224) hide show
  1. package/dist/index.js +1866 -301
  2. package/package.json +14 -12
  3. package/runtime/scene-harness.json +1 -1
  4. package/runtime/sdk-types.json +1 -1
  5. package/src/api.ts +302 -0
  6. package/src/browserAuth.ts +87 -0
  7. package/src/cmgenTool.ts +104 -0
  8. package/src/commands/app.ts +891 -0
  9. package/src/commands/appAndroid.ts +603 -0
  10. package/src/commands/appDesktop.ts +326 -0
  11. package/src/commands/appDesktopMac.ts +470 -0
  12. package/src/commands/appIcon.ts +187 -0
  13. package/src/commands/appShared.ts +448 -0
  14. package/src/commands/appTemplates.ts +883 -0
  15. package/src/commands/appTemplatesAndroid.ts +599 -0
  16. package/src/commands/appTemplatesGradlew.ts +9 -0
  17. package/src/commands/assets.ts +28 -0
  18. package/src/commands/clone.ts +60 -0
  19. package/src/commands/compile.ts +253 -0
  20. package/src/commands/create.ts +59 -0
  21. package/src/commands/design.ts +863 -0
  22. package/src/commands/designTemplates.ts +11 -0
  23. package/src/commands/desktop.ts +122 -0
  24. package/src/commands/dev.ts +214 -0
  25. package/src/commands/diff.ts +59 -0
  26. package/src/commands/init.ts +191 -0
  27. package/src/commands/install.ts +198 -0
  28. package/src/commands/lightmap.ts +290 -0
  29. package/src/commands/link.ts +147 -0
  30. package/src/commands/login.ts +24 -0
  31. package/src/commands/navmesh.ts +225 -0
  32. package/src/commands/pn.ts +305 -0
  33. package/src/commands/projectTemplates.ts +129 -0
  34. package/src/commands/pull.ts +109 -0
  35. package/src/commands/push.ts +173 -0
  36. package/src/commands/render.ts +414 -0
  37. package/src/commands/scene.ts +148 -0
  38. package/src/commands/shaders.ts +157 -0
  39. package/src/commands/status.ts +29 -0
  40. package/src/commands/test.ts +370 -0
  41. package/src/commands/thumbs.ts +178 -0
  42. package/src/commands/types.ts +87 -0
  43. package/src/commands/update.ts +190 -0
  44. package/src/compile/assetIcons.ts +214 -0
  45. package/src/compile/collect.ts +46 -0
  46. package/src/compile/collectLocal.ts +37 -0
  47. package/src/compile/designCompile.ts +104 -0
  48. package/src/compile/fonts.ts +156 -0
  49. package/src/compile/headlessBundle.ts +129 -0
  50. package/src/compile/nativeStack.ts +21 -0
  51. package/src/compile/projectCompile.ts +109 -0
  52. package/src/compile/sceneCompile.ts +113 -0
  53. package/src/compile/screenEntry.ts +127 -0
  54. package/src/compile/shaders.ts +245 -0
  55. package/src/config.ts +42 -0
  56. package/src/designMeta.ts +35 -0
  57. package/src/desktopRenderer.ts +532 -0
  58. package/src/desktopScript.ts +276 -0
  59. package/src/dev/clientTemplates.ts +160 -0
  60. package/src/dev/devServer.ts +292 -0
  61. package/src/dev/wsServer.ts +144 -0
  62. package/src/distRoot.ts +20 -0
  63. package/src/ignore.ts +163 -0
  64. package/src/index.ts +491 -0
  65. package/src/lecodes-3d-editor.d.ts +41 -0
  66. package/src/lecodes-assets.d.ts +7 -0
  67. package/src/lecodes-design.d.ts +191 -0
  68. package/src/lecodes-renderer.d.ts +131 -0
  69. package/src/localFiles.ts +144 -0
  70. package/src/manifest.ts +46 -0
  71. package/src/matcTool.ts +137 -0
  72. package/src/peers.ts +40 -0
  73. package/src/project.ts +61 -0
  74. package/src/projectEnv.ts +94 -0
  75. package/src/qrcode-terminal.d.ts +9 -0
  76. package/src/releases.ts +125 -0
  77. package/src/serverDiff.ts +78 -0
  78. package/src/textDiff.ts +103 -0
  79. package/src/types.ts +0 -0
  80. package/src/util.ts +146 -0
  81. package/runtime/sdk/animate/animate.ts +0 -238
  82. package/runtime/sdk/animate/bezier.ts +0 -138
  83. package/runtime/sdk/animate/easings.ts +0 -126
  84. package/runtime/sdk/canvas/Canvas.ts +0 -305
  85. package/runtime/sdk/core/Aspect.ts +0 -512
  86. package/runtime/sdk/core/InspectorUI.ts +0 -212
  87. package/runtime/sdk/core/color.ts +0 -66
  88. package/runtime/sdk/core/compWrite.ts +0 -42
  89. package/runtime/sdk/core/events.ts +0 -38
  90. package/runtime/sdk/core/fields.ts +0 -120
  91. package/runtime/sdk/core/registry.ts +0 -23
  92. package/runtime/sdk/core/signals.ts +0 -277
  93. package/runtime/sdk/core/time.ts +0 -81
  94. package/runtime/sdk/g2/Camera2D.ts +0 -40
  95. package/runtime/sdk/g2/CharacterController2D.ts +0 -276
  96. package/runtime/sdk/g2/Node2D.ts +0 -267
  97. package/runtime/sdk/g2/OneWay2D.ts +0 -66
  98. package/runtime/sdk/g2/Physics2D.ts +0 -346
  99. package/runtime/sdk/g2/Scene2D.ts +0 -209
  100. package/runtime/sdk/g2/Shape2D.ts +0 -259
  101. package/runtime/sdk/g2/Sprite.ts +0 -89
  102. package/runtime/sdk/g2/SpriteAnimation.ts +0 -171
  103. package/runtime/sdk/g2/SpriteSheet.ts +0 -166
  104. package/runtime/sdk/g2/Texture2D.ts +0 -47
  105. package/runtime/sdk/g2/Tilemap.ts +0 -41
  106. package/runtime/sdk/g2/Tileset.ts +0 -71
  107. package/runtime/sdk/g2/Trigger2D.ts +0 -77
  108. package/runtime/sdk/g2/autotile.ts +0 -433
  109. package/runtime/sdk/g2/cells.ts +0 -91
  110. package/runtime/sdk/g2/defineScene2d.ts +0 -381
  111. package/runtime/sdk/g2/groups2d.ts +0 -106
  112. package/runtime/sdk/g2/loop.ts +0 -50
  113. package/runtime/sdk/g2/scenarios2d.ts +0 -69
  114. package/runtime/sdk/g2/touch.ts +0 -83
  115. package/runtime/sdk/gl/Camera.ts +0 -160
  116. package/runtime/sdk/gl/CameraPlace.ts +0 -52
  117. package/runtime/sdk/gl/CharacterController.ts +0 -238
  118. package/runtime/sdk/gl/Gearbox.ts +0 -212
  119. package/runtime/sdk/gl/Geometry.ts +0 -279
  120. package/runtime/sdk/gl/IK.ts +0 -193
  121. package/runtime/sdk/gl/InstancedMesh.ts +0 -132
  122. package/runtime/sdk/gl/Light.ts +0 -99
  123. package/runtime/sdk/gl/Lightmap.ts +0 -179
  124. package/runtime/sdk/gl/Material.ts +0 -245
  125. package/runtime/sdk/gl/Mesh.ts +0 -83
  126. package/runtime/sdk/gl/Model.ts +0 -64
  127. package/runtime/sdk/gl/Node.ts +0 -350
  128. package/runtime/sdk/gl/Noise.ts +0 -30
  129. package/runtime/sdk/gl/Particles.ts +0 -676
  130. package/runtime/sdk/gl/Physics.ts +0 -222
  131. package/runtime/sdk/gl/Plane.ts +0 -53
  132. package/runtime/sdk/gl/Ray.ts +0 -16
  133. package/runtime/sdk/gl/Scene.ts +0 -479
  134. package/runtime/sdk/gl/Shape.ts +0 -377
  135. package/runtime/sdk/gl/Texture.ts +0 -46
  136. package/runtime/sdk/gl/Trigger.ts +0 -45
  137. package/runtime/sdk/gl/Vehicle.ts +0 -473
  138. package/runtime/sdk/gl/Wheel.ts +0 -240
  139. package/runtime/sdk/gl/animation/AnimationClip.ts +0 -204
  140. package/runtime/sdk/gl/animation/Animator.ts +0 -87
  141. package/runtime/sdk/gl/animation/Layer.ts +0 -29
  142. package/runtime/sdk/gl/animation/Loop.ts +0 -25
  143. package/runtime/sdk/gl/animation/Playback.ts +0 -43
  144. package/runtime/sdk/gl/animation/core.ts +0 -294
  145. package/runtime/sdk/gl/controls.ts +0 -95
  146. package/runtime/sdk/gl/physicsEvents.ts +0 -20
  147. package/runtime/sdk/gl/scenarios.ts +0 -291
  148. package/runtime/sdk/gl/state.ts +0 -6
  149. package/runtime/sdk/gl/touch.ts +0 -68
  150. package/runtime/sdk/inject.ts +0 -186
  151. package/runtime/sdk/math/Mathf.ts +0 -118
  152. package/runtime/sdk/math/mat4.ts +0 -278
  153. package/runtime/sdk/math/quat.ts +0 -232
  154. package/runtime/sdk/math/vec.ts +0 -255
  155. package/runtime/sdk/plugins/camera.ts +0 -81
  156. package/runtime/sdk/plugins/geolocation.ts +0 -123
  157. package/runtime/sdk/plugins/oauth.ts +0 -61
  158. package/runtime/sdk/plugins/permission.ts +0 -7
  159. package/runtime/sdk/plugins/push.ts +0 -132
  160. package/runtime/sdk/plugins/qr.ts +0 -73
  161. package/runtime/sdk/plugins/service.ts +0 -47
  162. package/runtime/sdk/runtime/app.ts +0 -101
  163. package/runtime/sdk/runtime/appEvents.ts +0 -54
  164. package/runtime/sdk/runtime/channel.ts +0 -61
  165. package/runtime/sdk/runtime/clipboard.ts +0 -20
  166. package/runtime/sdk/runtime/datetime.ts +0 -329
  167. package/runtime/sdk/runtime/device.ts +0 -293
  168. package/runtime/sdk/runtime/fetch.ts +0 -77
  169. package/runtime/sdk/runtime/files.ts +0 -23
  170. package/runtime/sdk/runtime/input.ts +0 -175
  171. package/runtime/sdk/runtime/media.ts +0 -111
  172. package/runtime/sdk/runtime/misc.ts +0 -16
  173. package/runtime/sdk/runtime/net.ts +0 -36
  174. package/runtime/sdk/runtime/rpc.ts +0 -218
  175. package/runtime/sdk/runtime/service.ts +0 -83
  176. package/runtime/sdk/runtime/share.ts +0 -9
  177. package/runtime/sdk/runtime/storage.ts +0 -13
  178. package/runtime/sdk/runtime/touch.ts +0 -76
  179. package/runtime/sdk/scene/defineScene.ts +0 -1227
  180. package/runtime/sdk/scene/editorPlugins.ts +0 -92
  181. package/runtime/sdk/scene/gizmos.ts +0 -148
  182. package/runtime/sdk/scene/grammar.ts +0 -120
  183. package/runtime/sdk/scene/material.ts +0 -188
  184. package/runtime/sdk/server/auth/appConfig.ts +0 -12
  185. package/runtime/sdk/server/auth/global.ts +0 -80
  186. package/runtime/sdk/server/auth/host.ts +0 -318
  187. package/runtime/sdk/server/auth/models.ts +0 -83
  188. package/runtime/sdk/server/auth/types.ts +0 -50
  189. package/runtime/sdk/server/channel.ts +0 -56
  190. package/runtime/sdk/server/context.ts +0 -36
  191. package/runtime/sdk/server/db/defineDb.ts +0 -237
  192. package/runtime/sdk/server/db/fields.ts +0 -132
  193. package/runtime/sdk/server/db/httpTransport.ts +0 -93
  194. package/runtime/sdk/server/db/index.ts +0 -7
  195. package/runtime/sdk/server/db/marci/query.ts +0 -412
  196. package/runtime/sdk/server/db/types.ts +0 -202
  197. package/runtime/sdk/server/errors.ts +0 -12
  198. package/runtime/sdk/server/host.ts +0 -74
  199. package/runtime/sdk/server/inject.ts +0 -13
  200. package/runtime/sdk/server/runtime.ts +0 -133
  201. package/runtime/sdk/server/validate.ts +0 -87
  202. package/runtime/sdk/ui/NativeView.ts +0 -142
  203. package/runtime/sdk/ui/UI.ts +0 -39
  204. package/runtime/sdk/ui/UIBottomSheet.ts +0 -139
  205. package/runtime/sdk/ui/UIButton.ts +0 -101
  206. package/runtime/sdk/ui/UIContainer.ts +0 -60
  207. package/runtime/sdk/ui/UIImage.ts +0 -83
  208. package/runtime/sdk/ui/UIInput.ts +0 -185
  209. package/runtime/sdk/ui/UIModal.ts +0 -139
  210. package/runtime/sdk/ui/UINode.ts +0 -826
  211. package/runtime/sdk/ui/UIPager.ts +0 -362
  212. package/runtime/sdk/ui/UIPopover.ts +0 -100
  213. package/runtime/sdk/ui/UIScreen.ts +0 -123
  214. package/runtime/sdk/ui/UIScrollable.ts +0 -87
  215. package/runtime/sdk/ui/UISpacer.ts +0 -14
  216. package/runtime/sdk/ui/UITabs.ts +0 -236
  217. package/runtime/sdk/ui/UIText.ts +0 -51
  218. package/runtime/sdk/ui/UIVideo.ts +0 -88
  219. package/runtime/sdk/ui/UIVirtualizedList.ts +0 -241
  220. package/runtime/sdk/ui/UIWidget.ts +0 -127
  221. package/runtime/sdk/ui/fonts.ts +0 -13
  222. package/runtime/sdk/ui/presentable.ts +0 -117
  223. package/runtime/sdk/ui/router.ts +0 -132
  224. package/runtime/sdk/ui/theme.ts +0 -84
@@ -0,0 +1,470 @@
1
+ import { execFileSync, spawn, spawnSync } from "node:child_process"
2
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { basename, join, resolve } from "node:path"
5
+ import { CliError, c, flagBool, flagStr, log, note, success, warn, type Args } from "../util"
6
+ import { writeJson, type AppConfig, type MacDesktopConfig } from "./appShared"
7
+ import { loadIconMaster } from "./appIcon"
8
+
9
+ /*
10
+ * `lecodes app build desktop` on macOS — the .app arm of appDesktop.ts.
11
+ *
12
+ * Windows/Linux stage a portable FOLDER (exe + app.js + assets/ + runtime/). macOS has no such
13
+ * thing: Gatekeeper judges one signed bundle as a whole, and a Finder launch runs with cwd "/", so
14
+ * everything the app needs must live INSIDE the .app and be sealed by its signature. The host is
15
+ * the installed LeCodes Desktop.app (resolveDesktopExe finds it; LECODES_DESKTOP_EXE points at a
16
+ * dev build): it is copied, the project staged into Contents/Resources/project/ (app.js + app.json
17
+ * + assets/ — the host looks there first, see ProjectLaunch in the macOS host), Info.plist is
18
+ * rewritten for the app's own identity, then the bundle is re-signed as a whole and, given a
19
+ * notarytool profile, notarized + stapled.
20
+ *
21
+ * Why notarize at all: since macOS 10.15 a downloaded app (quarantine xattr — AirDrop, browsers and
22
+ * messengers all set it) that is Developer-ID-signed but NOT notarized is refused outright; the only
23
+ * way past is System Settings › Privacy & Security › "Open Anyway", which is exactly the prompt a
24
+ * distributed app must not have. Ad-hoc signatures are the same story. Notarized + stapled, it
25
+ * opens on any Mac (offline included) after the ordinary "downloaded from the internet" confirm.
26
+ *
27
+ * Config — app.json `desktop.macos`, every key optional — and what overrides it:
28
+ * bundleId CFBundleIdentifier (default codes.le.app.<slug>)
29
+ * buildNumber CFBundleVersion (default 1); the top-level `version` is CFBundleShortVersionString
30
+ * category LSApplicationCategoryType, e.g. "public.app-category.games"
31
+ * signing.identity codesign identity — a "Developer ID Application: …" cert, or "adhoc"
32
+ * (local only). Default: the keychain's Developer ID cert, else ad-hoc.
33
+ * --sign <identity|adhoc> / LECODES_MACOS_SIGN_ID win.
34
+ * notarize.profile a `xcrun notarytool store-credentials <name>` keychain profile.
35
+ * --notarize <name> / LECODES_NOTARY_PROFILE win; --no-notarize skips.
36
+ * Packaging flags: --zip (ditto archive) and --dmg (drag-to-Applications image); both may be given.
37
+ * Each is signed and notarized in its own right when a profile is set, so every artifact carries a
38
+ * ticket — that is two Apple round-trips for --dmg, which is why it is opt-in.
39
+ * Secrets never enter app.json: the profile NAME is config, the app-specific password lives in
40
+ * the keychain (the same split as android.signing vs keystore.properties).
41
+ */
42
+
43
+ const mb = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(1)} MB`
44
+
45
+ const dirSize = (dir: string): number =>
46
+ readdirSync(dir, { withFileTypes: true }).reduce((sum, e) => {
47
+ const p = join(dir, e.name)
48
+ return sum + (e.isDirectory() ? dirSize(p) : e.isSymbolicLink() ? 0 : statSync(p).size)
49
+ }, 0)
50
+
51
+ /** Bundle name: Finder shows it, so keep the display name — only path separators can't be in it. */
52
+ const cleanBundleName = (name: string) => name.replace(/[/:]/g, "").trim() || "App"
53
+
54
+ /* ------------------------------------- host bundle ------------------------------------- */
55
+
56
+ /** The .app that owns a host executable (…/X.app/Contents/MacOS/lecodes-app → …/X.app). */
57
+ export const macHostAppOf = (exe: string): string => {
58
+ const app = resolve(exe, "..", "..", "..")
59
+ if (!app.endsWith(".app") || !existsSync(join(app, "Contents", "Info.plist"))) {
60
+ throw new CliError(
61
+ `The macOS host must be an .app bundle (got ${exe}). Install LeCodes Desktop.app, or point ` +
62
+ `LECODES_DESKTOP_EXE at <Build>.app/Contents/MacOS/lecodes-app — a bare SwiftPM binary has no ` +
63
+ `resource bundles and can't be re-signed into an app.`,
64
+ )
65
+ }
66
+ return app
67
+ }
68
+
69
+ /** Every file name the host bundle itself can serve to fetchLocal (its SwiftPM resource
70
+ * bundles: materials, IBL, initial.js …) — the macOS twin of the Windows host's assets/ dir. */
71
+ export const macHostProvidedNames = (exe: string): Set<string> => {
72
+ const names = new Set<string>()
73
+ const walk = (dir: string) => {
74
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
75
+ if (e.isDirectory()) walk(join(dir, e.name))
76
+ else names.add(e.name)
77
+ }
78
+ }
79
+ const res = join(macHostAppOf(exe), "Contents", "Resources")
80
+ if (existsSync(res)) walk(res)
81
+ return names
82
+ }
83
+
84
+ const plistRead = (plist: string, key: string): string | null => {
85
+ const r = spawnSync("/usr/libexec/PlistBuddy", ["-c", `Print :${key}`, plist], { encoding: "utf8" })
86
+ return r.status === 0 ? r.stdout.trim() : null
87
+ }
88
+
89
+ /** Set-or-add a string key. PlistBuddy takes the value as the rest of its command line, so it is
90
+ * double-quoted (with the two characters that matter inside quotes escaped). */
91
+ const plistSet = (plist: string, key: string, value: string) => {
92
+ const quoted = `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`
93
+ const set = spawnSync("/usr/libexec/PlistBuddy", ["-c", `Set :${key} ${quoted}`, plist], { stdio: "ignore" })
94
+ if (set.status !== 0) execFileSync("/usr/libexec/PlistBuddy", ["-c", `Add :${key} string ${quoted}`, plist], { stdio: "ignore" })
95
+ }
96
+
97
+ /** The host's ENGINE version, for the build note and `lecodes desktop`. `LeCodesHostVersion` is
98
+ * the stamped one (build-app.sh); CFBundleShortVersionString is the fallback for hosts built
99
+ * before that key existed — and never the right field once a bundle has been rewritten for a
100
+ * project, since that one then carries the project's version. */
101
+ export const macHostVersion = (exe: string): string => {
102
+ const plist = join(macHostAppOf(exe), "Contents", "Info.plist")
103
+ return plistRead(plist, "LeCodesHostVersion") ?? plistRead(plist, "CFBundleShortVersionString") ?? "unknown"
104
+ }
105
+
106
+ /** What the host can do, from the `LeCodesHostFeatures` array its build script stamps into
107
+ * Info.plist. A FEATURE list, not a version check: the macOS host's own version string has not
108
+ * tracked the release line (the published 1.3.2 DMG reports 1.0.0), so a number proves nothing. */
109
+ export const macHostFeatures = (exe: string): Set<string> => {
110
+ const raw = plistRead(join(macHostAppOf(exe), "Contents", "Info.plist"), "LeCodesHostFeatures") ?? ""
111
+ // PlistBuddy prints an array as "Array {\n item\n item\n}".
112
+ return new Set(raw.split("\n").map((l) => l.trim()).filter((l) => l && l !== "Array {" && l !== "}"))
113
+ }
114
+
115
+ /** The host must know to look in Contents/Resources/project/ — an older one boots to its own home
116
+ * screen, and the built app would ship exactly that. */
117
+ export const assertMacHostCanBundle = (exe: string) => {
118
+ if (macHostFeatures(exe).has("project-bundle")) return
119
+ throw new CliError(
120
+ `This LeCodes Desktop host (${macHostAppOf(exe)}) predates project bundles — an app built from it would ` +
121
+ "open the host's home screen instead of the project.\n" +
122
+ " Run `lecodes desktop update` (needs a published macOS host with project-bundle support), or point " +
123
+ "LECODES_DESKTOP_EXE at a current build (…/LeCodes Desktop.app/Contents/MacOS/lecodes-app).",
124
+ )
125
+ }
126
+
127
+ /* --------------------------------------- signing --------------------------------------- */
128
+
129
+ export interface MacSigning {
130
+ /** A codesign identity, or "-" for ad-hoc. */
131
+ identity: string
132
+ /** notarytool keychain profile, or null = don't notarize. */
133
+ notaryProfile: string | null
134
+ }
135
+
136
+ /** "Developer ID Application: …" from the login keychain, if there is one. */
137
+ export const developerIdInKeychain = (): string | null => {
138
+ const r = spawnSync("security", ["find-identity", "-v", "-p", "codesigning"], { encoding: "utf8" })
139
+ if (r.status !== 0) return null
140
+ return /"(Developer ID Application: [^"]+)"/.exec(r.stdout)?.[1] ?? null
141
+ }
142
+
143
+ /** Precedence: flag > env > app.json > keychain auto-detect > ad-hoc. Pure, so it's testable —
144
+ * the keychain lookup is injected. */
145
+ export const resolveMacSigning = (
146
+ cfg: MacDesktopConfig | undefined,
147
+ overrides: { sign?: string, notarize?: string, noNotarize?: boolean, env: NodeJS.ProcessEnv },
148
+ keychainDeveloperId: () => string | null,
149
+ ): MacSigning => {
150
+ let identity = overrides.sign ?? overrides.env.LECODES_MACOS_SIGN_ID ?? cfg?.signing?.identity
151
+ if (identity === undefined || identity === "" || identity === "auto") identity = keychainDeveloperId() ?? "-"
152
+ if (identity === "adhoc" || identity === "ad-hoc") identity = "-"
153
+ const notaryProfile = overrides.noNotarize
154
+ ? null
155
+ : overrides.notarize ?? overrides.env.LECODES_NOTARY_PROFILE ?? cfg?.notarize?.profile ?? null
156
+ if (notaryProfile && identity === "-") {
157
+ throw new CliError(
158
+ "Notarization needs a Developer ID signature — an ad-hoc-signed app can't be notarized.\n" +
159
+ " Set desktop.macos.signing.identity (or --sign) to a \"Developer ID Application: …\" certificate, " +
160
+ "or drop the notarize profile (--no-notarize).",
161
+ )
162
+ }
163
+ return { identity, notaryProfile: notaryProfile || null }
164
+ }
165
+
166
+ /** Hardened-Runtime entitlements for the signature: the host's own (a signed LeCodes Desktop.app
167
+ * carries them), else the one the JS engine needs — JavaScriptCore's JIT must be allowed under
168
+ * Hardened Runtime or the app dies at boot. */
169
+ const FALLBACK_ENTITLEMENTS = `<?xml version="1.0" encoding="UTF-8"?>
170
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
171
+ <plist version="1.0">
172
+ <dict>
173
+ <key>com.apple.security.cs.allow-jit</key>
174
+ <true/>
175
+ </dict>
176
+ </plist>
177
+ `
178
+
179
+ const entitlementsFor = (hostApp: string): string => {
180
+ const r = spawnSync("codesign", ["-d", "--entitlements", "-", "--xml", hostApp], { encoding: "utf8" })
181
+ const xml = r.status === 0 ? r.stdout.trim() : ""
182
+ if (xml.startsWith("<?xml") && xml.includes("com.apple.security.cs.allow-jit")) return xml + "\n"
183
+ return FALLBACK_ENTITLEMENTS
184
+ }
185
+
186
+ /** codesign with a retry on Apple's timestamp service: a release signs several times in a row and
187
+ * timestamp.apple.com throttles bursts ("The timestamp service is not available"). A secure
188
+ * timestamp is REQUIRED for notarization, so dropping --timestamp is never the fix — waiting is.
189
+ * Any other failure (bad identity, malformed bundle) is immediate. */
190
+ const codesignRetrying = async (args: string[]) => {
191
+ for (let attempt = 1; ; attempt++) {
192
+ const r = spawnSync("codesign", args, { encoding: "utf8" })
193
+ if (r.status === 0) return
194
+ const out = (r.stdout + "\n" + r.stderr).trim()
195
+ if (!/timestamp/i.test(out) || attempt >= 5) throw new CliError(`codesign failed:\n${out}`)
196
+ const delay = attempt * 15
197
+ warn(`timestamp service unavailable (attempt ${attempt}/5) — retrying in ${delay}s…`)
198
+ await new Promise((r) => setTimeout(r, delay * 1000))
199
+ }
200
+ }
201
+
202
+ const signApp = async (app: string, signing: MacSigning, hostApp: string, tmp: string) => {
203
+ if (signing.identity === "-") {
204
+ note("Signing ad-hoc (runs on this Mac only — pass a Developer ID identity to distribute)…")
205
+ await codesignRetrying(["--force", "--sign", "-", app])
206
+ return
207
+ }
208
+ note(`Signing with "${signing.identity}" (Hardened Runtime + JIT entitlement)…`)
209
+ const ent = join(tmp, "entitlements.plist")
210
+ writeFileSync(ent, entitlementsFor(hostApp))
211
+ await codesignRetrying(["--force", "--options", "runtime", "--timestamp", "--entitlements", ent, "--sign", signing.identity, app])
212
+ const verify = spawnSync("codesign", ["--verify", "--strict", "--verbose=2", app], { encoding: "utf8" })
213
+ if (verify.status !== 0) throw new CliError(`The signature doesn't verify:\n${(verify.stdout + verify.stderr).trim()}`)
214
+ }
215
+
216
+ /* -------------------------------------- notarization -------------------------------------- */
217
+
218
+ const notarize = (target: string, profile: string, tmp: string, what: string) => {
219
+ // notarytool takes a FLAT file: a .app is zipped first, a .dmg goes as it is.
220
+ const submission = target.endsWith(".app") ? join(tmp, `${basename(target, ".app")}.zip`) : target
221
+ if (submission !== target) execFileSync("ditto", ["-c", "-k", "--keepParent", target, submission])
222
+ note(`Notarizing the ${what} with profile "${profile}" — this waits on Apple (usually 1–5 minutes)…`)
223
+ const r = spawnSync("xcrun", ["notarytool", "submit", submission, "--keychain-profile", profile, "--wait"], { encoding: "utf8" })
224
+ const out = (r.stdout + "\n" + r.stderr).trim()
225
+ const id = /id: ([0-9a-f-]{36})/.exec(out)?.[1]
226
+ if (r.status !== 0 || !/status: Accepted/.test(out)) {
227
+ throw new CliError(
228
+ `Notarization failed:\n${out}\n` +
229
+ (id ? ` Details: xcrun notarytool log ${id} --keychain-profile ${profile}` : ""),
230
+ )
231
+ }
232
+ // The ticket is what makes it open OFFLINE; without it Gatekeeper has to reach Apple.
233
+ const staple = spawnSync("xcrun", ["stapler", "staple", target], { encoding: "utf8" })
234
+ if (staple.status !== 0) throw new CliError(`Stapling failed:\n${(staple.stdout + staple.stderr).trim()}`)
235
+ success(`Notarized + stapled (${what}).`)
236
+ }
237
+
238
+ /** `--dmg`: the signed app inside a drag-to-Applications disk image.
239
+ *
240
+ * Worth having even though a notarized .zip passes Gatekeeper identically (verified): a DMG is
241
+ * MOUNTED rather than unpacked, so "the recipient extracted it with something that mangled the
242
+ * bundle", "the archive only half-extracted" and "it was launched from inside the unarchiver's
243
+ * temp dir" all stop being possible. The /Applications alias also nudges people to move it there
244
+ * instead of running it from Downloads, which is where App Translocation applies.
245
+ *
246
+ * The image is signed and notarized in its OWN right, so its ticket travels with the download;
247
+ * the app inside is already stapled, so it still opens offline once dragged out. */
248
+ const buildDmg = async (app: string, out: string, appName: string, signing: MacSigning, tmp: string) => {
249
+ const dmg = join(out, `${appName}.dmg`)
250
+ rmSync(dmg, { force: true })
251
+ const stage = join(tmp, "dmg")
252
+ rmSync(stage, { recursive: true, force: true })
253
+ mkdirSync(stage, { recursive: true })
254
+ note("Building the disk image…")
255
+ // cp -R, not cpSync: the bundle's signature seals file modes and the resource fork layout, and
256
+ // Node's copy has been known to normalise both.
257
+ execFileSync("cp", ["-R", app, join(stage, basename(app))])
258
+ symlinkSync("/Applications", join(stage, "Applications"))
259
+ const r = spawnSync("hdiutil", [
260
+ "create", "-volname", appName, "-srcfolder", stage, "-fs", "HFS+", "-format", "UDZO", "-ov", dmg,
261
+ ], { encoding: "utf8" })
262
+ rmSync(stage, { recursive: true, force: true })
263
+ if (r.status !== 0) throw new CliError(`hdiutil failed:\n${(r.stdout + r.stderr).trim()}`)
264
+
265
+ if (signing.identity !== "-") {
266
+ await codesignRetrying(["--force", "--timestamp", "--sign", signing.identity, dmg])
267
+ if (signing.notaryProfile) notarize(dmg, signing.notaryProfile, tmp, "disk image")
268
+ }
269
+ success(`DMG: ${dmg} (${mb(statSync(dmg).size)})`)
270
+ }
271
+
272
+ /** Gatekeeper's own verdict on the bundle — the thing the recipient's Mac will decide. */
273
+ const gatekeeperVerdict = (app: string): { accepted: boolean, line: string } => {
274
+ const r = spawnSync("spctl", ["-a", "-t", "exec", "-vv", app], { encoding: "utf8" })
275
+ const line = (r.stdout + r.stderr).trim().split("\n").filter((l) => l.includes("source=") || l.includes("rejected") || l.includes("accepted")).join("; ")
276
+ return { accepted: r.status === 0, line: line || (r.stdout + r.stderr).trim() }
277
+ }
278
+
279
+ /* ----------------------------------------- icon ----------------------------------------- */
280
+
281
+ /** AppIcon.icns from the app.json icon master. macOS icons are NOT full-bleed: the system draws
282
+ * them on a ~80% rounded square inside the 1024 canvas (Big Sur+ grid), so the flattened master
283
+ * is inset and corner-rounded before iconutil packs the size ladder. */
284
+ const writeIcns = async (root: string, config: AppConfig, dest: string, tmp: string) => {
285
+ const master = await loadIconMaster(root, config)
286
+ const { Jimp } = await import("jimp")
287
+ const SIZE = 1024, INSET = 100, RADIUS = 185 // Apple's icon grid: 824px tile, r≈22.5%
288
+ const tileSize = SIZE - 2 * INSET
289
+ const tile = new Jimp({ width: tileSize, height: tileSize, color: master.background })
290
+ const art = master.image.clone()
291
+ art.resize({ w: tileSize, h: tileSize })
292
+ tile.composite(art, 0, 0)
293
+ // Rounded corners: zero the alpha outside the corner circles.
294
+ const { data } = tile.bitmap
295
+ for (let y = 0; y < tileSize; y++) {
296
+ for (let x = 0; x < tileSize; x++) {
297
+ const cx = x < RADIUS ? RADIUS : x >= tileSize - RADIUS ? tileSize - RADIUS - 1 : x
298
+ const cy = y < RADIUS ? RADIUS : y >= tileSize - RADIUS ? tileSize - RADIUS - 1 : y
299
+ const dx = x - cx, dy = y - cy
300
+ if (dx * dx + dy * dy > RADIUS * RADIUS) data[(y * tileSize + x) * 4 + 3] = 0
301
+ }
302
+ }
303
+ const canvas = new Jimp({ width: SIZE, height: SIZE, color: 0x00000000 })
304
+ canvas.composite(tile, INSET, INSET)
305
+
306
+ const iconset = join(tmp, "AppIcon.iconset")
307
+ mkdirSync(iconset, { recursive: true })
308
+ for (const pt of [16, 32, 128, 256, 512]) {
309
+ for (const scale of [1, 2]) {
310
+ const px = pt * scale
311
+ const frame = canvas.clone()
312
+ frame.resize({ w: px, h: px })
313
+ writeFileSync(join(iconset, `icon_${pt}x${pt}${scale === 2 ? "@2x" : ""}.png`), await frame.getBuffer("image/png"))
314
+ }
315
+ }
316
+ execFileSync("iconutil", ["-c", "icns", "-o", dest, iconset])
317
+ }
318
+
319
+ /* ---------------------------------------- probe ---------------------------------------- */
320
+
321
+ /** `--probe`: launch the built app for a few seconds and grab the presented 3D frame through the
322
+ * host's LECODES_SNAPSHOT hook. The window shows briefly — the macOS host has no hidden mode. */
323
+ const probeMacApp = async (app: string, out: string, args: Args) => {
324
+ const after = Number(flagStr(args, "after")) || 8
325
+ const shot = join(out, `${basename(app, ".app")}-probe.png`)
326
+ rmSync(shot, { force: true })
327
+ note(`Probe: launching the built app for ${after + 2}s (the window will show)…`)
328
+ await new Promise<void>((done, fail) => {
329
+ const child = spawn(join(app, "Contents", "MacOS", "lecodes-app"), [], {
330
+ cwd: "/", // what Finder gives a double-clicked app — proves nothing leans on the cwd
331
+ env: { ...process.env, LECODES_SNAPSHOT: shot, LECODES_SNAPSHOT_AFTER: String(after), LECODES_EXIT_AFTER: String(after + 2) },
332
+ stdio: ["ignore", "pipe", "pipe"],
333
+ })
334
+ const tail: string[] = []
335
+ const sink = (d: unknown) => { tail.push(String(d)); while (tail.length > 60) tail.shift() }
336
+ child.stdout.on("data", sink)
337
+ child.stderr.on("data", sink)
338
+ const killer = setTimeout(() => { child.kill(); fail(new CliError("The probe timed out after 120s.")) }, 120_000)
339
+ child.on("error", (e) => { clearTimeout(killer); fail(new CliError(`Probe couldn't run: ${e.message}`)) })
340
+ child.on("exit", (code) => {
341
+ clearTimeout(killer)
342
+ const detail = tail.length > 0 ? `\n${tail.join("").trimEnd()}` : ""
343
+ if (code === 0 && existsSync(shot)) { success(`Probe: ${shot}`); done() }
344
+ else fail(new CliError(`Probe failed (exit ${code}).${detail}`))
345
+ })
346
+ })
347
+ }
348
+
349
+ /* ---------------------------------------- build ---------------------------------------- */
350
+
351
+ export interface MacBuildInput {
352
+ root: string
353
+ config: AppConfig
354
+ name: string
355
+ slug: string
356
+ out: string
357
+ js: string
358
+ shipped: { name: string, path: string, absPath?: string }[]
359
+ hostExe: string
360
+ args: Args
361
+ }
362
+
363
+ export const buildMacApp = async (input: MacBuildInput) => {
364
+ const { root, config, name, slug, out, js, shipped, hostExe, args } = input
365
+ const hostApp = macHostAppOf(hostExe)
366
+ assertMacHostCanBundle(hostExe)
367
+ const mac = config.desktop?.macos ?? {}
368
+ const appName = cleanBundleName(name)
369
+ const app = join(out, `${appName}.app`)
370
+ const tmp = mkdtempSync(join(tmpdir(), "lecodes-macapp-"))
371
+ try {
372
+ note(` host: LeCodes Desktop ${macHostVersion(hostExe)} (${hostApp})`)
373
+ rmSync(out, { recursive: true, force: true })
374
+ mkdirSync(out, { recursive: true })
375
+
376
+ // 1) The host bundle, whole. `cp -R` keeps the bundle layout; the quarantine xattr a downloaded
377
+ // host may carry is dropped (it would be sealed into OUR signature otherwise), and the host's
378
+ // signature + stapled ticket go — both are re-made for this app.
379
+ execFileSync("cp", ["-R", hostApp, app])
380
+ execFileSync("xattr", ["-cr", app])
381
+ rmSync(join(app, "Contents", "_CodeSignature"), { recursive: true, force: true })
382
+ rmSync(join(app, "Contents", "CodeResources"), { force: true })
383
+
384
+ // 2) The project, inside: Contents/Resources/project/{app.js, app.json, assets/}. The host
385
+ // resolves fetchLocal against this dir (assets/ first), so the cwd never matters.
386
+ const proj = join(app, "Contents", "Resources", "project")
387
+ mkdirSync(join(proj, "assets"), { recursive: true })
388
+ writeFileSync(join(proj, "app.js"), js)
389
+ let assetBytes = 0, assetCount = 0
390
+ for (const r of shipped) {
391
+ if (!r.absPath || !existsSync(r.absPath) || !statSync(r.absPath).isFile()) {
392
+ warn(`asset "${r.name}" (${r.path}) has no local file — skipped`)
393
+ continue
394
+ }
395
+ cpSync(r.absPath, join(proj, "assets", r.name))
396
+ assetBytes += statSync(r.absPath).size
397
+ assetCount++
398
+ }
399
+ note(` assets: ${assetCount} files, ${mb(assetBytes)} → Contents/Resources/project/assets/`)
400
+ writeJson(join(proj, "app.json"), { name, ...(config.desktop ? { desktop: { window: config.desktop.window } } : {}) })
401
+
402
+ // 3) Identity. Everything Finder / Gatekeeper / the Dock attribute to THIS app rather than the host.
403
+ const plist = join(app, "Contents", "Info.plist")
404
+ const bundleId = mac.bundleId ?? `codes.le.app.${slug.toLowerCase().replace(/[^a-z0-9.-]+/g, "-")}`
405
+ plistSet(plist, "CFBundleName", appName)
406
+ plistSet(plist, "CFBundleDisplayName", appName)
407
+ plistSet(plist, "CFBundleIdentifier", bundleId)
408
+ plistSet(plist, "CFBundleShortVersionString", config.version ?? "1.0")
409
+ plistSet(plist, "CFBundleVersion", String(mac.buildNumber ?? 1))
410
+ if (mac.category) plistSet(plist, "LSApplicationCategoryType", mac.category)
411
+ if (config.icon) {
412
+ await writeIcns(root, config, join(app, "Contents", "Resources", "AppIcon.icns"), tmp)
413
+ plistSet(plist, "CFBundleIconFile", "AppIcon")
414
+ note(" icon: AppIcon.icns rendered from " + config.icon)
415
+ }
416
+ note(` bundle: ${bundleId} ${config.version ?? "1.0"} (${mac.buildNumber ?? 1})`)
417
+
418
+ // 4) Sign the whole bundle (the project files are now sealed too), then notarize + staple.
419
+ const signing = resolveMacSigning(mac, {
420
+ sign: flagStr(args, "sign"),
421
+ notarize: flagStr(args, "notarize"),
422
+ noNotarize: flagBool(args, "no-notarize"),
423
+ env: process.env,
424
+ }, developerIdInKeychain)
425
+ await signApp(app, signing, hostApp, tmp)
426
+ if (signing.notaryProfile) {
427
+ notarize(app, signing.notaryProfile, tmp, "app")
428
+ } else if (signing.identity !== "-") {
429
+ warn("Signed with Developer ID but NOT notarized — Gatekeeper refuses a downloaded copy on every other Mac.")
430
+ warn(" Set desktop.macos.notarize.profile in app.json (a `xcrun notarytool store-credentials` profile) or pass --notarize <profile>.")
431
+ }
432
+ const verdict = gatekeeperVerdict(app)
433
+ log(`${verdict.accepted ? c.green("gatekeeper") : c.yellow("gatekeeper")} ${verdict.line}`)
434
+ if (signing.notaryProfile && !verdict.accepted) {
435
+ throw new CliError("The app was notarized but Gatekeeper still rejects it — see the spctl line above.")
436
+ }
437
+
438
+ writeFileSync(join(out, "README.txt"), readmeText(appName, !!signing.notaryProfile, signing.identity !== "-"))
439
+ success(`App: ${app} (${mb(dirSize(app))})`)
440
+
441
+ // The DMG goes before --probe/--zip so a failure there costs nothing already earned.
442
+ if (flagBool(args, "dmg")) await buildDmg(app, out, appName, signing, tmp)
443
+ if (flagBool(args, "probe")) await probeMacApp(app, out, args)
444
+ if (flagBool(args, "zip")) {
445
+ // ditto, not zip(1): it keeps the bundle's structure/xattrs (the stapled ticket rides in
446
+ // Contents/CodeResources) exactly as Finder's "Compress" would.
447
+ const zip = join(out, `${appName}.zip`)
448
+ rmSync(zip, { force: true })
449
+ execFileSync("ditto", ["-c", "-k", "--keepParent", app, zip])
450
+ success(`Zip: ${zip} (${mb(statSync(zip).size)})`)
451
+ } else if (!flagBool(args, "dmg")) {
452
+ note("Tip: --zip packs the .app for sending; --dmg wraps it in a drag-to-Applications image.")
453
+ }
454
+ } finally {
455
+ rmSync(tmp, { recursive: true, force: true })
456
+ }
457
+ }
458
+
459
+ const readmeText = (name: string, notarized: boolean, developerId: boolean) => `${name}
460
+ ${"=".repeat(Math.max(name.length, 4))}
461
+
462
+ A LeCodes desktop app for macOS (Apple Silicon). Nothing to install: unzip and double-click
463
+ ${name}.app — or drag it to Applications first.
464
+
465
+ ${notarized
466
+ ? "The app is signed and notarized by Apple: macOS asks once whether to open a downloaded app,\nthen it runs."
467
+ : developerId
468
+ ? "The app is signed but NOT notarized: macOS will refuse to open the downloaded copy. To run it\nanyway: System Settings › Privacy & Security › \"Open Anyway\" (after the first refused launch),\nor remove the quarantine flag in Terminal: xattr -dr com.apple.quarantine \"" + name + ".app\""
469
+ : "The app is not signed for distribution: macOS will refuse to open the downloaded copy. To run it\nanyway: System Settings › Privacy & Security › \"Open Anyway\" (after the first refused launch),\nor remove the quarantine flag in Terminal: xattr -dr com.apple.quarantine \"" + name + ".app\""}
470
+ `
@@ -0,0 +1,187 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
+ import { dirname, join, resolve } from "node:path"
3
+ import type { JimpInstance } from "jimp"
4
+ import { CliError, c, log, warn } from "../util"
5
+ import type { AppConfig } from "./appShared"
6
+
7
+ /* App icons rendered from the one master image app.json "icon" names (a square ≥1024px
8
+ * PNG/JPEG, project-root-relative). Strictly additive: without the key, sync never touches an
9
+ * icon file, so hand-made icons (Xcode's appiconset, Android Studio's Image Asset output) stay
10
+ * as they are — and removing the key later just stops regeneration, it deletes nothing. With
11
+ * the key set, the generated files become CLI-owned carve-outs like assets/:
12
+ * ios/App/Assets.xcassets/AppIcon.appiconset/ (AppIcon.png + Contents.json)
13
+ * android/app/src/main/res/mipmap-* / values/lecodes_icon.xml
14
+ * jimp is imported lazily — it's a decode/encode stack most CLI invocations never need. */
15
+
16
+ const MASTER_PX = 1024
17
+
18
+ /** Launcher buckets, mdpi ×1 … xxxhdpi ×4. Legacy icons (API 23-25) are 48dp full-bleed;
19
+ * adaptive layers (26+) live on a 108dp canvas of which launchers show the middle ~72dp. */
20
+ const DENSITIES = [
21
+ { qualifier: "mdpi", scale: 1 },
22
+ { qualifier: "hdpi", scale: 1.5 },
23
+ { qualifier: "xhdpi", scale: 2 },
24
+ { qualifier: "xxhdpi", scale: 3 },
25
+ { qualifier: "xxxhdpi", scale: 4 },
26
+ ] as const
27
+
28
+ export interface IconMaster {
29
+ image: JimpInstance // square, MASTER_PX², straight alpha
30
+ background: number // 0xRRGGBBFF for jimp canvases
31
+ backgroundHex: string // "#RRGGBB" for resource files
32
+ }
33
+
34
+ export const loadIconMaster = async (root: string, config: AppConfig): Promise<IconMaster> => {
35
+ const rel = config.icon!
36
+ const path = resolve(root, rel)
37
+ if (!existsSync(path)) throw new CliError(`app.json "icon": ${rel} not found`)
38
+
39
+ const raw = (config.iconBackground ?? "#FFFFFF").trim()
40
+ const expanded = /^#[0-9a-fA-F]{3}$/.test(raw) ? "#" + [...raw.slice(1)].map(ch => ch + ch).join("") : raw
41
+ const m = /^#([0-9a-fA-F]{6})$/.exec(expanded)
42
+ if (!m) throw new CliError(`app.json "iconBackground" must be #RGB or #RRGGBB (got "${config.iconBackground}")`)
43
+ const backgroundHex = "#" + m[1].toUpperCase()
44
+ const background = (parseInt(m[1], 16) * 0x100 + 0xff) >>> 0
45
+
46
+ const { Jimp } = await import("jimp")
47
+ let image: JimpInstance
48
+ try {
49
+ image = (await Jimp.read(path)) as JimpInstance
50
+ } catch (e) {
51
+ throw new CliError(`Could not decode "icon" ${rel} (${(e as Error).message}) — use a square PNG or JPEG.`)
52
+ }
53
+ if (image.width !== image.height) {
54
+ warn(`"icon" ${rel} is ${image.width}×${image.height} — not square, so it gets center-cropped.`)
55
+ }
56
+ if (Math.min(image.width, image.height) < MASTER_PX) {
57
+ warn(`"icon" ${rel} is under ${MASTER_PX}px — it will be upscaled and may look soft.`)
58
+ }
59
+ image.cover({ w: MASTER_PX, h: MASTER_PX })
60
+ return { image, background, backgroundHex }
61
+ }
62
+
63
+ /* ------------------------------------------ iOS ------------------------------------------ */
64
+
65
+ /** The init-time Contents.json plus the filename binding for the light icon; dark/tinted slots
66
+ * stay unassigned and fall back to it (exactly what Xcode writes when a PNG is dropped in). */
67
+ const IOS_CONTENTS_JSON = `{
68
+ "images" : [
69
+ {
70
+ "filename" : "AppIcon.png",
71
+ "idiom" : "universal",
72
+ "platform" : "ios",
73
+ "size" : "1024x1024"
74
+ },
75
+ {
76
+ "appearances" : [
77
+ {
78
+ "appearance" : "luminosity",
79
+ "value" : "dark"
80
+ }
81
+ ],
82
+ "idiom" : "universal",
83
+ "platform" : "ios",
84
+ "size" : "1024x1024"
85
+ },
86
+ {
87
+ "appearances" : [
88
+ {
89
+ "appearance" : "luminosity",
90
+ "value" : "tinted"
91
+ }
92
+ ],
93
+ "idiom" : "universal",
94
+ "platform" : "ios",
95
+ "size" : "1024x1024"
96
+ }
97
+ ],
98
+ "info" : {
99
+ "author" : "xcode",
100
+ "version" : 1
101
+ }
102
+ }
103
+ `
104
+
105
+ export const writeIosAppIcon = async (iosDir: string, master: IconMaster): Promise<void> => {
106
+ const { Jimp } = await import("jimp")
107
+ const dir = join(iosDir, "App", "Assets.xcassets", "AppIcon.appiconset")
108
+ mkdirSync(dir, { recursive: true })
109
+ // The App Store marketing icon must be fully opaque: flatten over the background and encode
110
+ // RGB (colorType 2) so no alpha channel survives for the validator to reject.
111
+ const flat = new Jimp({ width: MASTER_PX, height: MASTER_PX, color: master.background })
112
+ flat.composite(master.image, 0, 0)
113
+ writeFileSync(join(dir, "AppIcon.png"), await flat.getBuffer("image/png", { colorType: 2 }))
114
+ writeFileSync(join(dir, "Contents.json"), IOS_CONTENTS_JSON)
115
+ log(`${c.green("icon")} AppIcon.png (${MASTER_PX}×${MASTER_PX}) → ios/App/Assets.xcassets/AppIcon.appiconset/`)
116
+ }
117
+
118
+ /* ---------------------------------------- Android ---------------------------------------- */
119
+
120
+ const ADAPTIVE_ICON_XML = `<?xml version="1.0" encoding="utf-8"?>
121
+ <!-- Generated by \`lecodes app sync\` from app.json "icon" — regenerated on every sync. -->
122
+ <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
123
+ <background android:drawable="@color/lecodes_icon_background" />
124
+ <foreground android:drawable="@mipmap/ic_launcher_foreground" />
125
+ <monochrome android:drawable="@mipmap/ic_launcher_foreground" />
126
+ </adaptive-icon>
127
+ `
128
+
129
+ const iconColorXml = (hex: string): string => `<?xml version="1.0" encoding="utf-8"?>
130
+ <!-- Generated by \`lecodes app sync\` from app.json "iconBackground" — regenerated on every sync. -->
131
+ <resources>
132
+ <color name="lecodes_icon_background">${hex}</color>
133
+ </resources>
134
+ `
135
+
136
+ export const writeAndroidIcons = async (androidDir: string, master: IconMaster): Promise<void> => {
137
+ const { Jimp } = await import("jimp")
138
+ const resDir = join(androidDir, "app", "src", "main", "res")
139
+ const writeRes = (rel: string, content: Uint8Array | string) => {
140
+ const abs = join(resDir, rel)
141
+ mkdirSync(dirname(abs), { recursive: true })
142
+ writeFileSync(abs, content)
143
+ }
144
+
145
+ // Legacy (API 23-25) icons are shown as-is — full-bleed master flattened over the background.
146
+ const legacy = new Jimp({ width: MASTER_PX, height: MASTER_PX, color: master.background })
147
+ legacy.composite(master.image, 0, 0)
148
+
149
+ for (const { qualifier, scale } of DENSITIES) {
150
+ const legacyPx = Math.round(48 * scale)
151
+ const l = legacy.clone() as JimpInstance
152
+ l.resize({ w: legacyPx, h: legacyPx })
153
+ writeRes(`mipmap-${qualifier}/ic_launcher.png`, await l.getBuffer("image/png"))
154
+
155
+ // Adaptive foreground: the master at 72dp centered on the transparent 108dp canvas — the
156
+ // outer ring is parallax bleed that launcher masks crop, so content there would be lost.
157
+ const canvasPx = Math.round(108 * scale)
158
+ const contentPx = Math.round(72 * scale)
159
+ const fg = new Jimp({ width: canvasPx, height: canvasPx, color: 0x00000000 })
160
+ const content = master.image.clone() as JimpInstance
161
+ content.resize({ w: contentPx, h: contentPx })
162
+ fg.composite(content, Math.round((canvasPx - contentPx) / 2), Math.round((canvasPx - contentPx) / 2))
163
+ writeRes(`mipmap-${qualifier}/ic_launcher_foreground.png`, await fg.getBuffer("image/png"))
164
+ }
165
+
166
+ // The monochrome (Android 13 themed-icon) layer reuses the foreground: launchers tint its
167
+ // alpha silhouette, which is right for transparent-background marks and acceptable (a tinted
168
+ // rounded square) for full-bleed ones. Redefine @mipmap/ic_launcher_foreground to override.
169
+ writeRes("mipmap-anydpi-v26/ic_launcher.xml", ADAPTIVE_ICON_XML)
170
+ writeRes("values/lecodes_icon.xml", iconColorXml(master.backgroundHex))
171
+ log(`${c.green("icon")} adaptive + legacy launcher icons → android/app/src/main/res/mipmap-*/`)
172
+ }
173
+
174
+ /** Point the USER-owned manifest at the generated set — a value-only restamp in the
175
+ * versionName spirit: warn-not-fail when the user restructured the manifest. */
176
+ export const restampManifestIcon = (androidDir: string): void => {
177
+ const path = join(androidDir, "app", "src", "main", "AndroidManifest.xml")
178
+ if (!existsSync(path)) return
179
+ const content = readFileSync(path, "utf8")
180
+ if (content.includes(`android:icon="@mipmap/ic_launcher"`)) return
181
+ if (!/android:icon="[^"]*"/.test(content)) {
182
+ warn(`couldn't find android:icon in app/src/main/AndroidManifest.xml — set it to "@mipmap/ic_launcher" yourself.`)
183
+ return
184
+ }
185
+ writeFileSync(path, content.replace(/android:icon="[^"]*"/, `android:icon="@mipmap/ic_launcher"`))
186
+ log(`${c.green("updated")} android/app/src/main/AndroidManifest.xml: android:icon → @mipmap/ic_launcher`)
187
+ }