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
package/src/exports/clean.ts
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
88
|
+
return true
|
|
27
89
|
} catch (err) {
|
|
28
90
|
if (getErrorCode(err) !== 'EEXIST') {
|
|
29
91
|
throw err
|
|
30
92
|
}
|
|
93
|
+
}
|
|
31
94
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
package/src/exports/prebuild.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
})
|