@microsoft/power-apps-native-host 0.2.22
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/LICENSE +112 -0
- package/README.md +59 -0
- package/app.plugin.js +4 -0
- package/lib/NOTICE.txt +1659 -0
- package/lib/commonjs/ErrorBoundary.js +128 -0
- package/lib/commonjs/auth/AuthContext.js +281 -0
- package/lib/commonjs/bridge/NativePowerAppsBridge.js +279 -0
- package/lib/commonjs/bridge/setupNativeHost.js +65 -0
- package/lib/commonjs/connections/ConnectionSetupScreen.js +531 -0
- package/lib/commonjs/connections/disambiguate.js +88 -0
- package/lib/commonjs/connections/types.js +58 -0
- package/lib/commonjs/connections/useConnectionSetup.js +236 -0
- package/lib/commonjs/controls/FilePicker.js +183 -0
- package/lib/commonjs/controls/ImagePicker.js +236 -0
- package/lib/commonjs/index.js +246 -0
- package/lib/commonjs/logger.js +27 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/platform/PowerPlatformConnectionApi.js +606 -0
- package/lib/commonjs/platform/PowerPlatformEnvironmentApi.js +194 -0
- package/lib/commonjs/platform/resolveConnections.js +264 -0
- package/lib/commonjs/platform/useConnectionRefs.js +270 -0
- package/lib/commonjs/provider/PowerAppsHostProvider.js +336 -0
- package/lib/commonjs/provider/PowerAppsProvider.js +351 -0
- package/lib/commonjs/telemetry/index.js +70 -0
- package/lib/commonjs/theme/ThemeProvider.js +80 -0
- package/lib/commonjs/theme/tokens.js +50 -0
- package/lib/commonjs/utils/dataverseUtils.js +98 -0
- package/lib/commonjs/utils/isDevPlayer.js +27 -0
- package/lib/module/ErrorBoundary.js +122 -0
- package/lib/module/auth/AuthContext.js +276 -0
- package/lib/module/bridge/NativePowerAppsBridge.js +273 -0
- package/lib/module/bridge/setupNativeHost.js +62 -0
- package/lib/module/connections/ConnectionSetupScreen.js +526 -0
- package/lib/module/connections/disambiguate.js +83 -0
- package/lib/module/connections/types.js +60 -0
- package/lib/module/connections/useConnectionSetup.js +233 -0
- package/lib/module/controls/FilePicker.js +178 -0
- package/lib/module/controls/ImagePicker.js +231 -0
- package/lib/module/index.js +41 -0
- package/lib/module/logger.js +22 -0
- package/lib/module/package.json +1 -0
- package/lib/module/platform/PowerPlatformConnectionApi.js +595 -0
- package/lib/module/platform/PowerPlatformEnvironmentApi.js +186 -0
- package/lib/module/platform/resolveConnections.js +259 -0
- package/lib/module/platform/useConnectionRefs.js +267 -0
- package/lib/module/provider/PowerAppsHostProvider.js +332 -0
- package/lib/module/provider/PowerAppsProvider.js +345 -0
- package/lib/module/telemetry/index.js +55 -0
- package/lib/module/theme/ThemeProvider.js +63 -0
- package/lib/module/theme/tokens.js +46 -0
- package/lib/module/utils/dataverseUtils.js +90 -0
- package/lib/module/utils/isDevPlayer.js +23 -0
- package/lib/typescript/commonjs/ErrorBoundary.d.ts +30 -0
- package/lib/typescript/commonjs/auth/AuthContext.d.ts +57 -0
- package/lib/typescript/commonjs/bridge/NativePowerAppsBridge.d.ts +112 -0
- package/lib/typescript/commonjs/bridge/setupNativeHost.d.ts +39 -0
- package/lib/typescript/commonjs/connections/ConnectionSetupScreen.d.ts +107 -0
- package/lib/typescript/commonjs/connections/disambiguate.d.ts +43 -0
- package/lib/typescript/commonjs/connections/types.d.ts +91 -0
- package/lib/typescript/commonjs/connections/useConnectionSetup.d.ts +96 -0
- package/lib/typescript/commonjs/controls/FilePicker.d.ts +65 -0
- package/lib/typescript/commonjs/controls/ImagePicker.d.ts +76 -0
- package/lib/typescript/commonjs/index.d.ts +42 -0
- package/lib/typescript/commonjs/logger.d.ts +11 -0
- package/lib/typescript/commonjs/package.json +1 -0
- package/lib/typescript/commonjs/platform/PowerPlatformConnectionApi.d.ts +205 -0
- package/lib/typescript/commonjs/platform/PowerPlatformEnvironmentApi.d.ts +108 -0
- package/lib/typescript/commonjs/platform/resolveConnections.d.ts +86 -0
- package/lib/typescript/commonjs/platform/useConnectionRefs.d.ts +126 -0
- package/lib/typescript/commonjs/provider/PowerAppsHostProvider.d.ts +123 -0
- package/lib/typescript/commonjs/provider/PowerAppsProvider.d.ts +148 -0
- package/lib/typescript/commonjs/telemetry/index.d.ts +48 -0
- package/lib/typescript/commonjs/theme/ThemeProvider.d.ts +30 -0
- package/lib/typescript/commonjs/theme/tokens.d.ts +27 -0
- package/lib/typescript/commonjs/utils/dataverseUtils.d.ts +39 -0
- package/lib/typescript/commonjs/utils/isDevPlayer.d.ts +19 -0
- package/lib/typescript/module/ErrorBoundary.d.ts +30 -0
- package/lib/typescript/module/auth/AuthContext.d.ts +57 -0
- package/lib/typescript/module/bridge/NativePowerAppsBridge.d.ts +112 -0
- package/lib/typescript/module/bridge/setupNativeHost.d.ts +39 -0
- package/lib/typescript/module/connections/ConnectionSetupScreen.d.ts +107 -0
- package/lib/typescript/module/connections/disambiguate.d.ts +43 -0
- package/lib/typescript/module/connections/types.d.ts +91 -0
- package/lib/typescript/module/connections/useConnectionSetup.d.ts +96 -0
- package/lib/typescript/module/controls/FilePicker.d.ts +65 -0
- package/lib/typescript/module/controls/ImagePicker.d.ts +76 -0
- package/lib/typescript/module/index.d.ts +42 -0
- package/lib/typescript/module/logger.d.ts +11 -0
- package/lib/typescript/module/package.json +1 -0
- package/lib/typescript/module/platform/PowerPlatformConnectionApi.d.ts +205 -0
- package/lib/typescript/module/platform/PowerPlatformEnvironmentApi.d.ts +108 -0
- package/lib/typescript/module/platform/resolveConnections.d.ts +86 -0
- package/lib/typescript/module/platform/useConnectionRefs.d.ts +126 -0
- package/lib/typescript/module/provider/PowerAppsHostProvider.d.ts +123 -0
- package/lib/typescript/module/provider/PowerAppsProvider.d.ts +148 -0
- package/lib/typescript/module/telemetry/index.d.ts +48 -0
- package/lib/typescript/module/theme/ThemeProvider.d.ts +30 -0
- package/lib/typescript/module/theme/tokens.d.ts +27 -0
- package/lib/typescript/module/utils/dataverseUtils.d.ts +39 -0
- package/lib/typescript/module/utils/isDevPlayer.d.ts +19 -0
- package/package.json +119 -0
- package/scripts/build-ios-base.js +96 -0
- package/scripts/bundle.js +95 -0
- package/scripts/generate-connector-schemas.js +75 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/*!
|
|
4
|
+
* power-apps-native-host — useConnectionRefs
|
|
5
|
+
*
|
|
6
|
+
* Resolves per-user connectionIds from the Power Platform environment-scoped
|
|
7
|
+
* connectivity API, then builds a fully-populated connection-references map
|
|
8
|
+
* ready to pass to <PowerAppsHostProvider connections={...} />.
|
|
9
|
+
*
|
|
10
|
+
* Now supports connection disambiguation: when multiple Connected connections
|
|
11
|
+
* exist for the same connector, the `onDisambiguationNeeded` callback lets the
|
|
12
|
+
* consuming app present a native picker UI (e.g. a bottom sheet) so the user
|
|
13
|
+
* can choose which connection to use. When no connection exists for a connector
|
|
14
|
+
* the `onMissingConnection` callback fires so the consuming app can guide the
|
|
15
|
+
* user to create one.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* import { useConnectionRefs } from '@microsoft/power-apps-native-host';
|
|
19
|
+
* import powerConfig from '../power.config.json';
|
|
20
|
+
* import { schemaMap } from '../src/connectorSchemas';
|
|
21
|
+
*
|
|
22
|
+
* const { connectionRefs, isReady, error, missingConnectors } = useConnectionRefs({
|
|
23
|
+
* isSignedIn,
|
|
24
|
+
* acquireToken,
|
|
25
|
+
* environmentId: authConfig.powerPlatform.environmentId,
|
|
26
|
+
* region: authConfig.powerPlatform.region,
|
|
27
|
+
* connectionReferences: powerConfig.connectionReferences,
|
|
28
|
+
* schemaMap,
|
|
29
|
+
* onDisambiguationNeeded: async ({ localRefId, apiId, candidates }) => {
|
|
30
|
+
* // Show a picker sheet and return the selected connectionId, or null to skip.
|
|
31
|
+
* return showConnectionPicker(candidates);
|
|
32
|
+
* },
|
|
33
|
+
* });
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { useState, useEffect, useRef } from 'react';
|
|
37
|
+
import { isDevPlayer } from "../utils/isDevPlayer.js";
|
|
38
|
+
import { powerPlatformScope } from "./PowerPlatformEnvironmentApi.js";
|
|
39
|
+
import { ConnectionStatus } from "../connections/types.js";
|
|
40
|
+
import { fetchAppLaunchData } from "./PowerPlatformConnectionApi.js";
|
|
41
|
+
import { applyAclGrants, resolveViaStaticRefs } from "./resolveConnections.js";
|
|
42
|
+
import { debugLog, debugWarn } from "../logger.js";
|
|
43
|
+
import { getAppLogger, HostOperationNames } from "../telemetry/index.js";
|
|
44
|
+
import { buildConnectionReferences } from "../bridge/NativePowerAppsBridge.js";
|
|
45
|
+
|
|
46
|
+
// Re-exported for backward compatibility with consumers that import ConnectorSchema
|
|
47
|
+
// from this module (e.g. via the package index).
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolves Power Platform connection references for the current user, with
|
|
51
|
+
* disambiguation support for environments where multiple connections exist for
|
|
52
|
+
* the same connector.
|
|
53
|
+
*
|
|
54
|
+
* On sign-in this hook:
|
|
55
|
+
* 1. If `appId` is set (production template), fetches connection references
|
|
56
|
+
* from the PowerApps RP `/apps/{appId}/launch` endpoint and uses those
|
|
57
|
+
* in place of the static `connectionReferences` from power.config.json.
|
|
58
|
+
* 2. Acquires a Power Platform API token.
|
|
59
|
+
* 3. In parallel: fetches the Dataverse instanceUrl and ALL connections for
|
|
60
|
+
* each configured connector.
|
|
61
|
+
* 4. Runs disambiguation: resolves single matches, invokes `onDisambiguationNeeded`
|
|
62
|
+
* for multiple matches, and invokes `onMissingConnection` for absent ones.
|
|
63
|
+
* 5. Pre-warms the Dataverse token in the MSAL cache.
|
|
64
|
+
* 6. Sets isReady=true with the fully-populated connections map.
|
|
65
|
+
*/
|
|
66
|
+
export function useConnectionRefs({
|
|
67
|
+
isSignedIn,
|
|
68
|
+
acquireToken,
|
|
69
|
+
appId,
|
|
70
|
+
environmentId,
|
|
71
|
+
region = 'prod',
|
|
72
|
+
connectionReferences,
|
|
73
|
+
schemaMap,
|
|
74
|
+
onDisambiguationNeeded,
|
|
75
|
+
onMissingConnection,
|
|
76
|
+
isvClientId
|
|
77
|
+
}) {
|
|
78
|
+
const [connectionRefs, setConnectionRefs] = useState({});
|
|
79
|
+
const [isReady, setIsReady] = useState(false);
|
|
80
|
+
const [error, setError] = useState(null);
|
|
81
|
+
const [missingConnectors, setMissingConnectors] = useState([]);
|
|
82
|
+
const [dataverseOrgUrl, setDataverseOrgUrl] = useState('');
|
|
83
|
+
|
|
84
|
+
// Bounded re-resolution for the transient null-token case. A null token at
|
|
85
|
+
// cold boot is usually a silent-auth miss (the account exists, but MSAL hasn't
|
|
86
|
+
// produced a token yet); without a retry the effect — keyed on isSignedIn —
|
|
87
|
+
// would never re-resolve once a token becomes available, leaving the bridge on
|
|
88
|
+
// the static fallback refs (no runtimeUrl) for the whole session. retryTick
|
|
89
|
+
// re-runs the effect; tokenRetryCountRef caps the attempts so it can never loop.
|
|
90
|
+
const [retryTick, setRetryTick] = useState(0);
|
|
91
|
+
const tokenRetryCountRef = useRef(0);
|
|
92
|
+
// True only when this effect run was triggered by the bounded background
|
|
93
|
+
// retry below (not an initial resolution or a sign-in). Used to avoid
|
|
94
|
+
// clobbering an already-ready UI: a retry must re-resolve in the background
|
|
95
|
+
// without dropping isReady, which would otherwise unmount the whole signed-in
|
|
96
|
+
// subtree (spinner) on every tick.
|
|
97
|
+
const isBackgroundRetryRef = useRef(false);
|
|
98
|
+
const MAX_TOKEN_RETRIES = 5;
|
|
99
|
+
const TOKEN_RETRY_DELAY_MS = 2000;
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!isSignedIn) {
|
|
102
|
+
tokenRetryCountRef.current = 0;
|
|
103
|
+
isBackgroundRetryRef.current = false;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Consume the background-retry flag once per run so a later initial run or
|
|
108
|
+
// sign-in can never inherit a stale value.
|
|
109
|
+
const isBackgroundRetry = isBackgroundRetryRef.current;
|
|
110
|
+
isBackgroundRetryRef.current = false;
|
|
111
|
+
const hasConnectionRefs = Object.keys(connectionReferences).length > 0;
|
|
112
|
+
|
|
113
|
+
// The Dev Player shell has no connection references of its own. However,
|
|
114
|
+
// a played app loaded inside Dev Player can have required connections OR an
|
|
115
|
+
// environmentId it needs resolved (the appId-null / empty-refs path used by
|
|
116
|
+
// offline-first apps running online). Only skip when there is genuinely
|
|
117
|
+
// nothing to resolve — no refs AND no environment to fetch the Dataverse
|
|
118
|
+
// instanceUrl from. Otherwise fall through to the slow path so
|
|
119
|
+
// fetchInstanceUrl(environmentId) can populate dataverseOrgUrl; without this
|
|
120
|
+
// an online (offline-disabled) played app never resolves its instanceUrl in
|
|
121
|
+
// the Dev Player and every Dataverse read fails with "No instanceUrl found".
|
|
122
|
+
if (isDevPlayer && !hasConnectionRefs && !environmentId) {
|
|
123
|
+
setIsReady(true);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Nothing to resolve — no connection references and no environment to query
|
|
128
|
+
// (and no appId to fetch from the launch API).
|
|
129
|
+
// Mark ready immediately so the bridge can initialise without any network calls.
|
|
130
|
+
if (!environmentId && !appId) {
|
|
131
|
+
setIsReady(true);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
let cancelled = false;
|
|
135
|
+
let retryTimer;
|
|
136
|
+
// Skip the reset on a background-retry run: the UI is already mounted with
|
|
137
|
+
// static fallback refs, so re-resolution must happen in the background. The
|
|
138
|
+
// full-resolution path below still sets missingConnectors/error from the
|
|
139
|
+
// real result. Resetting here would drop isReady → unmount the subtree.
|
|
140
|
+
if (!isBackgroundRetry) {
|
|
141
|
+
setIsReady(false);
|
|
142
|
+
setError(null);
|
|
143
|
+
setMissingConnectors([]);
|
|
144
|
+
}
|
|
145
|
+
async function loadConnections() {
|
|
146
|
+
const token = await acquireToken([powerPlatformScope(region)]);
|
|
147
|
+
if (cancelled) return;
|
|
148
|
+
if (!token) {
|
|
149
|
+
// Cold boot before an auth token is available (e.g. silent-token miss or
|
|
150
|
+
// offline launch). The per-user runtimeUrl/connectionName can't be
|
|
151
|
+
// resolved yet, but seed the bridge with whatever static apiId entries can
|
|
152
|
+
// be derived from connectionReferences so the runtime fields populate on
|
|
153
|
+
// the next resolution. NOTE: when connectionReferences is empty (the
|
|
154
|
+
// appId-only fast path) this seed is itself empty, so the bridge starts
|
|
155
|
+
// with no connections until the bounded retry below resolves a token.
|
|
156
|
+
// That gap is transient as long as a retry succeeds within
|
|
157
|
+
// MAX_TOKEN_RETRIES; if the budget is exhausted the bridge stays seeded
|
|
158
|
+
// with the empty map until the next sign-out/in resets the retry count.
|
|
159
|
+
// Still mark ready so the host mounts the UI (the offline SDK serves local
|
|
160
|
+
// SQLite without a token) instead of gating on !isReady forever.
|
|
161
|
+
setConnectionRefs(buildConnectionReferences(connectionReferences));
|
|
162
|
+
setIsReady(true);
|
|
163
|
+
// Schedule a bounded re-resolution so a transient silent-auth miss
|
|
164
|
+
// recovers once a token is available, instead of staying on the static
|
|
165
|
+
// fallback refs for the rest of the session. Capped by MAX_TOKEN_RETRIES
|
|
166
|
+
// so it can never spin.
|
|
167
|
+
if (tokenRetryCountRef.current < MAX_TOKEN_RETRIES) {
|
|
168
|
+
tokenRetryCountRef.current += 1;
|
|
169
|
+
retryTimer = setTimeout(() => {
|
|
170
|
+
if (!cancelled) {
|
|
171
|
+
isBackgroundRetryRef.current = true;
|
|
172
|
+
setRetryTick(t => t + 1);
|
|
173
|
+
}
|
|
174
|
+
}, TOKEN_RETRY_DELAY_MS);
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
// A token was obtained — reset the retry budget so a later token loss
|
|
179
|
+
// (e.g. across a sign-out/in cycle) gets a fresh set of attempts.
|
|
180
|
+
tokenRetryCountRef.current = 0;
|
|
181
|
+
|
|
182
|
+
// Fast path: single launch API call — production APKs only.
|
|
183
|
+
// Skipped for the Dev Player (always uses static refs so any app can be
|
|
184
|
+
// loaded without being published first).
|
|
185
|
+
let result = null;
|
|
186
|
+
if (appId && environmentId && !isDevPlayer) {
|
|
187
|
+
result = await fetchAppLaunchData(appId, environmentId, region, token);
|
|
188
|
+
if (result && isvClientId) {
|
|
189
|
+
await applyAclGrants(result.connectionRefs, environmentId, isvClientId, acquireToken);
|
|
190
|
+
} else {
|
|
191
|
+
debugWarn('[useConnectionRefs] could not verify connection ACLs; treating existing connections as setup-required');
|
|
192
|
+
getAppLogger().trackErrorEvent(HostOperationNames.PlatformConnectionAclGrant, {
|
|
193
|
+
phase: 'launchAclVerify',
|
|
194
|
+
reason: result ? 'missingIsvClientId' : 'launchDataUnavailable',
|
|
195
|
+
connectionRefCount: result ? Object.keys(result.connectionRefs).length : 0
|
|
196
|
+
});
|
|
197
|
+
for (const r of result ? Object.values(result.connectionRefs) : []) {
|
|
198
|
+
for (const inst of r.instances || []) {
|
|
199
|
+
if (r.status === ConnectionStatus.Connected) {
|
|
200
|
+
r.status = ConnectionStatus.NeedsAclGrant;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Slow path: per-connector resolution.
|
|
208
|
+
// Used when: running in Dev Player, no appId (local dev), or launch API failed.
|
|
209
|
+
if (!result) {
|
|
210
|
+
result = await resolveViaStaticRefs({
|
|
211
|
+
connectionReferences,
|
|
212
|
+
schemaMap,
|
|
213
|
+
environmentId,
|
|
214
|
+
region,
|
|
215
|
+
acquireToken,
|
|
216
|
+
token,
|
|
217
|
+
isvClientId,
|
|
218
|
+
onDisambiguationNeeded,
|
|
219
|
+
onMissingConnection
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
if (cancelled) return;
|
|
223
|
+
|
|
224
|
+
// Pre-warm the Dataverse token while the user waits.
|
|
225
|
+
if (result.dataverseOrgUrl) {
|
|
226
|
+
void acquireToken([`${result.dataverseOrgUrl}/.default`]);
|
|
227
|
+
}
|
|
228
|
+
if (!cancelled) {
|
|
229
|
+
setConnectionRefs(result.connectionRefs);
|
|
230
|
+
setMissingConnectors(result.missingConnectors);
|
|
231
|
+
setDataverseOrgUrl(result.dataverseOrgUrl);
|
|
232
|
+
setIsReady(true);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
loadConnections().catch(e => {
|
|
236
|
+
if (!cancelled) {
|
|
237
|
+
// Recoverable transport failure (e.g. offline cold boot). Record a
|
|
238
|
+
// telemetry error event — matching every other failure path in the
|
|
239
|
+
// host (AuthContext, bridge, provider) — so it is always visible in
|
|
240
|
+
// production without raising a __DEV__ LogBox redbox over the
|
|
241
|
+
// otherwise-working UI. We still recover via setIsReady below.
|
|
242
|
+
debugLog('[useConnectionRefs] Failed to load connections:', e);
|
|
243
|
+
getAppLogger().trackErrorEvent(HostOperationNames.PlatformLoadConnectionsFailed, {
|
|
244
|
+
error: String(e),
|
|
245
|
+
hasAppId: Boolean(appId),
|
|
246
|
+
hasEnvironmentId: Boolean(environmentId),
|
|
247
|
+
connectionReferenceCount: Object.keys(connectionReferences).length,
|
|
248
|
+
runningInDevPlayer: isDevPlayer
|
|
249
|
+
});
|
|
250
|
+
setError(String(e));
|
|
251
|
+
setIsReady(true);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
return () => {
|
|
255
|
+
cancelled = true;
|
|
256
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
257
|
+
};
|
|
258
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
259
|
+
}, [isSignedIn, retryTick]);
|
|
260
|
+
return {
|
|
261
|
+
connectionRefs,
|
|
262
|
+
isReady,
|
|
263
|
+
error,
|
|
264
|
+
missingConnectors,
|
|
265
|
+
dataverseOrgUrl
|
|
266
|
+
};
|
|
267
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/*!
|
|
4
|
+
* power-apps-native-host — PowerAppsHostProvider
|
|
5
|
+
*
|
|
6
|
+
* A React component that owns the full pre-launch pipeline:
|
|
7
|
+
*
|
|
8
|
+
* 1. Waits for the user to be signed in.
|
|
9
|
+
* 2. Resolves connection references via useConnectionRefs (with
|
|
10
|
+
* disambiguation support).
|
|
11
|
+
* 3. If any connections are missing, renders ConnectionSetupScreen — which
|
|
12
|
+
* the user MUST complete before the app opens. There is no skip path.
|
|
13
|
+
* 4. Once all connections are resolved, initialises NativePowerAppsBridge
|
|
14
|
+
* and renders children.
|
|
15
|
+
*
|
|
16
|
+
* The consuming app passes raw config (connectionReferences, schemaMap,
|
|
17
|
+
* databaseReferences) and never touches connection resolution logic.
|
|
18
|
+
*
|
|
19
|
+
* <PowerAppsHostProvider
|
|
20
|
+
* acquireToken={acquireToken}
|
|
21
|
+
* isSignedIn={isSignedIn}
|
|
22
|
+
* environmentId="..."
|
|
23
|
+
* region="prod"
|
|
24
|
+
* connectionReferences={powerConfig.connectionReferences}
|
|
25
|
+
* schemaMap={schemaMap}
|
|
26
|
+
* databaseReferences={powerConfig.databaseReferences}
|
|
27
|
+
* >
|
|
28
|
+
* <Slot />
|
|
29
|
+
* </PowerAppsHostProvider>
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import React, { useEffect, useLayoutEffect, useRef, useState, useCallback, useMemo } from 'react';
|
|
33
|
+
import { ActivityIndicator, View } from 'react-native';
|
|
34
|
+
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
|
35
|
+
import { setupNativeHost } from "../bridge/setupNativeHost.js";
|
|
36
|
+
import { buildConnectionReferences } from "../bridge/NativePowerAppsBridge.js";
|
|
37
|
+
import { useConnectionRefs } from "../platform/useConnectionRefs.js";
|
|
38
|
+
import { buildCdsDataSources } from "../platform/PowerPlatformEnvironmentApi.js";
|
|
39
|
+
import { ConnectionSetupScreen } from "../connections/ConnectionSetupScreen.js";
|
|
40
|
+
import { debugLog } from "../logger.js";
|
|
41
|
+
import { getAppLogger, HostOperationNames } from "../telemetry/index.js";
|
|
42
|
+
|
|
43
|
+
// Entra ID token scope for the APIM connector proxy (API Hub).
|
|
44
|
+
import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
45
|
+
const CONNECTOR_SCOPE = 'https://apihub.azure.com/.default';
|
|
46
|
+
const EMPTY_DATABASE_REFERENCES = {};
|
|
47
|
+
/**
|
|
48
|
+
* Initialises the NativePowerAppsBridge and manages the full pre-launch
|
|
49
|
+
* pipeline — connection resolution, disambiguation, and creation — before
|
|
50
|
+
* rendering children.
|
|
51
|
+
*
|
|
52
|
+
* Children are never rendered until all required connections are resolved.
|
|
53
|
+
*/
|
|
54
|
+
export function PowerAppsHostProvider({
|
|
55
|
+
acquireToken,
|
|
56
|
+
isSignedIn,
|
|
57
|
+
appId,
|
|
58
|
+
environmentId,
|
|
59
|
+
region = 'prod',
|
|
60
|
+
connectionReferences,
|
|
61
|
+
schemaMap,
|
|
62
|
+
databaseReferences,
|
|
63
|
+
renderOAuthWebView,
|
|
64
|
+
isvClientId,
|
|
65
|
+
onDisambiguationNeeded,
|
|
66
|
+
children
|
|
67
|
+
}) {
|
|
68
|
+
// Extra refs produced by ConnectionSetupScreen for newly-created connections.
|
|
69
|
+
const [extraConnectionRefs, setExtraConnectionRefs] = useState({});
|
|
70
|
+
|
|
71
|
+
// setupDone is derived — true as soon as extraConnectionRefs is populated.
|
|
72
|
+
const setupDone = Object.keys(extraConnectionRefs).length > 0;
|
|
73
|
+
const {
|
|
74
|
+
connectionRefs,
|
|
75
|
+
isReady,
|
|
76
|
+
missingConnectors,
|
|
77
|
+
dataverseOrgUrl
|
|
78
|
+
} = useConnectionRefs({
|
|
79
|
+
isSignedIn,
|
|
80
|
+
acquireToken,
|
|
81
|
+
appId,
|
|
82
|
+
environmentId,
|
|
83
|
+
region,
|
|
84
|
+
connectionReferences,
|
|
85
|
+
schemaMap,
|
|
86
|
+
onDisambiguationNeeded,
|
|
87
|
+
isvClientId
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Reset setup gate when the user signs out.
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!isSignedIn) {
|
|
93
|
+
debugLog('[PAHost] user signed out — resetting bridge state');
|
|
94
|
+
setExtraConnectionRefs({});
|
|
95
|
+
}
|
|
96
|
+
}, [isSignedIn]);
|
|
97
|
+
|
|
98
|
+
// Merge hook refs with anything the setup screen created.
|
|
99
|
+
const mergedRefs = useMemo(() => ({
|
|
100
|
+
...connectionRefs,
|
|
101
|
+
...extraConnectionRefs
|
|
102
|
+
}), [connectionRefs, extraConnectionRefs]);
|
|
103
|
+
const staticConnectionRefs = useMemo(() => buildConnectionReferences(connectionReferences), [connectionReferences]);
|
|
104
|
+
|
|
105
|
+
// dataverseOrgUrl comes directly from useConnectionRefs (fetched from the
|
|
106
|
+
// environment metadata regardless of whether a commondataserviceforapps
|
|
107
|
+
// connection reference is configured). This allows Dataverse calls to work
|
|
108
|
+
// even when there is no explicit Dataverse connection in connectionReferences.
|
|
109
|
+
const cdsDataSources = useMemo(() => buildCdsDataSources(databaseReferences ?? EMPTY_DATABASE_REFERENCES, dataverseOrgUrl), [databaseReferences, dataverseOrgUrl]);
|
|
110
|
+
|
|
111
|
+
// Build the missing-connector entries for ConnectionSetupScreen.
|
|
112
|
+
const missingEntries = useMemo(() => missingConnectors.map(apiId => {
|
|
113
|
+
const localRefId = Object.keys(connectionReferences).find(k => connectionReferences[k].id === apiId) ?? apiId;
|
|
114
|
+
return {
|
|
115
|
+
apiId,
|
|
116
|
+
localRefId
|
|
117
|
+
};
|
|
118
|
+
}), [missingConnectors, connectionReferences]);
|
|
119
|
+
|
|
120
|
+
// Called by ConnectionSetupScreen once every missing connector is resolved.
|
|
121
|
+
const handleSetupComplete = useCallback(resolvedIds => {
|
|
122
|
+
const refs = {};
|
|
123
|
+
for (const [apiId, connectionId] of Object.entries(resolvedIds)) {
|
|
124
|
+
const localRefId = Object.keys(connectionReferences).find(k => connectionReferences[k].id === apiId);
|
|
125
|
+
if (!localRefId) continue;
|
|
126
|
+
const dataSources = connectionReferences[localRefId].dataSources;
|
|
127
|
+
const runtimeUrl = dataSources.map(ds => schemaMap[ds]?.properties?.primaryRuntimeUrl).find(Boolean) ?? '';
|
|
128
|
+
for (const ds of dataSources) {
|
|
129
|
+
refs[ds] = {
|
|
130
|
+
apiId,
|
|
131
|
+
runtimeUrl,
|
|
132
|
+
connectionName: connectionId,
|
|
133
|
+
// Preserve datasetName from the resolved connectionRefs (e.g. the
|
|
134
|
+
// Dataverse org URL that useConnectionRefs fetches via fetchInstanceUrl,
|
|
135
|
+
// which exists even when the connection itself was missing).
|
|
136
|
+
datasetName: connectionRefsRef.current[ds]?.datasetName ?? ''
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
setExtraConnectionRefs(refs);
|
|
141
|
+
}, [connectionReferences, schemaMap]);
|
|
142
|
+
|
|
143
|
+
// ---- Bridge wiring ----
|
|
144
|
+
|
|
145
|
+
const acquireTokenRef = useRef(acquireToken);
|
|
146
|
+
acquireTokenRef.current = acquireToken;
|
|
147
|
+
const dataverseOrgUrlRef = useRef(dataverseOrgUrl);
|
|
148
|
+
dataverseOrgUrlRef.current = dataverseOrgUrl;
|
|
149
|
+
const connectionRefsRef = useRef(connectionRefs);
|
|
150
|
+
connectionRefsRef.current = connectionRefs;
|
|
151
|
+
const configuredBridgeInputsRef = useRef(undefined);
|
|
152
|
+
const [, setBridgeRegistrationVersion] = useState(0);
|
|
153
|
+
const hasResolvedConnections = isSignedIn && isReady && (missingConnectors.length === 0 || setupDone);
|
|
154
|
+
const isBridgeConfiguredFor = (connections, dataSources) => {
|
|
155
|
+
const currentInputs = configuredBridgeInputsRef.current;
|
|
156
|
+
return currentInputs?.connections === connections && currentInputs.dataSources === dataSources;
|
|
157
|
+
};
|
|
158
|
+
const configureBridgeNow = useCallback((connections, dataSources) => {
|
|
159
|
+
const previousInputs = configuredBridgeInputsRef.current;
|
|
160
|
+
const shouldConfigureBridge = !previousInputs || previousInputs.connections !== connections || previousInputs.dataSources !== dataSources;
|
|
161
|
+
if (!shouldConfigureBridge) return;
|
|
162
|
+
debugLog(`[PAHost] setupNativeHost: isReady=${isReady} missingConnectors=${missingConnectors.length} setupDone=${setupDone} connections=${Object.keys(connections).join(', ') || '(none)'}`);
|
|
163
|
+
setupNativeHost({
|
|
164
|
+
getConnectorToken: async _apiId => {
|
|
165
|
+
debugLog(`[PAHost] getConnectorToken: acquiring token for apiId="${_apiId}"`);
|
|
166
|
+
let token;
|
|
167
|
+
try {
|
|
168
|
+
token = await acquireTokenRef.current([CONNECTOR_SCOPE]);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
debugLog(`[PAHost] getConnectorToken: acquireToken threw for apiId="${_apiId}":`, err);
|
|
171
|
+
getAppLogger().trackErrorEvent(HostOperationNames.BridgeTokenAcquireFailed, {
|
|
172
|
+
tokenKind: 'connector',
|
|
173
|
+
reason: 'exception',
|
|
174
|
+
error: String(err)
|
|
175
|
+
});
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
if (!token) {
|
|
179
|
+
debugLog(`[PAHost] getConnectorToken: acquireToken returned null for apiId="${_apiId}"`);
|
|
180
|
+
getAppLogger().trackErrorEvent(HostOperationNames.BridgeTokenAcquireFailed, {
|
|
181
|
+
tokenKind: 'connector',
|
|
182
|
+
reason: 'nullToken'
|
|
183
|
+
});
|
|
184
|
+
throw new Error('NativePowerAppsBridge: failed to acquire connector token (acquireToken returned null)');
|
|
185
|
+
}
|
|
186
|
+
debugLog(`[PAHost] getConnectorToken: token acquired for apiId="${_apiId}"`);
|
|
187
|
+
return token;
|
|
188
|
+
},
|
|
189
|
+
getDataverseToken: async datasetName => {
|
|
190
|
+
const orgUrl = datasetName.startsWith('https://') ? datasetName : dataverseOrgUrlRef.current || datasetName;
|
|
191
|
+
debugLog(`[PAHost] getDataverseToken: acquiring token for orgUrl="${orgUrl}"`);
|
|
192
|
+
let token;
|
|
193
|
+
try {
|
|
194
|
+
token = await acquireTokenRef.current([`${orgUrl}/.default`]);
|
|
195
|
+
} catch (err) {
|
|
196
|
+
debugLog(`[PAHost] getDataverseToken: acquireToken threw for orgUrl="${orgUrl}":`, err);
|
|
197
|
+
getAppLogger().trackErrorEvent(HostOperationNames.BridgeTokenAcquireFailed, {
|
|
198
|
+
tokenKind: 'dataverse',
|
|
199
|
+
reason: 'exception',
|
|
200
|
+
error: String(err)
|
|
201
|
+
});
|
|
202
|
+
throw err;
|
|
203
|
+
}
|
|
204
|
+
if (!token) {
|
|
205
|
+
debugLog(`[PAHost] getDataverseToken: acquireToken returned null for orgUrl="${orgUrl}"`);
|
|
206
|
+
getAppLogger().trackErrorEvent(HostOperationNames.BridgeTokenAcquireFailed, {
|
|
207
|
+
tokenKind: 'dataverse',
|
|
208
|
+
reason: 'nullToken'
|
|
209
|
+
});
|
|
210
|
+
throw new Error(`NativePowerAppsBridge: failed to acquire Dataverse token for ${orgUrl} (acquireToken returned null)`);
|
|
211
|
+
}
|
|
212
|
+
debugLog(`[PAHost] getDataverseToken: token acquired for orgUrl="${orgUrl}"`);
|
|
213
|
+
return token;
|
|
214
|
+
},
|
|
215
|
+
dataSources,
|
|
216
|
+
connections
|
|
217
|
+
});
|
|
218
|
+
configuredBridgeInputsRef.current = {
|
|
219
|
+
connections,
|
|
220
|
+
dataSources
|
|
221
|
+
};
|
|
222
|
+
setBridgeRegistrationVersion(version => version + 1);
|
|
223
|
+
debugLog('[PAHost] bridge registered');
|
|
224
|
+
}, [isReady, missingConnectors.length, setupDone]);
|
|
225
|
+
const staticBridgeRegistered = isBridgeConfiguredFor(staticConnectionRefs, cdsDataSources);
|
|
226
|
+
const resolvedBridgeRegistered = hasResolvedConnections && isBridgeConfiguredFor(mergedRefs, cdsDataSources);
|
|
227
|
+
|
|
228
|
+
// @microsoft/power-apps caches the first bridge returned by getBridge().
|
|
229
|
+
// Register our native bridge with static manifest refs, then update the same
|
|
230
|
+
// bridge instance with resolved per-user refs. Child mounting is gated by the
|
|
231
|
+
// render guards below (staticBridgeRegistered / resolvedBridgeRegistered), so
|
|
232
|
+
// children never mount before the bridge is configured; useLayoutEffect is
|
|
233
|
+
// used so the bridge is registered synchronously before paint, avoiding a
|
|
234
|
+
// one-frame window where a child could read an unconfigured bridge.
|
|
235
|
+
useLayoutEffect(() => {
|
|
236
|
+
if (!staticBridgeRegistered && !hasResolvedConnections) {
|
|
237
|
+
configureBridgeNow(staticConnectionRefs, cdsDataSources);
|
|
238
|
+
}
|
|
239
|
+
}, [configureBridgeNow, staticBridgeRegistered, hasResolvedConnections, staticConnectionRefs, cdsDataSources]);
|
|
240
|
+
useLayoutEffect(() => {
|
|
241
|
+
if (hasResolvedConnections && !resolvedBridgeRegistered) {
|
|
242
|
+
configureBridgeNow(mergedRefs, cdsDataSources);
|
|
243
|
+
}
|
|
244
|
+
}, [configureBridgeNow, hasResolvedConnections, resolvedBridgeRegistered, mergedRefs, cdsDataSources]);
|
|
245
|
+
|
|
246
|
+
// Log UI-phase transitions once per state change, not on every render.
|
|
247
|
+
useEffect(() => {
|
|
248
|
+
if (isSignedIn && !isReady) {
|
|
249
|
+
debugLog('[PAHost] render: waiting for connection resolution (spinner)');
|
|
250
|
+
}
|
|
251
|
+
}, [isSignedIn, isReady]);
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
if (isSignedIn && isReady && missingConnectors.length > 0 && !setupDone) {
|
|
254
|
+
debugLog(`[PAHost] render: showing ConnectionSetupScreen for ${missingConnectors.length} missing connector(s):`, missingConnectors.join(', '));
|
|
255
|
+
}
|
|
256
|
+
}, [isSignedIn, isReady, missingConnectors, setupDone]);
|
|
257
|
+
|
|
258
|
+
// ---- Render ----
|
|
259
|
+
|
|
260
|
+
// First mount: register the native bridge with static manifest refs before
|
|
261
|
+
// any route children can mount and call into @microsoft/power-apps.
|
|
262
|
+
if (!staticBridgeRegistered && !hasResolvedConnections) {
|
|
263
|
+
return /*#__PURE__*/_jsx(View, {
|
|
264
|
+
style: {
|
|
265
|
+
flex: 1,
|
|
266
|
+
alignItems: 'center',
|
|
267
|
+
justifyContent: 'center',
|
|
268
|
+
backgroundColor: '#F8F8F8'
|
|
269
|
+
},
|
|
270
|
+
children: /*#__PURE__*/_jsx(ActivityIndicator, {
|
|
271
|
+
size: "large"
|
|
272
|
+
})
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// While resolving connections, show a spinner.
|
|
277
|
+
if (isSignedIn && !isReady) {
|
|
278
|
+
return /*#__PURE__*/_jsx(View, {
|
|
279
|
+
style: {
|
|
280
|
+
flex: 1,
|
|
281
|
+
alignItems: 'center',
|
|
282
|
+
justifyContent: 'center',
|
|
283
|
+
backgroundColor: '#F8F8F8'
|
|
284
|
+
},
|
|
285
|
+
children: /*#__PURE__*/_jsx(ActivityIndicator, {
|
|
286
|
+
size: "large"
|
|
287
|
+
})
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Connections resolved but some are missing — gate on setup screen.
|
|
292
|
+
// Wrapped in SafeAreaProvider so useSafeAreaInsets() works even when the
|
|
293
|
+
// consuming app hasn't mounted its own SafeAreaProvider yet. Nesting is
|
|
294
|
+
// safe — react-native-safe-area-context treats inner providers as no-ops.
|
|
295
|
+
if (isSignedIn && isReady && missingConnectors.length > 0 && !setupDone) {
|
|
296
|
+
return /*#__PURE__*/_jsx(SafeAreaProvider, {
|
|
297
|
+
children: /*#__PURE__*/_jsx(ConnectionSetupScreen, {
|
|
298
|
+
missingConnectors: missingEntries,
|
|
299
|
+
schemaMap: schemaMap,
|
|
300
|
+
environmentId: environmentId,
|
|
301
|
+
region: region,
|
|
302
|
+
acquireToken: acquireToken,
|
|
303
|
+
isvClientId: isvClientId,
|
|
304
|
+
onAllConnected: handleSetupComplete,
|
|
305
|
+
renderOAuthWebView: renderOAuthWebView
|
|
306
|
+
// No onSkip — connections are required.
|
|
307
|
+
})
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Keep signed-in app content gated until user-specific connection refs have
|
|
312
|
+
// resolved and the already-cached bridge has been updated in-place.
|
|
313
|
+
// Only block when signed in — when signed out, isReady never becomes true
|
|
314
|
+
// (useConnectionRefs skips fetching when !isSignedIn), so the login screen
|
|
315
|
+
// would otherwise never render.
|
|
316
|
+
if (isSignedIn && (!hasResolvedConnections || !resolvedBridgeRegistered)) {
|
|
317
|
+
return /*#__PURE__*/_jsx(View, {
|
|
318
|
+
style: {
|
|
319
|
+
flex: 1,
|
|
320
|
+
alignItems: 'center',
|
|
321
|
+
justifyContent: 'center',
|
|
322
|
+
backgroundColor: '#F8F8F8'
|
|
323
|
+
},
|
|
324
|
+
children: /*#__PURE__*/_jsx(ActivityIndicator, {
|
|
325
|
+
size: "large"
|
|
326
|
+
})
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
return /*#__PURE__*/_jsx(_Fragment, {
|
|
330
|
+
children: children
|
|
331
|
+
});
|
|
332
|
+
}
|