@mpgd/adapter-capacitor 0.4.12 → 0.5.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 +225 -0
- package/dist/app-events.d.ts +23 -0
- package/dist/app-events.js +332 -0
- package/dist/index.d.ts +14 -5
- package/dist/index.js +162 -13
- package/dist/native-http.d.ts +50 -0
- package/dist/native-http.js +259 -0
- package/dist/providers.d.ts +25 -0
- package/dist/providers.js +309 -0
- package/dist/viewport.d.ts +25 -0
- package/dist/viewport.js +221 -0
- package/package.json +10 -6
package/README.md
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# @mpgd/adapter-capacitor
|
|
2
|
+
|
|
3
|
+
The base adapter uses `@mpgd/capacitor-game-services` for bounded local JSON
|
|
4
|
+
storage and a small fail-closed bridge. It does **not** install a store, ad,
|
|
5
|
+
identity, leaderboard, or push SDK. Pass separately installed modules through
|
|
6
|
+
`createCapacitorPlatformGateway({ providers })`; each module declares its
|
|
7
|
+
owned bridge methods, provider features, and current availability. Duplicate
|
|
8
|
+
method/feature registrations and attempts to replace base storage are errors.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import {
|
|
12
|
+
createCapacitorPlatformGateway,
|
|
13
|
+
type CapacitorServiceProvider,
|
|
14
|
+
} from '@mpgd/adapter-capacitor';
|
|
15
|
+
|
|
16
|
+
export function createGameGateway(providers: readonly CapacitorServiceProvider[]) {
|
|
17
|
+
return createCapacitorPlatformGateway({
|
|
18
|
+
target: 'android',
|
|
19
|
+
appVersion: '1.0.0',
|
|
20
|
+
buildId: 'game-build',
|
|
21
|
+
providers,
|
|
22
|
+
classifyIncomingUrl(url) {
|
|
23
|
+
const incoming = new URL(url);
|
|
24
|
+
// Replace this example scheme with one registered by your native host.
|
|
25
|
+
if (incoming.protocol !== 'mygame:') return null;
|
|
26
|
+
if (incoming.hostname === 'oauth') return 'oauth';
|
|
27
|
+
if (incoming.hostname === 'game') return 'game';
|
|
28
|
+
return null;
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`getCapabilities()` reports a fresh boolean snapshot and optional
|
|
35
|
+
`providerAvailability` detail. The possible states are `unsupported`,
|
|
36
|
+
`configuration-required`, `action-required`, `temporarily-unavailable`, and
|
|
37
|
+
`available`. A target's `features` configuration is an upper bound, **not**
|
|
38
|
+
proof that a provider is installed or ready. `nativeIap` describes one-time
|
|
39
|
+
purchases; `subscriptionIap` is separate. Rewarded, interstitial, and banner
|
|
40
|
+
ads and native versus remote leaderboards remain distinct. Target-config
|
|
41
|
+
applies the configured upper bound to this live provider state.
|
|
42
|
+
|
|
43
|
+
When native and remote leaderboard routes coexist, the target-config wrapper
|
|
44
|
+
selects a `route` on score/open calls; direct adapter callers can request
|
|
45
|
+
`route: 'remote'` to use the base remote bridge instead of the native provider.
|
|
46
|
+
When one ads provider handles multiple formats, pass `format` to `ads.preload`
|
|
47
|
+
so its readiness check cannot use an available format for an unavailable one.
|
|
48
|
+
|
|
49
|
+
Provider initialization failures and availability reads stalled beyond three
|
|
50
|
+
seconds leave the base bridge and local guest boot available. A registered
|
|
51
|
+
provider never silently falls back for purchase, ad, or native leaderboard
|
|
52
|
+
operations; an explicit remote leaderboard route is a separate path. Errors
|
|
53
|
+
retain a stable code and retry hint. The
|
|
54
|
+
provider bridge must return a method-shaped response. In particular, an ad
|
|
55
|
+
`rewardGranted: true` result requires a backend ledger entry. A native callback
|
|
56
|
+
alone is evidence, not a grant. Game-specific product, consent, entitlement,
|
|
57
|
+
and identity policy belongs to the consuming game and its backend.
|
|
58
|
+
|
|
59
|
+
The source tests and installed-tarball consumer validate composition, types,
|
|
60
|
+
error paths, and fallback behavior. They do not establish that any optional
|
|
61
|
+
SDK works on a physical device or that a store/ad setup is release-ready.
|
|
62
|
+
|
|
63
|
+
`gateway.secureCredentials` uses dedicated native Keychain/Keystore methods for
|
|
64
|
+
opaque session credentials. Its absence or a native error must not be hidden by
|
|
65
|
+
writing the secret into `gateway.storage`. Keys are bounded, values are
|
|
66
|
+
size-limited, and malformed bridge responses fail closed. This is a storage
|
|
67
|
+
boundary, not proof of server authentication: an installation ID or local
|
|
68
|
+
`playerId` is never a server principal by itself.
|
|
69
|
+
|
|
70
|
+
## App lifecycle and native entry
|
|
71
|
+
|
|
72
|
+
The adapter uses the official Capacitor App plugin for foreground state,
|
|
73
|
+
Android back navigation, and incoming URLs. Install `@capacitor/app` in the
|
|
74
|
+
native shell and run `cap sync` so the native plugin is present. The reference
|
|
75
|
+
shell and generated game template include it. A `classifyIncomingUrl` callback
|
|
76
|
+
must explicitly return `game` or `oauth`; unknown URLs are discarded rather
|
|
77
|
+
than being delivered to gameplay. Register your URL scheme or App/Universal
|
|
78
|
+
Links in the native host separately. See the [Capacitor App API](https://capacitorjs.com/docs/apis/app).
|
|
79
|
+
|
|
80
|
+
`lifecycle.onPause` and `onResume` deduplicate App and web visibility events.
|
|
81
|
+
Purchases, ads, and identity upgrades keep execution paused until their
|
|
82
|
+
provider operation settles. Use `beginExternalActivity` for other external UI.
|
|
83
|
+
On Android, a back handler returns `true` if the game consumed the event;
|
|
84
|
+
otherwise the adapter navigates browser history when possible or exits the
|
|
85
|
+
app. Cold URLs are read through `getInitialGameUrl` or
|
|
86
|
+
`getInitialOAuthRedirect`; warm URLs use separate `onGameUrlOpen` and
|
|
87
|
+
`onOAuthRedirect` callbacks. OAuth responses never enter the game-link
|
|
88
|
+
callback. Call `lifecycle.dispose()` when the host tears down this gateway;
|
|
89
|
+
the adapter removes only its own listener handles, never every App listener.
|
|
90
|
+
|
|
91
|
+
Save progress at checkpoints and transaction boundaries. A pause callback can
|
|
92
|
+
request a best-effort save, but neither pause nor back is guaranteed to run
|
|
93
|
+
before the operating system terminates a process. The lifecycle tests use an
|
|
94
|
+
injected App API and do not claim device-level lifecycle verification.
|
|
95
|
+
|
|
96
|
+
For example, let the game's checkpoint flow own durability; the native pause
|
|
97
|
+
event is only an additional opportunity to flush the latest checkpoint:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
import type { PlatformGateway } from '@mpgd/platform';
|
|
101
|
+
|
|
102
|
+
export function attachCheckpointSaving(
|
|
103
|
+
gateway: PlatformGateway,
|
|
104
|
+
saveCheckpoint: () => Promise<void>,
|
|
105
|
+
): () => void {
|
|
106
|
+
return gateway.lifecycle.onPause(() => {
|
|
107
|
+
void saveCheckpoint().catch((error: unknown) => {
|
|
108
|
+
console.error('Best-effort pause save failed.', error);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The game also awaits saveCheckpoint() at level and transaction boundaries;
|
|
114
|
+
// it must never defer its only save until a close, back, or pause event.
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Scoped native JSON HTTP
|
|
118
|
+
|
|
119
|
+
`createCapacitorNativeJsonTransport` uses the explicit `CapacitorHttp.request`
|
|
120
|
+
helper from `@capacitor/core`; it does not enable global `fetch` or XHR patching.
|
|
121
|
+
Use it with the HTTP JSON path of `createGameServicesRuntime`, not the oRPC
|
|
122
|
+
path. The game imports only public kit APIs:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { createCapacitorNativeJsonTransport } from '@mpgd/adapter-capacitor';
|
|
126
|
+
import { createGameServicesRuntime } from '@mpgd/game-services/runtime';
|
|
127
|
+
|
|
128
|
+
const baseUrl = 'https://api.example.com';
|
|
129
|
+
const httpTransport = createCapacitorNativeJsonTransport({
|
|
130
|
+
target: 'android',
|
|
131
|
+
baseUrl,
|
|
132
|
+
allowedOrigin: 'https://api.example.com',
|
|
133
|
+
});
|
|
134
|
+
const runtime = createGameServicesRuntime({
|
|
135
|
+
gateway,
|
|
136
|
+
playerId,
|
|
137
|
+
authorityMode: 'production',
|
|
138
|
+
baseUrl,
|
|
139
|
+
transport: 'http',
|
|
140
|
+
httpTransport,
|
|
141
|
+
getHeaders: () => ({ authorization: `Bearer ${currentAccessToken()}` }),
|
|
142
|
+
});
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The game supplies its real gateway, player ID, and token resolver. The native
|
|
146
|
+
transport permits only its configured HTTPS origin and relative JSON API paths;
|
|
147
|
+
it rejects 3xx responses and changed routes while tolerating equivalent native
|
|
148
|
+
URL encoding of the same path or query. It requests
|
|
149
|
+
`disableRedirects: true` from Capacitor. A native implementation that ignored
|
|
150
|
+
that option could have followed a redirect before JS sees the result, so
|
|
151
|
+
redirect and credential handling still require native integration testing.
|
|
152
|
+
The transport owns `Accept: application/json` and POST `Content-Type`; callers
|
|
153
|
+
supplying either header are rejected instead of having it silently rewritten.
|
|
154
|
+
It supports GET and POST JSON only, not streaming, file uploads, or arbitrary
|
|
155
|
+
Fetch semantics. `AbortSignal` and the overall timeout stop the
|
|
156
|
+
JS wait; they do **not** prove the native request or server operation was
|
|
157
|
+
canceled. Reconcile purchases and reward claims before retrying them.
|
|
158
|
+
|
|
159
|
+
Request size is checked before calling the native bridge. The response size
|
|
160
|
+
limit is checked **after** Capacitor returns data to JS; it is not a native
|
|
161
|
+
receive-memory cap. The injected Http tests and platform staging builds cover
|
|
162
|
+
the contract, not real network behavior on both physical OSes. See the
|
|
163
|
+
[Capacitor HTTP API](https://capacitorjs.com/docs/apis/http) for the native
|
|
164
|
+
helper and its redirect/timeout options.
|
|
165
|
+
|
|
166
|
+
## Usable viewport and native occupied surfaces
|
|
167
|
+
|
|
168
|
+
`gateway.viewport` reports the full WebView size and separate safe-area,
|
|
169
|
+
system-bar, keyboard, and named occupied-surface measurements in **CSS pixels**.
|
|
170
|
+
Pass the state to `resolveTargetViewportUsableArea` from `@mpgd/target-config`;
|
|
171
|
+
it takes the furthest intrusion per edge rather than summing overlapping
|
|
172
|
+
safe-area, system-bar, keyboard, and banner values. The default host reads the
|
|
173
|
+
starter's `--mpgd-safe-area-*` CSS variables, Capacitor SystemBars'
|
|
174
|
+
`--safe-area-inset-*` fallback, and `visualViewport` changes. On Android,
|
|
175
|
+
SystemBars injects fallback CSS variables for older WebViews whose CSS `env`
|
|
176
|
+
safe-area values are incorrect. The injected `--safe-area-inset-*` values must
|
|
177
|
+
be valid CSS lengths on `documentElement` (`:root`); putting them only on
|
|
178
|
+
`body` or a nested container does not update the root aliases. See the
|
|
179
|
+
[SystemBars API](https://capacitorjs.com/docs/apis/system-bars).
|
|
180
|
+
Keyboard behavior also depends on the native [Keyboard resize mode](https://capacitorjs.com/docs/apis/keyboard),
|
|
181
|
+
so inspect the actual device layout before promising a particular inset.
|
|
182
|
+
|
|
183
|
+
Choose **one layout owner**. The starter's existing CSS padding already
|
|
184
|
+
reserves safe-area space; do not apply `usableArea.contentBounds` to that
|
|
185
|
+
already-padded `#game` element. For a JS-owned full-viewport canvas and DOM
|
|
186
|
+
overlay, remove the host's safe-area padding and apply the same rectangle to
|
|
187
|
+
both elements:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
import { resolveTargetViewportUsableArea } from '@mpgd/target-config';
|
|
191
|
+
import type { PlatformGateway } from '@mpgd/platform';
|
|
192
|
+
|
|
193
|
+
export function mountUsableViewport(
|
|
194
|
+
gateway: PlatformGateway,
|
|
195
|
+
canvasHost: HTMLElement,
|
|
196
|
+
overlayHost: HTMLElement,
|
|
197
|
+
): () => void {
|
|
198
|
+
const viewport = gateway.viewport;
|
|
199
|
+
if (viewport === undefined) return () => undefined;
|
|
200
|
+
const apply = () => {
|
|
201
|
+
const state = viewport.getState();
|
|
202
|
+
const bounds = resolveTargetViewportUsableArea(state, state).contentBounds;
|
|
203
|
+
for (const element of [canvasHost, overlayHost]) {
|
|
204
|
+
element.style.position = 'absolute';
|
|
205
|
+
element.style.left = `${bounds.x}px`;
|
|
206
|
+
element.style.top = `${bounds.y}px`;
|
|
207
|
+
element.style.width = `${bounds.width}px`;
|
|
208
|
+
element.style.height = `${bounds.height}px`;
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
apply();
|
|
212
|
+
return viewport.onChange(apply);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Call the returned unsubscribe during game teardown. lifecycle.dispose()
|
|
216
|
+
// tears down the gateway-owned native viewport listeners.
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Future banner providers can call `createCapacitorViewport()` and pass that
|
|
220
|
+
controller to `createCapacitorPlatformGateway({ viewport })`, then report
|
|
221
|
+
actual `surfaceId` bounds with `setOccupiedSurface`. For physical-pixel
|
|
222
|
+
measurements, the provider supplies `pixelsPerCssPixel`; the game never guesses
|
|
223
|
+
device pixel ratio. A caller-injected controller remains caller-owned and must
|
|
224
|
+
be disposed separately. Tests cover layout, rotation, multiple surfaces, and
|
|
225
|
+
unsubscribe, but do not establish physical-device inset or keyboard accuracy.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type AppPlugin } from '@capacitor/app';
|
|
2
|
+
import type { LifecycleAdapter } from '@mpgd/platform';
|
|
3
|
+
export type CapacitorIncomingUrlKind = 'game' | 'oauth';
|
|
4
|
+
export interface CapacitorVisibilitySource {
|
|
5
|
+
readonly hidden: boolean;
|
|
6
|
+
addEventListener(type: 'visibilitychange', listener: () => void): void;
|
|
7
|
+
removeEventListener(type: 'visibilitychange', listener: () => void): void;
|
|
8
|
+
}
|
|
9
|
+
export type CapacitorAppEventsApi = Pick<AppPlugin, 'addListener' | 'getState' | 'getLaunchUrl' | 'exitApp'>;
|
|
10
|
+
export interface CreateCapacitorAppEventsInput {
|
|
11
|
+
readonly target: 'android' | 'ios';
|
|
12
|
+
readonly app?: CapacitorAppEventsApi;
|
|
13
|
+
readonly visibility?: CapacitorVisibilitySource | null;
|
|
14
|
+
/** Return null for URLs that this game does not own. Never default OAuth to game. */
|
|
15
|
+
readonly classifyIncomingUrl?: (url: string) => CapacitorIncomingUrlKind | null;
|
|
16
|
+
readonly historyBack?: () => void;
|
|
17
|
+
readonly onError?: (error: unknown) => void;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Owns only handles it registers. Disposing during an awaited addListener
|
|
21
|
+
* removes the eventual handle without touching another gateway's listeners.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createCapacitorAppEvents(input: CreateCapacitorAppEventsInput): LifecycleAdapter;
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { App } from '@capacitor/app';
|
|
2
|
+
const maxWarmUrlsBeforeInitial = 32;
|
|
3
|
+
// Covers a near-simultaneous duplicate from getLaunchUrl and appUrlOpen, not a later tap.
|
|
4
|
+
const coldUrlDedupeWindowMs = 2_000;
|
|
5
|
+
function defaultVisibility() {
|
|
6
|
+
return typeof document === 'undefined' ? null : document;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Owns only handles it registers. Disposing during an awaited addListener
|
|
10
|
+
* removes the eventual handle without touching another gateway's listeners.
|
|
11
|
+
*/
|
|
12
|
+
export function createCapacitorAppEvents(input) {
|
|
13
|
+
const app = input.app ?? App;
|
|
14
|
+
const visibility = input.visibility === undefined ? defaultVisibility() : input.visibility;
|
|
15
|
+
const pauseCallbacks = new Set();
|
|
16
|
+
const resumeCallbacks = new Set();
|
|
17
|
+
const gameUrlCallbacks = new Set();
|
|
18
|
+
const oauthCallbacks = new Set();
|
|
19
|
+
const backHandlers = [];
|
|
20
|
+
const handles = [];
|
|
21
|
+
const warmUrlsBeforeInitial = new Set();
|
|
22
|
+
let disposed = false;
|
|
23
|
+
let initialResolved = false;
|
|
24
|
+
let appActive = true;
|
|
25
|
+
let visibilityActive = visibility?.hidden !== true;
|
|
26
|
+
let currentActive = true;
|
|
27
|
+
let transitionVersion = 0;
|
|
28
|
+
let stateEvents = 0;
|
|
29
|
+
let externalActivities = 0;
|
|
30
|
+
let backInProgress = false;
|
|
31
|
+
let startFailureReported = false;
|
|
32
|
+
let recentColdUrl;
|
|
33
|
+
let recentColdUntil = 0;
|
|
34
|
+
let startPromise;
|
|
35
|
+
let backPromise;
|
|
36
|
+
let initialPromise;
|
|
37
|
+
const reportError = (error) => {
|
|
38
|
+
try {
|
|
39
|
+
if (input.onError !== undefined) {
|
|
40
|
+
input.onError(error);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
console.error('Capacitor app event failed.', error);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Reporting must not replace the original listener or registration error.
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
function emit(callbacks, version) {
|
|
51
|
+
for (const callback of [...callbacks]) {
|
|
52
|
+
// A callback can open or close external UI and reenter reconcile().
|
|
53
|
+
// Never continue an older resume/pause emission after that transition.
|
|
54
|
+
if (disposed || version !== transitionVersion) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
callback();
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
reportError(error);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function reconcile() {
|
|
66
|
+
if (disposed) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const active = appActive && visibilityActive && externalActivities === 0;
|
|
70
|
+
if (active === currentActive) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
currentActive = active;
|
|
74
|
+
transitionVersion += 1;
|
|
75
|
+
emit(active ? resumeCallbacks : pauseCallbacks, transitionVersion);
|
|
76
|
+
}
|
|
77
|
+
function classify(url) {
|
|
78
|
+
try {
|
|
79
|
+
const kind = input.classifyIncomingUrl?.(url);
|
|
80
|
+
return kind === 'game' || kind === 'oauth' ? kind : null;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
reportError(error);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function dispatchWarmUrl(url) {
|
|
88
|
+
if (disposed || url.trim() === '') {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (url === recentColdUrl && Date.now() <= recentColdUntil) {
|
|
92
|
+
// Capacitor can deliver the same launch through getLaunchUrl and
|
|
93
|
+
// appUrlOpen. Suppress only that immediate duplicate, not a later tap.
|
|
94
|
+
recentColdUrl = undefined;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const kind = classify(url);
|
|
98
|
+
if (kind === null) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const callbacks = kind === 'game' ? gameUrlCallbacks : oauthCallbacks;
|
|
102
|
+
if (!initialResolved && callbacks.size > 0) {
|
|
103
|
+
// Keep enough recent entries to suppress a cold/warm duplicate without
|
|
104
|
+
// retaining an unbounded history when the host never requests cold URLs.
|
|
105
|
+
if (warmUrlsBeforeInitial.size >= maxWarmUrlsBeforeInitial) {
|
|
106
|
+
const oldest = warmUrlsBeforeInitial.values().next().value;
|
|
107
|
+
if (oldest !== undefined) {
|
|
108
|
+
warmUrlsBeforeInitial.delete(oldest);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
warmUrlsBeforeInitial.add(url);
|
|
112
|
+
}
|
|
113
|
+
const event = { url, source: 'warm' };
|
|
114
|
+
for (const callback of [...callbacks]) {
|
|
115
|
+
try {
|
|
116
|
+
callback(event);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
reportError(error);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async function own(registration) {
|
|
124
|
+
const handle = await registration;
|
|
125
|
+
if (disposed) {
|
|
126
|
+
await handle.remove();
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
handles.push(handle);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function onVisibilityChange() {
|
|
133
|
+
visibilityActive = visibility?.hidden !== true;
|
|
134
|
+
reconcile();
|
|
135
|
+
}
|
|
136
|
+
function start() {
|
|
137
|
+
if (disposed) {
|
|
138
|
+
return Promise.reject(new Error('Capacitor app events are disposed.'));
|
|
139
|
+
}
|
|
140
|
+
if (startPromise !== undefined) {
|
|
141
|
+
return startPromise;
|
|
142
|
+
}
|
|
143
|
+
visibility?.addEventListener('visibilitychange', onVisibilityChange);
|
|
144
|
+
onVisibilityChange();
|
|
145
|
+
const beforeRead = stateEvents;
|
|
146
|
+
startPromise = Promise.all([
|
|
147
|
+
own(app.addListener('appStateChange', ({ isActive }) => {
|
|
148
|
+
stateEvents += 1;
|
|
149
|
+
appActive = isActive;
|
|
150
|
+
reconcile();
|
|
151
|
+
})),
|
|
152
|
+
own(app.addListener('appUrlOpen', ({ url }) => dispatchWarmUrl(url))),
|
|
153
|
+
app.getState().then(({ isActive }) => {
|
|
154
|
+
if (!disposed && stateEvents === beforeRead) {
|
|
155
|
+
appActive = isActive;
|
|
156
|
+
reconcile();
|
|
157
|
+
}
|
|
158
|
+
}),
|
|
159
|
+
]).then(() => undefined);
|
|
160
|
+
return startPromise;
|
|
161
|
+
}
|
|
162
|
+
function startForSubscription() {
|
|
163
|
+
void start().catch((error) => {
|
|
164
|
+
if (startFailureReported) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
startFailureReported = true;
|
|
168
|
+
reportError(error);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
async function dispatchBack(event) {
|
|
172
|
+
for (const handler of [...backHandlers].reverse()) {
|
|
173
|
+
try {
|
|
174
|
+
if (await handler(event)) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (disposed) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
reportError(error);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (disposed) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (event.canGoBack) {
|
|
189
|
+
if (input.historyBack !== undefined) {
|
|
190
|
+
input.historyBack();
|
|
191
|
+
}
|
|
192
|
+
else if (typeof history !== 'undefined') {
|
|
193
|
+
history.back();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
await app.exitApp();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function startBackListener() {
|
|
201
|
+
if (input.target !== 'android' || backPromise !== undefined || disposed) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
backPromise = own(app.addListener('backButton', (event) => {
|
|
205
|
+
if (backInProgress || disposed) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
backInProgress = true;
|
|
209
|
+
void dispatchBack(event).catch(reportError).finally(() => {
|
|
210
|
+
backInProgress = false;
|
|
211
|
+
});
|
|
212
|
+
}));
|
|
213
|
+
void backPromise.catch(reportError);
|
|
214
|
+
}
|
|
215
|
+
function subscribe(callbacks, callback) {
|
|
216
|
+
if (disposed) {
|
|
217
|
+
return () => { };
|
|
218
|
+
}
|
|
219
|
+
callbacks.add(callback);
|
|
220
|
+
startForSubscription();
|
|
221
|
+
return () => {
|
|
222
|
+
callbacks.delete(callback);
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function getInitial() {
|
|
226
|
+
if (initialPromise !== undefined) {
|
|
227
|
+
return initialPromise;
|
|
228
|
+
}
|
|
229
|
+
startForSubscription();
|
|
230
|
+
initialPromise = app.getLaunchUrl().then((launch) => {
|
|
231
|
+
initialResolved = true;
|
|
232
|
+
const url = launch?.url;
|
|
233
|
+
const seenWarm = url === undefined ? false : warmUrlsBeforeInitial.has(url);
|
|
234
|
+
warmUrlsBeforeInitial.clear();
|
|
235
|
+
if (disposed || url === undefined || url.trim() === '' || seenWarm) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
const kind = classify(url);
|
|
239
|
+
if (kind === null) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
recentColdUrl = url;
|
|
243
|
+
recentColdUntil = Date.now() + coldUrlDedupeWindowMs;
|
|
244
|
+
return { kind, event: { url, source: 'cold' } };
|
|
245
|
+
}, (error) => {
|
|
246
|
+
initialResolved = true;
|
|
247
|
+
warmUrlsBeforeInitial.clear();
|
|
248
|
+
throw error;
|
|
249
|
+
});
|
|
250
|
+
return initialPromise;
|
|
251
|
+
}
|
|
252
|
+
async function dispose() {
|
|
253
|
+
if (disposed) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
disposed = true;
|
|
257
|
+
visibility?.removeEventListener('visibilitychange', onVisibilityChange);
|
|
258
|
+
pauseCallbacks.clear();
|
|
259
|
+
resumeCallbacks.clear();
|
|
260
|
+
gameUrlCallbacks.clear();
|
|
261
|
+
oauthCallbacks.clear();
|
|
262
|
+
backHandlers.length = 0;
|
|
263
|
+
warmUrlsBeforeInitial.clear();
|
|
264
|
+
recentColdUrl = undefined;
|
|
265
|
+
// Pending registrations remove themselves in own() if they later resolve.
|
|
266
|
+
// Teardown must not hang on an SDK registration that never does.
|
|
267
|
+
await Promise.allSettled(handles.splice(0).map((handle) => handle.remove()));
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
onPause(callback) {
|
|
271
|
+
// start() may synchronously emit the first pause while subscribing.
|
|
272
|
+
const alreadyPaused = !disposed && currentActive === false;
|
|
273
|
+
const unsubscribe = subscribe(pauseCallbacks, callback);
|
|
274
|
+
if (alreadyPaused) {
|
|
275
|
+
try {
|
|
276
|
+
callback();
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
reportError(error);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return unsubscribe;
|
|
283
|
+
},
|
|
284
|
+
onResume(callback) {
|
|
285
|
+
return subscribe(resumeCallbacks, callback);
|
|
286
|
+
},
|
|
287
|
+
onBackButton(handler) {
|
|
288
|
+
if (disposed || input.target !== 'android') {
|
|
289
|
+
return () => { };
|
|
290
|
+
}
|
|
291
|
+
backHandlers.push(handler);
|
|
292
|
+
startBackListener();
|
|
293
|
+
return () => {
|
|
294
|
+
const index = backHandlers.indexOf(handler);
|
|
295
|
+
if (index >= 0) {
|
|
296
|
+
backHandlers.splice(index, 1);
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
},
|
|
300
|
+
onGameUrlOpen(callback) {
|
|
301
|
+
return subscribe(gameUrlCallbacks, callback);
|
|
302
|
+
},
|
|
303
|
+
onOAuthRedirect(callback) {
|
|
304
|
+
return subscribe(oauthCallbacks, callback);
|
|
305
|
+
},
|
|
306
|
+
async getInitialGameUrl() {
|
|
307
|
+
const initial = await getInitial();
|
|
308
|
+
return initial?.kind === 'game' ? initial.event : null;
|
|
309
|
+
},
|
|
310
|
+
async getInitialOAuthRedirect() {
|
|
311
|
+
const initial = await getInitial();
|
|
312
|
+
return initial?.kind === 'oauth' ? initial.event : null;
|
|
313
|
+
},
|
|
314
|
+
beginExternalActivity() {
|
|
315
|
+
if (disposed) {
|
|
316
|
+
return () => { };
|
|
317
|
+
}
|
|
318
|
+
externalActivities += 1;
|
|
319
|
+
reconcile();
|
|
320
|
+
let released = false;
|
|
321
|
+
return () => {
|
|
322
|
+
if (released) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
released = true;
|
|
326
|
+
externalActivities -= 1;
|
|
327
|
+
reconcile();
|
|
328
|
+
};
|
|
329
|
+
},
|
|
330
|
+
dispose,
|
|
331
|
+
};
|
|
332
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
|
-
import { type
|
|
2
|
-
import { type
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
}
|
|
1
|
+
import { type PlatformGateway, type PlatformTarget, type ViewportAdapter } from '@mpgd/platform';
|
|
2
|
+
import { type CapacitorAppEventsApi, type CapacitorIncomingUrlKind, type CapacitorVisibilitySource } from './app-events.js';
|
|
3
|
+
import { type CapacitorServiceProvider, type NativeBridge } from './providers.js';
|
|
4
|
+
export { createCapacitorProviderRegistry, type CapacitorServiceProvider, type NativeBridge, } from './providers.js';
|
|
5
|
+
export { createCapacitorAppEvents, type CapacitorAppEventsApi, type CapacitorIncomingUrlKind, type CapacitorVisibilitySource, } from './app-events.js';
|
|
6
|
+
export { CapacitorNativeHttpError, createCapacitorNativeJsonTransport, type CapacitorJsonRequest, type CapacitorJsonResponse, type CapacitorNativeHttpErrorCode, type CapacitorNativeJsonTransport, type CreateCapacitorNativeJsonTransportInput, } from './native-http.js';
|
|
7
|
+
export { createCapacitorViewport, type CapacitorViewportBaseState, type CapacitorOccupiedSurfaceInput, type CapacitorViewportController, type CapacitorViewportHost, } from './viewport.js';
|
|
6
8
|
export declare function createCapacitorPlatformGateway(input: {
|
|
7
9
|
readonly target: Extract<PlatformTarget, 'android' | 'ios'>;
|
|
8
10
|
readonly appVersion: string;
|
|
9
11
|
readonly buildId: string;
|
|
10
12
|
readonly bridge?: NativeBridge;
|
|
13
|
+
readonly providers?: readonly CapacitorServiceProvider[];
|
|
14
|
+
readonly app?: CapacitorAppEventsApi;
|
|
15
|
+
readonly visibility?: CapacitorVisibilitySource | null;
|
|
16
|
+
readonly classifyIncomingUrl?: (url: string) => CapacitorIncomingUrlKind | null;
|
|
17
|
+
readonly historyBack?: () => void;
|
|
18
|
+
readonly onAppEventError?: (error: unknown) => void;
|
|
19
|
+
readonly viewport?: ViewportAdapter;
|
|
11
20
|
}): PlatformGateway;
|