@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.
Files changed (34) hide show
  1. package/package.json +4 -2
  2. package/runtime/app/main-page.ts +0 -2
  3. package/runtime/app/shell/capabilities.manifest.ts +27 -92
  4. package/runtime/app/shell/config.ts +4 -0
  5. package/runtime/app/shell/env.ts +8 -1
  6. package/runtime/app/shell/handlers.ts +2 -2
  7. package/runtime/tests/env-scheme.test.ts +44 -0
  8. package/src/cli.ts +398 -65
  9. package/src/config.ts +8 -1
  10. package/src/packs.ts +174 -0
  11. package/src/testing.ts +173 -0
  12. package/templates/module-pack/README.md +44 -0
  13. package/templates/module-pack/handler.ts +9 -0
  14. package/templates/module-pack/manifest.ts +27 -0
  15. package/templates/module-pack/native-src/__MODULE_NAME__/.gitkeep +0 -0
  16. package/runtime/app/shell/billing-offer.ts +0 -54
  17. package/runtime/app/shell/handlers-billing.ts +0 -662
  18. package/runtime/app/shell/handlers-health.ts +0 -178
  19. package/runtime/app/shell/handlers-widget.ts +0 -97
  20. package/runtime/modules-native/billing/App_Resources/iOS/src/AppwrapManageSubscriptions.swift +0 -44
  21. package/runtime/modules-native/health/App_Resources/Android/src/main/java/cc/livx/appwrap/HealthConnectBridge.kt +0 -74
  22. package/runtime/modules-native/widget/App_Resources/Android/src/main/java/cc/livx/appwrap/AppwrapWidgetProvider.kt +0 -118
  23. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/drawable/appwrap_badge_bg.xml +0 -9
  24. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/layout/appwrap_widget.xml +0 -123
  25. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_colors.xml +0 -10
  26. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_styles.xml +0 -67
  27. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values-night/appwrap_widget_colors.xml +0 -9
  28. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/xml/appwrap_widget_info.xml +0 -17
  29. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.entitlements +0 -10
  30. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.swift +0 -275
  31. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/Info.plist +0 -25
  32. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/extension.json +0 -9
  33. package/runtime/modules-native/widget/App_Resources/iOS/src/AppwrapWidgetReload.swift +0 -18
  34. package/runtime/tests/billing-offer.test.ts +0 -61
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.54.0",
3
+ "version": "0.55.0",
4
4
  "description": "Wrap any PWA into a native app with native capabilities (appwrap runtime + @livx.cc/native-kit).",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -32,7 +32,9 @@
32
32
  "./config": "./src/config.ts",
33
33
  "./handlers": "./src/handlers.ts",
34
34
  "./plugin": "./src/plugin/index.ts",
35
- "./cli": "./src/cli.ts"
35
+ "./cli": "./src/cli.ts",
36
+ "./testing": "./src/testing.ts",
37
+ "./runtime/*": "./runtime/*.ts"
36
38
  },
37
39
  "files": [
38
40
  "src",
@@ -5,7 +5,6 @@ import { registerHandlers } from './shell/handlers';
5
5
  import { registerExtendedHandlers } from './shell/handlers-extended';
6
6
  import { registerParityHandlers } from './shell/handlers-parity';
7
7
  import { registerSystemHandlers } from './shell/handlers-system';
8
- import { registerBillingHandlers } from './shell/handlers-billing';
9
8
  import { registerMediaHandlers } from './shell/handlers-media';
10
9
  import { registerKeyboardHandlers } from './shell/keyboard';
11
10
  import { registerFsHandlers } from './shell/handlers-fs';
@@ -100,7 +99,6 @@ export function onPageLoaded(args: EventData): void {
100
99
  registerExtendedHandlers();
101
100
  registerParityHandlers();
102
101
  registerSystemHandlers();
103
- registerBillingHandlers();
104
102
  registerMediaHandlers();
105
103
  registerKeyboardHandlers();
106
104
  registerFsHandlers();
@@ -61,8 +61,19 @@ export interface ModuleManifest {
61
61
  * App_Resources layout, copied into `native/` ONLY when the module is active (stays strippable).
62
62
  * Defaults to the module `name` when the convention dir exists. */
63
63
  nativeSrc?: string;
64
+ /** Build-time registration wiring for a module contributed by an OUT-OF-REPO pack (see packs.ts).
65
+ * Built-in modules leave this undefined — their handler barrel wiring comes from cli.ts's
66
+ * OPTIONAL_GROUP_HANDLERS (keyed by `group`). A pack module is self-describing: `file` is the
67
+ * handler module relative to the pack dir, `fn` the exported register function the shell barrel
68
+ * calls at page load. Runtime ignores this field (pure build-time metadata). */
69
+ handler?: { file: string; fn: string };
64
70
  }
65
71
 
72
+ /** Manifest schema version the built-in modules speak. A module pack (packs.ts) declares the version
73
+ * it targets; a mismatch is rejected at resolve time (loud) rather than silently mis-derived. Bump
74
+ * this only on a breaking change to the `ModuleManifest` shape the CLI's derivation depends on. */
75
+ export const MANIFEST_SCHEMA_VERSION = 1;
76
+
66
77
  export const MODULES: ModuleManifest[] = [
67
78
  // ── core (always on, no permissions) ───────────────────────────────────
68
79
  { name: 'haptics', core: true, group: 'core', capabilities: { haptics: 'native' } },
@@ -148,19 +159,6 @@ export const MODULES: ModuleManifest[] = [
148
159
  ] },
149
160
  android: { permissions: ['android.permission.READ_CALENDAR', 'android.permission.WRITE_CALENDAR'] },
150
161
  },
151
- {
152
- name: 'billing', group: 'billing',
153
- capabilities: { billing: { ios: true, android: true } },
154
- // iOS: Swift shim (AppwrapManageSubscriptions.swift) bridging StoreKit 2's Swift-async
155
- // showManageSubscriptions(in:) to an @objc completion the ObjC bridge can call. Compiled into
156
- // the app target only when `billing` is active (NS auto-compiles App_Resources/iOS/src/*.swift).
157
- // Android: Play Billing Library v7 (handlers-billing.ts registerAndroidBilling). The BILLING
158
- // permission is auto-merged by the library's own manifest, so no android.permissions entry —
159
- // only the gradle dep is needed, and it lands ONLY when `billing` is active (strippable weight).
160
- android: { gradleDeps: ['com.android.billingclient:billing:7.1.1'] },
161
- nativeSrc: 'billing',
162
- },
163
-
164
162
  // ── reviews — in-app store review prompt — opt-in, STRIPPABLE (own handler + group) ──
165
163
  // Moved out of the always-bundled parity/system handlers: the Android Play In-App Review path
166
164
  // references `com.google.android.play:review` classes — a gradle dep that must NOT land in builds
@@ -221,53 +219,6 @@ export const MODULES: ModuleManifest[] = [
221
219
  android: { permissions: ['android.permission.RECORD_AUDIO'] },
222
220
  },
223
221
 
224
- // ── health (steps) — opt-in heavy module; FG live + BG via OS step store ──
225
- {
226
- name: 'health', group: 'health',
227
- capabilities: { health: 'native' },
228
- // iOS reads HealthKit (the Health app's aggregated total incl. Apple Watch): health-share usage
229
- // string + the healthkit entitlement, both module-owned. No background execution — HealthKit
230
- // already has the steps the OS recorded while the app was killed.
231
- ios: {
232
- // App Store validation REQUIRES both Share AND Update purpose strings whenever the HealthKit
233
- // entitlement is present — even for a read-only app (else upload fails 409 "Missing purpose
234
- // string … NSHealthUpdateUsageDescription"). Device debug builds don't validate this; only the
235
- // App Store upload does.
236
- permissions: [
237
- { key: 'NSHealthShareUsageDescription', domain: 'health', defaultUsage: 'Read your step count from the Health app.' },
238
- { key: 'NSHealthUpdateUsageDescription', domain: 'health', defaultUsage: 'Read your step count from the Health app.' },
239
- // Live step stream (health.liveSteps) uses CMPedometer (CoreMotion) → iOS terminates the app
240
- // on access without this Motion & Fitness usage string. Required for the real-time count.
241
- { key: 'NSMotionUsageDescription', domain: 'motion', defaultUsage: 'Count your steps live as you walk.' },
242
- ],
243
- entitlements: { 'com.apple.developer.healthkit': true },
244
- },
245
- // Android primary = Health Connect (system store, Wear-inclusive, survives kill — matches iOS).
246
- // Its client is Kotlin-coroutine-only, so the module ships HealthConnectBridge.kt (nativeSrc) and
247
- // flags kotlin:true. Falls back to SensorManager TYPE_STEP_COUNTER (ACTIVITY_RECOGNITION) when HC absent.
248
- android: {
249
- permissions: ['android.permission.health.READ_STEPS', 'android.permission.ACTIVITY_RECOGNITION'],
250
- gradleDeps: [
251
- 'androidx.health.connect:connect-client:1.1.0-rc01',
252
- 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0',
253
- ],
254
- kotlin: true,
255
- // Health Connect refuses to honor permissions unless the app declares a rationale activity
256
- // handling VIEW_PERMISSION_USAGE / HEALTH_PERMISSIONS (alias onto the NS main activity).
257
- manifestApplication: `<activity-alias
258
- android:name="ViewPermissionUsageActivity"
259
- android:exported="true"
260
- android:targetActivity="com.tns.NativeScriptActivity"
261
- android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
262
- <intent-filter>
263
- <action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
264
- <category android:name="android.intent.category.HEALTH_PERMISSIONS" />
265
- </intent-filter>
266
- </activity-alias>`,
267
- },
268
- nativeSrc: 'health',
269
- },
270
-
271
222
  // ── tracking — App Tracking Transparency (iOS) — opt-in, STRIPPABLE (own handler + group) ──
272
223
  // The native-only store-compliance seam for cross-company tracking (IDFA / cross-app identity):
273
224
  // Apple REQUIRES the ATT prompt + NSUserTrackingUsageDescription and forbids tracking before
@@ -321,46 +272,30 @@ export const MODULES: ModuleManifest[] = [
321
272
  android: { gradleDeps: ['androidx.work:work-runtime:2.9.1', 'com.google.guava:guava:33.3.1-android'] },
322
273
  },
323
274
 
324
- // ── widget — home-screen presence via a native extension (iOS WidgetKit / Android AppWidget) ──
325
- // Opt-in; ships its own native surfaces under modules-native/widget/. The app publishes tiles/stats
326
- // into a shared container (App Group on iOS, SharedPreferences on Android) via the `widget.publish`
327
- // handler; the extension/provider reads it at render and each tile deep-links back on tap. Two kinds:
328
- // a `launcher` icon-grid (the host's pinned apps) and a `data` widget (a mini-app's live stat/list).
329
- // The App Group id is derived (`group.<appId>`) and templated into both targets by the CLI.
330
- {
331
- name: 'widget', group: 'widget',
332
- capabilities: { widget: { ios: true, android: true } },
333
- // Android launcher widget = an AppWidgetProvider receiver (icon-grid) fed by the widget.publish
334
- // handler via SharedPreferences; Kotlin source + res ship as nativeSrc. iOS side = the WidgetKit
335
- // extension under nativeSrc's App_Resources/iOS/extensions/.
336
- android: {
337
- kotlin: true,
338
- manifestApplication: `<receiver
339
- android:name="cc.livx.appwrap.AppwrapWidgetProvider"
340
- android:exported="true">
341
- <intent-filter>
342
- <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
343
- </intent-filter>
344
- <meta-data
345
- android:name="android.appwidget.provider"
346
- android:resource="@xml/appwrap_widget_info" />
347
- </receiver>`,
348
- },
349
- nativeSrc: 'widget',
350
- },
275
+ // NOTE: billing, health, and widget were EXTRACTED into appwrap-ee module packs
276
+ // (packages/ee/packs/{billing,health,widget}); a consumer opts in via `modulePacks`.
351
277
  ];
352
278
 
353
279
  /** Opt-in registration groups that own their own NS handler file (strippable when inactive). Core
354
- * groups (core/extended/parity/system/media/billing) are always bundled; only these are CLI-gated. */
355
- export const OPTIONAL_GROUPS = ['health', 'oauth', 'reviews', 'scanner', 'speech', 'tracking', 'appleSignIn', 'backgroundTask', 'widget'] as const;
280
+ * groups (core/extended/parity/system/media) are always bundled; only these are CLI-gated. */
281
+ export const OPTIONAL_GROUPS = ['oauth', 'reviews', 'scanner', 'speech', 'tracking', 'appleSignIn', 'backgroundTask'] as const;
282
+
283
+ /** CLI-gated groups (strippable in explicit mode) that legacy mode (no `modules` key) STILL auto-bundles
284
+ * for back-compat. Empty now that billing (its only member) moved to an appwrap-ee pack — kept as the
285
+ * seam so the carve-out can be reinstated without touching cli.ts if a future built-in needs it. */
286
+ export const LEGACY_BUNDLED_GROUPS = [] as const;
356
287
 
357
- /** Resolve the active capability map for the handshake from a set of active capability names. */
288
+ /** Resolve the active capability map for the handshake from a set of active capability names. Pack
289
+ * modules (from the config's `modulePacks`) aren't in the static MODULES, so their handshake-relevant
290
+ * subset is passed in via `extraModules` (generated into active-modules.generated.ts) and merged — a
291
+ * pack capability is advertised exactly like a built-in one. Empty for a pack-less build. */
358
292
  export function buildCapabilityMap(
359
293
  activeNames: Set<string>,
360
- platform: 'ios' | 'android'
294
+ platform: 'ios' | 'android',
295
+ extraModules: ReadonlyArray<Pick<ModuleManifest, 'name' | 'core' | 'capabilities' | 'group'>> = []
361
296
  ): Record<string, 'native' | 'none'> {
362
297
  const map: Record<string, 'native' | 'none'> = {};
363
- for (const m of MODULES) {
298
+ for (const m of [...MODULES, ...extraModules]) {
364
299
  if (!m.core && !activeNames.has(m.name)) continue;
365
300
  for (const [cap, val] of Object.entries(m.capabilities)) {
366
301
  if (val === 'native') map[cap] = 'native';
@@ -29,6 +29,10 @@ export const SHELL_CONFIG = {
29
29
  /** Absolute backend origin for an offline (app://) PWA whose API/WS calls were originally same-origin.
30
30
  * Injected to the page as `window.__APPWRAP_BACKEND_ORIGIN__`; empty = same-origin (browser default). */
31
31
  backendOrigin: '',
32
+ /** The app's own custom URL scheme (`appwrap.json.urlScheme`), the one registered for deep links.
33
+ * Surfaced to the page as `window.__APPWRAP__.scheme` so a webapp can build its own deep-link URLs;
34
+ * empty = no scheme configured, and the `scheme` key is OMITTED from `__APPWRAP__` (see `detectEnv`). */
35
+ urlScheme: '' as string,
32
36
  /** Debug/dev mode: keep the screen awake (no auto-lock while foreground) + WebView inspectable
33
37
  * (Safari Web Inspector / chrome://inspect). Set true by `appwrap deploy`; never in store builds. */
34
38
  debug: false,
@@ -1,4 +1,5 @@
1
1
  import { Application, isAndroid } from '@nativescript/core';
2
+ import { SHELL_CONFIG } from './config';
2
3
 
3
4
  /**
4
5
  * Runtime environment hints the page can't reliably detect itself, surfaced natively so a PWA can
@@ -10,18 +11,24 @@ import { Application, isAndroid } from '@nativescript/core';
10
11
  * cap DPR / disable heavy backgrounds when set.
11
12
  * • reduceMotion— the OS "reduce motion" accessibility setting is on. Apps should disable non-essential
12
13
  * animation (mirrors the `prefers-reduced-motion` media query, but works pre-paint).
14
+ * • scheme — the app's own custom URL scheme (from `SHELL_CONFIG.urlScheme`), so a webapp inside the
15
+ * shell can build its own deep-link URLs. OMITTED when no urlScheme is configured.
13
16
  */
14
17
  export interface AppwrapEnv {
15
18
  native: boolean;
16
19
  platform: 'android' | 'ios';
17
20
  isEmulator: boolean;
18
21
  reduceMotion: boolean;
22
+ scheme?: string;
19
23
  }
20
24
 
21
25
  export function detectEnv(): AppwrapEnv {
22
- return isAndroid
26
+ const base: AppwrapEnv = isAndroid
23
27
  ? { native: true, platform: 'android', isEmulator: androidIsEmulator(), reduceMotion: androidReduceMotion() }
24
28
  : { native: true, platform: 'ios', isEmulator: iosIsSimulator(), reduceMotion: iosReduceMotion() };
29
+ // Only surface `scheme` when configured — contract requires the key ABSENT for an empty urlScheme.
30
+ if (SHELL_CONFIG.urlScheme) base.scheme = SHELL_CONFIG.urlScheme;
31
+ return base;
25
32
  }
26
33
 
27
34
  /** Document-start snippet: merge the detected hints onto `window.__APPWRAP__` (idempotent). */
@@ -6,7 +6,7 @@ import { showToast } from './toast';
6
6
  import { showBanner, dismissBanner } from './banner';
7
7
  import { setStatusBarStyle } from './status-bar';
8
8
  import { buildCapabilityMap } from './capabilities.manifest';
9
- import { ACTIVE_MODULE_NAMES } from './active-modules.generated';
9
+ import { ACTIVE_MODULE_NAMES, PACK_MODULES } from './active-modules.generated';
10
10
  import { consumePendingBackgroundTaskId } from './background-context';
11
11
 
12
12
  /** Build identifier for the native shell bundle — bump per deploy to spot stale bundles. */
@@ -26,7 +26,7 @@ export function registerHandlers(): void {
26
26
  // Capability map is composed from the active module set (appwrap.json `modules` → the generated
27
27
  // ACTIVE_MODULE_NAMES) + the always-on core, deduped. Opt-out capabilities report 'none' so the
28
28
  // kit degrades gracefully. Push is special — gated by per-platform build config, not the manifest.
29
- const capabilities = buildCapabilityMap(new Set(ACTIVE_MODULE_NAMES), isIOS ? 'ios' : 'android') as Record<string, 'native' | 'none'>;
29
+ const capabilities = buildCapabilityMap(new Set(ACTIVE_MODULE_NAMES), isIOS ? 'ios' : 'android', PACK_MODULES) as Record<string, 'native' | 'none'>;
30
30
  capabilities.push = (isIOS ? SHELL_CONFIG.pushIos : SHELL_CONFIG.pushAndroid) ? 'native' : 'none';
31
31
  // Background launch: the headless runner (handlers-background) set the wake id before loading this
32
32
  // (offscreen) WebView. Report it so `kit.backgroundTask` dispatches the registered handler. Consumed
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `detectEnv()` / `envGlobalsJs()` surface the app's own custom URL scheme to the page as
3
+ * `window.__APPWRAP__.scheme`. Contract: the key is present ONLY when `SHELL_CONFIG.urlScheme` is set,
4
+ * and OMITTED (not `scheme:""`/undefined) when empty — for BOTH android and ios.
5
+ */
6
+ import { afterEach, describe, expect, mock, test } from 'bun:test';
7
+
8
+ // isAndroid is a getter over a mutable flag so both platform branches are exercised. Superset the
9
+ // named exports env.ts reaches (Application is only touched on the android branch's reduce-motion path,
10
+ // which throws-and-falls-back off-device — safe to stub minimally).
11
+ let androidFlag = false;
12
+ mock.module('@nativescript/core', () => ({
13
+ Application: { android: {} },
14
+ get isAndroid() { return androidFlag; },
15
+ isIOS: false,
16
+ WebView: class {},
17
+ }));
18
+
19
+ // Dynamic import AFTER mock.module so the stub is registered before env.ts pulls @nativescript/core
20
+ // (static imports hoist above mock.module and would load the real native module).
21
+ const { SHELL_CONFIG } = await import('../app/shell/config');
22
+ const { detectEnv, envGlobalsJs } = await import('../app/shell/env');
23
+
24
+ afterEach(() => { SHELL_CONFIG.urlScheme = ''; });
25
+
26
+ for (const platform of ['android', 'ios'] as const) {
27
+ describe(`scheme surfacing — ${platform}`, () => {
28
+ test('INCLUDES scheme when urlScheme is set', () => {
29
+ androidFlag = platform === 'android';
30
+ SHELL_CONFIG.urlScheme = 'demoapp';
31
+ expect(detectEnv().scheme).toBe('demoapp');
32
+ expect(envGlobalsJs()).toContain('"scheme":"demoapp"');
33
+ });
34
+
35
+ test('OMITS the scheme key when urlScheme is empty', () => {
36
+ androidFlag = platform === 'android';
37
+ SHELL_CONFIG.urlScheme = '';
38
+ const env = detectEnv();
39
+ expect('scheme' in env).toBe(false);
40
+ // The injected snippet must carry no `scheme` key at all (not scheme:"" / scheme:undefined).
41
+ expect(envGlobalsJs()).not.toContain('scheme');
42
+ });
43
+ });
44
+ }