@livx.cc/native-kit 0.35.2 → 0.37.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,7 +1,7 @@
1
1
  {
2
2
  "name": "@livx.cc/native-kit",
3
- "version": "0.35.2",
4
- "description": "Isomorphic native-capabilities kit for PWAs \u2014 same API in browser and in an appwrap native shell. Zero dependencies.",
3
+ "version": "0.37.0",
4
+ "description": "Isomorphic native-capabilities kit for PWAs — same API in browser and in an appwrap native shell. Zero dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
7
7
  "homepage": "https://github.com/Livshitz/appwrap#readme",
@@ -34,6 +34,7 @@ import { ToastModule } from '../modules/toast';
34
34
  import { TrackingModule } from '../modules/tracking';
35
35
  import { UiModule } from '../modules/ui';
36
36
  import { UpdatesModule } from '../modules/updates';
37
+ import { WidgetModule } from '../modules/widget';
37
38
 
38
39
  export class NativeKitOptions {
39
40
  /** Priority order; first adapter whose detect() passes wins. */
@@ -107,10 +108,17 @@ export class NativeKit {
107
108
  public readonly backgroundTask = new BackgroundTaskModule(this);
108
109
  public readonly tracking = new TrackingModule(this);
109
110
  public readonly appleSignIn = new AppleSignInModule(this);
111
+ public readonly widget = new WidgetModule(this);
110
112
 
111
113
  public handshakeInfo: Handshake | null = null;
112
114
  public options: NativeKitOptions;
113
115
  private adapter: NativeKitAdapter | null = null;
116
+ /** Web-platform fallback behind a native shell: methods the shell rejects as UNSUPPORTED
117
+ * are retried against the WebAdapter, so a capability the webview itself can satisfy
118
+ * (geolocation, speech, navigator.share, …) works even when the shell ships no handler
119
+ * for it. Capability keys the shell reports as 'none'/absent surface as 'web' when the
120
+ * fallback can fulfil them. */
121
+ private webFallback: NativeKitAdapter | null = null;
114
122
  private readyPromise: Promise<Handshake> | null = null;
115
123
  private contextPromise: Promise<KitContext> | null = null;
116
124
 
@@ -153,6 +161,29 @@ export class NativeKit {
153
161
  );
154
162
  }
155
163
  this.handshakeInfo = handshake;
164
+ // Hybrid degrade: pair a native shell with the web adapter for anything the shell
165
+ // doesn't implement. WebAdapter.handshake() is pure feature detection (no side
166
+ // effects), so probing it here is safe.
167
+ if (this.adapter!.kind !== 'web') {
168
+ const web = this.options.adapters.find((a) => a.kind === 'web');
169
+ if (web?.detect()) {
170
+ try {
171
+ const webHs = await web.handshake(this.options.handshakeTimeoutMs);
172
+ this.webFallback = web;
173
+ for (const [key, value] of Object.entries(webHs.capabilities)) {
174
+ // Only ABSENT keys are upgraded. An explicit 'none' from the shell is a
175
+ // veto — it means "this platform genuinely can't", not "no handler" (e.g.
176
+ // desktop reports motion:'none': DeviceMotionEvent exists in the webview
177
+ // but never fires without an accelerometer, so 'web' would be a lie).
178
+ if (handshake.capabilities[key] === undefined && value !== 'none') {
179
+ handshake.capabilities[key] = 'web';
180
+ }
181
+ }
182
+ } catch (e) {
183
+ console.warn('[native-kit] web fallback probe failed', e);
184
+ }
185
+ }
186
+ }
156
187
  // Zero-config: a native server-loader app begins polling for remote updates.
157
188
  this.updates.__autostart();
158
189
  // A background launch carries the wake id in the handshake → dispatch the registered handler
@@ -229,7 +260,14 @@ export class NativeKit {
229
260
 
230
261
  async invoke<T = unknown>(method: string, params?: unknown, opts?: InvokeOptions): Promise<T> {
231
262
  if (!this.adapter) await this.ready();
232
- return this.adapter!.invoke<T>(method, params, opts);
263
+ try {
264
+ return await this.adapter!.invoke<T>(method, params, opts);
265
+ } catch (e) {
266
+ if (this.webFallback && e instanceof KitError && e.code === 'UNSUPPORTED') {
267
+ return this.webFallback.invoke<T>(method, params, opts);
268
+ }
269
+ throw e;
270
+ }
233
271
  }
234
272
 
235
273
  on(event: string, cb: (payload: unknown) => void): Unsubscribe {
@@ -239,12 +277,20 @@ export class NativeKit {
239
277
  let cancelled = false;
240
278
  this.ready()
241
279
  .then(() => {
242
- if (!cancelled) unsub = this.adapter!.on(event, cb);
280
+ if (!cancelled) unsub = this.subscribe(event, cb);
243
281
  })
244
282
  .catch(() => {}); // ready() failure already surfaces to the ready() caller
245
283
  return () => { cancelled = true; unsub?.(); };
246
284
  }
247
- return this.adapter.on(event, cb);
285
+ return this.subscribe(event, cb);
286
+ }
287
+
288
+ /** Listen on the active adapter AND the web fallback — a watch that fell back to the
289
+ * WebAdapter (geo.position, motion.data) emits its events there, not on the shell. */
290
+ private subscribe(event: string, cb: (payload: unknown) => void): Unsubscribe {
291
+ const unsubs = [this.adapter!.on(event, cb)];
292
+ if (this.webFallback) unsubs.push(this.webFallback.on(event, cb));
293
+ return () => unsubs.forEach((u) => u());
248
294
  }
249
295
  }
250
296
 
package/src/core/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type Platform = 'ios' | 'android' | 'web';
1
+ export type Platform = 'ios' | 'android' | 'desktop' | 'web';
2
2
  export type AdapterKind = 'appwrap' | 'web';
3
3
 
4
4
  /** Bridge protocol version this kit speaks. The native shell reports its own in the handshake;
package/src/index.ts CHANGED
@@ -42,6 +42,7 @@ export type { CalendarEventOptions } from './modules/calendar';
42
42
  export type { BrowserOptions } from './modules/browser';
43
43
  export type { OAuthAuthorizeParams, OAuthResult } from './modules/oauth';
44
44
  export type { TrackingStatus } from './modules/tracking';
45
+ export type { WidgetEntry, WidgetPayload, WidgetStat, WidgetPublishResult } from './modules/widget';
45
46
  export type {
46
47
  AppleSignInName,
47
48
  AppleSignInParams,
@@ -9,6 +9,26 @@ export interface AppShortcut {
9
9
  subtitle?: string;
10
10
  }
11
11
 
12
+ /** A standalone home-screen icon to pin via {@link AppModule.pinShortcut}. Unlike {@link AppShortcut}
13
+ * it carries its own `url` (the deep link the icon opens) and an optional `iconUrl` tile image. */
14
+ export interface PinShortcut {
15
+ /** Stable id — dedups repeat pins of the same target. */
16
+ id: string;
17
+ title: string;
18
+ /** Full deep link the pinned icon opens (e.g. `myapp://a/123`). */
19
+ url: string;
20
+ /** Tile image URL; fetched to a bitmap. Falls back to the app launcher icon on failure/omission. */
21
+ iconUrl?: string;
22
+ subtitle?: string;
23
+ }
24
+
25
+ export interface PinShortcutResult {
26
+ /** True once the OS accepted the pin request (the user still confirms the system prompt). */
27
+ pinned: boolean;
28
+ /** Present when not pinned: `'unsupported'` (iOS/web/old Android/incapable launcher). */
29
+ reason?: 'unsupported';
30
+ }
31
+
12
32
  /** Where this build was installed from. Drives beta-vs-prod analytics cohorts.
13
33
  * - `appstore` / `playstore` — public store install
14
34
  * - `testflight` — iOS beta
@@ -111,4 +131,21 @@ export class AppModule {
111
131
  onShortcut(cb: (id: string) => void): Unsubscribe {
112
132
  return this.kit.on('app.shortcut', (p) => cb((p as { id: string }).id));
113
133
  }
134
+
135
+ /** 'native' where the OS can pin a STANDALONE launcher icon on request (Android 8+); iOS has no such
136
+ * API (`{ios:false}`), so on iOS this reads 'none' — surface a widget there instead. Branch on this. */
137
+ get pinShortcutCapability() {
138
+ return this.kit.capability('pinShortcut');
139
+ }
140
+
141
+ /**
142
+ * Ask the OS to add a standalone home-screen icon for a deep target (distinct from {@link setShortcuts},
143
+ * which is the app-icon long-press menu). Android 8+ shows the system add-to-home prompt; unsupported
144
+ * launchers / iOS / web resolve `{pinned:false}`. `url` is the full deep link the icon opens (e.g.
145
+ * `myapp://a/123`) — routed through the app's normal deep-link handling. `iconUrl` is fetched to a tile
146
+ * (falls back to the launcher icon). Returns whether the pin request was accepted by the system.
147
+ */
148
+ pinShortcut(shortcut: PinShortcut): Promise<PinShortcutResult> {
149
+ return this.kit.invoke('app.pinShortcut', shortcut);
150
+ }
114
151
  }
@@ -12,6 +12,24 @@ export interface ScheduleOptions {
12
12
  * `kit.lifecycle.onDeepLink` routes it with no extra wiring.
13
13
  */
14
14
  deepLink?: string;
15
+ /**
16
+ * Display name of the notification's SENDER (e.g. a mini-app's name). When set,
17
+ * iOS renders a communication-style notification (iOS 15+) and Android posts on a
18
+ * per-sender channel, both showing this name as the "from" identity — the original
19
+ * `title` is demoted to the subtitle. Falls back to a plain notification otherwise.
20
+ */
21
+ sender?: string;
22
+ /**
23
+ * Sender avatar — an image URL or data-URI. Rendered as the circular avatar on
24
+ * iOS (INImage) and the large icon on Android. Ignored if it can't be loaded.
25
+ */
26
+ icon?: string;
27
+ /**
28
+ * App-icon badge count to apply when this notification is DELIVERED (iOS sets the
29
+ * springboard badge on delivery, even while the app is closed). Absent = leave the
30
+ * badge unchanged. Set 0 to clear on delivery.
31
+ */
32
+ badge?: number;
15
33
  }
16
34
 
17
35
  export class NotificationsModule {
@@ -0,0 +1,65 @@
1
+ import type { NativeKit } from '../core/NativeKit';
2
+
3
+ /** One tile/row in an icon-grid or list widget. `deepLink` is the full URL the tile opens (routed by
4
+ * the app's deep-link handling); `iconUrl` is fetched to a tile image on the device. */
5
+ export interface WidgetEntry {
6
+ label: string;
7
+ sublabel?: string;
8
+ deepLink?: string;
9
+ iconUrl?: string;
10
+ /** unread/attention count drawn as a bubble on the tile (icon-grid); absent/0 = no badge. */
11
+ badge?: number;
12
+ }
13
+
14
+ /** A single big number for the `stat` template. */
15
+ export interface WidgetStat {
16
+ value: string;
17
+ label?: string;
18
+ sublabel?: string;
19
+ }
20
+
21
+ /** Payload published to the home-screen widget. `template` picks the layout:
22
+ * - `icon-grid` — grid of {@link WidgetEntry} tiles (the app-launcher use)
23
+ * - `list` — rows of {@link WidgetEntry}
24
+ * - `stat` — one {@link WidgetStat} (optional whole-widget `deepLink`) */
25
+ export interface WidgetPayload {
26
+ template: 'icon-grid' | 'list' | 'stat';
27
+ title?: string;
28
+ entries?: WidgetEntry[];
29
+ stat?: WidgetStat;
30
+ /** Whole-widget deep link (stat template, or the empty-state fallback). */
31
+ deepLink?: string;
32
+ /** Which home-screen widget kind this targets. `launcher` (default) = the app-launcher/icon-grid
33
+ * widget; `data` = the separate data widget an in-app view can drive. Two kinds so a data publish
34
+ * never clobbers the launcher (each renders from its own shared-store key). */
35
+ slot?: 'launcher' | 'data';
36
+ }
37
+
38
+ export interface WidgetPublishResult {
39
+ published: boolean;
40
+ reason?: 'unsupported';
41
+ }
42
+
43
+ /**
44
+ * Publish content to the app's home-screen widget (iOS WidgetKit / Android AppWidget). The shell writes
45
+ * the payload into a shared container the widget reads at render, fetching any `iconUrl` to a local
46
+ * tile image, then reloads the widget. Web/unsupported builds resolve `{published:false}`. Branch on
47
+ * {@link WidgetModule.capability}.
48
+ */
49
+ export class WidgetModule {
50
+ constructor(private kit: NativeKit) {}
51
+
52
+ /** 'native' where a home-screen widget surface exists (iOS 14+ / Android AppWidget) · else 'none'. */
53
+ get capability() {
54
+ return this.kit.capability('widget');
55
+ }
56
+
57
+ publish(payload: WidgetPayload): Promise<WidgetPublishResult> {
58
+ return this.kit.invoke('widget.publish', payload);
59
+ }
60
+
61
+ /** Clear the widget (renders its empty state). */
62
+ clear(): Promise<WidgetPublishResult> {
63
+ return this.kit.invoke('widget.publish', { template: 'icon-grid', entries: [] });
64
+ }
65
+ }