@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/package.json +1 -1
- package/runtime/app/main-page.ts +12 -3
- package/runtime/app/shell/bridge.ts +6 -0
- package/runtime/app/shell/capabilities.manifest.ts +15 -33
- package/runtime/app/shell/config.ts +5 -0
- package/runtime/app/shell/custom-webview.android.ts +19 -6
- package/runtime/app/shell/custom-webview.ios.ts +106 -0
- package/runtime/app/shell/devmenu.ts +9 -1
- package/runtime/app/shell/env-banner.ts +236 -0
- package/runtime/app/shell/env-switcher.ts +163 -0
- package/runtime/app/shell/handlers-android.ts +38 -0
- package/runtime/app/shell/handlers-background.ts +3 -2
- package/runtime/app/shell/handlers-parity.ts +55 -1
- package/runtime/app/shell/plugin-host.ts +51 -0
- package/runtime/app/shell/plugins.generated.ts +8 -0
- package/runtime/app/shell/server-url.ts +74 -0
- package/runtime-desktop/src-tauri/src/bridge_mac.rs +65 -0
- package/runtime-desktop/src-tauri/src/main.rs +36 -0
- package/src/cli.ts +133 -1
- package/src/config.ts +31 -1
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',
|