vxrn 2.8.1 → 2.9.1
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/dist/exports/clean.mjs +128 -19
- package/dist/exports/clean.mjs.map +1 -1
- package/dist/exports/clean.native.js +147 -21
- package/dist/exports/clean.native.js.map +1 -1
- package/dist/exports/prebuild.mjs +2 -1
- package/dist/exports/prebuild.mjs.map +1 -1
- package/dist/exports/prebuild.native.js +2 -1
- package/dist/exports/prebuild.native.js.map +1 -1
- package/dist/exports/prebuildWithoutExpo.mjs +196 -7
- package/dist/exports/prebuildWithoutExpo.mjs.map +1 -1
- package/dist/exports/prebuildWithoutExpo.native.js +280 -42
- package/dist/exports/prebuildWithoutExpo.native.js.map +1 -1
- package/dist/plugins/reactNativeDevServer.mjs +43 -8
- package/dist/plugins/reactNativeDevServer.mjs.map +1 -1
- package/dist/plugins/reactNativeDevServer.native.js +45 -8
- package/dist/plugins/reactNativeDevServer.native.js.map +1 -1
- package/dist/user-interface/index.mjs +31 -24
- package/dist/user-interface/index.mjs.map +1 -1
- package/dist/user-interface/index.native.js +32 -25
- package/dist/user-interface/index.native.js.map +1 -1
- package/dist/utils/createNativeDevEngine.mjs +111 -14
- package/dist/utils/createNativeDevEngine.mjs.map +1 -1
- package/dist/utils/createNativeDevEngine.native.js +143 -14
- package/dist/utils/createNativeDevEngine.native.js.map +1 -1
- package/dist/utils/scanDepsToOptimize.mjs +10 -6
- package/dist/utils/scanDepsToOptimize.mjs.map +1 -1
- package/dist/utils/scanDepsToOptimize.native.js +12 -6
- package/dist/utils/scanDepsToOptimize.native.js.map +1 -1
- package/expo-plugin.cjs +71 -8
- package/package.json +14 -14
- package/src/exports/clean.test.ts +502 -5
- package/src/exports/clean.ts +176 -19
- package/src/exports/expoPlugin.test.mjs +44 -1
- package/src/exports/prebuild.ts +6 -0
- package/src/exports/prebuildWithoutExpo.test.ts +134 -2
- package/src/exports/prebuildWithoutExpo.ts +235 -10
- package/src/plugins/reactNativeDevServer.test.ts +75 -1
- package/src/plugins/reactNativeDevServer.ts +45 -8
- package/src/user-interface/index.ts +50 -42
- package/src/utils/createNativeDevEngine.test.ts +480 -1
- package/src/utils/createNativeDevEngine.ts +220 -17
- package/src/utils/imageSizePatch.test.ts +3 -3
- package/src/utils/scanDepsToOptimize.test.ts +35 -2
- package/src/utils/scanDepsToOptimize.ts +25 -8
- package/types/exports/clean.d.ts.map +1 -1
- package/types/exports/prebuild.d.ts +2 -1
- package/types/exports/prebuild.d.ts.map +1 -1
- package/types/exports/prebuildWithoutExpo.d.ts.map +1 -1
- package/types/plugins/reactNativeDevServer.d.ts.map +1 -1
- package/types/user-interface/index.d.ts.map +1 -1
- package/types/utils/createNativeDevEngine.d.ts +1 -1
- package/types/utils/createNativeDevEngine.d.ts.map +1 -1
- package/types/utils/scanDepsToOptimize.d.ts +25 -0
- package/types/utils/scanDepsToOptimize.d.ts.map +1 -1
|
@@ -4,7 +4,10 @@ import module from 'node:module'
|
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { pathToFileURL } from 'node:url'
|
|
6
6
|
import {
|
|
7
|
+
androidIconSuffix,
|
|
8
|
+
appIconPreviewResources,
|
|
7
9
|
validateNativeApp,
|
|
10
|
+
type NativeAppIcon,
|
|
8
11
|
type NativeAppManifest,
|
|
9
12
|
type PlistValue,
|
|
10
13
|
} from '@vxrn/utils/nativeAppManifest'
|
|
@@ -1353,6 +1356,59 @@ const IPAD_APP_ICON_SIZES: Array<[string, string[]]> = [
|
|
|
1353
1356
|
['83.5x83.5', ['2x']],
|
|
1354
1357
|
]
|
|
1355
1358
|
|
|
1359
|
+
// ios 18 home screen appearances use the single 1024 universal entry, which
|
|
1360
|
+
// the asset compiler scales to every size, with a dark and a tinted variant
|
|
1361
|
+
// beside the light one.
|
|
1362
|
+
async function writeAppearanceIcon(args: {
|
|
1363
|
+
root: string
|
|
1364
|
+
iconDir: string
|
|
1365
|
+
icon: NativeAppIcon
|
|
1366
|
+
source: string
|
|
1367
|
+
}): Promise<void> {
|
|
1368
|
+
const { root, iconDir, icon, source } = args
|
|
1369
|
+
const variantSource = (variant: { source?: string } | undefined) => {
|
|
1370
|
+
if (!variant?.source) return source
|
|
1371
|
+
const resolved = path.resolve(root, variant.source)
|
|
1372
|
+
if (!FSExtra.existsSync(resolved)) {
|
|
1373
|
+
throw new Error(`[vxrn] native.app icon appearance source does not exist: ${resolved}`)
|
|
1374
|
+
}
|
|
1375
|
+
return resolved
|
|
1376
|
+
}
|
|
1377
|
+
const render = async (file: string, from: string, background?: string, gray = false) => {
|
|
1378
|
+
let image = sharp(from).rotate().resize(1024, 1024, { fit: 'cover' })
|
|
1379
|
+
if (background) image = image.flatten({ background })
|
|
1380
|
+
if (gray) image = image.greyscale()
|
|
1381
|
+
await image.png().toFile(path.join(iconDir, file))
|
|
1382
|
+
}
|
|
1383
|
+
const images: Array<Record<string, unknown>> = []
|
|
1384
|
+
const entry = (filename: string, value?: string) => ({
|
|
1385
|
+
...(value ? { appearances: [{ appearance: 'luminosity', value }] } : {}),
|
|
1386
|
+
filename,
|
|
1387
|
+
idiom: 'universal',
|
|
1388
|
+
platform: 'ios',
|
|
1389
|
+
size: '1024x1024',
|
|
1390
|
+
})
|
|
1391
|
+
await render('icon-1024.png', source, icon.backgroundColor)
|
|
1392
|
+
images.push(entry('icon-1024.png'))
|
|
1393
|
+
if (icon.dark) {
|
|
1394
|
+
await render('icon-1024-dark.png', variantSource(icon.dark), icon.dark.backgroundColor)
|
|
1395
|
+
images.push(entry('icon-1024-dark.png', 'dark'))
|
|
1396
|
+
}
|
|
1397
|
+
if (icon.tinted) {
|
|
1398
|
+
await render(
|
|
1399
|
+
'icon-1024-tinted.png',
|
|
1400
|
+
variantSource(icon.tinted),
|
|
1401
|
+
icon.tinted.backgroundColor ?? '#000000',
|
|
1402
|
+
true
|
|
1403
|
+
)
|
|
1404
|
+
images.push(entry('icon-1024-tinted.png', 'tinted'))
|
|
1405
|
+
}
|
|
1406
|
+
FSExtra.writeFileSync(
|
|
1407
|
+
path.join(iconDir, 'Contents.json'),
|
|
1408
|
+
`${JSON.stringify({ images, info: { author: 'xcode', version: 1 } }, null, 2)}\n`
|
|
1409
|
+
)
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1356
1412
|
async function generateAppIcons(args: {
|
|
1357
1413
|
root: string
|
|
1358
1414
|
dest: string
|
|
@@ -1368,9 +1424,9 @@ async function generateAppIcons(args: {
|
|
|
1368
1424
|
images: Array<{ idiom: string; scale: string; size: string; filename?: string }>
|
|
1369
1425
|
info: { author: string; version: number }
|
|
1370
1426
|
} = JSON.parse(FSExtra.readFileSync(path.join(assetsDir, 'AppIcon.appiconset', 'Contents.json'), 'utf8'))
|
|
1371
|
-
const icons: Array<[string,
|
|
1427
|
+
const icons: Array<[string, NativeAppIcon]> = [
|
|
1372
1428
|
['AppIcon', app.icon],
|
|
1373
|
-
...Object.entries(app.
|
|
1429
|
+
...Object.entries(app.alternateIcons ?? {}),
|
|
1374
1430
|
]
|
|
1375
1431
|
for (const [name, icon] of icons) {
|
|
1376
1432
|
const source = path.resolve(root, icon.source)
|
|
@@ -1383,6 +1439,10 @@ async function generateAppIcons(args: {
|
|
|
1383
1439
|
}
|
|
1384
1440
|
const iconDir = path.join(assetsDir, `${name}.appiconset`)
|
|
1385
1441
|
FSExtra.mkdirSync(iconDir, { recursive: true })
|
|
1442
|
+
if (icon.dark || icon.tinted) {
|
|
1443
|
+
await writeAppearanceIcon({ root, iconDir, icon, source })
|
|
1444
|
+
continue
|
|
1445
|
+
}
|
|
1386
1446
|
const contents = structuredClone(template)
|
|
1387
1447
|
// the template is iPhone-only. App Store upload rejects a tablet build
|
|
1388
1448
|
// without the iPad sizes (ITMS-90023, 152 and 167 pixels among them).
|
|
@@ -1418,17 +1478,172 @@ async function generateAppIcons(args: {
|
|
|
1418
1478
|
if (metadata.width !== metadata.height || metadata.width === undefined || metadata.width < 1024) {
|
|
1419
1479
|
throw new Error('[vxrn] native.app.icon source must be a square image at least 1024px wide')
|
|
1420
1480
|
}
|
|
1481
|
+
// the primary icon and each alternate's launcher icon, one legacy png per
|
|
1482
|
+
// density. an alternate's alias points at its own ic_launcher_<name> pair.
|
|
1483
|
+
const launchers: Array<[string, NativeAppIcon, string]> = [
|
|
1484
|
+
['ic_launcher', app.icon, source],
|
|
1485
|
+
...(await Promise.all(
|
|
1486
|
+
Object.entries(app.alternateIcons ?? {}).map(
|
|
1487
|
+
async ([name, icon]): Promise<[string, NativeAppIcon, string]> => {
|
|
1488
|
+
const alternateSource = path.resolve(root, icon.source)
|
|
1489
|
+
if (!FSExtra.existsSync(alternateSource)) {
|
|
1490
|
+
throw new Error(`[vxrn] native.app icon source does not exist: ${alternateSource}`)
|
|
1491
|
+
}
|
|
1492
|
+
const alternate = await sharp(alternateSource).metadata()
|
|
1493
|
+
if (alternate.width !== alternate.height || alternate.width === undefined || alternate.width < 1024) {
|
|
1494
|
+
throw new Error(`[vxrn] native.app icon source must be a square image at least 1024px wide: ${alternateSource}`)
|
|
1495
|
+
}
|
|
1496
|
+
return [androidAliasIconName(name), icon, alternateSource]
|
|
1497
|
+
}
|
|
1498
|
+
)
|
|
1499
|
+
)),
|
|
1500
|
+
]
|
|
1421
1501
|
for (const [density, multiplier] of Object.entries(ANDROID_DENSITIES)) {
|
|
1422
1502
|
const pixels = 48 * multiplier
|
|
1423
1503
|
const iconDir = path.join(dest, 'app', 'src', 'main', 'res', `mipmap-${density}`)
|
|
1424
|
-
for (const
|
|
1425
|
-
|
|
1504
|
+
for (const [resource, icon, iconSource] of launchers) {
|
|
1505
|
+
for (const filename of [`${resource}.png`, `${resource}_round.png`]) {
|
|
1506
|
+
await sharp(iconSource)
|
|
1507
|
+
.rotate()
|
|
1508
|
+
.resize(pixels, pixels, { fit: 'cover' })
|
|
1509
|
+
.flatten({ background: icon.backgroundColor })
|
|
1510
|
+
.png()
|
|
1511
|
+
.toFile(path.join(iconDir, filename))
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function androidAliasIconName(name: string): string {
|
|
1518
|
+
return `ic_launcher_${androidIconSuffix(name)}`
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
const LAUNCHER_FILTER = /[ \t]*<intent-filter>(?:(?!<\/intent-filter>)[\s\S])*android\.intent\.category\.LAUNCHER[\s\S]*?<\/intent-filter>\n?/
|
|
1522
|
+
|
|
1523
|
+
// launcher aliases for One.AppIcon: the real MainActivity stays enabled and
|
|
1524
|
+
// loses its launcher filter to a Primary alias, each alternate is a disabled
|
|
1525
|
+
// alias, and every alias targets a small activity that forwards into the
|
|
1526
|
+
// host. android finishes activities launched through an alias when that alias
|
|
1527
|
+
// is disabled, so an alias must never target the react native host itself.
|
|
1528
|
+
function stampAndroidIconAliases(manifest: string, app: NativeAppManifest): string {
|
|
1529
|
+
const alternates = Object.keys(app.alternateIcons ?? {})
|
|
1530
|
+
const host = manifest.match(/<activity\b(?:(?!<\/activity>)[\s\S])*android:name="\.MainActivity"[\s\S]*?<\/activity>/)?.[0]
|
|
1531
|
+
if (!host || !LAUNCHER_FILTER.test(host)) {
|
|
1532
|
+
throw new Error(
|
|
1533
|
+
'[vxrn] cannot stamp alternate icons: expected the launcher filter on .MainActivity in app/src/main/AndroidManifest.xml'
|
|
1534
|
+
)
|
|
1535
|
+
}
|
|
1536
|
+
const alias = (name: string, icon: string, enabled: boolean) => ` <activity-alias
|
|
1537
|
+
android:name=".${name}"
|
|
1538
|
+
android:targetActivity=".AppIconLauncherActivity"
|
|
1539
|
+
android:enabled="${enabled}"
|
|
1540
|
+
android:exported="true"
|
|
1541
|
+
android:icon="@mipmap/${icon}"
|
|
1542
|
+
android:roundIcon="@mipmap/${icon}_round"
|
|
1543
|
+
android:label="@string/app_name">
|
|
1544
|
+
<intent-filter>
|
|
1545
|
+
<action android:name="android.intent.action.MAIN" />
|
|
1546
|
+
<category android:name="android.intent.category.LAUNCHER" />
|
|
1547
|
+
</intent-filter>
|
|
1548
|
+
</activity-alias>`
|
|
1549
|
+
const stamps = [
|
|
1550
|
+
` <activity
|
|
1551
|
+
android:name=".AppIconLauncherActivity"
|
|
1552
|
+
android:exported="false"
|
|
1553
|
+
android:taskAffinity=""
|
|
1554
|
+
android:excludeFromRecents="true"
|
|
1555
|
+
android:theme="@android:style/Theme.NoDisplay" />`,
|
|
1556
|
+
alias('Primary', 'ic_launcher', true),
|
|
1557
|
+
...alternates.map((name) => alias(name, androidAliasIconName(name), false)),
|
|
1558
|
+
].join('\n')
|
|
1559
|
+
const anchor = ' </application>'
|
|
1560
|
+
if (!manifest.includes(anchor)) {
|
|
1561
|
+
throw new Error(
|
|
1562
|
+
'[vxrn] cannot stamp alternate icons: expected </application> in app/src/main/AndroidManifest.xml'
|
|
1563
|
+
)
|
|
1564
|
+
}
|
|
1565
|
+
return manifest
|
|
1566
|
+
.replace(host, host.replace(LAUNCHER_FILTER, ''))
|
|
1567
|
+
.replace(anchor, `${stamps}\n${anchor}`)
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
function generateAndroidIconForwarder(dest: string, app: NativeAppManifest): void {
|
|
1571
|
+
if (!app.alternateIcons || !app.android) return
|
|
1572
|
+
const packageName = app.android.applicationId
|
|
1573
|
+
const file = path.join(
|
|
1574
|
+
dest,
|
|
1575
|
+
'app',
|
|
1576
|
+
'src',
|
|
1577
|
+
'main',
|
|
1578
|
+
'java',
|
|
1579
|
+
...packageName.split('.'),
|
|
1580
|
+
'AppIconLauncherActivity.kt'
|
|
1581
|
+
)
|
|
1582
|
+
FSExtra.mkdirSync(path.dirname(file), { recursive: true })
|
|
1583
|
+
FSExtra.writeFileSync(
|
|
1584
|
+
file,
|
|
1585
|
+
`package ${packageName}
|
|
1586
|
+
|
|
1587
|
+
import android.app.Activity
|
|
1588
|
+
import android.content.Intent
|
|
1589
|
+
import android.os.Bundle
|
|
1590
|
+
|
|
1591
|
+
// launcher aliases finish before react native mounts in the permanent host.
|
|
1592
|
+
class AppIconLauncherActivity : Activity() {
|
|
1593
|
+
override fun onCreate(savedInstanceState: Bundle?) {
|
|
1594
|
+
super.onCreate(savedInstanceState)
|
|
1595
|
+
startActivity(Intent(intent).setClass(this, MainActivity::class.java)
|
|
1596
|
+
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
|
1597
|
+
Intent.FLAG_ACTIVITY_SINGLE_TOP))
|
|
1598
|
+
finish()
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
`
|
|
1602
|
+
)
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
// a 64 point preview of the primary icon and each alternate, bundled as plain
|
|
1606
|
+
// images so an in-app picker (One.AppIcon.list) needs no second copy of the
|
|
1607
|
+
// art: an imageset on ios, which cannot load an app icon set by name, and a
|
|
1608
|
+
// density-independent drawable on android.
|
|
1609
|
+
async function generateAppIconPreviews(args: {
|
|
1610
|
+
root: string
|
|
1611
|
+
dest: string
|
|
1612
|
+
platform: 'ios' | 'android'
|
|
1613
|
+
app: NativeAppManifest
|
|
1614
|
+
}): Promise<void> {
|
|
1615
|
+
const { root, dest, platform, app } = args
|
|
1616
|
+
if (!app.icon || !app.alternateIcons) return
|
|
1617
|
+
const icons: Array<[string | undefined, NativeAppIcon]> = [
|
|
1618
|
+
[undefined, app.icon],
|
|
1619
|
+
...Object.entries(app.alternateIcons),
|
|
1620
|
+
]
|
|
1621
|
+
for (const [name, icon] of icons) {
|
|
1622
|
+
const preview = (scale: number) =>
|
|
1623
|
+
sharp(path.resolve(root, icon.source))
|
|
1426
1624
|
.rotate()
|
|
1427
|
-
.resize(
|
|
1428
|
-
.flatten({ background:
|
|
1625
|
+
.resize(64 * scale, 64 * scale, { fit: 'cover' })
|
|
1626
|
+
.flatten({ background: icon.backgroundColor })
|
|
1429
1627
|
.png()
|
|
1430
|
-
|
|
1628
|
+
const resources = appIconPreviewResources(name)
|
|
1629
|
+
if (platform === 'android') {
|
|
1630
|
+
const dir = path.join(dest, 'app', 'src', 'main', 'res', 'drawable-nodpi')
|
|
1631
|
+
FSExtra.mkdirSync(dir, { recursive: true })
|
|
1632
|
+
await preview(3).toFile(path.join(dir, `${resources.android}.png`))
|
|
1633
|
+
continue
|
|
1431
1634
|
}
|
|
1635
|
+
const dir = path.join(dest, app.name, 'Images.xcassets', `${resources.ios}.imageset`)
|
|
1636
|
+
FSExtra.mkdirSync(dir, { recursive: true })
|
|
1637
|
+
const images: Array<Record<string, string>> = []
|
|
1638
|
+
for (const scale of [1, 2, 3]) {
|
|
1639
|
+
const filename = `preview@${scale}x.png`
|
|
1640
|
+
await preview(scale).toFile(path.join(dir, filename))
|
|
1641
|
+
images.push({ filename, idiom: 'universal', scale: `${scale}x` })
|
|
1642
|
+
}
|
|
1643
|
+
FSExtra.writeFileSync(
|
|
1644
|
+
path.join(dir, 'Contents.json'),
|
|
1645
|
+
`${JSON.stringify({ images, info: { author: 'xcode', version: 1 } }, null, 2)}\n`
|
|
1646
|
+
)
|
|
1432
1647
|
}
|
|
1433
1648
|
}
|
|
1434
1649
|
|
|
@@ -1855,7 +2070,8 @@ export function renderPrebuildFile(args: {
|
|
|
1855
2070
|
app: NativeAppManifest
|
|
1856
2071
|
nitroWebImage?: boolean
|
|
1857
2072
|
}): RenderedPrebuildFile {
|
|
1858
|
-
const {
|
|
2073
|
+
const { content, platform, app, nitroWebImage } = args
|
|
2074
|
+
const relativePath = args.relativePath.replace(/\\/g, '/')
|
|
1859
2075
|
const appName = app.name
|
|
1860
2076
|
const schemes =
|
|
1861
2077
|
app.scheme === undefined ? [] : Array.isArray(app.scheme) ? app.scheme : [app.scheme]
|
|
@@ -2493,6 +2709,13 @@ ${schemes.map((scheme) => ` <data android:scheme="${scheme}" />`).joi
|
|
|
2493
2709
|
</activity>`
|
|
2494
2710
|
)
|
|
2495
2711
|
}
|
|
2712
|
+
if (
|
|
2713
|
+
platform === 'android' &&
|
|
2714
|
+
relativePath === 'app/src/main/AndroidManifest.xml' &&
|
|
2715
|
+
app.alternateIcons !== undefined
|
|
2716
|
+
) {
|
|
2717
|
+
rendered = stampAndroidIconAliases(rendered, app)
|
|
2718
|
+
}
|
|
2496
2719
|
if (platform === 'ios' && relativePath.endsWith('.xcodeproj/project.pbxproj')) {
|
|
2497
2720
|
rendered = rendered.replace(
|
|
2498
2721
|
/TARGETED_DEVICE_FAMILY = "1,2";/g,
|
|
@@ -2518,12 +2741,12 @@ ${schemes.map((scheme) => ` <data android:scheme="${scheme}" />`).joi
|
|
|
2518
2741
|
}
|
|
2519
2742
|
}
|
|
2520
2743
|
if (platform === 'ios' && relativePath.endsWith('.xcodeproj/project.pbxproj') &&
|
|
2521
|
-
app.
|
|
2744
|
+
app.alternateIcons !== undefined) {
|
|
2522
2745
|
const anchor = 'ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;'
|
|
2523
2746
|
if (!rendered.includes(anchor)) {
|
|
2524
2747
|
throw new Error('[vxrn] prebuild template lost its primary app icon build setting')
|
|
2525
2748
|
}
|
|
2526
|
-
const names = Object.keys(app.
|
|
2749
|
+
const names = Object.keys(app.alternateIcons).sort().join(' ')
|
|
2527
2750
|
rendered = rendered.replaceAll(anchor,
|
|
2528
2751
|
`${anchor}\n\t\t\t\tASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "${names}";`)
|
|
2529
2752
|
}
|
|
@@ -2879,6 +3102,7 @@ export const generateForPlatform = async (
|
|
|
2879
3102
|
}
|
|
2880
3103
|
|
|
2881
3104
|
await generateAppIcons({ root, dest, platform, app })
|
|
3105
|
+
await generateAppIconPreviews({ root, dest, platform, app })
|
|
2882
3106
|
if (platform === 'android') await generateAdaptiveIcon({ root, dest, app })
|
|
2883
3107
|
await generateSplashScreen({ root, dest, platform, app })
|
|
2884
3108
|
if (platform === 'ios') generateAccentColor({ dest, app })
|
|
@@ -2888,6 +3112,7 @@ export const generateForPlatform = async (
|
|
|
2888
3112
|
if (platform === 'ios') generateOneBridgingHeader(dest, app)
|
|
2889
3113
|
if (platform === 'ios') generateIosWidgets(dest, app)
|
|
2890
3114
|
if (platform === 'ios') await generateSwiftPackages({ root, dest })
|
|
3115
|
+
if (platform === 'android') generateAndroidIconForwarder(dest, app)
|
|
2891
3116
|
if (platform === 'android') await generateKotlinSources({ root, dest })
|
|
2892
3117
|
}
|
|
2893
3118
|
|
|
@@ -2,7 +2,7 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { createServer } from 'vite'
|
|
5
|
-
import { expect, it } from 'vitest'
|
|
5
|
+
import { expect, it, vi } from 'vitest'
|
|
6
6
|
import { createReactNativeDevServerPlugin } from './reactNativeDevServer'
|
|
7
7
|
|
|
8
8
|
it('serves native media with byte ranges and preview content types', async () => {
|
|
@@ -72,3 +72,77 @@ it('serves native media with byte ranges and preview content types', async () =>
|
|
|
72
72
|
await rm(root, { recursive: true, force: true })
|
|
73
73
|
}
|
|
74
74
|
})
|
|
75
|
+
|
|
76
|
+
it('starts the native multipart response before bundling finishes', async () => {
|
|
77
|
+
const root = await mkdtemp(join(tmpdir(), 'native-bundle-http-'))
|
|
78
|
+
let finishBuild!: () => void
|
|
79
|
+
const building = new Promise<void>((resolve) => {
|
|
80
|
+
finishBuild = resolve
|
|
81
|
+
})
|
|
82
|
+
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
|
|
83
|
+
let failBuild = false
|
|
84
|
+
const previousPlugins = globalThis.__vxrnAddNativePlugins
|
|
85
|
+
globalThis.__vxrnAddNativePlugins = () => [
|
|
86
|
+
{
|
|
87
|
+
name: 'blocked-native-bundle',
|
|
88
|
+
transform(_code, id) {
|
|
89
|
+
if (id.endsWith('/__virtual-native-entry.tsx'))
|
|
90
|
+
return 'globalThis.nativeBundleReady = true'
|
|
91
|
+
},
|
|
92
|
+
async renderChunk() {
|
|
93
|
+
await building
|
|
94
|
+
if (failBuild) throw new Error('native fixture failed')
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
]
|
|
98
|
+
const server = await createServer({
|
|
99
|
+
root,
|
|
100
|
+
configFile: false,
|
|
101
|
+
plugins: [createReactNativeDevServerPlugin()],
|
|
102
|
+
server: { host: '127.0.0.1', port: 0 },
|
|
103
|
+
})
|
|
104
|
+
try {
|
|
105
|
+
await server.listen()
|
|
106
|
+
const { port } = server.httpServer!.address() as { port: number }
|
|
107
|
+
const response = await fetch(`http://127.0.0.1:${port}/index.bundle?platform=ios`, {
|
|
108
|
+
headers: { accept: 'multipart/mixed' },
|
|
109
|
+
signal: AbortSignal.timeout(1_000),
|
|
110
|
+
})
|
|
111
|
+
expect(response.headers.get('content-type')).toMatch(
|
|
112
|
+
/multipart\/mixed; boundary="[^"]+"/
|
|
113
|
+
)
|
|
114
|
+
const reader = response.body!.getReader()
|
|
115
|
+
const preamble = await reader.read()
|
|
116
|
+
await vi.advanceTimersByTimeAsync(10_000)
|
|
117
|
+
const progress = await reader.read()
|
|
118
|
+
expect(new TextDecoder().decode(progress.value)).toContain('"status":"Bundling"')
|
|
119
|
+
finishBuild()
|
|
120
|
+
reader.releaseLock()
|
|
121
|
+
let body =
|
|
122
|
+
new TextDecoder().decode(preamble.value) + new TextDecoder().decode(progress.value)
|
|
123
|
+
for await (const chunk of response.body!) {
|
|
124
|
+
body += new TextDecoder().decode(chunk)
|
|
125
|
+
}
|
|
126
|
+
expect(body).toContain('X-Http-Status: 200')
|
|
127
|
+
expect(body).toContain('nativeBundleReady')
|
|
128
|
+
const boundary = response.headers.get('content-type')!.match(/boundary="([^"]+)"/)![1]
|
|
129
|
+
expect(body.endsWith(`\r\n--${boundary}--\r\n`)).toBe(true)
|
|
130
|
+
const plain = await fetch(`http://127.0.0.1:${port}/index.bundle?platform=ios`)
|
|
131
|
+
expect(plain.headers.get('content-type')).toBe('text/javascript')
|
|
132
|
+
expect(await plain.text()).toContain('nativeBundleReady')
|
|
133
|
+
|
|
134
|
+
failBuild = true
|
|
135
|
+
const failed = await fetch(`http://127.0.0.1:${port}/index.bundle?platform=android`, {
|
|
136
|
+
headers: { accept: 'multipart/mixed' },
|
|
137
|
+
})
|
|
138
|
+
const error = await failed.text()
|
|
139
|
+
expect(error).toContain('X-Http-Status: 500')
|
|
140
|
+
expect(error).toContain('native fixture failed')
|
|
141
|
+
} finally {
|
|
142
|
+
finishBuild()
|
|
143
|
+
await server.close()
|
|
144
|
+
globalThis.__vxrnAddNativePlugins = previousPlugins
|
|
145
|
+
vi.useRealTimers()
|
|
146
|
+
await rm(root, { recursive: true, force: true })
|
|
147
|
+
}
|
|
148
|
+
})
|
|
@@ -116,7 +116,7 @@ export function createReactNativeDevServerPlugin(
|
|
|
116
116
|
let compileBytecode: ReturnType<typeof nativeDevBytecodeCompiler> | undefined
|
|
117
117
|
|
|
118
118
|
const devToolsSocketEndpoints = ['/inspector/device', '/inspector/debug']
|
|
119
|
-
const reactNativeDevToolsUrl = `http://${host}:${getBoundPort(server)}`
|
|
119
|
+
const reactNativeDevToolsUrl = `http://${typeof host === 'string' && host !== '0.0.0.0' ? host : 'localhost'}:${getBoundPort(server)}`
|
|
120
120
|
const { middleware, websocketEndpoints } = createDevMiddleware({
|
|
121
121
|
serverBaseUrl: reactNativeDevToolsUrl,
|
|
122
122
|
logger: console,
|
|
@@ -573,6 +573,42 @@ export function createReactNativeDevServerPlugin(
|
|
|
573
573
|
)
|
|
574
574
|
}
|
|
575
575
|
|
|
576
|
+
// react native's default URLSession times out while waiting for a
|
|
577
|
+
// silent rebuild. start its multipart stream before building, as metro
|
|
578
|
+
// does, so the native loader can wait for the bundle on that stream.
|
|
579
|
+
const boundary = String(req.headers.accept || '').includes('multipart/mixed')
|
|
580
|
+
? `vxrn-${randomUUID()}`
|
|
581
|
+
: undefined
|
|
582
|
+
let progressTimer: ReturnType<typeof setInterval> | undefined
|
|
583
|
+
if (boundary) {
|
|
584
|
+
res.writeHead(200, {
|
|
585
|
+
'Cache-Control': 'no-store',
|
|
586
|
+
'Content-Type': `multipart/mixed; boundary="${boundary}"`,
|
|
587
|
+
})
|
|
588
|
+
res.write('Bundling native code')
|
|
589
|
+
// URLSession also enforces an idle timeout after receiving headers.
|
|
590
|
+
// keep reporting the ongoing build until its final part is ready.
|
|
591
|
+
progressTimer = setInterval(() => {
|
|
592
|
+
res.write(
|
|
593
|
+
`\r\n--${boundary}\r\nContent-Type: application/json\r\n\r\n${JSON.stringify({ status: 'Bundling' })}`
|
|
594
|
+
)
|
|
595
|
+
}, 10_000)
|
|
596
|
+
progressTimer.unref()
|
|
597
|
+
res.once('close', () => clearInterval(progressTimer))
|
|
598
|
+
}
|
|
599
|
+
const sendBundle = (status: number, type: string, body: string | Buffer) => {
|
|
600
|
+
if (boundary) {
|
|
601
|
+
res.write(
|
|
602
|
+
`\r\n--${boundary}\r\nContent-Type: ${type}\r\nX-Http-Status: ${status}\r\n\r\n`
|
|
603
|
+
)
|
|
604
|
+
res.write(body)
|
|
605
|
+
res.end(`\r\n--${boundary}--\r\n`)
|
|
606
|
+
} else {
|
|
607
|
+
res.writeHead(status, { 'Cache-Control': 'no-store', 'Content-Type': type })
|
|
608
|
+
res.end(body)
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
576
612
|
try {
|
|
577
613
|
const bundle = await (await getDevEngine(platform)).getBundle()
|
|
578
614
|
let body: string | Buffer = bundle.code
|
|
@@ -591,19 +627,20 @@ export function createReactNativeDevServerPlugin(
|
|
|
591
627
|
// a DevSettings reload requests this exact URL again. native URL
|
|
592
628
|
// loading may otherwise reuse the first response and restart the app
|
|
593
629
|
// on a route map from before a file was added or removed.
|
|
594
|
-
|
|
595
|
-
'Cache-Control': 'no-store',
|
|
596
|
-
'Content-Type': 'text/javascript',
|
|
597
|
-
})
|
|
598
|
-
res.end(body)
|
|
630
|
+
sendBundle(200, 'text/javascript', body)
|
|
599
631
|
} catch (err) {
|
|
600
632
|
console.error(` Error building React Native bundle`)
|
|
601
633
|
console.error(err)
|
|
602
634
|
console.error(
|
|
603
635
|
`\n\n Note, some errors may happen due to a stale Vite cache, you may want to try re-running with the "--clean" flag`
|
|
604
636
|
)
|
|
605
|
-
|
|
606
|
-
|
|
637
|
+
sendBundle(
|
|
638
|
+
500,
|
|
639
|
+
'text/plain',
|
|
640
|
+
err instanceof Error ? err.stack || err.message : String(err)
|
|
641
|
+
)
|
|
642
|
+
} finally {
|
|
643
|
+
clearInterval(progressTimer)
|
|
607
644
|
}
|
|
608
645
|
}
|
|
609
646
|
|
|
@@ -60,57 +60,65 @@ const COMMANDS = [
|
|
|
60
60
|
keys: 'dt',
|
|
61
61
|
label: 'open React Native DevTools',
|
|
62
62
|
terminalLabel: 'open React Native \x1b[1mD\x1b[0mev\x1b[1mT\x1b[0mools',
|
|
63
|
-
action:
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
// url.searchParams.set('appId', );
|
|
71
|
-
// url.searchParams.set('device', );
|
|
72
|
-
// url.searchParams.set('target', );
|
|
73
|
-
|
|
74
|
-
// The `/open-debugger` endpoint may not respond, so we don't wait for it and will ignore timeout errors
|
|
75
|
-
;(async () => {
|
|
76
|
-
const response = await fetch(url, {
|
|
77
|
-
method: 'POST',
|
|
78
|
-
signal: AbortSignal.timeout(3000),
|
|
79
|
-
}).catch((error) => {
|
|
80
|
-
if (error.name === 'TimeoutError') {
|
|
81
|
-
return null
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
throw error
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
if (!response) {
|
|
88
|
-
// This is common for now, so don't log it
|
|
89
|
-
// console.info(`No response received from the React Native DevTools.`)
|
|
90
|
-
} else if (response.ok === false) {
|
|
91
|
-
const responseText = await response.text()
|
|
92
|
-
|
|
93
|
-
if (responseText.includes('Unable to find debugger target')) {
|
|
94
|
-
// Will already print "No compatible apps connected. React Native DevTools can only be used with the Hermes engine.", so no need to warn again
|
|
95
|
-
return
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
console.warn(
|
|
99
|
-
`Failed to open React Native DevTools, ${url} returns ${response.status}: ${responseText}.`
|
|
100
|
-
)
|
|
101
|
-
}
|
|
102
|
-
})()
|
|
103
|
-
},
|
|
63
|
+
action: openReactNativeDevTools,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
keys: 'j',
|
|
67
|
+
label: 'open React Native DevTools',
|
|
68
|
+
terminalLabel: 'open React Native DevTools',
|
|
69
|
+
action: openReactNativeDevTools,
|
|
104
70
|
},
|
|
105
71
|
|
|
106
72
|
{
|
|
107
73
|
keys: '?',
|
|
108
|
-
label: '
|
|
74
|
+
label: 'show commands',
|
|
109
75
|
terminalLabel: 'show this menu',
|
|
110
76
|
action: async (ctx) => {},
|
|
111
77
|
},
|
|
112
78
|
] satisfies Command[]
|
|
113
79
|
|
|
80
|
+
function openReactNativeDevTools(ctx: Context) {
|
|
81
|
+
const { host } = ctx.server.config.server
|
|
82
|
+
const serverUrl = `http://${typeof host === 'string' && !!host ? host : 'localhost'}:${getBoundPort(ctx.server)}`
|
|
83
|
+
const url = new URL('/open-debugger', serverUrl)
|
|
84
|
+
|
|
85
|
+
// TODO: Seems to need these if multiple devices are connected, but haven't figured out how to pass these yet
|
|
86
|
+
// Currently will just launch DevTools for most recently connected device
|
|
87
|
+
// url.searchParams.set('appId', );
|
|
88
|
+
// url.searchParams.set('device', );
|
|
89
|
+
// url.searchParams.set('target', );
|
|
90
|
+
|
|
91
|
+
// The `/open-debugger` endpoint may not respond, so we don't wait for it and will ignore timeout errors
|
|
92
|
+
;(async () => {
|
|
93
|
+
const response = await fetch(url, {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
signal: AbortSignal.timeout(3000),
|
|
96
|
+
}).catch((error) => {
|
|
97
|
+
if (error.name === 'TimeoutError') {
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
throw error
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
if (!response) {
|
|
105
|
+
// This is common for now, so don't log it
|
|
106
|
+
// console.info(`No response received from the React Native DevTools.`)
|
|
107
|
+
} else if (response.ok === false) {
|
|
108
|
+
const responseText = await response.text()
|
|
109
|
+
|
|
110
|
+
if (responseText.includes('Unable to find debugger target')) {
|
|
111
|
+
// Will already print "No compatible apps connected. React Native DevTools can only be used with the Hermes engine.", so no need to warn again
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
console.warn(
|
|
116
|
+
`Failed to open React Native DevTools, ${url} returns ${response.status}: ${responseText}.`
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
})()
|
|
120
|
+
}
|
|
121
|
+
|
|
114
122
|
export async function startUserInterface(context: Context) {
|
|
115
123
|
if (!supportsKeyboardCommands()) {
|
|
116
124
|
return
|