lecodes-cli 0.19.0 → 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.
- package/dist/index.js +730 -235
- package/package.json +14 -13
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk-types.json +1 -1
- package/src/api.ts +302 -0
- package/src/browserAuth.ts +87 -0
- package/src/cmgenTool.ts +104 -0
- package/src/commands/app.ts +891 -0
- package/src/commands/appAndroid.ts +603 -0
- package/src/commands/appDesktop.ts +326 -0
- package/src/commands/appDesktopMac.ts +470 -0
- package/src/commands/appIcon.ts +187 -0
- package/src/commands/appShared.ts +448 -0
- package/src/commands/appTemplates.ts +883 -0
- package/src/commands/appTemplatesAndroid.ts +599 -0
- package/src/commands/appTemplatesGradlew.ts +9 -0
- package/src/commands/assets.ts +28 -0
- package/src/commands/clone.ts +60 -0
- package/src/commands/compile.ts +253 -0
- package/src/commands/create.ts +59 -0
- package/src/commands/design.ts +863 -0
- package/src/commands/designTemplates.ts +11 -0
- package/src/commands/desktop.ts +122 -0
- package/src/commands/dev.ts +214 -0
- package/src/commands/diff.ts +59 -0
- package/src/commands/init.ts +191 -0
- package/src/commands/install.ts +198 -0
- package/src/commands/lightmap.ts +290 -0
- package/src/commands/link.ts +147 -0
- package/src/commands/login.ts +24 -0
- package/src/commands/navmesh.ts +225 -0
- package/src/commands/pn.ts +305 -0
- package/src/commands/projectTemplates.ts +129 -0
- package/src/commands/pull.ts +109 -0
- package/src/commands/push.ts +173 -0
- package/src/commands/render.ts +414 -0
- package/src/commands/scene.ts +148 -0
- package/src/commands/shaders.ts +157 -0
- package/src/commands/status.ts +29 -0
- package/src/commands/test.ts +370 -0
- package/src/commands/thumbs.ts +178 -0
- package/src/commands/types.ts +87 -0
- package/src/commands/update.ts +190 -0
- package/src/compile/assetIcons.ts +214 -0
- package/src/compile/collect.ts +46 -0
- package/src/compile/collectLocal.ts +37 -0
- package/src/compile/designCompile.ts +104 -0
- package/src/compile/fonts.ts +156 -0
- package/src/compile/headlessBundle.ts +129 -0
- package/src/compile/nativeStack.ts +21 -0
- package/src/compile/projectCompile.ts +109 -0
- package/src/compile/sceneCompile.ts +113 -0
- package/src/compile/screenEntry.ts +127 -0
- package/src/compile/shaders.ts +245 -0
- package/src/config.ts +42 -0
- package/src/designMeta.ts +35 -0
- package/src/desktopRenderer.ts +532 -0
- package/src/desktopScript.ts +276 -0
- package/src/dev/clientTemplates.ts +160 -0
- package/src/dev/devServer.ts +292 -0
- package/src/dev/wsServer.ts +144 -0
- package/src/distRoot.ts +20 -0
- package/src/ignore.ts +163 -0
- package/src/index.ts +491 -0
- package/src/lecodes-3d-editor.d.ts +41 -0
- package/src/lecodes-assets.d.ts +7 -0
- package/src/lecodes-design.d.ts +191 -0
- package/src/lecodes-renderer.d.ts +131 -0
- package/src/localFiles.ts +144 -0
- package/src/manifest.ts +46 -0
- package/src/matcTool.ts +137 -0
- package/src/peers.ts +40 -0
- package/src/project.ts +61 -0
- package/src/projectEnv.ts +94 -0
- package/src/qrcode-terminal.d.ts +9 -0
- package/src/releases.ts +125 -0
- package/src/serverDiff.ts +78 -0
- package/src/textDiff.ts +103 -0
- package/src/types.ts +0 -0
- package/src/util.ts +146 -0
- package/runtime/sdk/animate/animate.ts +0 -238
- package/runtime/sdk/animate/bezier.ts +0 -138
- package/runtime/sdk/animate/easings.ts +0 -126
- package/runtime/sdk/canvas/Canvas.ts +0 -305
- package/runtime/sdk/compile/aspectMacro.ts +0 -86
- package/runtime/sdk/compile/assetIconMacro.ts +0 -384
- package/runtime/sdk/compile/assetMacro.ts +0 -146
- package/runtime/sdk/compile/assetName.ts +0 -50
- package/runtime/sdk/compile/bundler.ts +0 -287
- package/runtime/sdk/compile/compileProject.ts +0 -129
- package/runtime/sdk/compile/detectEntry.ts +0 -128
- package/runtime/sdk/compile/fontMacro.ts +0 -459
- package/runtime/sdk/compile/fontRegistry.ts +0 -78
- package/runtime/sdk/compile/header.ts +0 -67
- package/runtime/sdk/compile/index.ts +0 -100
- package/runtime/sdk/compile/libraryImports.ts +0 -52
- package/runtime/sdk/compile/liteMaterial.ts +0 -247
- package/runtime/sdk/compile/sceneEditor.ts +0 -88
- package/runtime/sdk/compile/serverSplit.ts +0 -233
- package/runtime/sdk/compile/serverTypes.ts +0 -227
- package/runtime/sdk/compile/sfnt.ts +0 -98
- package/runtime/sdk/compile/shaderSchema.ts +0 -202
- package/runtime/sdk/compile/shaderTargets.ts +0 -81
- package/runtime/sdk/compile/sourcemap.ts +0 -25
- package/runtime/sdk/core/Aspect.ts +0 -568
- package/runtime/sdk/core/InspectorUI.ts +0 -212
- package/runtime/sdk/core/StateMachine.ts +0 -308
- package/runtime/sdk/core/color.ts +0 -66
- package/runtime/sdk/core/compWrite.ts +0 -42
- package/runtime/sdk/core/events.ts +0 -38
- package/runtime/sdk/core/fields.ts +0 -120
- package/runtime/sdk/core/registry.ts +0 -23
- package/runtime/sdk/core/signals.ts +0 -277
- package/runtime/sdk/core/time.ts +0 -84
- package/runtime/sdk/g2/Camera2D.ts +0 -40
- package/runtime/sdk/g2/CharacterController2D.ts +0 -276
- package/runtime/sdk/g2/Node2D.ts +0 -267
- package/runtime/sdk/g2/OneWay2D.ts +0 -66
- package/runtime/sdk/g2/Physics2D.ts +0 -346
- package/runtime/sdk/g2/Scene2D.ts +0 -209
- package/runtime/sdk/g2/Shape2D.ts +0 -259
- package/runtime/sdk/g2/Sprite.ts +0 -89
- package/runtime/sdk/g2/SpriteAnimation.ts +0 -171
- package/runtime/sdk/g2/SpriteSheet.ts +0 -166
- package/runtime/sdk/g2/Texture2D.ts +0 -47
- package/runtime/sdk/g2/Tilemap.ts +0 -41
- package/runtime/sdk/g2/Tileset.ts +0 -71
- package/runtime/sdk/g2/Trigger2D.ts +0 -77
- package/runtime/sdk/g2/autotile.ts +0 -433
- package/runtime/sdk/g2/cells.ts +0 -91
- package/runtime/sdk/g2/defineScene2d.ts +0 -381
- package/runtime/sdk/g2/groups2d.ts +0 -106
- package/runtime/sdk/g2/loop.ts +0 -50
- package/runtime/sdk/g2/scenarios2d.ts +0 -69
- package/runtime/sdk/g2/touch.ts +0 -83
- package/runtime/sdk/gl/Camera.ts +0 -160
- package/runtime/sdk/gl/CameraPlace.ts +0 -52
- package/runtime/sdk/gl/CharacterController.ts +0 -238
- package/runtime/sdk/gl/Gearbox.ts +0 -212
- package/runtime/sdk/gl/Geometry.ts +0 -279
- package/runtime/sdk/gl/IK.ts +0 -193
- package/runtime/sdk/gl/InstancedMesh.ts +0 -132
- package/runtime/sdk/gl/Light.ts +0 -107
- package/runtime/sdk/gl/Lightmap.ts +0 -256
- package/runtime/sdk/gl/Material.ts +0 -260
- package/runtime/sdk/gl/Mesh.ts +0 -83
- package/runtime/sdk/gl/Model.ts +0 -124
- package/runtime/sdk/gl/NavAgent.ts +0 -337
- package/runtime/sdk/gl/NavMesh.ts +0 -397
- package/runtime/sdk/gl/Node.ts +0 -350
- package/runtime/sdk/gl/Noise.ts +0 -30
- package/runtime/sdk/gl/Particles.ts +0 -676
- package/runtime/sdk/gl/Physics.ts +0 -222
- package/runtime/sdk/gl/Plane.ts +0 -53
- package/runtime/sdk/gl/Ragdoll.ts +0 -270
- package/runtime/sdk/gl/Ray.ts +0 -16
- package/runtime/sdk/gl/Scene.ts +0 -486
- package/runtime/sdk/gl/Shape.ts +0 -409
- package/runtime/sdk/gl/Terrain.ts +0 -1092
- package/runtime/sdk/gl/Texture.ts +0 -63
- package/runtime/sdk/gl/Trigger.ts +0 -45
- package/runtime/sdk/gl/Vehicle.ts +0 -473
- package/runtime/sdk/gl/Wheel.ts +0 -240
- package/runtime/sdk/gl/animation/AnimationClip.ts +0 -204
- package/runtime/sdk/gl/animation/Animator.ts +0 -120
- package/runtime/sdk/gl/animation/Layer.ts +0 -31
- package/runtime/sdk/gl/animation/Loop.ts +0 -30
- package/runtime/sdk/gl/animation/Playback.ts +0 -44
- package/runtime/sdk/gl/animation/core.ts +0 -336
- package/runtime/sdk/gl/controls.ts +0 -95
- package/runtime/sdk/gl/physicsEvents.ts +0 -20
- package/runtime/sdk/gl/scenarios.ts +0 -291
- package/runtime/sdk/gl/state.ts +0 -6
- package/runtime/sdk/gl/terrainMesh.ts +0 -219
- package/runtime/sdk/gl/touch.ts +0 -68
- package/runtime/sdk/inject.ts +0 -197
- package/runtime/sdk/math/Mathf.ts +0 -118
- package/runtime/sdk/math/mat4.ts +0 -278
- package/runtime/sdk/math/quat.ts +0 -232
- package/runtime/sdk/math/vec.ts +0 -255
- package/runtime/sdk/net/codec.ts +0 -119
- package/runtime/sdk/net/core.ts +0 -384
- package/runtime/sdk/net/index.ts +0 -181
- package/runtime/sdk/net/replication.ts +0 -622
- package/runtime/sdk/plugins/camera.ts +0 -81
- package/runtime/sdk/plugins/geolocation.ts +0 -123
- package/runtime/sdk/plugins/oauth.ts +0 -61
- package/runtime/sdk/plugins/permission.ts +0 -7
- package/runtime/sdk/plugins/push.ts +0 -132
- package/runtime/sdk/plugins/qr.ts +0 -73
- package/runtime/sdk/plugins/service.ts +0 -47
- package/runtime/sdk/runtime/app.ts +0 -101
- package/runtime/sdk/runtime/appEvents.ts +0 -54
- package/runtime/sdk/runtime/channel.ts +0 -61
- package/runtime/sdk/runtime/clipboard.ts +0 -20
- package/runtime/sdk/runtime/datetime.ts +0 -329
- package/runtime/sdk/runtime/device.ts +0 -293
- package/runtime/sdk/runtime/fetch.ts +0 -77
- package/runtime/sdk/runtime/files.ts +0 -23
- package/runtime/sdk/runtime/input.ts +0 -175
- package/runtime/sdk/runtime/media.ts +0 -111
- package/runtime/sdk/runtime/misc.ts +0 -16
- package/runtime/sdk/runtime/net.ts +0 -36
- package/runtime/sdk/runtime/rpc.ts +0 -218
- package/runtime/sdk/runtime/service.ts +0 -83
- package/runtime/sdk/runtime/share.ts +0 -9
- package/runtime/sdk/runtime/storage.ts +0 -13
- package/runtime/sdk/runtime/touch.ts +0 -76
- package/runtime/sdk/scene/defineScene.ts +0 -1353
- package/runtime/sdk/scene/editorPlugins.ts +0 -110
- package/runtime/sdk/scene/gizmos.ts +0 -148
- package/runtime/sdk/scene/grammar.ts +0 -120
- package/runtime/sdk/scene/material.ts +0 -188
- package/runtime/sdk/server/auth/appConfig.ts +0 -12
- package/runtime/sdk/server/auth/global.ts +0 -80
- package/runtime/sdk/server/auth/host.ts +0 -318
- package/runtime/sdk/server/auth/models.ts +0 -83
- package/runtime/sdk/server/auth/types.ts +0 -50
- package/runtime/sdk/server/channel.ts +0 -56
- package/runtime/sdk/server/context.ts +0 -36
- package/runtime/sdk/server/db/defineDb.ts +0 -237
- package/runtime/sdk/server/db/fields.ts +0 -132
- package/runtime/sdk/server/db/httpTransport.ts +0 -93
- package/runtime/sdk/server/db/index.ts +0 -7
- package/runtime/sdk/server/db/marci/query.ts +0 -412
- package/runtime/sdk/server/db/types.ts +0 -202
- package/runtime/sdk/server/errors.ts +0 -12
- package/runtime/sdk/server/host.ts +0 -74
- package/runtime/sdk/server/inject.ts +0 -13
- package/runtime/sdk/server/runtime.ts +0 -133
- package/runtime/sdk/server/validate.ts +0 -87
- package/runtime/sdk/ui/NativeView.ts +0 -142
- package/runtime/sdk/ui/UI.ts +0 -39
- package/runtime/sdk/ui/UIBottomSheet.ts +0 -139
- package/runtime/sdk/ui/UIButton.ts +0 -101
- package/runtime/sdk/ui/UIContainer.ts +0 -60
- package/runtime/sdk/ui/UIImage.ts +0 -83
- package/runtime/sdk/ui/UIInput.ts +0 -185
- package/runtime/sdk/ui/UIModal.ts +0 -139
- package/runtime/sdk/ui/UINode.ts +0 -830
- package/runtime/sdk/ui/UIPager.ts +0 -362
- package/runtime/sdk/ui/UIPopover.ts +0 -100
- package/runtime/sdk/ui/UIScreen.ts +0 -123
- package/runtime/sdk/ui/UIScrollable.ts +0 -87
- package/runtime/sdk/ui/UISpacer.ts +0 -14
- package/runtime/sdk/ui/UITabs.ts +0 -236
- package/runtime/sdk/ui/UIText.ts +0 -51
- package/runtime/sdk/ui/UIVideo.ts +0 -88
- package/runtime/sdk/ui/UIVirtualizedList.ts +0 -241
- package/runtime/sdk/ui/UIWidget.ts +0 -127
- package/runtime/sdk/ui/fonts.ts +0 -13
- package/runtime/sdk/ui/presentable.ts +0 -117
- package/runtime/sdk/ui/router.ts +0 -132
- 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
|
+
}
|