@livx.cc/appwrap 0.54.0 → 0.55.0
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/package.json +4 -2
- package/runtime/app/main-page.ts +0 -2
- package/runtime/app/shell/capabilities.manifest.ts +27 -92
- package/runtime/app/shell/config.ts +4 -0
- package/runtime/app/shell/env.ts +8 -1
- package/runtime/app/shell/handlers.ts +2 -2
- package/runtime/tests/env-scheme.test.ts +44 -0
- package/src/cli.ts +398 -65
- package/src/config.ts +8 -1
- package/src/packs.ts +174 -0
- package/src/testing.ts +173 -0
- package/templates/module-pack/README.md +44 -0
- package/templates/module-pack/handler.ts +9 -0
- package/templates/module-pack/manifest.ts +27 -0
- package/templates/module-pack/native-src/__MODULE_NAME__/.gitkeep +0 -0
- package/runtime/app/shell/billing-offer.ts +0 -54
- package/runtime/app/shell/handlers-billing.ts +0 -662
- package/runtime/app/shell/handlers-health.ts +0 -178
- package/runtime/app/shell/handlers-widget.ts +0 -97
- package/runtime/modules-native/billing/App_Resources/iOS/src/AppwrapManageSubscriptions.swift +0 -44
- package/runtime/modules-native/health/App_Resources/Android/src/main/java/cc/livx/appwrap/HealthConnectBridge.kt +0 -74
- package/runtime/modules-native/widget/App_Resources/Android/src/main/java/cc/livx/appwrap/AppwrapWidgetProvider.kt +0 -118
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/drawable/appwrap_badge_bg.xml +0 -9
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/layout/appwrap_widget.xml +0 -123
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_colors.xml +0 -10
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_styles.xml +0 -67
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values-night/appwrap_widget_colors.xml +0 -9
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/xml/appwrap_widget_info.xml +0 -17
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.entitlements +0 -10
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.swift +0 -275
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/Info.plist +0 -25
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/extension.json +0 -9
- package/runtime/modules-native/widget/App_Resources/iOS/src/AppwrapWidgetReload.swift +0 -18
- package/runtime/tests/billing-offer.test.ts +0 -61
package/src/cli.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { execFileSync, spawn, spawnSync } from 'child_process';
|
|
12
12
|
import { createHash } from 'crypto';
|
|
13
|
-
import { copyFileSync, cpSync, existsSync, mkdirSync, openSync, closeSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync, writeSync } from 'fs';
|
|
13
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, openSync, closeSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from 'fs';
|
|
14
14
|
import { builtinModules } from 'module';
|
|
15
15
|
import { networkInterfaces, tmpdir } from 'os';
|
|
16
16
|
import { basename, delimiter as pathDelimiter, dirname, extname, join, resolve } from 'path';
|
|
@@ -23,6 +23,7 @@ import type * as CapManifest from '../../../runtime/app/shell/capabilities.manif
|
|
|
23
23
|
// type + `defineConfig` helper without pulling in (and running) the CLI dispatch.
|
|
24
24
|
import type { AppwrapConfig } from './config';
|
|
25
25
|
import { unknownConfigKeys } from './config';
|
|
26
|
+
import { resolveModulePacks, type ResolvedModule, type SyncContext } from './packs';
|
|
26
27
|
import { createHash } from 'crypto';
|
|
27
28
|
import { buildInfoPlist, buildMacEntitlements, deriveDesktopConfig, parseMacProfile, resolveCargoPath, stampTauriConf } from './desktop';
|
|
28
29
|
import type { DesktopShellConfig } from './desktop';
|
|
@@ -103,10 +104,42 @@ function resolveAssetRoot(rel: string): string {
|
|
|
103
104
|
return existsSync(local) ? local : resolve(import.meta.dir, '../../..', rel);
|
|
104
105
|
}
|
|
105
106
|
const TEMPLATE_DIR = resolveAssetRoot('runtime');
|
|
107
|
+
/** Ordered runtime-template roots copied into native/ (later roots overlay earlier — file-level
|
|
108
|
+
* last-wins), defaulting to the single built-in template. runCli (A5) can extend this so a consumer
|
|
109
|
+
* like appwrap-ee layers extra shell files on top of CE without forking the template. A single root
|
|
110
|
+
* is byte-identical to the pre-overlay behavior. */
|
|
111
|
+
let TEMPLATE_ROOTS: string[] = [TEMPLATE_DIR];
|
|
106
112
|
const CI_TEMPLATE_DIR = resolveAssetRoot('templates/ci');
|
|
113
|
+
/** Scaffold for `appwrap create-module` — a starter module pack (see packs.ts / @livx.cc/appwrap/testing). */
|
|
114
|
+
const MODULE_PACK_TEMPLATE_DIR = resolveAssetRoot('templates/module-pack');
|
|
107
115
|
/** Desktop (Tauri chassis) template — phase-0, macOS-only. Its own generated wrapper (`native-desktop/`)
|
|
108
|
-
* is disposable, like `native/`; the template stays canonical (edit here, regenerate propagates).
|
|
109
|
-
|
|
116
|
+
* is disposable, like `native/`; the template stays canonical (edit here, regenerate propagates).
|
|
117
|
+
* A `let` so runCli (A5) can point it at a host-provided template (e.g. appwrap-ee owns the desktop lane). */
|
|
118
|
+
let DESKTOP_TEMPLATE_DIR = resolveAssetRoot('runtime-desktop');
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Composition seam for a HOST that wraps this CLI (appwrap-ee, or any consumer). Every field is
|
|
122
|
+
* optional and no-op by default, so the bare `appwrap` bin behaves exactly as before. A host calls
|
|
123
|
+
* `runCli({...})` to extend the CLI without forking it.
|
|
124
|
+
*/
|
|
125
|
+
export interface CliOptions {
|
|
126
|
+
/** Extra or overriding top-level commands, keyed by command name — consulted BEFORE the built-in
|
|
127
|
+
* switch, so a host can add a command or replace a built-in one. */
|
|
128
|
+
commands?: Record<string, (cwd: string, flags: Record<string, string>, positionals: string[]) => Promise<void> | void>;
|
|
129
|
+
/** Pluggable platform handlers for `dev`/`build` (keyed by the platform arg, e.g. 'desktop') —
|
|
130
|
+
* consulted BEFORE the built-in platform routing, so a host can add/replace a platform lane. */
|
|
131
|
+
platforms?: Record<string, (cwd: string, flags: Record<string, string>, positionals: string[]) => Promise<void> | void>;
|
|
132
|
+
/** Module packs injected by the host, applied BEFORE the app config's own `modulePacks` (so the app
|
|
133
|
+
* can still override them, last-wins). */
|
|
134
|
+
modulePacks?: string[];
|
|
135
|
+
/** Extra runtime-template overlay roots, appended after the built-in template (later roots win). */
|
|
136
|
+
templateRoots?: string[];
|
|
137
|
+
/** Override for the desktop (Tauri) template dir. */
|
|
138
|
+
desktopTemplateDir?: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The host composition for THIS run, set once by runCli. Empty by default → bare-bin behavior. */
|
|
142
|
+
let cliOptions: CliOptions = {};
|
|
110
143
|
|
|
111
144
|
/** This CLI's own published version — used to pin the `bunx @livx.cc/appwrap@^x.y.z` invocations the
|
|
112
145
|
* emitted workflow runs. Pinning to THIS version's floor means CI fails LOUDLY ("version not found")
|
|
@@ -116,10 +149,67 @@ const CLI_VERSION: string = (await import(resolve(import.meta.dir, '..', 'packag
|
|
|
116
149
|
|
|
117
150
|
// Load the capability manifest VALUES from the resolved runtime (pure data — safe outside NativeScript).
|
|
118
151
|
// Top-level await resolves before any command dispatches at the bottom of this file.
|
|
119
|
-
const { MODULES, OPTIONAL_GROUPS } = (await import(
|
|
152
|
+
const { MODULES, OPTIONAL_GROUPS, LEGACY_BUNDLED_GROUPS, MANIFEST_SCHEMA_VERSION } = (await import(
|
|
120
153
|
resolve(TEMPLATE_DIR, 'app/shell/capabilities.manifest')
|
|
121
154
|
)) as typeof CapManifest;
|
|
122
155
|
|
|
156
|
+
/** The composed module set for THIS command run: built-ins + the config's `modulePacks`, resolved once
|
|
157
|
+
* at command entry by `applyModulePacks`. Stays `null` when NO packs are configured — every code path
|
|
158
|
+
* then reads the built-in `MODULES` directly, so a pack-less build is byte-identical to pre-packs. */
|
|
159
|
+
let RESOLVED_MODULES: ResolvedModule[] | null = null;
|
|
160
|
+
|
|
161
|
+
/** The active module set the CLI derives from — merged (built-ins + packs) when packs are configured,
|
|
162
|
+
* else the built-ins verbatim. */
|
|
163
|
+
function moduleList(): CapManifest.ModuleManifest[] {
|
|
164
|
+
return RESOLVED_MODULES ? RESOLVED_MODULES.map((r) => r.manifest) : MODULES;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Pack provenance for a module name (source label + pack dir) — used by the generator to locate a
|
|
168
|
+
* pack module's handler file + native source. Undefined for a built-in. */
|
|
169
|
+
function packInfo(name: string): { source: string; packDir: string } | undefined {
|
|
170
|
+
const r = RESOLVED_MODULES?.find((m) => m.manifest.name === name);
|
|
171
|
+
return r && r.packDir ? { source: r.source, packDir: r.packDir } : undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Resolve the config's `modulePacks` (if any) into RESOLVED_MODULES for this run. No-op (leaves the
|
|
175
|
+
* built-ins untouched → byte-identical) when the config declares no packs. Async because pack manifests
|
|
176
|
+
* are dynamically imported; callers await it before the sync/regenerate pipeline. */
|
|
177
|
+
async function applyModulePacks(cwd: string, cfg: AppwrapConfig): Promise<void> {
|
|
178
|
+
// Host-injected packs first (appwrap-ee's), then the app config's — so the app can override, last-wins.
|
|
179
|
+
const refs = [...(cliOptions.modulePacks ?? []), ...(cfg.modulePacks ?? [])];
|
|
180
|
+
if (refs.length === 0) {
|
|
181
|
+
RESOLVED_MODULES = null;
|
|
182
|
+
} else {
|
|
183
|
+
const ctx: SyncContext = {
|
|
184
|
+
platform: 'both',
|
|
185
|
+
env: process.env.APPWRAP_ENV || 'default',
|
|
186
|
+
ci: !!process.env.CI,
|
|
187
|
+
};
|
|
188
|
+
const map = await resolveModulePacks({
|
|
189
|
+
builtins: MODULES,
|
|
190
|
+
builtinSchemaVersion: MANIFEST_SCHEMA_VERSION,
|
|
191
|
+
packRefs: refs,
|
|
192
|
+
ctx,
|
|
193
|
+
cwd,
|
|
194
|
+
});
|
|
195
|
+
RESOLVED_MODULES = [...map.values()];
|
|
196
|
+
}
|
|
197
|
+
warnUnknownModules(cfg);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Warn (never fail) for any name in `modules` that resolves to neither a built-in nor a pack module —
|
|
201
|
+
* with the pack model it's easy to list a capability (e.g. 'billing') and forget to add its pack to
|
|
202
|
+
* `modulePacks`, which would otherwise silently no-op (no handler, no capability). */
|
|
203
|
+
function warnUnknownModules(cfg: AppwrapConfig): void {
|
|
204
|
+
if (!cfg.modules) return;
|
|
205
|
+
const known = new Set(moduleList().map((m) => m.name));
|
|
206
|
+
for (const name of cfg.modules) {
|
|
207
|
+
if (!known.has(name)) {
|
|
208
|
+
console.warn(`⚠ module '${name}' is listed in \`modules\` but isn't a built-in or provided by any \`modulePacks\` — did you forget to add its pack? (ignored)`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
123
213
|
/** Native requirements composed for a build: the union (deduped) of the active modules' self-contained
|
|
124
214
|
* manifest declarations. Two modes:
|
|
125
215
|
* - `modules` ABSENT (legacy): every capability active; permissions come ONLY from `permissions{}`
|
|
@@ -137,20 +227,25 @@ interface NativeReqs {
|
|
|
137
227
|
androidGradleDeps: string[];
|
|
138
228
|
androidKotlin: boolean; // any active module ships Kotlin native source
|
|
139
229
|
androidManifestApp: string[]; // raw XML injected inside AndroidManifest <application>
|
|
140
|
-
nativeSrc: string[]; // active modules' nativeSrc dir names (under runtime/modules-native/)
|
|
141
|
-
|
|
142
|
-
|
|
230
|
+
nativeSrc: string[]; // active BUILT-IN modules' nativeSrc dir names (under runtime/modules-native/)
|
|
231
|
+
packNativeSrc: Array<{ name: string; srcDir: string }>; // active PACK modules' native source (absolute src dirs)
|
|
232
|
+
packHandlers: Array<{ name: string; source: string; packDir: string; file: string; fn: string }>; // active pack modules with a register handler (for the generated barrel + staging)
|
|
233
|
+
packModules: Array<Pick<CapManifest.ModuleManifest, 'name' | 'core' | 'capabilities' | 'group'>>; // active pack modules' handshake-relevant subset (their capabilities aren't in the runtime's static MODULES)
|
|
143
234
|
}
|
|
144
235
|
|
|
145
236
|
function nativeReqs(cfg: AppwrapConfig): NativeReqs {
|
|
146
|
-
const
|
|
237
|
+
const modules = moduleList();
|
|
238
|
+
const optIn = modules.filter((m) => !m.core);
|
|
147
239
|
// Legacy default (no `modules` key) = every opt-in capability EXCEPT strictly-opt-in own-file
|
|
148
240
|
// modules (OPTIONAL_GROUPS, e.g. health): those carry deps/perms legacy won't stamp, so they must
|
|
149
241
|
// be explicitly requested. Explicit mode = exactly what `modules` lists.
|
|
150
242
|
const active = cfg.modules
|
|
151
243
|
? new Set(cfg.modules)
|
|
152
|
-
: new Set(optIn.filter((m) =>
|
|
153
|
-
|
|
244
|
+
: new Set(optIn.filter((m) =>
|
|
245
|
+
!OPTIONAL_GROUPS.includes(m.group as (typeof OPTIONAL_GROUPS)[number]) ||
|
|
246
|
+
LEGACY_BUNDLED_GROUPS.includes(m.group as (typeof LEGACY_BUNDLED_GROUPS)[number])
|
|
247
|
+
).map((m) => m.name));
|
|
248
|
+
const activeMods = modules.filter((m) => m.core || active.has(m.name));
|
|
154
249
|
|
|
155
250
|
const iosPlist: Array<{ key: string; usage: string }> = [];
|
|
156
251
|
const seenKeys = new Set<string>();
|
|
@@ -158,6 +253,9 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
|
|
|
158
253
|
const gradle = new Set<string>();
|
|
159
254
|
const iosEntitlements: Record<string, boolean | string | string[]> = {};
|
|
160
255
|
const nativeSrc: string[] = [];
|
|
256
|
+
const packNativeSrc: NativeReqs['packNativeSrc'] = [];
|
|
257
|
+
const packHandlers: NativeReqs['packHandlers'] = [];
|
|
258
|
+
const packModules: NativeReqs['packModules'] = [];
|
|
161
259
|
const androidManifestApp: string[] = [];
|
|
162
260
|
let androidKotlin = false;
|
|
163
261
|
|
|
@@ -174,7 +272,9 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
|
|
|
174
272
|
Object.assign(iosEntitlements, m.ios?.entitlements ?? {});
|
|
175
273
|
if (m.android?.kotlin) androidKotlin = true;
|
|
176
274
|
if (m.android?.manifestApplication) androidManifestApp.push(m.android.manifestApplication);
|
|
177
|
-
|
|
275
|
+
// built-in native source resolves under runtime/modules-native/; pack native source (below)
|
|
276
|
+
// resolves under the pack's own dir, so it must not be conflated with the built-in dir names.
|
|
277
|
+
if (m.nativeSrc && !packInfo(m.name)) nativeSrc.push(m.nativeSrc);
|
|
178
278
|
}
|
|
179
279
|
} else {
|
|
180
280
|
// legacy: only what `permissions{}` declares (via the key maps) — no behavior change
|
|
@@ -186,6 +286,18 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
|
|
|
186
286
|
}
|
|
187
287
|
}
|
|
188
288
|
|
|
289
|
+
// Active PACK modules (from the config's modulePacks) — their native source + register handler
|
|
290
|
+
// resolve against the pack's own dir. No-op when no packs are configured (packInfo → undefined).
|
|
291
|
+
for (const m of activeMods) {
|
|
292
|
+
const info = packInfo(m.name);
|
|
293
|
+
if (!info) continue;
|
|
294
|
+
if (m.nativeSrc) packNativeSrc.push({ name: m.name, srcDir: join(info.packDir, 'native-src', m.nativeSrc) });
|
|
295
|
+
if (m.handler) packHandlers.push({ name: m.name, source: info.source, packDir: info.packDir, file: m.handler.file, fn: m.handler.fn });
|
|
296
|
+
// The runtime's static MODULES doesn't include pack modules, so their capabilities must be carried
|
|
297
|
+
// into the generated handshake map explicitly (see generateModuleArtifacts + buildCapabilityMap).
|
|
298
|
+
packModules.push({ name: m.name, core: m.core, capabilities: m.capabilities, group: m.group });
|
|
299
|
+
}
|
|
300
|
+
|
|
189
301
|
return {
|
|
190
302
|
explicit: !!cfg.modules,
|
|
191
303
|
activeOptIn: optIn.filter((m) => active.has(m.name)).map((m) => m.name),
|
|
@@ -197,15 +309,14 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
|
|
|
197
309
|
androidKotlin,
|
|
198
310
|
androidManifestApp,
|
|
199
311
|
nativeSrc,
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
312
|
+
packNativeSrc,
|
|
313
|
+
packHandlers,
|
|
314
|
+
packModules,
|
|
203
315
|
};
|
|
204
316
|
}
|
|
205
317
|
|
|
206
318
|
/** Map a strippable optional group → its handler file + register fn (for the generated barrel). */
|
|
207
319
|
const OPTIONAL_GROUP_HANDLERS: Record<string, { file: string; fn: string }> = {
|
|
208
|
-
health: { file: './handlers-health', fn: 'registerHealthHandlers' },
|
|
209
320
|
oauth: { file: './handlers-oauth', fn: 'registerOAuthHandlers' },
|
|
210
321
|
reviews: { file: './handlers-reviews', fn: 'registerReviewsHandlers' },
|
|
211
322
|
scanner: { file: './handlers-scanner', fn: 'registerScannerHandlers' },
|
|
@@ -213,22 +324,112 @@ const OPTIONAL_GROUP_HANDLERS: Record<string, { file: string; fn: string }> = {
|
|
|
213
324
|
tracking: { file: './handlers-tracking', fn: 'registerTrackingHandlers' },
|
|
214
325
|
appleSignIn: { file: './handlers-apple-signin', fn: 'registerAppleSignInHandlers' },
|
|
215
326
|
backgroundTask: { file: './handlers-background', fn: 'registerBackgroundTaskHandlers' },
|
|
216
|
-
widget
|
|
327
|
+
// billing/health/widget moved to appwrap-ee packs — their handlers stage from packages/ee/packs/*.
|
|
217
328
|
};
|
|
218
329
|
|
|
330
|
+
/** The bare specifier a pack handler uses to import a built-in shell API — rewritten to a relative
|
|
331
|
+
* `./<mod>` when the file is staged next to the shell sources. A pack authored out-of-repo imports
|
|
332
|
+
* e.g. `@livx.cc/appwrap/runtime/app/shell/bridge` (resolvable, since the npm package ships runtime/),
|
|
333
|
+
* so it typechecks standalone; staging drops it beside the real shell files, where `./bridge` resolves. */
|
|
334
|
+
const SHELL_API_SPECIFIER = /(['"])@livx\.cc\/appwrap\/runtime\/app\/shell\//g;
|
|
335
|
+
|
|
336
|
+
/** Rewrite a pack handler's shell-API imports from the package specifier to relative, so the file
|
|
337
|
+
* resolves once staged beside the real shell sources. Pure (exported for tests). */
|
|
338
|
+
export function rewritePackShellImports(src: string): string {
|
|
339
|
+
return src.replace(SHELL_API_SPECIFIER, '$1./');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Resolve a pack-local relative import specifier to an absolute source file (trying the usual TS/JS
|
|
343
|
+
* extensions + an index file), or null if it doesn't resolve to a real file. */
|
|
344
|
+
function resolvePackImport(fromDir: string, spec: string): string | null {
|
|
345
|
+
const base = resolve(fromDir, spec);
|
|
346
|
+
const cands = [base, `${base}.ts`, `${base}.tsx`, `${base}.js`, `${base}.jsx`, join(base, 'index.ts'), join(base, 'index.js')];
|
|
347
|
+
return cands.find((c) => existsSync(c) && statSync(c).isFile()) ?? null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Replace an exact (quoted) import specifier in source with another — anchored so `./x` never
|
|
351
|
+
* partial-matches `./x-y`. Pure (exported for tests). */
|
|
352
|
+
export function replaceImportSpecifier(src: string, oldSpec: string, newSpec: string): string {
|
|
353
|
+
const esc = oldSpec.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
354
|
+
return src.replace(new RegExp(`(['"])${esc}\\1`, 'g'), `$1${newSpec}$1`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Stage a pack source file into the shell, namespaced `pack-<label>-<name>.ts` (never colliding with a
|
|
358
|
+
* built-in file — keeps built-in staged filenames stable, preserving the byte-compare gate). Rewrites
|
|
359
|
+
* (a) shell-API imports → relative `./<x>`, and (b) pack-LOCAL relative imports → their namespaced
|
|
360
|
+
* staged sibling, staging each RECURSIVELY — so a handler's own helper modules (e.g. billing's
|
|
361
|
+
* billing-offer.ts) are staged too. `staged` memoizes by abs path (dedup + cycle guard). `entryName`
|
|
362
|
+
* overrides the staged basename for the handler entry (so the barrel imports it by module identity). */
|
|
363
|
+
function stagePackFile(shell: string, label: string, packDir: string, absFile: string, staged: Map<string, string>, entryName?: string): string {
|
|
364
|
+
const cached = staged.get(absFile);
|
|
365
|
+
if (cached) return cached;
|
|
366
|
+
if (!existsSync(absFile)) {
|
|
367
|
+
console.error(`✖ module pack "${label}": imported file not found: ${absFile}`);
|
|
368
|
+
process.exit(1);
|
|
369
|
+
}
|
|
370
|
+
const name = entryName ?? basename(absFile).replace(/\.(tsx?|jsx?)$/, '');
|
|
371
|
+
const destName = `pack-${label}-${name}.ts`;
|
|
372
|
+
const spec = `./${destName.replace(/\.ts$/, '')}`;
|
|
373
|
+
staged.set(absFile, spec); // set BEFORE recursing so an import cycle terminates
|
|
374
|
+
|
|
375
|
+
let src = rewritePackShellImports(readFileSync(absFile, 'utf8'));
|
|
376
|
+
const fromDir = dirname(absFile);
|
|
377
|
+
for (const imp of new Bun.Transpiler({ loader: loaderForEntry(absFile) }).scanImports(src)) {
|
|
378
|
+
if (!imp.path.startsWith('.')) continue; // only pack-LOCAL relative imports need staging
|
|
379
|
+
const depAbs = resolvePackImport(fromDir, imp.path);
|
|
380
|
+
if (!depAbs || !depAbs.startsWith(packDir + '/')) continue; // must stay inside the pack dir
|
|
381
|
+
const depSpec = stagePackFile(shell, label, packDir, depAbs, staged);
|
|
382
|
+
src = replaceImportSpecifier(src, imp.path, depSpec);
|
|
383
|
+
}
|
|
384
|
+
writeFileSync(join(shell, destName), src);
|
|
385
|
+
return spec;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Stage a pack module's handler (+ its pack-local imports) into the shell. Returns the import
|
|
389
|
+
* specifier the generated barrel imports the handler by. */
|
|
390
|
+
function stagePackHandler(shell: string, h: NativeReqs['packHandlers'][number]): string {
|
|
391
|
+
const srcFile = resolve(h.packDir, h.file);
|
|
392
|
+
if (!existsSync(srcFile)) {
|
|
393
|
+
console.error(`✖ module pack "${h.source}": handler file not found: ${srcFile}`);
|
|
394
|
+
process.exit(1);
|
|
395
|
+
}
|
|
396
|
+
const label = h.source.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
397
|
+
const spec = stagePackFile(shell, label, h.packDir, srcFile, new Map(), h.name);
|
|
398
|
+
console.log(` pack ← ${h.source} (${h.name} handler)`);
|
|
399
|
+
return spec;
|
|
400
|
+
}
|
|
401
|
+
|
|
219
402
|
/** Generate the two composition artifacts in the wrapper: the active capability list (drives the
|
|
220
403
|
* handshake map) and the optional-handler barrel (imports only active strippable groups). */
|
|
221
404
|
function generateModuleArtifacts(outDir: string, req: NativeReqs): void {
|
|
222
405
|
const shell = join(outDir, 'app/shell');
|
|
406
|
+
// Sweep stale pack-staged handlers first so a removed/renamed pack never leaves an orphan behind
|
|
407
|
+
// (self-pruning, like regenerateMobilePlugins wiping its dir) — the active ones are re-staged below.
|
|
408
|
+
for (const f of existsSync(shell) ? readdirSync(shell) : []) {
|
|
409
|
+
if (f.startsWith('pack-') && f.endsWith('.ts')) rmSync(join(shell, f), { force: true });
|
|
410
|
+
}
|
|
223
411
|
writeFileSync(
|
|
224
412
|
join(shell, 'active-modules.generated.ts'),
|
|
225
|
-
`/** Generated by \`appwrap\` from the appwrap config \`modules\`. Do not edit. */\n` +
|
|
226
|
-
`
|
|
413
|
+
`/** Generated by \`appwrap\` from the appwrap config \`modules\` + \`modulePacks\`. Do not edit. */\n` +
|
|
414
|
+
`import type { ModuleManifest } from './capabilities.manifest';\n` +
|
|
415
|
+
`export const ACTIVE_MODULE_NAMES: string[] = ${JSON.stringify(req.activeOptIn)};\n` +
|
|
416
|
+
// Pack modules aren't in the runtime's static MODULES, so their handshake capabilities travel here
|
|
417
|
+
// and buildCapabilityMap merges them (empty for a pack-less build).
|
|
418
|
+
`export const PACK_MODULES: Array<Pick<ModuleManifest, 'name' | 'core' | 'capabilities' | 'group'>> = ${JSON.stringify(req.packModules)};\n`
|
|
227
419
|
);
|
|
228
420
|
|
|
229
421
|
const groups = req.activeOptionalGroups.filter((g) => OPTIONAL_GROUP_HANDLERS[g]);
|
|
230
|
-
const
|
|
231
|
-
const
|
|
422
|
+
const importLines = groups.map((g) => `import { ${OPTIONAL_GROUP_HANDLERS[g].fn} } from '${OPTIONAL_GROUP_HANDLERS[g].file}';`);
|
|
423
|
+
const callLines = groups.map((g) => ` ${OPTIONAL_GROUP_HANDLERS[g].fn}();`);
|
|
424
|
+
// Active PACK modules with a register handler — staged into the shell + APPENDED after the built-in
|
|
425
|
+
// groups (empty when no packs → byte-identical to the pre-packs barrel).
|
|
426
|
+
for (const h of req.packHandlers) {
|
|
427
|
+
const spec = stagePackHandler(shell, h);
|
|
428
|
+
importLines.push(`import { ${h.fn} } from '${spec}';`);
|
|
429
|
+
callLines.push(` ${h.fn}();`);
|
|
430
|
+
}
|
|
431
|
+
const imports = importLines.join('\n');
|
|
432
|
+
const calls = callLines.join('\n');
|
|
232
433
|
writeFileSync(
|
|
233
434
|
join(shell, 'optional-handlers.generated.ts'),
|
|
234
435
|
`/** Generated by \`appwrap\` — only the active strippable modules are imported. Do not edit. */\n` +
|
|
@@ -394,9 +595,13 @@ function stampEntitlements(outDir: string, cfg: AppwrapConfig, req: NativeReqs):
|
|
|
394
595
|
const file = join(iosDir, 'app.entitlements');
|
|
395
596
|
const ent: Record<string, boolean | string | string[]> = { ...req.iosEntitlements, ...cfg.iosEntitlements };
|
|
396
597
|
if (!!cfg.push?.enabled && cfg.push?.ios !== false) ent['aps-environment'] = cfg.push.apsEnvironment ?? 'development';
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
|
|
598
|
+
// Resolve build tokens in entitlement values (e.g. a module's `__APP_GROUP__` → group.<appId>) so a
|
|
599
|
+
// module declaring an app-derived entitlement (widget's App Group) needs no hardcoded stamper case.
|
|
600
|
+
const tokens = buildTokens(cfg);
|
|
601
|
+
for (const [k, v] of Object.entries(ent)) {
|
|
602
|
+
if (typeof v === 'string') ent[k] = substituteBuildTokens(v, tokens);
|
|
603
|
+
else if (Array.isArray(v)) ent[k] = v.map((x) => substituteBuildTokens(x, tokens));
|
|
604
|
+
}
|
|
400
605
|
const keys = Object.keys(ent);
|
|
401
606
|
if (keys.length === 0) { rmSync(file, { force: true }); return; }
|
|
402
607
|
const val = (v: boolean | string | string[]): string =>
|
|
@@ -426,29 +631,52 @@ function stampPrivacyManifest(outDir: string, cfg: AppwrapConfig, req: NativeReq
|
|
|
426
631
|
if (active) console.log(` priv ← NSPrivacyTracking=true${cfg.trackingDomains?.length ? ` (${cfg.trackingDomains.length} domain${cfg.trackingDomains.length > 1 ? 's' : ''})` : ''}`);
|
|
427
632
|
}
|
|
428
633
|
|
|
429
|
-
/** Copy active modules' native source (runtime/modules-native/<name>/
|
|
430
|
-
*
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
634
|
+
/** Copy active modules' native source (runtime/modules-native/<name>/ for built-ins, <packDir>/
|
|
635
|
+
* native-src/<name>/ for packs) into native/ — only when the module is active, so module native code
|
|
636
|
+
* stays stripped from builds that don't use it. Returns the set of dest paths (relative to outDir) it
|
|
637
|
+
* copied, so regenerateCore can prune a now-INACTIVE module's native files on re-sync (they mirror the
|
|
638
|
+
* App_Resources layout and cpSync only overwrites, never deletes). */
|
|
639
|
+
function copyModuleNativeSrc(outDir: string, req: NativeReqs): Set<string> {
|
|
640
|
+
const copied = new Set<string>();
|
|
641
|
+
const copyFrom = (src: string, label: string) => {
|
|
642
|
+
if (!existsSync(src)) { console.warn(`⚠ module nativeSrc not found: ${src}`); return; }
|
|
435
643
|
cpSync(src, outDir, { recursive: true, force: true });
|
|
436
|
-
|
|
437
|
-
}
|
|
644
|
+
for (const rel of collectRelFiles(src)) copied.add(rel);
|
|
645
|
+
console.log(` natv ← ${label} native source`);
|
|
646
|
+
};
|
|
647
|
+
for (const name of req.nativeSrc) copyFrom(join(MODULES_NATIVE_DIR, name), `module '${name}'`);
|
|
648
|
+
for (const { name, srcDir } of req.packNativeSrc) copyFrom(srcDir, `pack module '${name}'`);
|
|
649
|
+
return copied;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** Build-time tokens a module's native source / entitlements may reference — resolved from the app
|
|
653
|
+
* config so a module ships app-agnostic and the CLI stamps the app-specific value in. Not module-
|
|
654
|
+
* specific: any module (built-in or pack) can use these tokens. Currently `__APP_GROUP__` → the App
|
|
655
|
+
* Group id shared by the app + an extension (e.g. widget); extend here as new app-derived needs arise. */
|
|
656
|
+
function buildTokens(cfg: AppwrapConfig): Record<string, string> {
|
|
657
|
+
return { __APP_GROUP__: `group.${cfg.id}` };
|
|
438
658
|
}
|
|
439
659
|
|
|
440
|
-
/** Substitute
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
660
|
+
/** Substitute any {@link buildTokens} in a stamped string (entitlement value or native-source file). */
|
|
661
|
+
function substituteBuildTokens(s: string, tokens: Record<string, string>): string {
|
|
662
|
+
let out = s;
|
|
663
|
+
for (const [tok, val] of Object.entries(tokens)) out = out.replaceAll(tok, val);
|
|
664
|
+
return out;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** Substitute build-time tokens in copied module native source (after copyModuleNativeSrc) — applied
|
|
668
|
+
* generically to every iOS extension file, so a module's app-agnostic source (e.g. the widget
|
|
669
|
+
* extension's Swift/entitlements/plist) gets the app-specific value stamped in. Idempotent (source is
|
|
670
|
+
* re-copied verbatim each sync, then re-substituted). No-op when no extensions were copied. */
|
|
671
|
+
function substituteModuleTokens(outDir: string, cfg: AppwrapConfig): void {
|
|
446
672
|
const extRoot = join(outDir, 'App_Resources/iOS/extensions');
|
|
447
673
|
if (!existsSync(extRoot)) return;
|
|
674
|
+
const tokens = buildTokens(cfg);
|
|
675
|
+
let touched = false;
|
|
448
676
|
const subst = (file: string) => {
|
|
449
677
|
const s = readFileSync(file, 'utf8');
|
|
450
|
-
|
|
451
|
-
|
|
678
|
+
const next = substituteBuildTokens(s, tokens);
|
|
679
|
+
if (next !== s) { writeFileSync(file, next); touched = true; }
|
|
452
680
|
};
|
|
453
681
|
const walk = (dir: string) => {
|
|
454
682
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -458,7 +686,7 @@ function substituteModuleTokens(outDir: string, req: NativeReqs): void {
|
|
|
458
686
|
}
|
|
459
687
|
};
|
|
460
688
|
walk(extRoot);
|
|
461
|
-
console.log(` tokn ← __APP_GROUP__ = ${
|
|
689
|
+
if (touched) console.log(` tokn ← __APP_GROUP__ = ${tokens.__APP_GROUP__} (extension source)`);
|
|
462
690
|
}
|
|
463
691
|
|
|
464
692
|
/** Hosts to register as Android App Links (autoVerify https intent-filters). Explicit
|
|
@@ -584,7 +812,7 @@ async function loadConfig(cwd: string, flags: Record<string, string>): Promise<A
|
|
|
584
812
|
}
|
|
585
813
|
|
|
586
814
|
// loader:'server' bakes serverUrl into the shell and loads it via NSURL/WKWebView. A scheme-less
|
|
587
|
-
// value (e.g. "
|
|
815
|
+
// value (e.g. "app.example.com") produces an unusable URL — the app silently fails to load (or
|
|
588
816
|
// shows a stale page) with no error. Normalize to https:// when no scheme is present, and fail loud
|
|
589
817
|
// on a genuinely malformed URL rather than shipping a broken build.
|
|
590
818
|
if (cfg.serverUrl) {
|
|
@@ -603,7 +831,7 @@ async function loadConfig(cwd: string, flags: Record<string, string>): Promise<A
|
|
|
603
831
|
return cfg;
|
|
604
832
|
}
|
|
605
833
|
|
|
606
|
-
function stampShellConfig(outDir: string, cfg: AppwrapConfig): void {
|
|
834
|
+
export function stampShellConfig(outDir: string, cfg: AppwrapConfig): void {
|
|
607
835
|
// Resolve the env-switcher block. Absent block OR `enabled:false` → the whole feature is inert
|
|
608
836
|
// (the shell reads `envSwitcher.enabled`). `allowPattern`/`envs` default to empty (default-deny).
|
|
609
837
|
const es = cfg.envSwitcher;
|
|
@@ -628,6 +856,7 @@ export const SHELL_CONFIG = {
|
|
|
628
856
|
loader: ${JSON.stringify(cfg.loader ?? 'app')} as 'app' | 'file' | 'server',
|
|
629
857
|
serverUrl: ${JSON.stringify(cfg.serverUrl ?? '')},
|
|
630
858
|
backendOrigin: ${JSON.stringify(cfg.backendOrigin ?? '')},
|
|
859
|
+
urlScheme: ${JSON.stringify(cfg.urlScheme ?? '')},
|
|
631
860
|
debug: ${JSON.stringify(cfg.debug ?? false)},
|
|
632
861
|
debugLog: ${JSON.stringify(cfg.debugLog ?? '*')},
|
|
633
862
|
devMenu: ${JSON.stringify(cfg.devMenu ?? true)},
|
|
@@ -1626,17 +1855,23 @@ export function applyOverrides(cwd: string, outDir: string, cfg: AppwrapConfig):
|
|
|
1626
1855
|
const dir = resolve(cwd, cfg.overrides ?? 'appwrap.overrides');
|
|
1627
1856
|
// Files the template still provides must survive an override removal (regenerateCore ran first and
|
|
1628
1857
|
// re-copied them) → protect them from the override prune.
|
|
1629
|
-
const templateFiles =
|
|
1858
|
+
const templateFiles = allTemplateFiles();
|
|
1630
1859
|
// ACTIVE modules' native source needs the SAME protection: copyModuleNativeSrc re-copied it into
|
|
1631
1860
|
// these exact relative paths, but it lives under runtime/modules-native/<name>/, which
|
|
1632
1861
|
// templateCopyFilter deliberately excludes — so it is absent from templateFiles above. Without this,
|
|
1633
1862
|
// dropping a consumer override that happened to shadow a module file prunes the MODULE's own copy
|
|
1634
1863
|
// too (the prune only sees "was in the overrides manifest, isn't in overrides now"), silently
|
|
1635
1864
|
// deleting e.g. res/xml/appwrap_widget_info.xml and failing the build at AAPT.
|
|
1636
|
-
|
|
1865
|
+
const reqs = nativeReqs(cfg);
|
|
1866
|
+
for (const name of reqs.nativeSrc) {
|
|
1637
1867
|
const modDir = join(MODULES_NATIVE_DIR, name);
|
|
1638
1868
|
if (existsSync(modDir)) for (const rel of collectRelFiles(modDir)) templateFiles.add(rel);
|
|
1639
1869
|
}
|
|
1870
|
+
// Same protection for active PACK modules' native source (lives under the pack dir, absent from the
|
|
1871
|
+
// template file set) — so an override removal never prunes a pack module's own copied files.
|
|
1872
|
+
for (const { srcDir } of reqs.packNativeSrc) {
|
|
1873
|
+
if (existsSync(srcDir)) for (const rel of collectRelFiles(srcDir)) templateFiles.add(rel);
|
|
1874
|
+
}
|
|
1640
1875
|
if (!existsSync(dir)) {
|
|
1641
1876
|
// No overrides now: prune anything a PRIOR overrides run left behind (renamed/removed override files).
|
|
1642
1877
|
pruneStale(outDir, OVERRIDES_MANIFEST, new Set(), templateFiles);
|
|
@@ -1795,12 +2030,27 @@ function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig, force
|
|
|
1795
2030
|
// so without this a relocated source lingers in native/ and gets re-bundled.
|
|
1796
2031
|
const TEMPLATE_MANIFEST = '.appwrap-template-manifest.json';
|
|
1797
2032
|
const OVERRIDES_MANIFEST = '.appwrap-overrides-manifest.json';
|
|
2033
|
+
/** Tracks module-owned native source copied into native/ (built-in + pack), so a module DEACTIVATED
|
|
2034
|
+
* since the last sync has its App_Resources files pruned rather than lingering (cpSync never deletes). */
|
|
2035
|
+
const MODULE_NATIVE_MANIFEST = '.appwrap-module-native-manifest.json';
|
|
1798
2036
|
|
|
1799
2037
|
/** Files under TEMPLATE_DIR to skip when copying/walking it (deps, build output, PWA staging, per-module
|
|
1800
2038
|
* native — those are handled selectively elsewhere). Shared by the cpSync filter and the prune walk so
|
|
1801
2039
|
* the copied set and the pruned set are defined identically. */
|
|
1802
|
-
const
|
|
1803
|
-
!/(?:^|\/)(node_modules|platforms|hooks|app\/www|modules-native)(\/|$)/.test(src.slice(
|
|
2040
|
+
const templateCopyFilterFor = (root: string) => (src: string): boolean =>
|
|
2041
|
+
!/(?:^|\/)(node_modules|platforms|hooks|app\/www|modules-native)(\/|$)/.test(src.slice(root.length));
|
|
2042
|
+
/** Single-root filter for the built-in template (the common path). */
|
|
2043
|
+
const templateCopyFilter = templateCopyFilterFor(TEMPLATE_DIR);
|
|
2044
|
+
/** Every file the active template roots contribute, relative to each root (deduped across roots) —
|
|
2045
|
+
* the union prune set + override-protection set for the overlay chain. */
|
|
2046
|
+
function allTemplateFiles(): Set<string> {
|
|
2047
|
+
const out = new Set<string>();
|
|
2048
|
+
for (const root of TEMPLATE_ROOTS) {
|
|
2049
|
+
if (!existsSync(root)) continue;
|
|
2050
|
+
for (const rel of collectRelFiles(root, templateCopyFilterFor(root))) out.add(rel);
|
|
2051
|
+
}
|
|
2052
|
+
return out;
|
|
2053
|
+
}
|
|
1804
2054
|
|
|
1805
2055
|
/** File (not dir) paths under `root`, relative to it, skipping entries `accept` rejects. */
|
|
1806
2056
|
function collectRelFiles(root: string, accept: (abs: string) => boolean = () => true): Set<string> {
|
|
@@ -1851,16 +2101,23 @@ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: {
|
|
|
1851
2101
|
if (opts.firstRun && !req.explicit) {
|
|
1852
2102
|
console.log(' ℹ no `modules` in the appwrap config → all capabilities active. Declare `modules` to shrink the store build (strip unused handlers/perms).');
|
|
1853
2103
|
}
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
2104
|
+
// Copy each template root in order — later roots OVERLAY earlier (file-level last-wins), so a
|
|
2105
|
+
// consumer template can add or replace shell files without forking CE. Single root (default) is
|
|
2106
|
+
// exactly the pre-overlay copy.
|
|
2107
|
+
for (const root of TEMPLATE_ROOTS) {
|
|
2108
|
+
if (!existsSync(root)) { console.warn(`⚠ template root not found: ${root}`); continue; }
|
|
2109
|
+
cpSync(root, outDir, {
|
|
2110
|
+
recursive: true,
|
|
2111
|
+
force: true, // explicit: Bun's cpSync does not overwrite existing files by default
|
|
2112
|
+
// modules-native/ is copied selectively per active module (copyModuleNativeSrc), not wholesale.
|
|
2113
|
+
// Match RELATIVE to the root — when installed from npm, a root itself sits under node_modules/,
|
|
2114
|
+
// so testing the absolute path would wrongly exclude the entire template.
|
|
2115
|
+
filter: templateCopyFilterFor(root),
|
|
2116
|
+
});
|
|
2117
|
+
}
|
|
2118
|
+
// Delete template files removed/renamed since the last regenerate (cpSync only overwrites, never
|
|
2119
|
+
// deletes) — pruned against the UNION of all roots' files so an overlay-provided file isn't culled.
|
|
2120
|
+
pruneStale(outDir, TEMPLATE_MANIFEST, allTemplateFiles());
|
|
1864
2121
|
stampShellConfig(outDir, cfg);
|
|
1865
2122
|
stampNativeScriptConfig(outDir, cfg);
|
|
1866
2123
|
stampIOSDisplayName(outDir, cfg, req);
|
|
@@ -1872,8 +2129,12 @@ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: {
|
|
|
1872
2129
|
stampKotlin(outDir, req.androidKotlin);
|
|
1873
2130
|
generateModuleArtifacts(outDir, req);
|
|
1874
2131
|
regenerateMobilePlugins(cwd, cfg, outDir); // config-gated TS plugins → bridge handlers (in-process)
|
|
1875
|
-
copyModuleNativeSrc(outDir, req); // module-owned native source (e.g. health's Kotlin shim)
|
|
1876
|
-
|
|
2132
|
+
const moduleNativeFiles = copyModuleNativeSrc(outDir, req); // module-owned native source (e.g. health's Kotlin shim)
|
|
2133
|
+
// Prune a now-inactive module's native files (in the prior manifest, not re-copied this sync). Protect
|
|
2134
|
+
// base-template files so a module that overrode a template App_Resources file, once removed, reverts to
|
|
2135
|
+
// the template copy instead of being deleted outright.
|
|
2136
|
+
pruneStale(outDir, MODULE_NATIVE_MANIFEST, moduleNativeFiles, allTemplateFiles());
|
|
2137
|
+
substituteModuleTokens(outDir, cfg); // stamp __APP_GROUP__ etc. into the copied extension source
|
|
1877
2138
|
stampLaunchScreen(outDir, cfg);
|
|
1878
2139
|
stampStoreKit(cwd, outDir, cfg);
|
|
1879
2140
|
stampPush(cwd, outDir, cfg);
|
|
@@ -1908,6 +2169,7 @@ async function init(cwd: string, flags: Record<string, string>): Promise<void> {
|
|
|
1908
2169
|
|
|
1909
2170
|
console.log(`🎁 appwrap init → ${outDir}`);
|
|
1910
2171
|
mkdirSync(outDir, { recursive: true });
|
|
2172
|
+
await applyModulePacks(cwd, cfg); // resolve config `modulePacks` (no-op when none) before deriving
|
|
1911
2173
|
regenerateCore(cwd, outDir, cfg, { firstRun: true, flags });
|
|
1912
2174
|
copyCiTemplates(cwd, outDir, cfg, 'force' in flags, 'ci' in flags); // GH workflows: opt-in (--ci), first-time only; fastlane lane: re-emit on --force
|
|
1913
2175
|
writeFileSync(join(outDir, '.gitignore'), 'node_modules/\nplatforms/\nhooks/\n');
|
|
@@ -1928,6 +2190,7 @@ async function sync(cwd: string, flags: Record<string, string>, cfgOverride?: Ap
|
|
|
1928
2190
|
console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
|
|
1929
2191
|
process.exit(1);
|
|
1930
2192
|
}
|
|
2193
|
+
await applyModulePacks(cwd, cfg); // resolve config `modulePacks` (no-op when none) before deriving
|
|
1931
2194
|
regenerateCore(cwd, outDir, cfg, { flags });
|
|
1932
2195
|
applyOverrides(cwd, outDir, cfg); // overrides win last
|
|
1933
2196
|
stampManualSigning(outDir, cfg, { configPath: resolveConfigPath(cwd, flags) }); // AFTER overrides: a consumer's extension.json / build.xcconfig would otherwise clobber the signing stamp
|
|
@@ -2002,6 +2265,7 @@ function openInspector(cfg: AppwrapConfig, flags: Record<string, string>, platfo
|
|
|
2002
2265
|
*/
|
|
2003
2266
|
async function dev(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
|
|
2004
2267
|
const platform = positionals[0];
|
|
2268
|
+
if (platform && cliOptions.platforms?.[platform]) return void await cliOptions.platforms[platform](cwd, flags, positionals);
|
|
2005
2269
|
if (platform === 'desktop') return devDesktop(cwd, flags);
|
|
2006
2270
|
const sim = 'sim' in flags || positionals[1] === 'sim';
|
|
2007
2271
|
const wantDebug = 'debug' in flags;
|
|
@@ -2040,6 +2304,7 @@ async function dev(cwd: string, flags: Record<string, string>, positionals: stri
|
|
|
2040
2304
|
: stamped?.loader === 'server'
|
|
2041
2305
|
? { ...cfg, loader: 'server', serverUrl: stamped.serverUrl, debug: stamped.debug }
|
|
2042
2306
|
: cfg;
|
|
2307
|
+
await applyModulePacks(cwd, simCfg); // resolve config `modulePacks` (no-op when none) before deriving
|
|
2043
2308
|
regenerateCore(cwd, outDir, simCfg, { flags });
|
|
2044
2309
|
applyOverrides(cwd, outDir, simCfg); // overrides win last — same order as sync
|
|
2045
2310
|
stampIOSExtensionVersions(outDir, simCfg); // AFTER overrides: keep extension version matching the app's (Xcode warns on mismatch)
|
|
@@ -2921,6 +3186,7 @@ async function buildDesktop(cwd: string, flags: Record<string, string>): Promise
|
|
|
2921
3186
|
|
|
2922
3187
|
async function build(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
|
|
2923
3188
|
const platform = positionals[0];
|
|
3189
|
+
if (platform && cliOptions.platforms?.[platform]) return void await cliOptions.platforms[platform](cwd, flags, positionals);
|
|
2924
3190
|
if (platform === 'desktop') return buildDesktop(cwd, flags);
|
|
2925
3191
|
if (platform !== 'ios' && platform !== 'android') {
|
|
2926
3192
|
console.error('Usage: appwrap build <ios|android|desktop> [--release] [--aab] [--config <path>] [--out native]');
|
|
@@ -3277,8 +3543,8 @@ function pinTeamIdToConfig(configPath: string, teamId: string): void {
|
|
|
3277
3543
|
* `app/www` (PWA staging) is build OUTPUT and `node_modules`/`platforms`/`hooks` are generated, so all four
|
|
3278
3544
|
* would make the hash unstable across runs. Matched RELATIVE to TEMPLATE_DIR: when installed from npm
|
|
3279
3545
|
* TEMPLATE_DIR itself lives under node_modules/, and testing the absolute path would exclude everything. */
|
|
3280
|
-
const
|
|
3281
|
-
!/(?:^|\/)(node_modules|platforms|hooks|app\/www)(\/|$)/.test(src.slice(
|
|
3546
|
+
const templateFingerprintFilterFor = (root: string) => (src: string): boolean =>
|
|
3547
|
+
!/(?:^|\/)(node_modules|platforms|hooks|app\/www)(\/|$)/.test(src.slice(root.length));
|
|
3282
3548
|
|
|
3283
3549
|
/** Cheap fingerprint of SOURCE build inputs: mtime sum of the PWA dist/ + appwrap config + the appwrap
|
|
3284
3550
|
* `runtime/` template tree + this CLI's version.
|
|
@@ -3317,12 +3583,16 @@ function buildInputStats(cwd: string, cfg: { pwaDist?: string; overrides?: strin
|
|
|
3317
3583
|
const distDir = cfg.pwaDist ? resolve(cwd, cfg.pwaDist) : join(cwd, 'dist');
|
|
3318
3584
|
const overridesDir = resolve(cwd, cfg.overrides ?? 'appwrap.overrides');
|
|
3319
3585
|
const stats = [mtimeStats(distDir), mtimeStats(overridesDir), mtimeStats(resolveConfigPath(cwd, flags))];
|
|
3320
|
-
// The appwrap runtime template — bundled into bundle.js, so
|
|
3586
|
+
// The appwrap runtime template(s) — bundled into bundle.js, so a first-class build input. Every
|
|
3587
|
+
// overlay root counts, so editing a host-provided (e.g. appwrap-ee) template file busts the cache.
|
|
3321
3588
|
let runtime = 0, runtimeNewest = 0;
|
|
3322
|
-
for (const
|
|
3323
|
-
|
|
3589
|
+
for (const root of TEMPLATE_ROOTS) {
|
|
3590
|
+
if (!existsSync(root)) continue;
|
|
3591
|
+
for (const rel of collectRelFiles(root, templateFingerprintFilterFor(root))) {
|
|
3592
|
+
const s = mtimeStats(join(root, rel));
|
|
3324
3593
|
runtime += s.sum;
|
|
3325
3594
|
if (s.newest > runtimeNewest) runtimeNewest = s.newest;
|
|
3595
|
+
}
|
|
3326
3596
|
}
|
|
3327
3597
|
stats.push({ sum: runtime, newest: runtimeNewest });
|
|
3328
3598
|
return { parts: stats.map((s) => s.sum), newest: Math.max(...stats.map((s) => s.newest)) };
|
|
@@ -4240,10 +4510,69 @@ async function publish(cwd: string, flags: Record<string, string>, positionals:
|
|
|
4240
4510
|
console.log(`✓ Uploaded to Play ${track} track.`);
|
|
4241
4511
|
}
|
|
4242
4512
|
|
|
4243
|
-
|
|
4513
|
+
/** Scaffold a new module pack from the built-in template — the community entry point for authoring an
|
|
4514
|
+
* extension. `appwrap create-module <name> [--dir <parent>]` writes <parent>/<name>/ with a manifest +
|
|
4515
|
+
* handler + native-src stub, substituting the module name (and its PascalCase form) into the tokens,
|
|
4516
|
+
* then validates the result so a fresh scaffold is guaranteed pack-conformant. */
|
|
4517
|
+
async function createModule(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
|
|
4518
|
+
const name = positionals[0];
|
|
4519
|
+
if (!name || !/^[a-zA-Z][a-zA-Z0-9]*$/.test(name)) {
|
|
4520
|
+
console.error('Usage: appwrap create-module <name> [--dir <parent>]\n <name> must be a camelCase capability id (letters/digits, leading letter), e.g. `confetti`.');
|
|
4521
|
+
process.exit(1);
|
|
4522
|
+
}
|
|
4523
|
+
if (!existsSync(MODULE_PACK_TEMPLATE_DIR)) {
|
|
4524
|
+
console.error(`✖ module-pack template not found at ${MODULE_PACK_TEMPLATE_DIR}`);
|
|
4525
|
+
process.exit(1);
|
|
4526
|
+
}
|
|
4527
|
+
const pascal = name.replace(/(^|[-_])(\w)/g, (_m, _s, c: string) => c.toUpperCase());
|
|
4528
|
+
const dest = resolve(cwd, flags.dir ?? '.', name);
|
|
4529
|
+
if (existsSync(dest)) {
|
|
4530
|
+
console.error(`✖ ${dest} already exists — choose a different name or --dir.`);
|
|
4531
|
+
process.exit(1);
|
|
4532
|
+
}
|
|
4533
|
+
// Copy the scaffold, then substitute the name tokens in every text file + rename the token'd native-src dir.
|
|
4534
|
+
cpSync(MODULE_PACK_TEMPLATE_DIR, dest, { recursive: true });
|
|
4535
|
+
const rename = (from: string, to: string) => { if (existsSync(from)) renameSync(from, to); };
|
|
4536
|
+
rename(join(dest, 'native-src/__MODULE_NAME__'), join(dest, 'native-src', name));
|
|
4537
|
+
const walk = (dir: string) => {
|
|
4538
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
4539
|
+
const p = join(dir, e.name);
|
|
4540
|
+
if (e.isDirectory()) walk(p);
|
|
4541
|
+
else if (/\.(ts|md|json|swift|kt|xml)$/.test(e.name)) {
|
|
4542
|
+
writeFileSync(p, readFileSync(p, 'utf8').replaceAll('__MODULE_NAME__', name).replaceAll('__MODULE_PASCAL__', pascal));
|
|
4543
|
+
}
|
|
4544
|
+
}
|
|
4545
|
+
};
|
|
4546
|
+
walk(dest);
|
|
4547
|
+
const { validatePack } = await import('./testing');
|
|
4548
|
+
const res = await validatePack(dest);
|
|
4549
|
+
if (!res.ok) {
|
|
4550
|
+
console.error(`✖ scaffolded pack failed validation (this is a bug in the template):\n ${res.errors.join('\n ')}`);
|
|
4551
|
+
process.exit(1);
|
|
4552
|
+
}
|
|
4553
|
+
console.log(`✓ Created module pack '${name}' → ${dest}`);
|
|
4554
|
+
console.log(` Reference it from your appwrap.config.ts:`);
|
|
4555
|
+
console.log(` modulePacks: ['${flags.dir ? join(flags.dir, name) : `./${name}`}'],`);
|
|
4556
|
+
console.log(` modules: ['${name}', ...],`);
|
|
4557
|
+
}
|
|
4558
|
+
|
|
4559
|
+
/**
|
|
4560
|
+
* The CLI entry point — the bare `appwrap` bin calls `runCli()` with no options (identical to the
|
|
4561
|
+
* historical `main`). A host (appwrap-ee) calls `runCli({ commands, platforms, modulePacks,
|
|
4562
|
+
* templateRoots, desktopTemplateDir })` to compose extra capability on top of CE without forking it.
|
|
4563
|
+
*/
|
|
4564
|
+
export async function runCli(options: CliOptions = {}): Promise<void> {
|
|
4565
|
+
cliOptions = { ...options };
|
|
4566
|
+
if (options.desktopTemplateDir) DESKTOP_TEMPLATE_DIR = options.desktopTemplateDir;
|
|
4567
|
+
// Host template overlays land AFTER the built-in runtime template → they win (file-level last-wins).
|
|
4568
|
+
if (options.templateRoots?.length) TEMPLATE_ROOTS = [...TEMPLATE_ROOTS, ...options.templateRoots];
|
|
4569
|
+
|
|
4244
4570
|
const { command, flags, positionals } = parseArgs(process.argv.slice(2));
|
|
4245
4571
|
const cwd = process.cwd();
|
|
4246
4572
|
|
|
4573
|
+
// Host-provided commands are consulted first — they can add a new command or override a built-in.
|
|
4574
|
+
if (command && cliOptions.commands?.[command]) { await cliOptions.commands[command](cwd, flags, positionals); return; }
|
|
4575
|
+
|
|
4247
4576
|
switch (command) {
|
|
4248
4577
|
case 'init':
|
|
4249
4578
|
await init(cwd, flags);
|
|
@@ -4251,6 +4580,9 @@ async function main(): Promise<void> {
|
|
|
4251
4580
|
case 'sync':
|
|
4252
4581
|
await sync(cwd, flags);
|
|
4253
4582
|
break;
|
|
4583
|
+
case 'create-module':
|
|
4584
|
+
await createModule(cwd, flags, positionals);
|
|
4585
|
+
break;
|
|
4254
4586
|
case 'clean':
|
|
4255
4587
|
await clean(cwd, flags);
|
|
4256
4588
|
break;
|
|
@@ -4298,11 +4630,12 @@ async function main(): Promise<void> {
|
|
|
4298
4630
|
' build <ios|android> [--release] [--aab] (store artifact only — no install/upload)\n' +
|
|
4299
4631
|
' logs <ios|android> [--once] [--native] (stream WebView console; --native = full OS log)\n' +
|
|
4300
4632
|
' clean (ns clean: wipe generated platforms/hooks/node_modules, restore deps — fixes stale/corrupt Podfile after a repo move)\n' +
|
|
4633
|
+
' create-module <name> [--dir <parent>] (scaffold a new module pack — a swappable/extensible capability; see modulePacks)\n' +
|
|
4301
4634
|
' aliases: `release ios` = `publish ios`; `submit ios` = `publish ios prod`.');
|
|
4302
4635
|
process.exit(command ? 1 : 0);
|
|
4303
4636
|
}
|
|
4304
4637
|
}
|
|
4305
4638
|
|
|
4306
4639
|
if (import.meta.main) {
|
|
4307
|
-
|
|
4640
|
+
runCli();
|
|
4308
4641
|
}
|