@livx.cc/appwrap 0.48.1 → 0.49.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/src/cli.ts CHANGED
@@ -11,8 +11,9 @@
11
11
  import { execFileSync, spawn } from 'child_process';
12
12
  import { createHash } from 'crypto';
13
13
  import { copyFileSync, cpSync, existsSync, mkdirSync, openSync, closeSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync, writeSync } from 'fs';
14
+ import { builtinModules } from 'module';
14
15
  import { networkInterfaces, tmpdir } from 'os';
15
- import { delimiter as pathDelimiter, dirname, join, resolve } from 'path';
16
+ import { delimiter as pathDelimiter, dirname, extname, join, resolve } from 'path';
16
17
  import { pathToFileURL } from 'url';
17
18
  // PURE-DATA capability manifest (no NativeScript globals) — type-only import (erased at runtime);
18
19
  // the VALUES are loaded dynamically below from the resolved runtime so the CLI works both in the
@@ -247,6 +248,127 @@ function generateModuleArtifacts(outDir: string, req: NativeReqs): void {
247
248
  );
248
249
  }
249
250
 
251
+ /** Make `cfg.plugins` LIVE for the MOBILE (NativeScript) shell — the in-process analog of the desktop
252
+ * `regeneratePlugins`. There is no separate host on mobile (one WebView, the NS runtime IS the trusted
253
+ * host), so a plugin's `handlers` register directly on the bridge at boot. For each configured plugin:
254
+ * resolve the entry (path or npm), bun-build it to a single ESM bundle under
255
+ * `native/app/shell/plugins/<id>.js` (definePlugin inlined; @nativescript/core externalized), emit a
256
+ * typed `.d.ts` shim so the barrel type-checks, and generate `app/shell/plugins.generated.ts` that
257
+ * imports + registers each. No plugins → rewrite the barrel to the committed no-op default and drop the
258
+ * plugins dir, so a non-plugin build compiles NO plugin glue (parity with the module barrels).
259
+ *
260
+ * SKELETON scope: only `handlers` are consumed on mobile. `attachTo`/`onWindow`/`WindowCtx` are
261
+ * desktop-only; a plugin declaring them still builds here (those fields are ignored).
262
+ *
263
+ * CROSS-LANE SKIP: a plugin that fails to resolve OR that pulls in a Node.js CORE module
264
+ * (`net`/`fs`/`child_process`/…, unavailable in the NativeScript runtime) is skipped with a warning,
265
+ * so a shared `plugins:[]` list genuinely builds on both lanes. The build alone is NOT enough to
266
+ * decide this: `bun build --target=node` treats node builtins as valid passthrough externals, so a
267
+ * desktop-only plugin importing `net` builds successfully and the incompatible `import "net"` lands
268
+ * in the emitted bundle — which then breaks the NS webpack build. We therefore SCAN the plugin's
269
+ * authored SOURCE for node-builtin imports/requires and skip on a hit (deterministic across import
270
+ * styles). We scan the source (not the emitted bundle) on purpose: a raw-text/bundle scan false-
271
+ * positives on (a) builtin specifiers that appear inside STRING LITERALS in a handler body, and (b)
272
+ * bun's own `import { createRequire } from "node:module"` interop shim, which it injects into the
273
+ * bundle for ANY CJS plugin — even one that only `require()`s the documented external
274
+ * `@nativescript/core`. The source reflects the author's real imports; bun's shims do not.
275
+ * KNOWN LIMITATION: only the ENTRY's own imports are scanned — a builtin pulled in TRANSITIVELY via a
276
+ * dependency slips through and fails the NS webpack build later (loud, not silent). Acceptable at this
277
+ * stage: it degrades in the right direction (a rare, self-announcing build error) vs the silent-drop a
278
+ * bundle scan caused, and such a plugin is desktop-only by construction. Revisit with a resolve-graph
279
+ * walk if transitive desktop deps become common. */
280
+ /** Node.js core module names (bare + `node:` forms) — anything here is unavailable in the NS runtime. */
281
+ const NODE_BUILTIN_SET = new Set(builtinModules.flatMap((m) => [m, `node:${m}`]));
282
+
283
+ /** The distinct Node.js core modules a plugin's SOURCE actually imports/requires. Uses
284
+ * `Bun.Transpiler.scanImports`, which returns only real import/require STATEMENTS (never a specifier
285
+ * that merely appears inside a string literal), so a handler body containing `"x from 'fs'"` is not
286
+ * flagged. Subpaths like `fs/promises` and the `node:` prefix are normalised to the base module
287
+ * before the membership test. `loader` matches the source dialect (ts/tsx/js/jsx). */
288
+ function nodeBuiltinImports(src: string, loader: 'ts' | 'tsx' | 'js' | 'jsx'): string[] {
289
+ const hits = new Set<string>();
290
+ for (const { path } of new Bun.Transpiler({ loader }).scanImports(src)) {
291
+ const base = path.replace(/^node:/, '').split('/')[0];
292
+ if (NODE_BUILTIN_SET.has(path) || NODE_BUILTIN_SET.has(base)) hits.add(path);
293
+ }
294
+ return [...hits];
295
+ }
296
+
297
+ /** Map a plugin entrypoint's extension to the Bun.Transpiler loader for its source dialect. */
298
+ function loaderForEntry(entrypoint: string): 'ts' | 'tsx' | 'js' | 'jsx' {
299
+ const ext = extname(entrypoint).toLowerCase();
300
+ if (ext === '.tsx') return 'tsx';
301
+ if (ext === '.jsx') return 'jsx';
302
+ if (ext === '.ts' || ext === '.mts' || ext === '.cts') return 'ts';
303
+ return 'js';
304
+ }
305
+
306
+ export function regenerateMobilePlugins(cwd: string, cfg: AppwrapConfig, outDir: string): void {
307
+ const shellDir = join(outDir, 'app/shell');
308
+ const pluginsDir = join(shellDir, 'plugins');
309
+ // Start clean: stale bundles from a previous config must not linger in the disposable native/.
310
+ rmSync(pluginsDir, { recursive: true, force: true });
311
+
312
+ const barrelPath = join(shellDir, 'plugins.generated.ts');
313
+ const header = `/** Generated by \`appwrap\` from the appwrap config \`plugins\`. Do not edit. */\n`;
314
+ const writeNoop = () => writeFileSync(barrelPath, `${header}export function registerPlugins(): void {\n}\n`);
315
+
316
+ const entries = cfg.plugins ?? [];
317
+ if (entries.length === 0) { writeNoop(); return; }
318
+
319
+ mkdirSync(pluginsDir, { recursive: true });
320
+ const bun = process.execPath; // the CLI runs under bun → the exact runtime to build with
321
+ const imports: string[] = [];
322
+ const calls: string[] = [];
323
+ let idx = 0;
324
+ for (const raw of entries) {
325
+ const name = typeof raw === 'string' ? raw : raw.name;
326
+ // Resolve: an existing path (relative to the app root) wins; else treat as an npm package name.
327
+ let entrypoint = resolve(cwd, name);
328
+ if (!existsSync(entrypoint)) {
329
+ try {
330
+ entrypoint = (Bun as unknown as { resolveSync(id: string, parent: string): string }).resolveSync(name, cwd);
331
+ } catch {
332
+ console.warn(`⚠ mobile plugin "${name}" not found (no such path, not resolvable as an npm package) — skipping.`);
333
+ continue;
334
+ }
335
+ }
336
+ const bundleId = name.replace(/[^a-zA-Z0-9_-]/g, '_');
337
+ const outfile = join(pluginsDir, `${bundleId}.js`);
338
+ // Cross-lane guard: a desktop-only plugin (importing net/fs/child_process/…) BUILDS fine here — node
339
+ // builtins pass through as valid externals — so scan the plugin's SOURCE for real builtin imports and
340
+ // skip on a hit, BEFORE building (no point bundling a plugin we'll drop).
341
+ const nodeBuiltins = nodeBuiltinImports(readFileSync(entrypoint, 'utf8'), loaderForEntry(entrypoint));
342
+ if (nodeBuiltins.length) {
343
+ console.warn(`⚠ mobile plugin "${name}" imports Node.js core module(s) [${nodeBuiltins.join(', ')}] unavailable in the NativeScript runtime (desktop-only plugin on the mobile lane) — skipping.`);
344
+ continue;
345
+ }
346
+ try {
347
+ // ESM single-file so NS webpack bundles it; @nativescript/core stays external (provided by the shell).
348
+ execFileSync(bun, ['build', entrypoint, '--format=esm', '--target=node', '--external=@nativescript/core', '--outfile', outfile], { stdio: 'pipe' });
349
+ } catch (e: unknown) {
350
+ console.warn(`⚠ mobile plugin bun-build failed (${name}): ${e instanceof Error ? e.message : String(e)} — skipping.`);
351
+ continue;
352
+ }
353
+ // Typed shim so the generated barrel resolves the JS bundle's default export under tsc.
354
+ writeFileSync(
355
+ join(pluginsDir, `${bundleId}.d.ts`),
356
+ `import type { MobilePluginDef } from '../plugin-host';\ndeclare const plugin: MobilePluginDef;\nexport default plugin;\n`
357
+ );
358
+ const ident = `plugin_${idx++}`;
359
+ imports.push(`import ${ident} from './plugins/${bundleId}.js';`);
360
+ calls.push(` registerPluginHandlers(${ident});`);
361
+ console.log(` plugin ← ${name} (mobile: handlers)`);
362
+ }
363
+
364
+ if (imports.length === 0) { writeNoop(); return; }
365
+ writeFileSync(
366
+ barrelPath,
367
+ `${header}import { registerPluginHandlers } from './plugin-host';\n${imports.join('\n')}\n\n` +
368
+ `export function registerPlugins(): void {\n${calls.join('\n')}\n}\n`
369
+ );
370
+ }
371
+
250
372
  /** Stamp the active modules' gradle dependencies into Android app.gradle. Idempotent marker block. */
251
373
  function stampAndroidGradleDeps(outDir: string, deps: string[]): void {
252
374
  const appGradle = join(outDir, 'App_Resources/Android/app.gradle');
@@ -482,6 +604,14 @@ async function loadConfig(cwd: string, flags: Record<string, string>): Promise<A
482
604
  }
483
605
 
484
606
  function stampShellConfig(outDir: string, cfg: AppwrapConfig): void {
607
+ // Resolve the env-switcher block. Absent block OR `enabled:false` → the whole feature is inert
608
+ // (the shell reads `envSwitcher.enabled`). `allowPattern`/`envs` default to empty (default-deny).
609
+ const es = cfg.envSwitcher;
610
+ const envSwitcher = {
611
+ enabled: !!es && es.enabled !== false,
612
+ envs: (es?.envs ?? []).map((e) => ({ label: String(e.label), url: String(e.url) })),
613
+ allowPattern: es?.allowPattern ?? '',
614
+ };
485
615
  const content = `/**
486
616
  * Shell config — stamped by \`appwrap init\`/\`sync\` from the appwrap config. Do not edit.
487
617
  */
@@ -508,6 +638,7 @@ export const SHELL_CONFIG = {
508
638
  pushAndroid: ${JSON.stringify(!!cfg.push?.enabled && cfg.push?.android !== false)},
509
639
  pushRegistrationUrl: ${JSON.stringify(cfg.push?.registrationUrl ?? '')},
510
640
  iosKeyboardExtraLift: ${JSON.stringify(cfg.iosKeyboardExtraLift ?? 82)},
641
+ envSwitcher: ${JSON.stringify(envSwitcher)} as { enabled: boolean; envs: { label: string; url: string }[]; allowPattern: string },
511
642
  };
512
643
  `;
513
644
  writeFileSync(join(outDir, 'app/shell/config.ts'), content);
@@ -1712,6 +1843,7 @@ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: {
1712
1843
  stampAndroidGradleDeps(outDir, req.androidGradleDeps);
1713
1844
  stampKotlin(outDir, req.androidKotlin);
1714
1845
  generateModuleArtifacts(outDir, req);
1846
+ regenerateMobilePlugins(cwd, cfg, outDir); // config-gated TS plugins → bridge handlers (in-process)
1715
1847
  copyModuleNativeSrc(outDir, req); // module-owned native source (e.g. health's Kotlin shim)
1716
1848
  substituteModuleTokens(outDir, req); // stamp __APP_GROUP__ etc. into the copied extension source
1717
1849
  stampLaunchScreen(outDir, cfg);
package/src/config.ts CHANGED
@@ -17,6 +17,29 @@
17
17
  * JSON still supported as a fallback). See `loadConfig` in cli.ts.
18
18
  */
19
19
 
20
+ /** A single named environment shown in the switcher menu. */
21
+ export interface EnvSwitcherEnv {
22
+ /** Human label shown in the menu + banner (e.g. 'Prod', 'Lab', 'PR #123'). */
23
+ label: string;
24
+ /** Absolute origin the WebView loads for this env (e.g. 'https://lab.example.com'). */
25
+ url: string;
26
+ }
27
+
28
+ /** Runtime env-switcher config (see `AppwrapConfig.envSwitcher`). */
29
+ export interface EnvSwitcherConfig {
30
+ /** Kill-switch. Omitted → ON when the block is present. A prod config fork can set `false` to
31
+ * HARD-DISABLE the whole feature (menu action + banner + boot override all inert) even if `envs` /
32
+ * `allowPattern` are declared. Absent `envSwitcher` block entirely → also off (default for any app). */
33
+ enabled?: boolean;
34
+ /** Presets shown in the switch menu; always implicitly trusted (bypass `allowPattern`). */
35
+ envs?: EnvSwitcherEnv[];
36
+ /** Anchored regex (`^…$`) gating the free-form "Other" URL entry. DEFAULT-DENY: absent/invalid →
37
+ * "Other" is disabled (presets only). Compiled with try/catch; a throwing pattern → default-deny. */
38
+ allowPattern?: string;
39
+ /** Deeplink auto-switch (Phase 2 — not yet implemented). Opt-in. */
40
+ deeplink?: boolean;
41
+ }
42
+
20
43
  export interface AppwrapConfig {
21
44
  id: string;
22
45
  name: string;
@@ -168,6 +191,13 @@ export interface AppwrapConfig {
168
191
  loader?: 'app' | 'file' | 'server';
169
192
  /** Live URL loaded when loader === 'server'. Set via config or `appwrap dev --url <url>`. */
170
193
  serverUrl?: string;
194
+ /** Runtime env-switcher (loader:'server' apps). Lets a build re-point the WebView between declared
195
+ * environments (prod / lab / a preview URL) at runtime — via the native dev-menu "Switch Environment"
196
+ * action + a bottom env-indicator banner — surviving a cold start, with NO separate native build.
197
+ * The chosen URL persists in native storage (`kit:serverUrlOverride`) and is honored by the shell's
198
+ * boot loader. Runs in ALL build types when configured (gate = `allowPattern` + `enabled`, NOT debug —
199
+ * distinct from the debug-only dev-server cert trust). Absent block → the whole feature is inert. */
200
+ envSwitcher?: EnvSwitcherConfig;
171
201
  /** Absolute backend origin for an offline (loader:'app') PWA whose API/WebSocket calls were
172
202
  * originally same-origin (e.g. "https://api.example.com"). Injected to the page as
173
203
  * `window.__APPWRAP_BACKEND_ORIGIN__`; a same-origin PWA reads it to make its calls absolute.
@@ -337,7 +367,7 @@ export function defineConfig(config: AppwrapConfig): AppwrapConfig {
337
367
  export const KNOWN_CONFIG_KEYS: ReadonlySet<string> = new Set([
338
368
  'androidAppLinks', 'appBoundDomains', 'backendOrigin', 'backgroundAudio', 'backgroundColor', 'backgroundTasks', 'buildNumber', 'debug',
339
369
  'debugLog', 'desktop', 'devMenu', 'edgeToEdge', 'entry', 'icon', 'id', 'iosKeyboardExtraLift', 'loader', 'modules', 'name',
340
- 'iosEntitlements', 'neutralizeServiceWorker', 'oauthRedirectSchemes', 'openNewWindowsInBrowser', 'orientation', 'overrides', 'permissions',
370
+ 'envSwitcher', 'iosEntitlements', 'neutralizeServiceWorker', 'oauthRedirectSchemes', 'openNewWindowsInBrowser', 'orientation', 'overrides', 'permissions',
341
371
  'plugins', 'push', 'pwaDist', 'queryPackages', 'queryUrlSchemes', 'serverUrl', 'signing', 'signingProfiles', 'statusBarStyle',
342
372
  'splashIcon', 'storekitConfig', 'targetedDevices', 'teamId', 'themeColor', 'trackingDomains', 'urlScheme',
343
373
  'usesNonExemptEncryption', 'vendorPaths', 'version',