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.
Files changed (54) hide show
  1. package/dist/exports/clean.mjs +128 -19
  2. package/dist/exports/clean.mjs.map +1 -1
  3. package/dist/exports/clean.native.js +147 -21
  4. package/dist/exports/clean.native.js.map +1 -1
  5. package/dist/exports/prebuild.mjs +2 -1
  6. package/dist/exports/prebuild.mjs.map +1 -1
  7. package/dist/exports/prebuild.native.js +2 -1
  8. package/dist/exports/prebuild.native.js.map +1 -1
  9. package/dist/exports/prebuildWithoutExpo.mjs +196 -7
  10. package/dist/exports/prebuildWithoutExpo.mjs.map +1 -1
  11. package/dist/exports/prebuildWithoutExpo.native.js +280 -42
  12. package/dist/exports/prebuildWithoutExpo.native.js.map +1 -1
  13. package/dist/plugins/reactNativeDevServer.mjs +43 -8
  14. package/dist/plugins/reactNativeDevServer.mjs.map +1 -1
  15. package/dist/plugins/reactNativeDevServer.native.js +45 -8
  16. package/dist/plugins/reactNativeDevServer.native.js.map +1 -1
  17. package/dist/user-interface/index.mjs +31 -24
  18. package/dist/user-interface/index.mjs.map +1 -1
  19. package/dist/user-interface/index.native.js +32 -25
  20. package/dist/user-interface/index.native.js.map +1 -1
  21. package/dist/utils/createNativeDevEngine.mjs +111 -14
  22. package/dist/utils/createNativeDevEngine.mjs.map +1 -1
  23. package/dist/utils/createNativeDevEngine.native.js +143 -14
  24. package/dist/utils/createNativeDevEngine.native.js.map +1 -1
  25. package/dist/utils/scanDepsToOptimize.mjs +10 -6
  26. package/dist/utils/scanDepsToOptimize.mjs.map +1 -1
  27. package/dist/utils/scanDepsToOptimize.native.js +12 -6
  28. package/dist/utils/scanDepsToOptimize.native.js.map +1 -1
  29. package/expo-plugin.cjs +71 -8
  30. package/package.json +14 -14
  31. package/src/exports/clean.test.ts +502 -5
  32. package/src/exports/clean.ts +176 -19
  33. package/src/exports/expoPlugin.test.mjs +44 -1
  34. package/src/exports/prebuild.ts +6 -0
  35. package/src/exports/prebuildWithoutExpo.test.ts +134 -2
  36. package/src/exports/prebuildWithoutExpo.ts +235 -10
  37. package/src/plugins/reactNativeDevServer.test.ts +75 -1
  38. package/src/plugins/reactNativeDevServer.ts +45 -8
  39. package/src/user-interface/index.ts +50 -42
  40. package/src/utils/createNativeDevEngine.test.ts +480 -1
  41. package/src/utils/createNativeDevEngine.ts +220 -17
  42. package/src/utils/imageSizePatch.test.ts +3 -3
  43. package/src/utils/scanDepsToOptimize.test.ts +35 -2
  44. package/src/utils/scanDepsToOptimize.ts +25 -8
  45. package/types/exports/clean.d.ts.map +1 -1
  46. package/types/exports/prebuild.d.ts +2 -1
  47. package/types/exports/prebuild.d.ts.map +1 -1
  48. package/types/exports/prebuildWithoutExpo.d.ts.map +1 -1
  49. package/types/plugins/reactNativeDevServer.d.ts.map +1 -1
  50. package/types/user-interface/index.d.ts.map +1 -1
  51. package/types/utils/createNativeDevEngine.d.ts +1 -1
  52. package/types/utils/createNativeDevEngine.d.ts.map +1 -1
  53. package/types/utils/scanDepsToOptimize.d.ts +25 -0
  54. package/types/utils/scanDepsToOptimize.d.ts.map +1 -1
@@ -1,4 +1,5 @@
1
- import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
1
+ import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
2
+ import { createServer, type Server } from 'node:net'
2
3
  import { dirname, join } from 'node:path'
3
4
  import type { VXRNOptions } from '../types'
4
5
  import { fillOptions } from '../config/getOptionsFilled'
@@ -15,40 +16,196 @@ function getErrorCode(err: unknown) {
15
16
  }
16
17
  }
17
18
 
18
- async function withCacheCleanLock<T>(cacheDir: string, action: () => Promise<T>) {
19
- const lockPath = join(dirname(cacheDir), '.vxrn-clean.lock')
20
- const startedAt = Date.now()
21
- await mkdir(dirname(lockPath), { recursive: true })
19
+ // test knob in the same vein as VXRN_DONT_CLEAN_SELF: shorten the 60s
20
+ // acquisition timeout so failure-path cleanup is provable without a
21
+ // minute-long test. production callers never set it.
22
+ function getCleanLockTimeoutMs(): number {
23
+ const override = Number.parseInt(process.env.VXRN_CLEAN_LOCK_TIMEOUT_MS ?? '', 10)
24
+ if (Number.isSafeInteger(override) && override > 0) {
25
+ return override
26
+ }
27
+ return CLEAN_LOCK_TIMEOUT_MS
28
+ }
29
+
30
+ // well clear of dev ports (3000/5173/8081/19000s) and the ephemeral ranges,
31
+ // so a foreign occupant is only ever a hash collision with another vxrn root
32
+ const CLEAN_LOCK_PORT_BASE = 24_000
33
+ const CLEAN_LOCK_PORT_COUNT = 1_000
34
+
35
+ // canonical ownership identity: device and inode of the lock directory, so
36
+ // every pathname spelling of the same physical cache (symlinks, dot
37
+ // segments, case variants, bind mounts) shares one mutex port. hashing the
38
+ // raw pathname would let aliases acquire simultaneously.
39
+ async function getCacheCleanLockPort(lockDir: string): Promise<number> {
40
+ const info = await stat(lockDir)
41
+ let hash = 0x811c9dc5
42
+ const identity = `${info.dev}:${info.ino}`
43
+ for (let index = 0; index < identity.length; index++) {
44
+ hash ^= identity.charCodeAt(index)
45
+ hash = Math.imul(hash, 0x01000193)
46
+ }
47
+ return CLEAN_LOCK_PORT_BASE + (hash >>> 0) % CLEAN_LOCK_PORT_COUNT
48
+ }
49
+
50
+ // only ESRCH means the owner is gone, any other failure keeps the lock safe
51
+ // by treating it as live (same semantics as processExists in dev.ts)
52
+ function isLockOwnerAlive(pid: number): boolean {
53
+ try {
54
+ process.kill(pid, 0)
55
+ return true
56
+ } catch (err) {
57
+ return getErrorCode(err) !== 'ESRCH'
58
+ }
59
+ }
60
+
61
+ // legacy file content is pid-only; anything else is an unknown owner that
62
+ // must be waited on, never removed
63
+ function parseLockOwnerPid(content: string): number | undefined {
64
+ const trimmed = content.trim()
65
+ if (!/^\d+$/.test(trimmed)) {
66
+ return undefined
67
+ }
68
+ const pid = Number.parseInt(trimmed, 10)
69
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
70
+ return undefined
71
+ }
72
+ return pid
73
+ }
22
74
 
75
+ // join the legacy pid-file protocol so old-version cleaners and this cleaner
76
+ // exclude each other. returns true when this process created the file and
77
+ // must delete it on release. a dead owner's corpse is never deleted (that
78
+ // read-then-unlink cannot be atomic); it only routes around file creation
79
+ // while the port mutex below still serializes new cleaners.
80
+ async function joinLegacyLock(
81
+ lockPath: string,
82
+ startedAt: number,
83
+ timeoutMs: number
84
+ ): Promise<boolean> {
23
85
  while (true) {
24
86
  try {
25
87
  await writeFile(lockPath, `${process.pid}\n`, { flag: 'wx' })
26
- break
88
+ return true
27
89
  } catch (err) {
28
90
  if (getErrorCode(err) !== 'EEXIST') {
29
91
  throw err
30
92
  }
93
+ }
31
94
 
32
- if (Date.now() - startedAt >= CLEAN_LOCK_TIMEOUT_MS) {
33
- let owner = 'unknown process'
34
- try {
35
- owner = `process ${(await readFile(lockPath, 'utf8')).trim()}`
36
- } catch (readErr) {
37
- if (getErrorCode(readErr) === 'ENOENT') {
38
- continue
39
- }
40
- throw readErr
41
- }
42
- throw new Error(`Timed out waiting for VXRN cache cleanup lock held by ${owner}`)
95
+ let content: string
96
+ try {
97
+ content = await readFile(lockPath, 'utf8')
98
+ } catch (err) {
99
+ if (getErrorCode(err) === 'ENOENT') {
100
+ continue
43
101
  }
44
- await new Promise<void>((resolve) => setTimeout(resolve, CLEAN_LOCK_WAIT_MS))
102
+ throw err
45
103
  }
104
+
105
+ const pid = parseLockOwnerPid(content)
106
+ if (pid !== undefined && !isLockOwnerAlive(pid)) {
107
+ return false
108
+ }
109
+ if (Date.now() - startedAt >= timeoutMs) {
110
+ const owner = pid === undefined ? 'unknown process' : `process ${pid}`
111
+ throw new Error(`Timed out waiting for VXRN cache cleanup lock held by ${owner}`)
112
+ }
113
+ await new Promise<void>((resolve) => setTimeout(resolve, CLEAN_LOCK_WAIT_MS))
114
+ }
115
+ }
116
+
117
+ function tryListen(server: Server, port: number): Promise<NodeJS.ErrnoException | undefined> {
118
+ return new Promise((resolve) => {
119
+ server.once('error', (err: Error) => resolve(err as NodeJS.ErrnoException))
120
+ server.once('listening', () => resolve(undefined))
121
+ server.listen({ port, host: '127.0.0.1', exclusive: true })
122
+ })
123
+ }
124
+
125
+ // ownership is an exclusive loopback listen, which the kernel grants
126
+ // atomically and frees on any holder death. live and unknown legacy owners
127
+ // are respected by the file phase above; the port serializes new cleaners
128
+ // even past a dead owner's corpse, which no participant ever deletes.
129
+ async function acquirePortLock(
130
+ port: number,
131
+ startedAt: number,
132
+ timeoutMs: number
133
+ ): Promise<() => Promise<void>> {
134
+ while (true) {
135
+ // fresh server per attempt, same shape as the daemon port probe.
136
+ // ordinary connections are rejected at once so a stray client can never
137
+ // strand release: under node an accepted connection otherwise keeps the
138
+ // close callback pending.
139
+ const server = createServer((socket) => {
140
+ socket.on('error', () => {})
141
+ socket.destroy()
142
+ })
143
+ const err = await tryListen(server, port)
144
+ if (!err) {
145
+ // held referenced until release so the loop cannot exit mid-hold
146
+ return async () => {
147
+ await new Promise<void>((resolve) => {
148
+ server.close(() => resolve())
149
+ })
150
+ }
151
+ }
152
+
153
+ if (getErrorCode(err) !== 'EADDRINUSE') {
154
+ throw err
155
+ }
156
+ if (Date.now() - startedAt >= timeoutMs) {
157
+ throw new Error(`Timed out waiting for VXRN cache cleanup lock (127.0.0.1:${port})`)
158
+ }
159
+ await new Promise<void>((resolve) => setTimeout(resolve, CLEAN_LOCK_WAIT_MS))
160
+ }
161
+ }
162
+
163
+ // only our own participation file is removed, and only while it still
164
+ // carries our pid; a replacement is never touched. the read-then-remove
165
+ // cannot hit a replacement: installing one needs the path absent, and the
166
+ // path is only absent after our own remove (or a crash, whose corpse no
167
+ // participant ever deletes), since every participant creates with wx.
168
+ async function removeOwnLegacyLock(lockPath: string): Promise<void> {
169
+ try {
170
+ if ((await readFile(lockPath, 'utf8')) === `${process.pid}\n`) {
171
+ await rm(lockPath, { force: true })
172
+ }
173
+ } catch (err) {
174
+ if (getErrorCode(err) !== 'ENOENT') {
175
+ throw err
176
+ }
177
+ }
178
+ }
179
+
180
+ async function withCacheCleanLock<T>(cacheDir: string, action: () => Promise<T>) {
181
+ const lockDir = dirname(cacheDir)
182
+ const lockPath = join(lockDir, '.vxrn-clean.lock')
183
+ const startedAt = Date.now()
184
+ const timeoutMs = getCleanLockTimeoutMs()
185
+ await mkdir(lockDir, { recursive: true })
186
+ const created = await joinLegacyLock(lockPath, startedAt, timeoutMs)
187
+
188
+ let releasePort: () => Promise<void>
189
+ try {
190
+ releasePort = await acquirePortLock(await getCacheCleanLockPort(lockDir), startedAt, timeoutMs)
191
+ } catch (err) {
192
+ if (created) {
193
+ try {
194
+ await removeOwnLegacyLock(lockPath)
195
+ } catch {
196
+ // acquisition already failed; the original error stays actionable
197
+ }
198
+ }
199
+ throw err
46
200
  }
47
201
 
48
202
  try {
49
203
  return await action()
50
204
  } finally {
51
- await rm(lockPath, { force: true })
205
+ await releasePort()
206
+ if (created) {
207
+ await removeOwnLegacyLock(lockPath)
208
+ }
52
209
  }
53
210
  }
54
211
 
@@ -48,7 +48,7 @@ describe('vxrn Expo project patches', () => {
48
48
  it('keeps WebGPU header resolution inside its own CocoaPods target', () => {
49
49
  const generated = patches.injectWebGPUHeaderPathsIntoPodfile(podfile)
50
50
  expect(patches.injectWebGPUHeaderPathsIntoPodfile(generated)).toBe(generated)
51
- const result = JSON.parse(execFileSync('ruby', ['-rjson', '-e', `
51
+ const result = JSON.parse(execFileSync('ruby', ['--disable-gems', '-rjson', '-e', `
52
52
  Config = Struct.new(:build_settings)
53
53
  Target = Struct.new(:name, :build_configurations)
54
54
  Project = Struct.new(:targets)
@@ -147,10 +147,53 @@ function expoProject() {
147
147
  join(scopedDirectory, 'config-plugins'),
148
148
  'dir'
149
149
  )
150
+ // the plugin writes the sdk bindings with the app's one package
151
+ const oneRoot = createRequire(import.meta.url).resolve('one/package.json')
152
+ symlinkSync(oneRoot.slice(0, -'/package.json'.length), join(projectRoot, 'node_modules', 'one'), 'dir')
150
153
  return projectRoot
151
154
  }
152
155
 
153
156
  describe('vxrn/expo-plugin', () => {
157
+ it('prebuilds an app without SDK imports when Xcode is unavailable', () => {
158
+ const projectRoot = expoProject()
159
+ const iosRoot = join(projectRoot, 'ios')
160
+ mkdirSync(iosRoot)
161
+ writeFileSync(join(iosRoot, 'Podfile'), podfile)
162
+ const output = execFileSync(process.execPath, ['--input-type=module', '-e', `
163
+ import withVxrn from ${JSON.stringify(new URL('../../expo-plugin.cjs', import.meta.url).href)}
164
+ const projectRoot = process.argv[1]
165
+ const config = withVxrn({ name: 'TestApp', slug: 'test-app', _internal: { projectRoot } })
166
+ await config.mods.ios.dangerous({
167
+ ...config,
168
+ modRequest: {
169
+ projectRoot, platformProjectRoot: projectRoot + '/ios',
170
+ platform: 'ios', modName: 'dangerous', projectName: 'TestApp', introspect: false,
171
+ },
172
+ modResults: {},
173
+ })
174
+ `, projectRoot], { env: { ...process.env, PATH: '' }, encoding: 'utf8' })
175
+ expect(output).toBe('')
176
+ expect(existsSync(join(iosRoot, 'OneSwiftPackages', 'OneSDKBindings'))).toBe(false)
177
+ expect(readFileSync(join(iosRoot, 'Podfile'), 'utf8')).toContain('[vxrn/one] fmt c++17 fix')
178
+ })
179
+
180
+ it('raises the ios deployment target to the one the One pod requires', async () => {
181
+ const projectRoot = expoProject()
182
+ const config = withVxrn({ name: 'TestApp', slug: 'test-app', _internal: { projectRoot } })
183
+ const run = async (modResults) =>
184
+ (await config.mods.ios.podfileProperties({
185
+ ...config,
186
+ modRequest: { projectRoot, platform: 'ios', modName: 'podfileProperties', projectName: 'TestApp', introspect: false },
187
+ modResults,
188
+ })).modResults['ios.deploymentTarget']
189
+ const { minimumVersion } = JSON.parse(
190
+ readFileSync(join(projectRoot, 'node_modules/one/schema.json'), 'utf8')
191
+ )
192
+ expect(await run({})).toBe(`${minimumVersion}.0`)
193
+ expect(await run({ 'ios.deploymentTarget': '16.4' })).toBe(`${minimumVersion}.0`)
194
+ expect(await run({ 'ios.deploymentTarget': '99.0' })).toBe('99.0')
195
+ })
196
+
154
197
  it('combines background location and audio modes', async () => {
155
198
  const projectRoot = expoProject()
156
199
  const config = withVxrn(
@@ -20,11 +20,15 @@ export const prebuild = async ({
20
20
  platform,
21
21
  'no-install': noInstall = false,
22
22
  app,
23
+ beforeInstall,
23
24
  }: {
24
25
  root: string
25
26
  platform?: 'ios' | 'android' | string
26
27
  'no-install'?: boolean
27
28
  app: PrebuildAppConfig
29
+ // runs once the native projects exist and before discovery and install, so
30
+ // a caller can add local pods the podfile picks up
31
+ beforeInstall?: () => Promise<void>
28
32
  }) => {
29
33
  validatePrebuildApp(app, platform)
30
34
 
@@ -67,6 +71,8 @@ export const prebuild = async ({
67
71
  await generateForPlatform(root, 'android', app)
68
72
  }
69
73
 
74
+ await beforeInstall?.()
75
+
70
76
  // community config reads the generated projects, so discover after generation
71
77
  // and before installation. `--no-install` never fakes the linked package set.
72
78
  const inventory = await getNativeDependencyInventory(root)
@@ -165,6 +165,45 @@ const APP_DELEGATE_PBXPROJ = ` 761780EF2CA45674006654EF /* SceneDelegate.swift
165
165
  \t\t\t\tINFOPLIST_FILE = HelloWorld/Info.plist;`
166
166
 
167
167
  describe('template rendering', () => {
168
+ it.each(['/', '\\'])('remaps Android package paths using %s separators', (separator) => {
169
+ for (const file of ['MainActivity.kt', 'MainApplication.kt']) {
170
+ expect(
171
+ renderPrebuildFile({
172
+ relativePath: ['app', 'src', 'main', 'java', 'com', 'helloworld', file].join(separator),
173
+ content: 'package com.helloworld\n',
174
+ platform: 'android',
175
+ app: {
176
+ name: 'OneBasic',
177
+ android: { applicationId: 'dev.onestack.starter' },
178
+ },
179
+ })
180
+ ).toEqual({
181
+ destRelativePath: `app/src/main/java/dev/onestack/starter/${file}`,
182
+ content: 'package dev.onestack.starter\n',
183
+ })
184
+ }
185
+ })
186
+
187
+ it.each(['/', '\\'])('stamps platform files using %s separators', (separator) => {
188
+ const manifest = renderPrebuildFile({
189
+ relativePath: ['app', 'src', 'main', 'AndroidManifest.xml'].join(separator),
190
+ content: '<manifest>\n<uses-permission android:name="android.permission.INTERNET" />\n</manifest>',
191
+ platform: 'android',
192
+ app: { name: 'OneBasic', android: { applicationId: 'dev.onestack.starter', permissions: ['CAMERA'] } },
193
+ })
194
+ expect(manifest.destRelativePath).toBe('app/src/main/AndroidManifest.xml')
195
+ expect(manifest.content).toContain('<uses-permission android:name="android.permission.CAMERA" />')
196
+
197
+ const plist = renderPrebuildFile({
198
+ relativePath: ['HelloWorld', 'Info.plist'].join(separator),
199
+ content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
200
+ platform: 'ios',
201
+ app: { name: 'OneBasic', scheme: 'onebasic', ios: { bundleId: 'dev.onestack.starter' } },
202
+ })
203
+ expect(plist.destRelativePath).toBe('OneBasic/Info.plist')
204
+ expect(plist.content).toContain('<string>onebasic</string>')
205
+ })
206
+
168
207
  it('applies names, ids, and platform versions', () => {
169
208
  const ios = renderPrebuildFile({
170
209
  relativePath: 'HelloWorld.xcodeproj/project.pbxproj',
@@ -1510,12 +1549,13 @@ describe('generateForPlatform determinism', () => {
1510
1549
  const appWithIcon = {
1511
1550
  ...app,
1512
1551
  version: '9.9.9',
1513
- ios: { ...app.ios, buildNumber: '4242', tablet: true, alternateIcons: {
1552
+ alternateIcons: {
1514
1553
  TestAlternate: {
1515
1554
  source: fileURLToPath(new URL('../../../../examples/one-basic/public/app-icon.png', import.meta.url)),
1516
1555
  backgroundColor: '#123456',
1517
1556
  },
1518
- } },
1557
+ },
1558
+ ios: { ...app.ios, buildNumber: '4242', tablet: true },
1519
1559
  android: {
1520
1560
  ...app.android,
1521
1561
  versionCode: 4242,
@@ -1627,6 +1667,39 @@ describe('generateForPlatform determinism', () => {
1627
1667
  )
1628
1668
  ).metadata()
1629
1669
  expect(androidIcon).toMatchObject({ width: 192, height: 192 })
1670
+ // the picker previews ship as plain resources on both platforms
1671
+ expect(
1672
+ await sharp(
1673
+ join(output, 'android', 'app', 'src', 'main', 'res', 'drawable-nodpi', 'app_icon_preview_test_alternate.png')
1674
+ ).metadata()
1675
+ ).toMatchObject({ width: 192, height: 192 })
1676
+ expect(
1677
+ await sharp(
1678
+ join(output, 'ios', 'MyApp', 'Images.xcassets', 'AppIconPreviewPrimary.imageset', 'preview@2x.png')
1679
+ ).metadata()
1680
+ ).toMatchObject({ width: 128, height: 128 })
1681
+ // alternate icons become launcher aliases that forward into the host
1682
+ const androidMain = join(output, 'android', 'app', 'src', 'main')
1683
+ const manifest = readFileSync(join(androidMain, 'AndroidManifest.xml'), 'utf8')
1684
+ const hostActivity = manifest.match(/<activity\b[^>]*android:name="\.MainActivity"[\s\S]*?<\/activity>/)?.[0]
1685
+ expect(hostActivity).toBeDefined()
1686
+ expect(hostActivity).not.toContain('category.LAUNCHER')
1687
+ expect(manifest).toMatch(
1688
+ /<activity-alias\s+android:name="\.Primary"\s+android:targetActivity="\.AppIconLauncherActivity"\s+android:enabled="true"/
1689
+ )
1690
+ expect(manifest).toMatch(
1691
+ /<activity-alias\s+android:name="\.TestAlternate"\s+android:targetActivity="\.AppIconLauncherActivity"\s+android:enabled="false"[^>]*android:icon="@mipmap\/ic_launcher_test_alternate"/
1692
+ )
1693
+ expect(manifest.match(/category\.LAUNCHER/g)).toHaveLength(2)
1694
+ expect(
1695
+ readFileSync(
1696
+ join(androidMain, 'java', 'dev', 'one', 'myapp', 'AppIconLauncherActivity.kt'),
1697
+ 'utf8'
1698
+ )
1699
+ ).toContain('setClass(this, MainActivity::class.java)')
1700
+ expect(
1701
+ await sharp(join(androidMain, 'res', 'mipmap-xxxhdpi', 'ic_launcher_test_alternate_round.png')).metadata()
1702
+ ).toMatchObject({ width: 192, height: 192 })
1630
1703
  const iosApp = join(output, 'ios', 'MyApp')
1631
1704
  // contained artwork is drawn at its launch width in points, at 1x to 3x
1632
1705
  for (const [filename, width] of [
@@ -2911,3 +2984,62 @@ dependencies {
2911
2984
  expect(readFileSync(appGradle, 'utf8')).toBe(updated)
2912
2985
  })
2913
2986
  })
2987
+
2988
+ describe('ios 18 icon appearances', () => {
2989
+ it('writes dark and tinted entries beside the light icon', async () => {
2990
+ const workspaceRoot = fileURLToPath(new URL('../../../..', import.meta.url))
2991
+ const output = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-appearances-'))
2992
+ const art = fileURLToPath(
2993
+ new URL('../../../../examples/one-basic/public/app-icon.png', import.meta.url)
2994
+ )
2995
+ await generateForPlatform(
2996
+ workspaceRoot,
2997
+ 'ios',
2998
+ {
2999
+ ...app,
3000
+ icon: {
3001
+ source: art,
3002
+ backgroundColor: '#336699',
3003
+ dark: { backgroundColor: '#101820' },
3004
+ tinted: {},
3005
+ },
3006
+ },
3007
+ join(output, 'ios')
3008
+ )
3009
+ const dir = join(output, 'ios', 'MyApp', 'Images.xcassets', 'AppIcon.appiconset')
3010
+ const contents: { images: Array<Record<string, unknown>> } = JSON.parse(
3011
+ readFileSync(join(dir, 'Contents.json'), 'utf8')
3012
+ )
3013
+ expect(contents.images).toEqual([
3014
+ { filename: 'icon-1024.png', idiom: 'universal', platform: 'ios', size: '1024x1024' },
3015
+ {
3016
+ appearances: [{ appearance: 'luminosity', value: 'dark' }],
3017
+ filename: 'icon-1024-dark.png',
3018
+ idiom: 'universal',
3019
+ platform: 'ios',
3020
+ size: '1024x1024',
3021
+ },
3022
+ {
3023
+ appearances: [{ appearance: 'luminosity', value: 'tinted' }],
3024
+ filename: 'icon-1024-tinted.png',
3025
+ idiom: 'universal',
3026
+ platform: 'ios',
3027
+ size: '1024x1024',
3028
+ },
3029
+ ])
3030
+ for (const file of ['icon-1024.png', 'icon-1024-dark.png', 'icon-1024-tinted.png']) {
3031
+ expect(await sharp(join(dir, file)).metadata()).toMatchObject({
3032
+ width: 1024,
3033
+ height: 1024,
3034
+ })
3035
+ }
3036
+ // the tinted layer is gray: equal channels at the center pixel
3037
+ const { data } = await sharp(join(dir, 'icon-1024-tinted.png'))
3038
+ .removeAlpha()
3039
+ .extract({ left: 512, top: 512, width: 1, height: 1 })
3040
+ .raw()
3041
+ .toBuffer({ resolveWithObject: true })
3042
+ expect(data[0]).toBe(data[1])
3043
+ expect(data[1]).toBe(data[2])
3044
+ })
3045
+ })