@microsoft/power-apps-native-host 0.2.22 → 0.2.25

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.
Files changed (47) hide show
  1. package/README.md +143 -26
  2. package/compatibility/current.json +88 -0
  3. package/lib/NOTICE.txt +146 -1226
  4. package/lib/commonjs/auth/AuthContext.web.js +32 -0
  5. package/lib/commonjs/auth/completePowerAppsAuthSession.js +16 -0
  6. package/lib/commonjs/bridge/NativePowerAppsBridge.js +11 -1
  7. package/lib/commonjs/bridge/PowerPagesBridge.js +138 -0
  8. package/lib/commonjs/index.js +22 -2
  9. package/lib/commonjs/offline/useOfflineIntegration.js +12 -0
  10. package/lib/commonjs/offline/useOfflineIntegration.web.js +11 -0
  11. package/lib/commonjs/provider/PowerAppsHostProvider.web.js +41 -0
  12. package/lib/commonjs/provider/PowerAppsProvider.js +67 -10
  13. package/lib/module/auth/AuthContext.web.js +26 -0
  14. package/lib/module/auth/completePowerAppsAuthSession.js +11 -0
  15. package/lib/module/bridge/NativePowerAppsBridge.js +11 -1
  16. package/lib/module/bridge/PowerPagesBridge.js +132 -0
  17. package/lib/module/index.js +4 -2
  18. package/lib/module/offline/useOfflineIntegration.js +3 -0
  19. package/lib/module/offline/useOfflineIntegration.web.js +7 -0
  20. package/lib/module/provider/PowerAppsHostProvider.web.js +36 -0
  21. package/lib/module/provider/PowerAppsProvider.js +66 -9
  22. package/lib/typescript/commonjs/auth/AuthContext.web.d.ts +20 -0
  23. package/lib/typescript/commonjs/auth/completePowerAppsAuthSession.d.ts +8 -0
  24. package/lib/typescript/commonjs/bridge/PowerPagesBridge.d.ts +18 -0
  25. package/lib/typescript/commonjs/index.d.ts +3 -0
  26. package/lib/typescript/commonjs/offline/useOfflineIntegration.d.ts +2 -0
  27. package/lib/typescript/commonjs/offline/useOfflineIntegration.web.d.ts +4 -0
  28. package/lib/typescript/commonjs/provider/PowerAppsHostProvider.web.d.ts +5 -0
  29. package/lib/typescript/commonjs/provider/PowerAppsProvider.d.ts +2 -2
  30. package/lib/typescript/module/auth/AuthContext.web.d.ts +20 -0
  31. package/lib/typescript/module/auth/completePowerAppsAuthSession.d.ts +8 -0
  32. package/lib/typescript/module/bridge/PowerPagesBridge.d.ts +18 -0
  33. package/lib/typescript/module/index.d.ts +7 -4
  34. package/lib/typescript/module/offline/useOfflineIntegration.d.ts +2 -0
  35. package/lib/typescript/module/offline/useOfflineIntegration.web.d.ts +4 -0
  36. package/lib/typescript/module/provider/PowerAppsHostProvider.web.d.ts +5 -0
  37. package/lib/typescript/module/provider/PowerAppsProvider.d.ts +4 -4
  38. package/package.json +36 -16
  39. package/scripts/build-ios-base.js +66 -27
  40. package/scripts/bundle.js +212 -5
  41. package/scripts/generate-connector-schemas.js +8 -6
  42. package/scripts/start-web.mjs +137 -0
  43. package/scripts/upgrade/runtime.js +457 -0
  44. package/scripts/upgrade/system.js +244 -0
  45. package/scripts/upgrade-authoring.js +535 -0
  46. package/scripts/upgrade.js +188 -0
  47. package/scripts/wrap.js +536 -0
@@ -56,8 +56,13 @@ if (!xcworkspace) {
56
56
  // Derive scheme name from workspace name (strip .xcworkspace).
57
57
  const scheme = xcworkspace.replace('.xcworkspace', '');
58
58
 
59
- const archivePath = path.join(os.tmpdir(), 'pawrap-base.xcarchive');
60
- const ipaDir = path.join(os.tmpdir(), 'pawrap-base-ipa');
59
+ // --simulator: build for iOS Simulator instead of a physical device.
60
+ // Use this for local testing; the default (device) build is for App Store / wrap pipeline.
61
+ const isSimulator = process.argv.includes('--simulator');
62
+
63
+ const archivePath = path.join(os.tmpdir(), 'pawrap-base.xcarchive');
64
+ const simDerivedPath = path.join(os.tmpdir(), 'pawrap-sim-derived');
65
+ const ipaDir = path.join(os.tmpdir(), 'pawrap-base-ipa');
61
66
  const payloadDir = path.join(ipaDir, 'Payload');
62
67
  const ipaPath = path.join(os.tmpdir(), 'pawrap-base.ipa');
63
68
 
@@ -66,31 +71,65 @@ function run(cmd, args, opts = {}) {
66
71
  execFileSync(cmd, args, { stdio: 'inherit', ...opts });
67
72
  }
68
73
 
69
- // 1. Archive (no code signing — wrap pipeline signs with customer cert).
70
- run('xcodebuild', [
71
- '-workspace', path.join(iosDir, xcworkspace),
72
- '-scheme', scheme,
73
- '-configuration', 'Release',
74
- 'archive',
75
- '-archivePath', archivePath,
76
- '-destination', 'generic/platform=iOS',
77
- 'CODE_SIGNING_REQUIRED=NO',
78
- 'CODE_SIGN_IDENTITY=',
79
- 'CODE_SIGNING_ALLOWED=NO',
80
- ]);
81
-
82
- // 2. Package into an IPA.
83
- fs.rmSync(ipaDir, { recursive: true, force: true });
84
- fs.mkdirSync(payloadDir, { recursive: true });
85
-
86
- const appsGlob = path.join(archivePath, 'Products', 'Applications');
87
- for (const app of fs.readdirSync(appsGlob)) {
88
- if (app.endsWith('.app')) {
89
- fs.cpSync(path.join(appsGlob, app), path.join(payloadDir, app), { recursive: true });
74
+ if (isSimulator) {
75
+ // ── Simulator build ──────────────────────────────────────────────────────
76
+ // Uses xcodebuild build (not archive) targeting iOS Simulator so the Mach-O
77
+ // platform is IOSSIMULATOR and xcrun simctl can launch it.
78
+ run('xcodebuild', [
79
+ '-workspace', path.join(iosDir, xcworkspace),
80
+ '-scheme', scheme,
81
+ '-configuration', 'Release',
82
+ 'build',
83
+ '-destination', 'generic/platform=iOS Simulator',
84
+ '-derivedDataPath', simDerivedPath,
85
+ 'CODE_SIGNING_REQUIRED=NO',
86
+ 'CODE_SIGN_IDENTITY=',
87
+ 'CODE_SIGNING_ALLOWED=NO',
88
+ ]);
89
+
90
+ const simProductsDir = path.join(simDerivedPath, 'Build', 'Products', 'Release-iphonesimulator');
91
+ const appBundle = fs.readdirSync(simProductsDir).find(f => f.endsWith('.app'));
92
+ if (!appBundle) {
93
+ console.error('build-ios-base: no .app found in simulator build output');
94
+ process.exit(1);
90
95
  }
91
- }
92
96
 
93
- fs.rmSync(ipaPath, { force: true });
94
- run('zip', ['-r', ipaPath, '.'], { cwd: ipaDir });
97
+ // Package as IPA so the output format matches the device build.
98
+ // xcrun simctl install accepts simulator-built IPAs.
99
+ fs.rmSync(ipaDir, { recursive: true, force: true });
100
+ fs.mkdirSync(payloadDir, { recursive: true });
101
+ fs.cpSync(path.join(simProductsDir, appBundle), path.join(payloadDir, appBundle), { recursive: true });
102
+ fs.rmSync(ipaPath, { force: true });
103
+ run('zip', ['-r', ipaPath, '.'], { cwd: ipaDir });
104
+ console.log(`\n✓ Base IPA (simulator) ready: ${ipaPath}`);
105
+
106
+ } else {
107
+ // ── Device build (default) ───────────────────────────────────────────────
108
+ // Archives without code signing so the wrap pipeline can re-sign with the
109
+ // customer's provisioning profile.
110
+ run('xcodebuild', [
111
+ '-workspace', path.join(iosDir, xcworkspace),
112
+ '-scheme', scheme,
113
+ '-configuration', 'Release',
114
+ 'archive',
115
+ '-archivePath', archivePath,
116
+ '-destination', 'generic/platform=iOS',
117
+ 'CODE_SIGNING_REQUIRED=NO',
118
+ 'CODE_SIGN_IDENTITY=',
119
+ 'CODE_SIGNING_ALLOWED=NO',
120
+ ]);
95
121
 
96
- console.log(`\n✓ Base IPA ready: ${ipaPath}`);
122
+ fs.rmSync(ipaDir, { recursive: true, force: true });
123
+ fs.mkdirSync(payloadDir, { recursive: true });
124
+
125
+ const appsGlob = path.join(archivePath, 'Products', 'Applications');
126
+ for (const app of fs.readdirSync(appsGlob)) {
127
+ if (app.endsWith('.app')) {
128
+ fs.cpSync(path.join(appsGlob, app), path.join(payloadDir, app), { recursive: true });
129
+ }
130
+ }
131
+
132
+ fs.rmSync(ipaPath, { force: true });
133
+ run('zip', ['-r', ipaPath, '.'], { cwd: ipaDir });
134
+ console.log(`\n✓ Base IPA ready: ${ipaPath}`);
135
+ }
package/scripts/bundle.js CHANGED
@@ -1,27 +1,35 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Cross-platform Metro → Hermes bundle script.
3
+ * Cross-platform Expo bundle script.
4
4
  *
5
5
  * Runs two steps:
6
6
  * 1. expo export:embed — produces a plain Metro JS bundle
7
- * 2. hermesc — compiles it to Hermes bytecode (.hbc)
7
+ * 2. hermesc — compiles native bundles to Hermes bytecode (.hbc)
8
8
  *
9
9
  * Usage (via npm script in the consuming expo-app):
10
10
  * bundle android → dist/index.android.bundle.hbc
11
11
  * bundle ios → dist/main.jsbundle.hbc
12
+ * bundle web → dist-web/ (Code Apps static site)
13
+ * bundle web powerpages → dist-web/ (Power Pages static site)
12
14
  *
13
15
  * Works on macOS, Linux, and Windows.
14
16
  */
15
17
 
16
18
  'use strict';
17
19
 
18
- const { execFileSync } = require('child_process');
20
+ const { execFileSync, spawn } = require('child_process');
19
21
  const path = require('path');
20
22
  const fs = require('fs');
21
23
 
22
24
  const platform = process.argv[2];
23
- if (platform !== 'android' && platform !== 'ios') {
24
- console.error('Usage: bundle <android|ios>');
25
+ if (platform !== 'android' && platform !== 'ios' && platform !== 'web') {
26
+ console.error('Usage: bundle <android|ios|web> [powerpages]');
27
+ process.exit(1);
28
+ }
29
+
30
+ const webTarget = process.argv[3] ?? 'codeapps';
31
+ if (platform === 'web' && webTarget !== 'codeapps' && webTarget !== 'powerpages') {
32
+ console.error('Usage: bundle web [powerpages]');
25
33
  process.exit(1);
26
34
  }
27
35
 
@@ -32,6 +40,204 @@ const isLinux = process.platform === 'linux';
32
40
  const projectRoot = process.cwd();
33
41
  const workspaceRoot = path.resolve(projectRoot, '../..');
34
42
 
43
+ function validatePowerPagesDataSources() {
44
+ const configPath = path.join(projectRoot, 'power.config.json');
45
+ const powerConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
46
+ const unsupportedConnectors = Object.values(powerConfig.connectionReferences ?? {})
47
+ .filter((reference) => {
48
+ const connectorName = String(reference.id ?? '').split('/').pop()?.toLowerCase();
49
+ return connectorName !== 'shared_commondataserviceforapps';
50
+ })
51
+ .map((reference) => reference.displayName || reference.id || '<unknown>');
52
+
53
+ if (unsupportedConnectors.length > 0) {
54
+ throw new Error(
55
+ `Power Pages bundles support Dataverse only. Remove these connector references: ${unsupportedConnectors.join(', ')}`
56
+ );
57
+ }
58
+ }
59
+
60
+ function findFiles(directory, predicate) {
61
+ return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
62
+ const entryPath = path.join(directory, entry.name);
63
+ return entry.isDirectory()
64
+ ? findFiles(entryPath, predicate)
65
+ : predicate(entryPath) ? [entryPath] : [];
66
+ });
67
+ }
68
+
69
+ function prepareHostedWebOutput(outputDir) {
70
+ const hostedFiles = findFiles(
71
+ outputDir,
72
+ (filePath) => /\.(?:html|js|css)$/.test(filePath),
73
+ );
74
+ for (const hostedFile of hostedFiles) {
75
+ const contents = fs.readFileSync(hostedFile, 'utf8');
76
+ const relativeAssetContents = contents.replace(
77
+ /(["'`])\/(assets|_expo)\//g,
78
+ '$1./$2/',
79
+ );
80
+ if (relativeAssetContents !== contents) {
81
+ fs.writeFileSync(hostedFile, relativeAssetContents);
82
+ }
83
+ if (/(["'`])\/(assets|_expo)\//.test(relativeAssetContents)) {
84
+ throw new Error(`Hosted web output contains a root-relative asset URL: ${hostedFile}`);
85
+ }
86
+ }
87
+ }
88
+
89
+ function prepareHostedSpaOutput(outputDir, isPowerPages) {
90
+ const entryBundles = findFiles(
91
+ outputDir,
92
+ (filePath) => /[/\\]_expo[/\\]static[/\\]js[/\\]web[/\\]entry-[^/\\]+\.js$/.test(filePath),
93
+ );
94
+ if (entryBundles.length !== 1) {
95
+ throw new Error(`Expected one hosted web entry bundle, found ${entryBundles.length}`);
96
+ }
97
+
98
+ const entryContents = fs.readFileSync(entryBundles[0], 'utf8');
99
+ const reactRuntimeCount = (entryContents.match(/react\.production\.js/g) ?? []).length;
100
+ if (reactRuntimeCount > 1) {
101
+ throw new Error(
102
+ `Hosted web bundle contains ${reactRuntimeCount} React runtimes. Configure Metro to resolve react and react/* from one node_modules path.`
103
+ );
104
+ }
105
+ const routerContextCount = (
106
+ entryContents.match(/No filename found\. This is likely a bug in expo-router\./g) ?? []
107
+ ).length;
108
+ if (routerContextCount > 1) {
109
+ throw new Error(
110
+ `Hosted web bundle contains ${routerContextCount} Expo Router contexts. Build from the canonical workspace path instead of a symlinked project root.`
111
+ );
112
+ }
113
+
114
+ const entryUrl = `/${path.relative(outputDir, entryBundles[0]).split(path.sep).join('/')}`;
115
+ const htmlFiles = findFiles(outputDir, (filePath) => filePath.endsWith('.html'));
116
+ for (const htmlFile of htmlFiles) {
117
+ fs.rmSync(htmlFile);
118
+ }
119
+
120
+ fs.writeFileSync(
121
+ path.join(outputDir, 'index.html'),
122
+ `<!DOCTYPE html>
123
+ <html lang="en">
124
+ <head>
125
+ <meta charset="utf-8">
126
+ <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
127
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
128
+ <style>#root,body,html{height:100%}body{margin:0;overflow:hidden}#root{display:flex}</style>
129
+ <script>
130
+ if (location.pathname.includes('/powerapps/appruntime/')) {
131
+ const assetBase = document.createElement('base');
132
+ assetBase.href = new URL('.', location.href).href;
133
+ document.head.appendChild(assetBase);
134
+ history.replaceState(history.state, '', '/' + location.search + location.hash);
135
+ }
136
+ </script>
137
+ </head>
138
+ <body>
139
+ <div id="root"></div>
140
+ <script src="${entryUrl}" defer></script>
141
+ </body>
142
+ </html>
143
+ `,
144
+ );
145
+ }
146
+
147
+ function runExpoExport(expoBin, args, outputDir, env) {
148
+ const completionMarker = `Exported: ${outputDir}`;
149
+
150
+ return new Promise((resolve, reject) => {
151
+ const child = spawn(expoBin, args, {
152
+ cwd: projectRoot,
153
+ env,
154
+ shell: isWin,
155
+ stdio: ['ignore', 'pipe', 'pipe'],
156
+ });
157
+ let outputTail = '';
158
+ let exportCompleted = false;
159
+
160
+ const forwardOutput = (chunk, destination) => {
161
+ destination.write(chunk);
162
+ outputTail = (outputTail + chunk).slice(-completionMarker.length * 2);
163
+ if (!exportCompleted && outputTail.includes(completionMarker)) {
164
+ exportCompleted = true;
165
+ if (isWin && child.pid) {
166
+ try {
167
+ execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
168
+ } catch {
169
+ child.kill();
170
+ }
171
+ } else {
172
+ child.kill('SIGKILL');
173
+ }
174
+ }
175
+ };
176
+
177
+ child.stdout.on('data', (chunk) => forwardOutput(chunk, process.stdout));
178
+ child.stderr.on('data', (chunk) => forwardOutput(chunk, process.stderr));
179
+ child.once('error', reject);
180
+ child.once('exit', (code, signal) => {
181
+ if (exportCompleted) {
182
+ resolve();
183
+ } else {
184
+ reject(new Error(
185
+ code === 0
186
+ ? `Expo export exited before reporting completion: ${completionMarker}`
187
+ : `Expo export failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`,
188
+ ));
189
+ }
190
+ });
191
+ });
192
+ }
193
+
194
+ async function bundleWeb() {
195
+ const isPowerPages = webTarget === 'powerpages';
196
+ if (isPowerPages) {
197
+ validatePowerPagesDataSources();
198
+ }
199
+
200
+ const outputDir = path.join(projectRoot, 'dist-web');
201
+ fs.rmSync(outputDir, { recursive: true, force: true });
202
+ console.log(`\n[bundle] Expo static export (${isPowerPages ? 'Power Pages' : 'Code Apps'})`);
203
+
204
+ const expoBinName = isWin ? 'expo.cmd' : 'expo';
205
+ const expoBinLocal = path.join(projectRoot, 'node_modules', '.bin', expoBinName);
206
+ const expoBinHoisted = path.join(workspaceRoot, 'node_modules', '.bin', expoBinName);
207
+ const expoBin = fs.existsSync(expoBinLocal) ? expoBinLocal : expoBinHoisted;
208
+
209
+ await runExpoExport(
210
+ expoBin,
211
+ ['export', '--platform', 'web', '--output-dir', outputDir, '--clear'],
212
+ outputDir,
213
+ {
214
+ ...process.env,
215
+ CI: process.env.CI ?? '1',
216
+ EXPO_NO_TELEMETRY: '1',
217
+ },
218
+ );
219
+
220
+ if (isPowerPages) {
221
+ prepareHostedSpaOutput(outputDir, true);
222
+ } else {
223
+ prepareHostedSpaOutput(outputDir, false);
224
+ prepareHostedWebOutput(outputDir);
225
+ }
226
+
227
+ console.log(`\n[bundle] Done. ${isPowerPages ? 'Power Pages' : 'Code Apps'} site: "${outputDir}"`);
228
+ process.exit(0);
229
+ }
230
+
231
+ if (platform === 'web') {
232
+ bundleWeb().catch((error) => {
233
+ console.error(error);
234
+ process.exit(1);
235
+ });
236
+ } else {
237
+ bundleNative();
238
+ }
239
+
240
+ function bundleNative() {
35
241
  const distDir = path.join(projectRoot, 'dist');
36
242
  fs.mkdirSync(distDir, { recursive: true });
37
243
 
@@ -93,3 +299,4 @@ const hermesc = fs.existsSync(path.join(hcDir, binDir, binName))
93
299
  execFileSync(hermesc, ['-emit-binary', '-out', outputFile, bundleFile], { stdio: 'inherit' });
94
300
 
95
301
  console.log(`\n[bundle] Done. Output: "${outputFile}"`);
302
+ }
@@ -2,7 +2,8 @@
2
2
  /**
3
3
  * generate-connector-schemas
4
4
  *
5
- * Auto-generates src/connectorSchemas.ts by scanning .power/schemas/ for
5
+ * Customer-invoked generator for src/generated/connectorSchemas.ts. Scans
6
+ * .power/schemas/ for
6
7
  * connector schema JSON files placed there by `pac code add-data-source`.
7
8
  *
8
9
  * Run from the root of your Expo app (where .power/schemas/ lives):
@@ -11,9 +12,9 @@
11
12
  * Or wire it into package.json scripts:
12
13
  * "prestart": "generate-connector-schemas"
13
14
  *
14
- * The generated file is re-created on every run and should be committed to
15
- * source control so TypeScript can resolve the connector schema types at
16
- * compile time.
15
+ * The generated file is customer-owned output: upgrade-template never changes
16
+ * it. Re-run this command after adding or removing connectors, and commit the
17
+ * result so TypeScript can resolve connector schema types at compile time.
17
18
  */
18
19
 
19
20
  'use strict';
@@ -47,8 +48,9 @@ if (fs.existsSync(schemasDir)) {
47
48
  console.warn('[generate-connector-schemas] .power/schemas/ not found — emitting empty schemaMap.');
48
49
  }
49
50
 
50
- const content = `// AUTO-GENERATED — do not edit manually.
51
- // Re-generated automatically on every \`npm run start\` / \`npm run build\`.
51
+ const content = `// AUTO-GENERATED CUSTOMER FILE — do not edit manually.
52
+ // Run \`npm run generate-schemas\` after adding or removing connectors.
53
+ // This file is never modified by upgrade-template.
52
54
  // To add a connector: pac code add-data-source (no further changes needed).
53
55
  // Generator: @microsoft/power-apps-native-host/scripts/generate-connector-schemas.js
54
56
 
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from 'node:child_process';
4
+ import { readFile } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import process from 'node:process';
7
+
8
+ const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
9
+ const npxCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx';
10
+ const localAppUrl = 'http://localhost:8082';
11
+ const children = new Set();
12
+ let shuttingDown = false;
13
+
14
+ async function readPowerConfig() {
15
+ const configPath = path.resolve(process.cwd(), 'power.config.json');
16
+ let config;
17
+ try {
18
+ config = JSON.parse(await readFile(configPath, 'utf8'));
19
+ } catch (error) {
20
+ throw new Error(
21
+ `Unable to read ${configPath}: ${error instanceof Error ? error.message : String(error)}`
22
+ );
23
+ }
24
+
25
+ if (typeof config.environmentId !== 'string' || !config.environmentId.trim()) {
26
+ throw new Error(`environmentId is required in ${configPath}`);
27
+ }
28
+ return config;
29
+ }
30
+
31
+ function run(command, args, options = {}) {
32
+ const child = spawn(command, args, {
33
+ cwd: process.cwd(),
34
+ env: process.env,
35
+ stdio: 'inherit',
36
+ ...options,
37
+ });
38
+ children.add(child);
39
+ child.once('exit', (code, signal) => {
40
+ children.delete(child);
41
+ if (!shuttingDown && (code !== 0 || signal)) {
42
+ shutdown(code ?? 1);
43
+ }
44
+ });
45
+ return child;
46
+ }
47
+
48
+ function shutdown(exitCode = 0) {
49
+ if (shuttingDown) return;
50
+ shuttingDown = true;
51
+ for (const child of children) {
52
+ child.kill('SIGTERM');
53
+ }
54
+ process.exitCode = exitCode;
55
+ }
56
+
57
+ async function waitForApp(timeoutMs = 60_000) {
58
+ const deadline = Date.now() + timeoutMs;
59
+ while (Date.now() < deadline) {
60
+ try {
61
+ const response = await fetch(localAppUrl, {
62
+ headers: { Accept: 'text/html' },
63
+ });
64
+ if (response.ok) return;
65
+ } catch {
66
+ // Metro is still starting.
67
+ }
68
+ await new Promise((resolve) => setTimeout(resolve, 250));
69
+ }
70
+ throw new Error(`Timed out waiting for ${localAppUrl}`);
71
+ }
72
+
73
+ function openBrowser(url) {
74
+ if (process.platform === 'darwin') {
75
+ spawn('open', [url], { detached: true, stdio: 'ignore' }).unref();
76
+ } else if (process.platform === 'win32') {
77
+ spawn('cmd.exe', ['/d', '/s', '/c', 'start', '', url], {
78
+ detached: true,
79
+ stdio: 'ignore',
80
+ }).unref();
81
+ } else {
82
+ spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref();
83
+ }
84
+ }
85
+
86
+ function captureLocalPlayUrl(child, environmentId) {
87
+ let output = '';
88
+ child.stdout.on('data', (chunk) => {
89
+ const text = chunk.toString();
90
+ process.stdout.write(text);
91
+ output += text;
92
+
93
+ const match = output.match(/https:\/\/[^\s]+_localAppUrl=[^\s]+/);
94
+ if (!match) return;
95
+
96
+ const playUrl = new URL(match[0]);
97
+ const environmentFromUrl = playUrl.pathname.match(/\/play\/e\/([^/]+)\//)?.[1];
98
+ if (decodeURIComponent(environmentFromUrl ?? '') !== environmentId) {
99
+ console.error(
100
+ `Local Play URL environment mismatch: expected ${environmentId}, received ${environmentFromUrl ?? 'none'}`
101
+ );
102
+ shutdown(1);
103
+ return;
104
+ }
105
+
106
+ child.stdout.removeAllListeners('data');
107
+ child.stdout.pipe(process.stdout);
108
+ void waitForApp()
109
+ .then(() => openBrowser(playUrl.href))
110
+ .catch((error) => {
111
+ console.error(error instanceof Error ? error.message : String(error));
112
+ shutdown(1);
113
+ });
114
+ });
115
+ }
116
+
117
+ process.on('SIGINT', () => shutdown());
118
+ process.on('SIGTERM', () => shutdown());
119
+
120
+ const powerConfig = await readPowerConfig();
121
+ console.log(`Starting Power Apps Local Play for environment ${powerConfig.environmentId}`);
122
+
123
+ const schemaProcess = spawn(npmCommand, ['run', 'generate-schemas'], {
124
+ cwd: process.cwd(),
125
+ env: process.env,
126
+ stdio: 'inherit',
127
+ });
128
+ const schemaExitCode = await new Promise((resolve) => schemaProcess.once('exit', resolve));
129
+ if (schemaExitCode !== 0) process.exit(schemaExitCode ?? 1);
130
+
131
+ run(npxCommand, ['expo', 'start', '--port', '8082', '--localhost']);
132
+ const powerApps = run(
133
+ npxCommand,
134
+ ['power-apps', 'run', '--port', '8080', '--local-app-url', localAppUrl],
135
+ { stdio: ['inherit', 'pipe', 'inherit'] }
136
+ );
137
+ captureLocalPlayUrl(powerApps, powerConfig.environmentId);