@livx.cc/native-kit 0.35.1 → 0.36.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/src/core/NativeKit.ts +69 -7
- package/src/core/types.ts +1 -1
- package/src/core/web-adapter.ts +5 -1
- package/src/index.ts +2 -1
- package/src/modules/app.ts +37 -0
- package/src/modules/media.ts +39 -0
- package/src/modules/notifications.ts +12 -0
- package/src/modules/widget.ts +63 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livx.cc/native-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.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",
|
package/src/core/NativeKit.ts
CHANGED
|
@@ -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
|
|
|
@@ -122,10 +130,26 @@ export class NativeKit {
|
|
|
122
130
|
ready(): Promise<Handshake> {
|
|
123
131
|
if (!this.readyPromise) {
|
|
124
132
|
this.readyPromise = (async () => {
|
|
125
|
-
const
|
|
126
|
-
if (!
|
|
127
|
-
|
|
128
|
-
|
|
133
|
+
const candidates = this.options.adapters.filter((a) => a.detect());
|
|
134
|
+
if (!candidates.length) throw new KitError('NOT_READY', 'No adapter detected this environment');
|
|
135
|
+
// Try each detected adapter in priority order. A transport can LOOK native yet refuse
|
|
136
|
+
// the handshake — e.g. a host shell exposing a capability-GATED `appwrap` message
|
|
137
|
+
// handler to an embedded/mini-app page (CAP_DENIED, or no handshake handler at all).
|
|
138
|
+
// Falling through to the next adapter (web) lets the app degrade to standard web
|
|
139
|
+
// APIs instead of dying with no transport at all.
|
|
140
|
+
let handshake: Handshake | null = null;
|
|
141
|
+
let lastError: unknown = null;
|
|
142
|
+
for (const adapter of candidates) {
|
|
143
|
+
try {
|
|
144
|
+
handshake = await adapter.handshake(this.options.handshakeTimeoutMs);
|
|
145
|
+
this.adapter = adapter;
|
|
146
|
+
break;
|
|
147
|
+
} catch (e) {
|
|
148
|
+
lastError = e;
|
|
149
|
+
console.warn(`[native-kit] ${adapter.kind} adapter handshake failed (${(e as Error)?.message ?? e}) — trying next adapter`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (!handshake) throw lastError ?? new KitError('NOT_READY', 'All adapters failed the handshake');
|
|
129
153
|
// Version-skew safety net: a native shell from an older `appwrap init` may speak a
|
|
130
154
|
// different protocol. Fail loud rather than silently mis-degrade. The web adapter
|
|
131
155
|
// always reports the kit's own protocol, so this only ever fires against a stale shell.
|
|
@@ -137,6 +161,29 @@ export class NativeKit {
|
|
|
137
161
|
);
|
|
138
162
|
}
|
|
139
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
|
+
}
|
|
140
187
|
// Zero-config: a native server-loader app begins polling for remote updates.
|
|
141
188
|
this.updates.__autostart();
|
|
142
189
|
// A background launch carries the wake id in the handshake → dispatch the registered handler
|
|
@@ -213,7 +260,14 @@ export class NativeKit {
|
|
|
213
260
|
|
|
214
261
|
async invoke<T = unknown>(method: string, params?: unknown, opts?: InvokeOptions): Promise<T> {
|
|
215
262
|
if (!this.adapter) await this.ready();
|
|
216
|
-
|
|
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
|
+
}
|
|
217
271
|
}
|
|
218
272
|
|
|
219
273
|
on(event: string, cb: (payload: unknown) => void): Unsubscribe {
|
|
@@ -223,12 +277,20 @@ export class NativeKit {
|
|
|
223
277
|
let cancelled = false;
|
|
224
278
|
this.ready()
|
|
225
279
|
.then(() => {
|
|
226
|
-
if (!cancelled) unsub = this.
|
|
280
|
+
if (!cancelled) unsub = this.subscribe(event, cb);
|
|
227
281
|
})
|
|
228
282
|
.catch(() => {}); // ready() failure already surfaces to the ready() caller
|
|
229
283
|
return () => { cancelled = true; unsub?.(); };
|
|
230
284
|
}
|
|
231
|
-
return this.
|
|
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());
|
|
232
294
|
}
|
|
233
295
|
}
|
|
234
296
|
|
package/src/core/types.ts
CHANGED
package/src/core/web-adapter.ts
CHANGED
|
@@ -316,10 +316,14 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
316
316
|
if (state !== 'granted') throw new KitError('DENIED', 'Motion permission not granted');
|
|
317
317
|
}
|
|
318
318
|
if (!this.motionHandler) {
|
|
319
|
+
// Honor the requested rate (default 10 Hz, clamped 5–60 — mirrors the native cap);
|
|
320
|
+
// a tilt game asks 60 and 10 Hz steering is unplayably laggy.
|
|
321
|
+
const hz = Math.min(60, Math.max(5, Number(p.hz) || 10));
|
|
322
|
+
const minMs = 1000 / hz;
|
|
319
323
|
let last = 0;
|
|
320
324
|
this.motionHandler = (e) => {
|
|
321
325
|
const now = performance.now();
|
|
322
|
-
if (now - last <
|
|
326
|
+
if (now - last < minMs) return;
|
|
323
327
|
last = now;
|
|
324
328
|
const a = e.accelerationIncludingGravity;
|
|
325
329
|
const r = e.rotationRate;
|
package/src/index.ts
CHANGED
|
@@ -29,7 +29,7 @@ export type { ScheduleOptions } from './modules/notifications';
|
|
|
29
29
|
export type { PushMessage, PushPlatform, PushToken } from './modules/push';
|
|
30
30
|
export type { GeoPosition } from './modules/geo';
|
|
31
31
|
export type { PickedPhoto, PickPhotoOptions } from './modules/photos';
|
|
32
|
-
export type { AudioMode, MediaDeviceLite } from './modules/media';
|
|
32
|
+
export type { AudioMode, MediaDeviceLite, AudioState } from './modules/media';
|
|
33
33
|
export type { NetworkStatus } from './modules/network';
|
|
34
34
|
export type { ActionOptions, AlertOptions, ConfirmOptions, SafeAreaInsets } from './modules/ui';
|
|
35
35
|
export type { MotionSample } from './modules/motion';
|
|
@@ -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,
|
package/src/modules/app.ts
CHANGED
|
@@ -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
|
}
|
package/src/modules/media.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { NativeKit } from '../core/NativeKit';
|
|
2
|
+
import type { Unsubscribe } from '../core/types';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Live media bridge — mic / camera / speaker. The streams themselves are plain
|
|
@@ -9,6 +10,14 @@ import type { NativeKit } from '../core/NativeKit';
|
|
|
9
10
|
*/
|
|
10
11
|
export type AudioMode = 'playback' | 'playAndRecord' | 'voiceChat' | 'default';
|
|
11
12
|
|
|
13
|
+
/** Whether the device would actually play game/media sound right now, plus the media volume (0–1). */
|
|
14
|
+
export interface AudioState {
|
|
15
|
+
/** True when output is effectively silenced — iOS mute switch OR zero media volume; Android: zero media volume. */
|
|
16
|
+
silent: boolean;
|
|
17
|
+
/** Media output volume, 0–1 (iOS AVAudioSession.outputVolume / Android STREAM_MUSIC). */
|
|
18
|
+
volume: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
export interface MediaDeviceLite {
|
|
13
22
|
kind: MediaDeviceKind;
|
|
14
23
|
label: string;
|
|
@@ -68,6 +77,36 @@ export class MediaModule {
|
|
|
68
77
|
return this.kit.invoke('media.configureAudio', { mode });
|
|
69
78
|
}
|
|
70
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Watch whether sound would actually be heard — for reflecting the OS mute/volume state in UI
|
|
82
|
+
* (e.g. disabling an in-app speaker toggle while the device is silenced). Fires `cb` with the
|
|
83
|
+
* current {@link AudioState} immediately and again whenever it changes (~1s granularity). Native
|
|
84
|
+
* only — on web (no OS silent concept) this is a no-op returning a noop unsubscribe.
|
|
85
|
+
*/
|
|
86
|
+
async watchAudio(cb: (state: AudioState) => void): Promise<Unsubscribe> {
|
|
87
|
+
// Wait for the handshake so `platform` is resolved (it defaults to 'web' until ready — callers
|
|
88
|
+
// often subscribe during early app setup, before the bridge handshake lands).
|
|
89
|
+
await this.kit.ready().catch(() => {});
|
|
90
|
+
// Gate on PLATFORM, not the 'media' capability: the audio-state handler is always registered in
|
|
91
|
+
// the native shell, so this works even for apps that don't opt into the full media module. Only
|
|
92
|
+
// the browser (no OS silent/volume concept) is a genuine no-op.
|
|
93
|
+
if (this.kit.platform === 'web') return () => {};
|
|
94
|
+
const off = this.kit.on('media.audioState', (p) => cb(p as AudioState));
|
|
95
|
+
try {
|
|
96
|
+
await this.kit.invoke('media.audioWatch.start');
|
|
97
|
+
} catch (e) {
|
|
98
|
+
off();
|
|
99
|
+
throw e;
|
|
100
|
+
}
|
|
101
|
+
let stopped = false;
|
|
102
|
+
return () => {
|
|
103
|
+
if (stopped) return;
|
|
104
|
+
stopped = true;
|
|
105
|
+
off();
|
|
106
|
+
this.kit.invoke('media.audioWatch.stop').catch((e) => console.warn('[native-kit] audioWatch.stop failed', e));
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
71
110
|
/** Stop every track on a stream — convenience to release the camera/mic LED. */
|
|
72
111
|
stop(stream: MediaStream | null | undefined): void {
|
|
73
112
|
stream?.getTracks().forEach((t) => t.stop());
|
|
@@ -12,6 +12,18 @@ 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;
|
|
15
27
|
}
|
|
16
28
|
|
|
17
29
|
export class NotificationsModule {
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A single big number for the `stat` template. */
|
|
13
|
+
export interface WidgetStat {
|
|
14
|
+
value: string;
|
|
15
|
+
label?: string;
|
|
16
|
+
sublabel?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Payload published to the home-screen widget. `template` picks the layout:
|
|
20
|
+
* - `icon-grid` — grid of {@link WidgetEntry} tiles (the app-launcher use)
|
|
21
|
+
* - `list` — rows of {@link WidgetEntry}
|
|
22
|
+
* - `stat` — one {@link WidgetStat} (optional whole-widget `deepLink`) */
|
|
23
|
+
export interface WidgetPayload {
|
|
24
|
+
template: 'icon-grid' | 'list' | 'stat';
|
|
25
|
+
title?: string;
|
|
26
|
+
entries?: WidgetEntry[];
|
|
27
|
+
stat?: WidgetStat;
|
|
28
|
+
/** Whole-widget deep link (stat template, or the empty-state fallback). */
|
|
29
|
+
deepLink?: string;
|
|
30
|
+
/** Which home-screen widget kind this targets. `launcher` (default) = the app-launcher/icon-grid
|
|
31
|
+
* widget; `data` = the separate data widget an in-app view can drive. Two kinds so a data publish
|
|
32
|
+
* never clobbers the launcher (each renders from its own shared-store key). */
|
|
33
|
+
slot?: 'launcher' | 'data';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface WidgetPublishResult {
|
|
37
|
+
published: boolean;
|
|
38
|
+
reason?: 'unsupported';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Publish content to the app's home-screen widget (iOS WidgetKit / Android AppWidget). The shell writes
|
|
43
|
+
* the payload into a shared container the widget reads at render, fetching any `iconUrl` to a local
|
|
44
|
+
* tile image, then reloads the widget. Web/unsupported builds resolve `{published:false}`. Branch on
|
|
45
|
+
* {@link WidgetModule.capability}.
|
|
46
|
+
*/
|
|
47
|
+
export class WidgetModule {
|
|
48
|
+
constructor(private kit: NativeKit) {}
|
|
49
|
+
|
|
50
|
+
/** 'native' where a home-screen widget surface exists (iOS 14+ / Android AppWidget) · else 'none'. */
|
|
51
|
+
get capability() {
|
|
52
|
+
return this.kit.capability('widget');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
publish(payload: WidgetPayload): Promise<WidgetPublishResult> {
|
|
56
|
+
return this.kit.invoke('widget.publish', payload);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Clear the widget (renders its empty state). */
|
|
60
|
+
clear(): Promise<WidgetPublishResult> {
|
|
61
|
+
return this.kit.invoke('widget.publish', { template: 'icon-grid', entries: [] });
|
|
62
|
+
}
|
|
63
|
+
}
|