@livx.cc/native-kit 0.28.0 → 0.31.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/native-kit",
3
- "version": "0.28.0",
3
+ "version": "0.31.0",
4
4
  "description": "Isomorphic native-capabilities kit for PWAs \u2014 same API in browser and in an appwrap native shell. Zero dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -2,6 +2,7 @@ import { AppwrapAdapter } from './appwrap-adapter';
2
2
  import { WebAdapter } from './web-adapter';
3
3
  import { Capability, Handshake, InvokeOptions, KIT_PROTOCOL, KitError, NativeKitAdapter, Platform, Unsubscribe } from './types';
4
4
  import { AppModule } from '../modules/app';
5
+ import { BackgroundTaskModule } from '../modules/backgroundTask';
5
6
  import { BillingModule } from '../modules/billing/billing';
6
7
  import { BiometricsModule } from '../modules/biometrics';
7
8
  import { BrowserModule } from '../modules/browser';
@@ -101,6 +102,7 @@ export class NativeKit {
101
102
  public readonly oauth = new OAuthModule(this);
102
103
  public readonly billing = new BillingModule(this);
103
104
  public readonly updates = new UpdatesModule(this);
105
+ public readonly backgroundTask = new BackgroundTaskModule(this);
104
106
 
105
107
  public handshakeInfo: Handshake | null = null;
106
108
  public options: NativeKitOptions;
@@ -133,6 +135,9 @@ export class NativeKit {
133
135
  this.handshakeInfo = handshake;
134
136
  // Zero-config: a native server-loader app begins polling for remote updates.
135
137
  this.updates.__autostart();
138
+ // A background launch carries the wake id in the handshake → dispatch the registered handler
139
+ // (the app's boot register() may have already run, or land moments later — both dispatch).
140
+ this.backgroundTask.__onReady(handshake.backgroundTaskId);
136
141
  return this.handshakeInfo;
137
142
  })();
138
143
  }
package/src/core/types.ts CHANGED
@@ -24,6 +24,11 @@ export interface Handshake {
24
24
  platform: Platform;
25
25
  app: AppInfo;
26
26
  capabilities: Record<string, Capability>;
27
+ /** Set ONLY on a background launch: the OS woke the app (possibly cold, headless, no visible
28
+ * WebView) to run this registered background-task id. The shell populates it; `kit.backgroundTask`
29
+ * reads it from {@link NativeKit.handshakeInfo} and dispatches the registered handler. Absent on a
30
+ * normal foreground launch. See {@link BackgroundTaskModule}. */
31
+ backgroundTaskId?: string;
27
32
  /** Optional diagnostic payload (breadcrumbs, etc.). */
28
33
  debug?: Record<string, unknown>;
29
34
  }
@@ -88,10 +88,15 @@ export class WebAdapter implements NativeKitAdapter {
88
88
  typeof (window as any).webkitSpeechRecognition !== 'undefined'
89
89
  ? 'web'
90
90
  : 'none',
91
- app: 'web', // openUrl via window.open; openSettings unsupported
91
+ app: 'web', // openUrl via window.open; openSettings/canOpenUrl unsupported
92
+ shortcuts: 'none', // no home-screen quick actions for a PWA
93
+ privacyScreen: 'none', // a browser can't hide content in the app-switcher or block screenshots
92
94
  browser: 'web', // new tab/window
93
95
  billing: 'none', // no IAP in a plain browser — wire a web checkout yourself
94
96
  push: 'none', // remote push (APNs/FCM) is native-only — web push (VAPID) is the app's own concern
97
+ // headless background execution is native-only — a PWA's Background/Periodic Sync lives in a
98
+ // service worker the kit doesn't own → honest 'none' (schedule/cancel resolve as no-ops).
99
+ backgroundTask: 'none',
95
100
  };
96
101
  return {
97
102
  protocol: 1,
@@ -395,6 +400,14 @@ export class WebAdapter implements NativeKitAdapter {
395
400
  case 'app.openSettings':
396
401
  throw new KitError('UNSUPPORTED', 'No app settings page on web');
397
402
 
403
+ case 'app.canOpenUrl':
404
+ // A PWA can't probe installed apps — be honest rather than guess from the scheme.
405
+ return false as T;
406
+ case 'app.setShortcuts':
407
+ return undefined as T; // no home-screen quick actions in a browser — no-op (cap reported 'none')
408
+ case 'screen.setPrivacy':
409
+ return undefined as T; // a browser can't hide content in the app-switcher — no-op (cap 'none')
410
+
398
411
  case 'app.environment':
399
412
  // On web there's no install — report the honest 'web' source. is_emulator is native-only.
400
413
  return { source: 'web', isEmulator: false } as T;
@@ -417,9 +430,14 @@ export class WebAdapter implements NativeKitAdapter {
417
430
  case 'push.requestPermission':
418
431
  case 'push.register':
419
432
  case 'push.unregister':
420
- case 'push.sendTest':
421
433
  throw new KitError('UNSUPPORTED', 'No native remote push on web — use Web Push (VAPID) in your app, or run inside the appwrap shell');
422
434
 
435
+ case 'backgroundTask.schedule':
436
+ case 'backgroundTask.cancel':
437
+ // Honest no-op: a browser has no OS-scheduled headless wake the kit owns (that's a service
438
+ // worker's Background/Periodic Sync). Resolve so callers don't have to branch on platform.
439
+ return undefined as T;
440
+
423
441
  default:
424
442
  throw new KitError('UNSUPPORTED', `Unknown method: ${method}`);
425
443
  }
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { NativeKit, NativeKitOptions, kit } from './core/NativeKit';
2
2
  export type { KitContext } from './core/NativeKit';
3
- export type { AppEnvironment, InstallSource } from './modules/app';
3
+ export type { AppEnvironment, AppShortcut, InstallSource } from './modules/app';
4
4
  export { AppwrapAdapter } from './core/appwrap-adapter';
5
5
  export { WebAdapter } from './core/web-adapter';
6
6
  export { KitError } from './core/types';
@@ -45,6 +45,8 @@ export { ClientTrustedValidator, HttpValidator, HttpValidatorOptions } from './m
45
45
  export { HttpBillingProvider, HttpBillingProviderOptions } from './modules/billing/providers';
46
46
  export type { HeaderProvider } from './modules/billing/http';
47
47
  export { HealthModule } from './modules/health';
48
+ export { BackgroundTaskModule } from './modules/backgroundTask';
49
+ export type { BackgroundTaskHandler, ScheduleBackgroundTaskOptions } from './modules/backgroundTask';
48
50
  export type {
49
51
  BillingProvider,
50
52
  BillingValidator,
@@ -1,4 +1,13 @@
1
1
  import type { NativeKit } from '../core/NativeKit';
2
+ import type { Unsubscribe } from '../core/types';
3
+
4
+ /** A home-screen long-press quick action. `id` is echoed back to {@link AppModule.onShortcut} when
5
+ * the user activates it; keep it stable so your router can map it. Custom icons are v1-omitted. */
6
+ export interface AppShortcut {
7
+ id: string;
8
+ title: string;
9
+ subtitle?: string;
10
+ }
2
11
 
3
12
  /** Where this build was installed from. Drives beta-vs-prod analytics cohorts.
4
13
  * - `appstore` / `playstore` — public store install
@@ -57,8 +66,49 @@ export class AppModule {
57
66
  return this.kit.invoke('app.openUrl', { url });
58
67
  }
59
68
 
69
+ /**
70
+ * Probe whether the OS can open `url` — i.e. some installed app (or the OS) handles its scheme.
71
+ * Use it to hide a "Open in <App>" button when the target app isn't installed.
72
+ *
73
+ * Common schemes (http/https/tel/mailto/sms) resolve without any declaration. To probe a CUSTOM
74
+ * scheme (e.g. `whatsapp://`) it MUST be declared up-front, else the OS reports false for privacy:
75
+ * - Custom scheme, BOTH platforms — list the scheme in `appwrap.json.queryUrlSchemes`. It stamps iOS
76
+ * Info.plist `LSApplicationQueriesSchemes` AND Android `<queries>` (a VIEW `<intent>` per scheme),
77
+ * so a scheme probe works identically on iOS and Android with one declaration.
78
+ * - Android explicit package (optional) — to probe a specific package directly, list it in
79
+ * `appwrap.json.queryPackages` (→ AndroidManifest `<queries><package>`). Android-only.
80
+ * Web is always `false` — a PWA can't probe installed apps (honest).
81
+ */
82
+ canOpenUrl(url: string): Promise<boolean> {
83
+ return this.kit.invoke('app.canOpenUrl', { url });
84
+ }
85
+
60
86
  /** Open this app's page in the OS Settings app (to toggle permissions, etc.). */
61
87
  openSettings(): Promise<void> {
62
88
  return this.kit.invoke('app.openSettings');
63
89
  }
90
+
91
+ /** 'native' where the OS exposes home-screen quick actions (iOS 3D-Touch/long-press shortcut items;
92
+ * Android 7.1+ app shortcuts) · else 'none'. Branch on this, not try/catch. */
93
+ get shortcutsCapability() {
94
+ return this.kit.capability('shortcuts');
95
+ }
96
+
97
+ /**
98
+ * Set the app's home-screen long-press quick actions (replaces any previously set). Pass `[]` to
99
+ * clear. iOS assigns `UIApplication.shortcutItems`; Android sets dynamic shortcuts (API 25+, no-op
100
+ * below); web is a no-op. Activation is delivered via {@link onShortcut}. Custom icons are v1-omitted.
101
+ */
102
+ setShortcuts(items: AppShortcut[]): Promise<void> {
103
+ return this.kit.invoke('app.setShortcuts', { items });
104
+ }
105
+
106
+ /**
107
+ * Fire when the user activates a home-screen shortcut, with its `id`. Like deep links, a shortcut
108
+ * that COLD-LAUNCHED the app is buffered natively until the handshake, so a listener registered at
109
+ * startup still receives it.
110
+ */
111
+ onShortcut(cb: (id: string) => void): Unsubscribe {
112
+ return this.kit.on('app.shortcut', (p) => cb((p as { id: string }).id));
113
+ }
64
114
  }
@@ -0,0 +1,122 @@
1
+ import type { NativeKit } from '../core/NativeKit';
2
+
3
+ /**
4
+ * A headless background-task handler. Runs (possibly cold, with no visible WebView) when the OS wakes
5
+ * the app for `ctx.id`. `ctx.signal` aborts when the OS budget is nearly spent (~25s, below the iOS
6
+ * ~30s ceiling) or the OS calls the expiration handler — observe it for any long await and bail
7
+ * promptly, or the OS may kill the app and refuse future wakes. Resolve when done; a rejection is
8
+ * reported to the OS as a failed run (it still reschedules).
9
+ */
10
+ export type BackgroundTaskHandler = (ctx: { id: string; signal: AbortSignal }) => Promise<void>;
11
+
12
+ /** Constraints for {@link BackgroundTaskModule.schedule}. The OS treats these as HINTS — actual wake
13
+ * timing is the OS's call (it batches by power/network/usage), so a wake is opportunistic, not a timer. */
14
+ export interface ScheduleBackgroundTaskOptions {
15
+ /** Task id — must be one of `appwrap.json.backgroundTasks` (iOS requires identifiers declared at
16
+ * build time). The same id is matched against the wake handshake + the registered handler. */
17
+ id: string;
18
+ /** Earliest the OS should consider waking again (a floor, not a guarantee). iOS clamps app-refresh
19
+ * to its own minimum; Android `WorkManager` periodic work has a 15-min platform minimum. */
20
+ minIntervalMs?: number;
21
+ /** Only run when the network is reachable (iOS `BGProcessingTaskRequest.requiresNetworkConnectivity`
22
+ * / Android `NetworkType.CONNECTED`). */
23
+ requiresNetwork?: boolean;
24
+ /** Only run while charging (iOS `BGProcessingTaskRequest.requiresExternalPower` / Android
25
+ * `setRequiresCharging`). */
26
+ requiresCharging?: boolean;
27
+ }
28
+
29
+ /** Wall-clock guard below the iOS ~30s background budget. The kit aborts the signal at this point so a
30
+ * handler that ignores the OS expiration still releases the task before the OS force-kills the app. */
31
+ const SAFETY_TIMEOUT_MS = 25_000;
32
+
33
+ /**
34
+ * Headless background execution with a JS-handler contract — ONE API across platforms.
35
+ *
36
+ * Flow: at EVERY launch the app calls {@link register} (idempotent) for each task id. When the OS woke
37
+ * the app for a task, the handshake carries that id ({@link import('../core/types').Handshake.backgroundTaskId});
38
+ * the kit invokes the matching handler with an {@link AbortSignal}, arms a {@link SAFETY_TIMEOUT_MS}
39
+ * abort, and on resolve/reject/timeout calls `backgroundTask.finish` so the shell completes the OS
40
+ * task + reschedules (BGTask is one-shot — it MUST resubmit). Registration may land before OR after
41
+ * {@link NativeKit.ready} resolves; both orderings dispatch (a pending wake is replayed to a late
42
+ * registration, and an early registration is dispatched the moment ready resolves with a wake id).
43
+ *
44
+ * Native (iOS `BGTaskScheduler` + an offscreen `WKWebView`, Android `WorkManager` + a headless
45
+ * `WebView`) reports `capability === 'native'`. Web is honestly `'none'`: a PWA's background-sync
46
+ * lives in a service worker the kit doesn't own — {@link schedule}/{@link cancel} resolve as no-ops
47
+ * and {@link register} records nothing.
48
+ */
49
+ export class BackgroundTaskModule {
50
+ private handlers = new Map<string, BackgroundTaskHandler>();
51
+ /** A wake id seen before its handler was registered — replayed on the late {@link register}. */
52
+ private pendingWakeId: string | null = null;
53
+ /** Ids already dispatched this session — guards against a double-fire (replay + ready both matching). */
54
+ private dispatched = new Set<string>();
55
+
56
+ constructor(private kit: NativeKit) {}
57
+
58
+ /** Called by {@link NativeKit.ready} once the handshake resolves (never triggers the handshake
59
+ * itself — same lazy contract as the rest of the kit). A background launch carries the wake id in
60
+ * the handshake: if its handler already registered (early) → dispatch now; else remember it for the
61
+ * late {@link register}. @internal */
62
+ __onReady(backgroundTaskId?: string): void {
63
+ if (!backgroundTaskId) return;
64
+ if (this.handlers.has(backgroundTaskId)) this.dispatch(backgroundTaskId);
65
+ else this.pendingWakeId = backgroundTaskId;
66
+ }
67
+
68
+ /** 'native' on a shell · 'none' on web (PWA background-sync is the app's service-worker concern). */
69
+ get capability() {
70
+ return this.kit.capability('backgroundTask');
71
+ }
72
+
73
+ /**
74
+ * Record the handler for `id`. Call at boot on EVERY launch (idempotent — re-registering replaces).
75
+ * If the app was woken for this id (the handshake carried it), the handler dispatches immediately,
76
+ * whether the wake was already known (ready resolved first) or arrives later.
77
+ */
78
+ register(id: string, handler: BackgroundTaskHandler): void {
79
+ this.handlers.set(id, handler);
80
+ if (this.pendingWakeId === id) {
81
+ this.pendingWakeId = null;
82
+ this.dispatch(id);
83
+ }
84
+ }
85
+
86
+ /** Ask the OS to (re)schedule a wake for `id`. No-op resolve on web. */
87
+ schedule(opts: ScheduleBackgroundTaskOptions): Promise<void> {
88
+ return this.kit.invoke<void>('backgroundTask.schedule', opts);
89
+ }
90
+
91
+ /** Cancel a scheduled task. No-op resolve on web. */
92
+ cancel(id: string): Promise<void> {
93
+ return this.kit.invoke<void>('backgroundTask.cancel', { id });
94
+ }
95
+
96
+ /** Run the registered handler under an abort-guarded budget, then report completion to the shell so
97
+ * it finishes the OS task + reschedules. Idempotent per id per session. */
98
+ private async dispatch(id: string): Promise<void> {
99
+ if (this.dispatched.has(id)) return;
100
+ this.dispatched.add(id);
101
+ const handler = this.handlers.get(id);
102
+ if (!handler) return;
103
+
104
+ const controller = new AbortController();
105
+ const timer = setTimeout(() => controller.abort(), SAFETY_TIMEOUT_MS);
106
+ let success = true;
107
+ try {
108
+ await handler({ id, signal: controller.signal });
109
+ } catch (e) {
110
+ success = false;
111
+ console.warn('[native-kit] backgroundTask handler rejected', id, e);
112
+ } finally {
113
+ clearTimeout(timer);
114
+ // Tell the shell to complete the OS task + resubmit the next request. The handler's own work is
115
+ // done; a finish-report failure must not mask it, so swallow (logged) — the OS budget is spent
116
+ // either way.
117
+ await this.kit
118
+ .invoke<void>('backgroundTask.finish', { id, success })
119
+ .catch((e) => console.warn('[native-kit] backgroundTask.finish failed', id, e));
120
+ }
121
+ }
122
+ }
@@ -65,13 +65,6 @@ export class PushModule {
65
65
  return this.kit.invoke('push.unregister');
66
66
  }
67
67
 
68
- /** Dev/demo: ask your backend (via `push.registrationUrl`) to push THIS device on demand. The shell
69
- * POSTs the token natively (no WebView CORS) with `test:true`. Resolves `{ status }` (the backend's
70
- * HTTP code). Requires a configured registrationUrl + a registered token. */
71
- sendTest(): Promise<{ status: number }> {
72
- return this.kit.invoke('push.sendTest', undefined, { timeoutMs: 20_000 });
73
- }
74
-
75
68
  /** Foreground message delivery (app open, no tap). */
76
69
  onMessage(cb: (m: PushMessage) => void): Unsubscribe {
77
70
  return this.kit.on('push.message', (p) => cb(p as PushMessage));
@@ -11,13 +11,29 @@ export type OrientationLock =
11
11
  | 'landscape-right'
12
12
  | 'any';
13
13
 
14
- /** Screen-level controls. Today: orientation; brightness/keepAwake live on `kit.ui`. */
14
+ /** Screen-level controls. Today: orientation + privacy screen; brightness/keepAwake live on `kit.ui`. */
15
15
  export class ScreenModule {
16
16
  readonly orientation: OrientationController;
17
17
 
18
- constructor(kit: NativeKit) {
18
+ constructor(private kit: NativeKit) {
19
19
  this.orientation = new OrientationController(kit);
20
20
  }
21
+
22
+ /** 'native' on a shell · else 'none' — the browser can't hide content in the app-switcher or block
23
+ * screenshots. Branch on this, not try/catch. */
24
+ get privacyCapability() {
25
+ return this.kit.capability('privacyScreen');
26
+ }
27
+
28
+ /**
29
+ * Privacy screen — hide app content in the app-switcher / on backgrounding and block screenshots.
30
+ * `true` enables, `false` disables (the state persists across background/foreground until changed).
31
+ * iOS covers the window with a blur while inactive/backgrounded; Android sets `FLAG_SECURE` (which
32
+ * also blocks screenshots and screen recording — expected). Web is an honest no-op.
33
+ */
34
+ setPrivacy(enabled: boolean): Promise<void> {
35
+ return this.kit.invoke('screen.setPrivacy', { enabled });
36
+ }
21
37
  }
22
38
 
23
39
  /**