@pocketboss/core 0.0.0-stage → 0.1.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/README.md +40 -2
- package/dist/browser.d.ts +2 -0
- package/dist/browser.js +23 -0
- package/dist/client.d.ts +41 -0
- package/dist/client.js +328 -0
- package/dist/generated/methods.d.ts +96 -0
- package/dist/generated/methods.js +1 -0
- package/dist/generated/schemas.d.ts +616 -0
- package/dist/generated/schemas.js +3 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/media.d.ts +19 -0
- package/dist/media.js +60 -0
- package/dist/mock.d.ts +26 -0
- package/dist/mock.js +60 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.js +10 -0
- package/package.json +20 -4
package/README.md
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @pocketboss/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Framework-neutral PocketBoss bridge client and browser file controls for web mini-apps. The bridge client implements the candidate v1 contract; the native host advertises only methods it supports and the backend has approved.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { PocketBossClient } from '@pocketboss/core';
|
|
7
|
+
|
|
8
|
+
const client = new PocketBossClient();
|
|
9
|
+
if (client.isAvailable) {
|
|
10
|
+
await client.connect();
|
|
11
|
+
const info = await client.getInfo();
|
|
12
|
+
console.log(info.platform);
|
|
13
|
+
}
|
|
14
|
+
client.close();
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Use browser file controls for ordinary user-selected files and photos. These are standard HTML file inputs exposed as helpers; they do not call `file.pick` or `camera.capture` on the PocketBoss bridge.
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { captureDevicePhoto, pickDeviceFiles } from '@pocketboss/core';
|
|
21
|
+
|
|
22
|
+
// Call from a click/tap handler so the browser can present its native chooser.
|
|
23
|
+
const files = await pickDeviceFiles({ accept: 'image/*', multiple: true });
|
|
24
|
+
const photo = await captureDevicePhoto({ facingMode: 'environment' });
|
|
25
|
+
|
|
26
|
+
if (photo) {
|
|
27
|
+
const form = new FormData();
|
|
28
|
+
form.append('photo', photo, photo.name);
|
|
29
|
+
await fetch('/api/photos', { method: 'POST', body: form });
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`pickDeviceFiles()` returns browser `File` objects and resolves to `[]` on cancellation. `captureDevicePhoto()` returns the first selected image or `null`; `capture` is a best-effort hint and the OS may show a chooser instead of opening a camera. React, Vue, Angular, and Ionic mini-apps can import these helpers from `@pocketboss/core`.
|
|
34
|
+
|
|
35
|
+
The bridge methods `client.pickFiles()` and `client.capturePhoto()` remain separate and gated. They return short-lived opaque handles and are unavailable until the runtime has a supported consumer for those handles.
|
|
36
|
+
|
|
37
|
+
`isAvailable` is `false` in a normal browser and in explicit mock mode. `connect()` rejects with `BridgeError('UNAVAILABLE')` when no host exists. The SDK never creates a native result by itself. `@pocketboss/core/mock` supplies an explicit development host whose handlers are provided by the developer.
|
|
38
|
+
|
|
39
|
+
The client validates v1 messages and method payloads against generated copies of `packages/bridge-contract/schemas`, enforces message and pending-request limits, correlates random UUID requests, checks launch binding and event sequence, and cancels work on abort, timeout, pause and teardown. Native still owns sender verification, permission checks, and launch-ticket exchange.
|
|
40
|
+
|
|
41
|
+
Run `npm run build:core` and `npm test -w @pocketboss/core` from the repository root. Regenerate contract types before every build.
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function browserTransport() {
|
|
2
|
+
if (typeof window === 'undefined')
|
|
3
|
+
return null;
|
|
4
|
+
const bridge = window.PocketBoss;
|
|
5
|
+
if (!bridge || typeof bridge.postMessage !== 'function')
|
|
6
|
+
return null;
|
|
7
|
+
return {
|
|
8
|
+
kind: 'native',
|
|
9
|
+
postMessage(message) { bridge.postMessage(message); },
|
|
10
|
+
subscribe(listener) {
|
|
11
|
+
const handler = (event) => listener(event.data);
|
|
12
|
+
if (bridge.addEventListener && bridge.removeEventListener) {
|
|
13
|
+
bridge.addEventListener('message', handler);
|
|
14
|
+
return () => bridge.removeEventListener?.('message', handler);
|
|
15
|
+
}
|
|
16
|
+
const previous = bridge.onmessage;
|
|
17
|
+
const chained = (event) => { previous?.(event); handler(event); };
|
|
18
|
+
bridge.onmessage = chained;
|
|
19
|
+
return () => { if (bridge.onmessage === chained)
|
|
20
|
+
bridge.onmessage = previous; };
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { BridgeEvent, BridgeMethod, BridgeMethodMap, BridgeSnapshot, BridgeTransport, MethodRequest, MethodResponse, ReadyMessage, RequestOptions } from './types.js';
|
|
2
|
+
export declare class PocketBossClient {
|
|
3
|
+
private readonly transport;
|
|
4
|
+
private unsubscribeTransport;
|
|
5
|
+
private readonly stateListeners;
|
|
6
|
+
private readonly eventListeners;
|
|
7
|
+
private readonly pending;
|
|
8
|
+
private handshake;
|
|
9
|
+
private expiryTimer;
|
|
10
|
+
private lastSequence;
|
|
11
|
+
private readyMessage;
|
|
12
|
+
private state;
|
|
13
|
+
private currentSnapshot;
|
|
14
|
+
constructor(transport?: BridgeTransport | null);
|
|
15
|
+
get isAvailable(): boolean;
|
|
16
|
+
get isMock(): boolean;
|
|
17
|
+
get snapshot(): BridgeSnapshot;
|
|
18
|
+
getSnapshot: () => BridgeSnapshot;
|
|
19
|
+
subscribeState: (listener: () => void) => (() => void);
|
|
20
|
+
on(event: string, listener: (event: BridgeEvent) => void): () => void;
|
|
21
|
+
connect(options?: RequestOptions): Promise<ReadyMessage>;
|
|
22
|
+
request<M extends BridgeMethod>(method: M, payload: MethodRequest<M>, options?: RequestOptions): Promise<MethodResponse<M>>;
|
|
23
|
+
getInfo(options?: RequestOptions): Promise<BridgeMethodMap['runtime.getInfo']['response']>;
|
|
24
|
+
openExternal(url: string, options?: RequestOptions): Promise<BridgeMethodMap['navigation.openExternal']['response']>;
|
|
25
|
+
closeMiniApp(options?: RequestOptions): Promise<BridgeMethodMap['navigation.close']['response']>;
|
|
26
|
+
share(payload: BridgeMethodMap['share.open']['request'], options?: RequestOptions): Promise<BridgeMethodMap['share.open']['response']>;
|
|
27
|
+
pickFiles(payload?: BridgeMethodMap['file.pick']['request'], options?: RequestOptions): Promise<BridgeMethodMap['file.pick']['response']>;
|
|
28
|
+
capturePhoto(options?: RequestOptions): Promise<BridgeMethodMap['camera.capture']['response']>;
|
|
29
|
+
getAssertion(options?: RequestOptions): Promise<BridgeMethodMap['identity.getAssertion']['response']>;
|
|
30
|
+
getCurrentLocation(accuracy: 'approximate' | 'precise', options?: RequestOptions): Promise<BridgeMethodMap['location.getCurrent']['response']>;
|
|
31
|
+
subscribeNotifications(options?: RequestOptions): Promise<BridgeMethodMap['notifications.subscribe']['response']>;
|
|
32
|
+
sendNotification(payload: BridgeMethodMap['notifications.send']['request'], options?: RequestOptions): Promise<BridgeMethodMap['notifications.send']['response']>;
|
|
33
|
+
close(): void;
|
|
34
|
+
private makeSnapshot;
|
|
35
|
+
private setState;
|
|
36
|
+
private send;
|
|
37
|
+
private receive;
|
|
38
|
+
private finishHandshake;
|
|
39
|
+
private finishPending;
|
|
40
|
+
private expire;
|
|
41
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import Ajv2020Module from 'ajv/dist/2020.js';
|
|
2
|
+
import addFormatsModule from 'ajv-formats';
|
|
3
|
+
import { browserTransport } from './browser.js';
|
|
4
|
+
import { bridgeSchema, methodSchemas } from './generated/schemas.js';
|
|
5
|
+
import { BridgeError } from './types.js';
|
|
6
|
+
const SDK_VERSION = '0.1.0';
|
|
7
|
+
const MAX_MESSAGE_BYTES = 65_536;
|
|
8
|
+
const ZERO_UUID = '00000000-0000-4000-8000-000000000000';
|
|
9
|
+
const ajv = new Ajv2020Module.default({ strict: true, strictRequired: false });
|
|
10
|
+
addFormatsModule.default(ajv);
|
|
11
|
+
const validMessage = ajv.compile(bridgeSchema);
|
|
12
|
+
const methodValidators = Object.fromEntries(Object.entries(methodSchemas).map(([name, schemas]) => [name, {
|
|
13
|
+
request: ajv.compile(schemas.request), response: ajv.compile(schemas.result),
|
|
14
|
+
}]));
|
|
15
|
+
function isRecord(value) {
|
|
16
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
function randomNonce() {
|
|
19
|
+
const bytes = new Uint8Array(24);
|
|
20
|
+
if (!globalThis.crypto?.getRandomValues)
|
|
21
|
+
throw new BridgeError('UNAVAILABLE', 'Secure randomness is unavailable.');
|
|
22
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
23
|
+
let value = '';
|
|
24
|
+
for (const byte of bytes)
|
|
25
|
+
value += String.fromCharCode(byte);
|
|
26
|
+
return btoa(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
27
|
+
}
|
|
28
|
+
function randomId() {
|
|
29
|
+
if (!globalThis.crypto?.randomUUID)
|
|
30
|
+
throw new BridgeError('UNAVAILABLE', 'Secure randomness is unavailable.');
|
|
31
|
+
return globalThis.crypto.randomUUID();
|
|
32
|
+
}
|
|
33
|
+
function decode(raw) {
|
|
34
|
+
try {
|
|
35
|
+
if (typeof raw === 'string') {
|
|
36
|
+
if (new TextEncoder().encode(raw).length > MAX_MESSAGE_BYTES)
|
|
37
|
+
return null;
|
|
38
|
+
const parsed = JSON.parse(raw);
|
|
39
|
+
return isRecord(parsed) ? parsed : null;
|
|
40
|
+
}
|
|
41
|
+
if (isRecord(raw) && new TextEncoder().encode(JSON.stringify(raw)).length <= MAX_MESSAGE_BYTES)
|
|
42
|
+
return raw;
|
|
43
|
+
}
|
|
44
|
+
catch { /* Ignore untrusted messages. */ }
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
export class PocketBossClient {
|
|
48
|
+
transport;
|
|
49
|
+
unsubscribeTransport = null;
|
|
50
|
+
stateListeners = new Set();
|
|
51
|
+
eventListeners = new Map();
|
|
52
|
+
pending = new Map();
|
|
53
|
+
handshake = null;
|
|
54
|
+
expiryTimer = null;
|
|
55
|
+
lastSequence = -1;
|
|
56
|
+
readyMessage = null;
|
|
57
|
+
state;
|
|
58
|
+
currentSnapshot;
|
|
59
|
+
constructor(transport = browserTransport()) {
|
|
60
|
+
this.transport = transport;
|
|
61
|
+
this.state = transport ? 'idle' : 'unavailable';
|
|
62
|
+
this.currentSnapshot = this.makeSnapshot();
|
|
63
|
+
if (transport)
|
|
64
|
+
this.unsubscribeTransport = transport.subscribe((data) => this.receive(data));
|
|
65
|
+
}
|
|
66
|
+
get isAvailable() { return this.transport?.kind === 'native'; }
|
|
67
|
+
get isMock() { return this.transport?.kind === 'mock'; }
|
|
68
|
+
get snapshot() { return this.currentSnapshot; }
|
|
69
|
+
getSnapshot = () => this.currentSnapshot;
|
|
70
|
+
subscribeState = (listener) => {
|
|
71
|
+
this.stateListeners.add(listener);
|
|
72
|
+
return () => { this.stateListeners.delete(listener); };
|
|
73
|
+
};
|
|
74
|
+
on(event, listener) {
|
|
75
|
+
const group = this.eventListeners.get(event) ?? new Set();
|
|
76
|
+
group.add(listener);
|
|
77
|
+
this.eventListeners.set(event, group);
|
|
78
|
+
return () => {
|
|
79
|
+
group.delete(listener);
|
|
80
|
+
if (group.size === 0)
|
|
81
|
+
this.eventListeners.delete(event);
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
async connect(options = {}) {
|
|
85
|
+
if (this.state === 'ready' && this.readyMessage)
|
|
86
|
+
return this.readyMessage;
|
|
87
|
+
if (this.handshake)
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const unsubscribe = this.subscribeState(() => {
|
|
90
|
+
if (this.state === 'ready' && this.readyMessage) {
|
|
91
|
+
unsubscribe();
|
|
92
|
+
resolve(this.readyMessage);
|
|
93
|
+
}
|
|
94
|
+
else if (this.state !== 'connecting') {
|
|
95
|
+
unsubscribe();
|
|
96
|
+
reject(new BridgeError(this.state === 'expired' ? 'SESSION_EXPIRED' : 'UNAVAILABLE', 'The bridge is unavailable.'));
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
if (!this.transport || this.state === 'unavailable' || this.state === 'closed')
|
|
101
|
+
throw new BridgeError('UNAVAILABLE', 'PocketBoss is unavailable outside the app.');
|
|
102
|
+
if (this.state === 'expired')
|
|
103
|
+
throw new BridgeError('SESSION_EXPIRED', 'The launch session expired.');
|
|
104
|
+
if (options.signal?.aborted)
|
|
105
|
+
throw new BridgeError('CANCELLED', 'The handshake was cancelled.');
|
|
106
|
+
this.setState('connecting');
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const timeoutMs = Math.min(Math.max(options.timeoutMs ?? 15_000, 1_000), 60_000);
|
|
109
|
+
const fail = (error) => { this.finishHandshake(error); };
|
|
110
|
+
const onAbort = () => fail(new BridgeError('CANCELLED', 'The handshake was cancelled.'));
|
|
111
|
+
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
112
|
+
this.handshake = {
|
|
113
|
+
resolve, reject,
|
|
114
|
+
timer: setTimeout(() => fail(new BridgeError('TIMEOUT', 'The bridge handshake timed out.')), timeoutMs),
|
|
115
|
+
cleanup: () => options.signal?.removeEventListener('abort', onAbort),
|
|
116
|
+
};
|
|
117
|
+
try {
|
|
118
|
+
this.send({ protocolVersion: 1, type: 'hello', sdkVersion: SDK_VERSION, supportedProtocolVersions: [1], clientNonce: randomNonce(), sentAtMs: Date.now() });
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
fail(error instanceof BridgeError ? error : new BridgeError('UNAVAILABLE', 'The bridge could not send the handshake.'));
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
request(method, payload, options = {}) {
|
|
126
|
+
if (this.state !== 'ready' || !this.readyMessage)
|
|
127
|
+
return Promise.reject(new BridgeError(this.state === 'expired' ? 'SESSION_EXPIRED' : 'UNAVAILABLE', 'The bridge is not ready.'));
|
|
128
|
+
if (Date.now() >= this.readyMessage.sessionExpiresAtMs) {
|
|
129
|
+
this.expire();
|
|
130
|
+
return Promise.reject(new BridgeError('SESSION_EXPIRED', 'The launch session expired.'));
|
|
131
|
+
}
|
|
132
|
+
const validator = methodValidators[method];
|
|
133
|
+
if (!validator || !validator.request(payload))
|
|
134
|
+
return Promise.reject(new BridgeError('INVALID_ARGUMENT', 'The request payload is invalid.'));
|
|
135
|
+
if (!this.readyMessage.capabilities.some((capability) => capability.name === method && capability.version === 1))
|
|
136
|
+
return Promise.reject(new BridgeError('NOT_APPROVED', 'This capability is unavailable.'));
|
|
137
|
+
if (this.pending.size >= this.readyMessage.limits.maxPendingRequests)
|
|
138
|
+
return Promise.reject(new BridgeError('RATE_LIMITED', 'Too many bridge requests are pending.'));
|
|
139
|
+
if (options.signal?.aborted)
|
|
140
|
+
return Promise.reject(new BridgeError('CANCELLED', 'The request was cancelled.'));
|
|
141
|
+
const requestId = randomId();
|
|
142
|
+
const request = { protocolVersion: 1, type: 'request', launchId: this.readyMessage.launchId, appId: this.readyMessage.appId, requestId, method, payload, nonce: randomNonce(), sentAtMs: Date.now() };
|
|
143
|
+
const timeoutMs = Math.min(Math.max(options.timeoutMs ?? this.readyMessage.limits.defaultTimeoutMs, 1_000), 60_000);
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
const onAbort = () => this.finishPending(requestId, new BridgeError('CANCELLED', 'The request was cancelled.'), true);
|
|
146
|
+
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
147
|
+
this.pending.set(requestId, {
|
|
148
|
+
method,
|
|
149
|
+
resolve: (value) => resolve(value), reject,
|
|
150
|
+
timer: setTimeout(() => this.finishPending(requestId, new BridgeError('TIMEOUT', 'The bridge request timed out.'), true), timeoutMs),
|
|
151
|
+
cleanup: () => options.signal?.removeEventListener('abort', onAbort),
|
|
152
|
+
});
|
|
153
|
+
try {
|
|
154
|
+
this.send(request, this.readyMessage.limits.maxMessageBytes);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
this.finishPending(requestId, error instanceof BridgeError ? error : new BridgeError('UNAVAILABLE', 'The bridge could not send the request.'));
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
getInfo(options) {
|
|
162
|
+
return this.request('runtime.getInfo', {}, options);
|
|
163
|
+
}
|
|
164
|
+
openExternal(url, options) {
|
|
165
|
+
return this.request('navigation.openExternal', { url }, options);
|
|
166
|
+
}
|
|
167
|
+
closeMiniApp(options) {
|
|
168
|
+
return this.request('navigation.close', {}, options);
|
|
169
|
+
}
|
|
170
|
+
share(payload, options) {
|
|
171
|
+
return this.request('share.open', payload, options);
|
|
172
|
+
}
|
|
173
|
+
pickFiles(payload = {}, options) {
|
|
174
|
+
return this.request('file.pick', payload, options);
|
|
175
|
+
}
|
|
176
|
+
capturePhoto(options) {
|
|
177
|
+
return this.request('camera.capture', {}, options);
|
|
178
|
+
}
|
|
179
|
+
getAssertion(options) {
|
|
180
|
+
return this.request('identity.getAssertion', {}, options);
|
|
181
|
+
}
|
|
182
|
+
getCurrentLocation(accuracy, options) {
|
|
183
|
+
return this.request('location.getCurrent', { accuracy }, options);
|
|
184
|
+
}
|
|
185
|
+
subscribeNotifications(options) {
|
|
186
|
+
return this.request('notifications.subscribe', {}, options);
|
|
187
|
+
}
|
|
188
|
+
sendNotification(payload, options) {
|
|
189
|
+
return this.request('notifications.send', payload, options);
|
|
190
|
+
}
|
|
191
|
+
close() {
|
|
192
|
+
if (this.state === 'closed')
|
|
193
|
+
return;
|
|
194
|
+
this.finishHandshake(new BridgeError('CANCELLED', 'The bridge was closed.'));
|
|
195
|
+
for (const id of [...this.pending.keys()])
|
|
196
|
+
this.finishPending(id, new BridgeError('CANCELLED', 'The bridge was closed.'), true);
|
|
197
|
+
if (this.expiryTimer)
|
|
198
|
+
clearTimeout(this.expiryTimer);
|
|
199
|
+
this.unsubscribeTransport?.();
|
|
200
|
+
this.unsubscribeTransport = null;
|
|
201
|
+
this.eventListeners.clear();
|
|
202
|
+
this.setState('closed');
|
|
203
|
+
this.stateListeners.clear();
|
|
204
|
+
}
|
|
205
|
+
makeSnapshot() {
|
|
206
|
+
return { state: this.state, isAvailable: this.isAvailable, isMock: this.isMock, capabilities: this.readyMessage?.capabilities ?? [], ready: this.readyMessage };
|
|
207
|
+
}
|
|
208
|
+
setState(state) {
|
|
209
|
+
this.state = state;
|
|
210
|
+
this.currentSnapshot = this.makeSnapshot();
|
|
211
|
+
for (const listener of this.stateListeners)
|
|
212
|
+
listener();
|
|
213
|
+
}
|
|
214
|
+
send(message, limit = MAX_MESSAGE_BYTES) {
|
|
215
|
+
if (!this.transport)
|
|
216
|
+
throw new BridgeError('UNAVAILABLE', 'PocketBoss is unavailable.');
|
|
217
|
+
const encoded = JSON.stringify(message);
|
|
218
|
+
if (new TextEncoder().encode(encoded).length > Math.min(limit, MAX_MESSAGE_BYTES))
|
|
219
|
+
throw new BridgeError('INVALID_ARGUMENT', 'The bridge message is too large.');
|
|
220
|
+
if (!validMessage(message))
|
|
221
|
+
throw new BridgeError('INVALID_ARGUMENT', 'The bridge message is invalid.');
|
|
222
|
+
this.transport.postMessage(encoded);
|
|
223
|
+
}
|
|
224
|
+
receive(raw) {
|
|
225
|
+
const message = decode(raw);
|
|
226
|
+
if (!message)
|
|
227
|
+
return;
|
|
228
|
+
if (message.type === 'ready' && (message.protocolVersion !== 1 || message.selectedProtocolVersion !== 1)) {
|
|
229
|
+
this.finishHandshake(new BridgeError('UNSUPPORTED_PLATFORM', 'The bridge protocol version is unsupported.'));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (!validMessage(message))
|
|
233
|
+
return;
|
|
234
|
+
if (message.type === 'ready') {
|
|
235
|
+
if (!this.handshake || this.state !== 'connecting')
|
|
236
|
+
return;
|
|
237
|
+
const ready = message;
|
|
238
|
+
if (ready.sessionExpiresAtMs <= Date.now()) {
|
|
239
|
+
this.finishHandshake(new BridgeError('SESSION_EXPIRED', 'The launch session expired.'));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
this.readyMessage = ready;
|
|
243
|
+
this.lastSequence = -1;
|
|
244
|
+
this.finishHandshake(null, ready);
|
|
245
|
+
this.expiryTimer = setTimeout(() => this.expire(), Math.min(ready.sessionExpiresAtMs - Date.now(), 2_147_483_647));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (message.type === 'response') {
|
|
249
|
+
if (message.requestId === ZERO_UUID && this.handshake && message.status === 'error') {
|
|
250
|
+
const error = message.error;
|
|
251
|
+
this.finishHandshake(new BridgeError(error.code, error.message, error.details));
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (message.launchId !== this.readyMessage?.launchId || typeof message.requestId !== 'string')
|
|
255
|
+
return;
|
|
256
|
+
const pending = this.pending.get(message.requestId);
|
|
257
|
+
if (!pending)
|
|
258
|
+
return;
|
|
259
|
+
if (message.status === 'error') {
|
|
260
|
+
const error = message.error;
|
|
261
|
+
this.finishPending(message.requestId, new BridgeError(error.code, error.message, error.details));
|
|
262
|
+
if (error.code === 'SESSION_EXPIRED')
|
|
263
|
+
this.expire();
|
|
264
|
+
}
|
|
265
|
+
else if (methodValidators[pending.method]?.response(message.result)) {
|
|
266
|
+
this.finishPending(message.requestId, null, false, message.result);
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
this.finishPending(message.requestId, new BridgeError('INVALID_ARGUMENT', 'The bridge response is invalid.'));
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (message.type === 'event') {
|
|
274
|
+
const event = message;
|
|
275
|
+
if (event.launchId !== this.readyMessage?.launchId || event.appId !== this.readyMessage?.appId || event.sequence <= this.lastSequence)
|
|
276
|
+
return;
|
|
277
|
+
this.lastSequence = event.sequence;
|
|
278
|
+
if (event.event === 'lifecycle.pause')
|
|
279
|
+
for (const id of [...this.pending.keys()])
|
|
280
|
+
this.finishPending(id, new BridgeError('CANCELLED', 'The app was paused.'), true);
|
|
281
|
+
if (event.event === 'lifecycle.expired')
|
|
282
|
+
this.expire();
|
|
283
|
+
for (const listener of this.eventListeners.get(event.event) ?? [])
|
|
284
|
+
listener(event);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
finishHandshake(error, ready) {
|
|
288
|
+
const handshake = this.handshake;
|
|
289
|
+
if (!handshake)
|
|
290
|
+
return;
|
|
291
|
+
this.handshake = null;
|
|
292
|
+
clearTimeout(handshake.timer);
|
|
293
|
+
handshake.cleanup();
|
|
294
|
+
if (error) {
|
|
295
|
+
this.setState(error.code === 'SESSION_EXPIRED' ? 'expired' : 'idle');
|
|
296
|
+
handshake.reject(error);
|
|
297
|
+
}
|
|
298
|
+
else if (ready) {
|
|
299
|
+
this.setState('ready');
|
|
300
|
+
handshake.resolve(ready);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
finishPending(id, error, cancel = false, result) {
|
|
304
|
+
const pending = this.pending.get(id);
|
|
305
|
+
if (!pending)
|
|
306
|
+
return;
|
|
307
|
+
this.pending.delete(id);
|
|
308
|
+
clearTimeout(pending.timer);
|
|
309
|
+
pending.cleanup();
|
|
310
|
+
if (cancel && this.readyMessage) {
|
|
311
|
+
try {
|
|
312
|
+
this.send({ protocolVersion: 1, type: 'cancel', launchId: this.readyMessage.launchId, appId: this.readyMessage.appId, requestId: id }, this.readyMessage.limits.maxMessageBytes);
|
|
313
|
+
}
|
|
314
|
+
catch { /* Cancellation is best effort. */ }
|
|
315
|
+
}
|
|
316
|
+
if (error)
|
|
317
|
+
pending.reject(error);
|
|
318
|
+
else
|
|
319
|
+
pending.resolve(result);
|
|
320
|
+
}
|
|
321
|
+
expire() {
|
|
322
|
+
if (this.state === 'expired' || this.state === 'closed')
|
|
323
|
+
return;
|
|
324
|
+
for (const id of [...this.pending.keys()])
|
|
325
|
+
this.finishPending(id, new BridgeError('SESSION_EXPIRED', 'The launch session expired.'));
|
|
326
|
+
this.setState('expired');
|
|
327
|
+
}
|
|
328
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export interface BridgeMethodMap {
|
|
2
|
+
"camera.capture": {
|
|
3
|
+
request: Record<string, never>;
|
|
4
|
+
response: {
|
|
5
|
+
"file": {
|
|
6
|
+
"handle": string;
|
|
7
|
+
"name": string;
|
|
8
|
+
"mimeType": string;
|
|
9
|
+
"sizeBytes": number;
|
|
10
|
+
"expiresAtMs": number;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
"file.pick": {
|
|
15
|
+
request: {
|
|
16
|
+
"mimeTypes"?: Array<string>;
|
|
17
|
+
"multiple"?: boolean;
|
|
18
|
+
};
|
|
19
|
+
response: {
|
|
20
|
+
"files": Array<{
|
|
21
|
+
"handle": string;
|
|
22
|
+
"name": string;
|
|
23
|
+
"mimeType": string;
|
|
24
|
+
"sizeBytes": number;
|
|
25
|
+
"expiresAtMs": number;
|
|
26
|
+
}>;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
"identity.getAssertion": {
|
|
30
|
+
request: Record<string, never>;
|
|
31
|
+
response: {
|
|
32
|
+
"assertion": string;
|
|
33
|
+
"expiresAtMs": number;
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
"location.getCurrent": {
|
|
37
|
+
request: {
|
|
38
|
+
"accuracy": "approximate" | "precise";
|
|
39
|
+
};
|
|
40
|
+
response: {
|
|
41
|
+
"latitude": number;
|
|
42
|
+
"longitude": number;
|
|
43
|
+
"accuracyMeters": number;
|
|
44
|
+
"capturedAtMs": number;
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
"navigation.close": {
|
|
48
|
+
request: Record<string, never>;
|
|
49
|
+
response: {
|
|
50
|
+
"closed": true;
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
"navigation.openExternal": {
|
|
54
|
+
request: {
|
|
55
|
+
"url": string;
|
|
56
|
+
};
|
|
57
|
+
response: {
|
|
58
|
+
"opened": true;
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
"notifications.send": {
|
|
62
|
+
request: {
|
|
63
|
+
"title": string;
|
|
64
|
+
"body": string;
|
|
65
|
+
"path"?: string;
|
|
66
|
+
};
|
|
67
|
+
response: {
|
|
68
|
+
"queued": true;
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
"notifications.subscribe": {
|
|
72
|
+
request: Record<string, never>;
|
|
73
|
+
response: {
|
|
74
|
+
"subscribed": true;
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
"runtime.getInfo": {
|
|
78
|
+
request: Record<string, never>;
|
|
79
|
+
response: {
|
|
80
|
+
"platform": "android" | "ios";
|
|
81
|
+
"runtimeVersion": string;
|
|
82
|
+
"protocolVersion": 1;
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
"share.open": {
|
|
86
|
+
request: {
|
|
87
|
+
"title"?: string;
|
|
88
|
+
"text"?: string;
|
|
89
|
+
"url"?: string;
|
|
90
|
+
};
|
|
91
|
+
response: {
|
|
92
|
+
"presented": true;
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export type BridgeMethod = keyof BridgeMethodMap;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|