@expo/repack-app 0.0.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/README.md +100 -0
- package/assets/Configuration.proto +216 -0
- package/assets/Resources.proto +648 -0
- package/assets/debug.keystore +0 -0
- package/bin/cli.js +3 -0
- package/build/android/Resources.types.d.ts +93 -0
- package/build/android/Resources.types.js +4 -0
- package/build/android/build-tools.d.ts +32 -0
- package/build/android/build-tools.js +157 -0
- package/build/android/index.d.ts +5 -0
- package/build/android/index.js +53 -0
- package/build/android/resources.d.ts +10 -0
- package/build/android/resources.js +170 -0
- package/build/cli.d.ts +1 -0
- package/build/cli.js +78 -0
- package/build/expo.d.ts +10 -0
- package/build/expo.js +26 -0
- package/build/index.d.ts +2 -0
- package/build/index.js +2 -0
- package/build/ios/build-tools.d.ts +14 -0
- package/build/ios/build-tools.js +43 -0
- package/build/ios/index.d.ts +9 -0
- package/build/ios/index.js +65 -0
- package/build/ios/resources.d.ts +10 -0
- package/build/ios/resources.js +69 -0
- package/build/log.d.ts +13 -0
- package/build/log.js +23 -0
- package/build/types.d.ts +85 -0
- package/build/types.js +1 -0
- package/build/utils.d.ts +13 -0
- package/build/utils.js +27 -0
- package/package.json +44 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types mapped from the Resoruces.proto file.
|
|
3
|
+
*/
|
|
4
|
+
export interface XmlNode {
|
|
5
|
+
element?: XmlElement;
|
|
6
|
+
text?: string;
|
|
7
|
+
source: SourcePosition;
|
|
8
|
+
}
|
|
9
|
+
export interface XmlElement {
|
|
10
|
+
namespaceDeclaration: XmlNamespace[];
|
|
11
|
+
namespaceUri: string;
|
|
12
|
+
name: string;
|
|
13
|
+
attribute: XmlAttribute[];
|
|
14
|
+
child: XmlNode[];
|
|
15
|
+
}
|
|
16
|
+
export interface XmlNamespace {
|
|
17
|
+
prefix: string;
|
|
18
|
+
uri: string;
|
|
19
|
+
source: SourcePosition;
|
|
20
|
+
}
|
|
21
|
+
export interface XmlAttribute {
|
|
22
|
+
namespaceUri: string;
|
|
23
|
+
name: string;
|
|
24
|
+
value: string;
|
|
25
|
+
source: SourcePosition | null;
|
|
26
|
+
resourceId: number;
|
|
27
|
+
compiledItem: Item | null;
|
|
28
|
+
}
|
|
29
|
+
export interface Item {
|
|
30
|
+
ref?: unknown;
|
|
31
|
+
str?: {
|
|
32
|
+
value: string;
|
|
33
|
+
};
|
|
34
|
+
rawStr?: {
|
|
35
|
+
value: string;
|
|
36
|
+
};
|
|
37
|
+
styledStr?: unknown;
|
|
38
|
+
file?: unknown;
|
|
39
|
+
id?: unknown;
|
|
40
|
+
prim?: {
|
|
41
|
+
booleanValue?: boolean;
|
|
42
|
+
intDecimalValue?: number;
|
|
43
|
+
floatValue?: number;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export interface Source {
|
|
47
|
+
pathIdx: number;
|
|
48
|
+
position: SourcePosition;
|
|
49
|
+
}
|
|
50
|
+
export interface SourcePosition {
|
|
51
|
+
lineNumber: number;
|
|
52
|
+
columnNumber: number;
|
|
53
|
+
}
|
|
54
|
+
export interface ResourceTable {
|
|
55
|
+
stringPool: StringPool;
|
|
56
|
+
package: Package[];
|
|
57
|
+
overlayable: unknown[];
|
|
58
|
+
toolFingerprint: unknown[];
|
|
59
|
+
dynamicRefTable: unknown;
|
|
60
|
+
}
|
|
61
|
+
export interface StringPool {
|
|
62
|
+
data: unknown;
|
|
63
|
+
}
|
|
64
|
+
export interface Package {
|
|
65
|
+
packageId: number;
|
|
66
|
+
packageName: string;
|
|
67
|
+
type: Type[];
|
|
68
|
+
}
|
|
69
|
+
export interface Type {
|
|
70
|
+
typeId: number;
|
|
71
|
+
name: string;
|
|
72
|
+
entry: Entry[];
|
|
73
|
+
}
|
|
74
|
+
export interface Entry {
|
|
75
|
+
entryId: number;
|
|
76
|
+
name: string;
|
|
77
|
+
visibility: unknown;
|
|
78
|
+
allowNew: unknown;
|
|
79
|
+
overlayableItem: unknown;
|
|
80
|
+
configValue: ConfigValue[];
|
|
81
|
+
stagedId: unknown;
|
|
82
|
+
}
|
|
83
|
+
export interface ConfigValue {
|
|
84
|
+
config: unknown;
|
|
85
|
+
value: Value;
|
|
86
|
+
}
|
|
87
|
+
export interface Value {
|
|
88
|
+
source: Source;
|
|
89
|
+
comment: string;
|
|
90
|
+
weak: boolean;
|
|
91
|
+
item?: Item;
|
|
92
|
+
compoundValue?: unknown;
|
|
93
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { AndroidSigningOptions, NormalizedOptions } from '../types.js';
|
|
2
|
+
export interface AndroidTools {
|
|
3
|
+
aapt2Path: string;
|
|
4
|
+
apksignerPath: string;
|
|
5
|
+
zipalignPath: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Get the paths to the Android build-tools.
|
|
9
|
+
*/
|
|
10
|
+
export declare function getAndroidBuildToolsAsync(options?: NormalizedOptions): Promise<AndroidTools>;
|
|
11
|
+
/**
|
|
12
|
+
* Create an APK with proto-based resources.
|
|
13
|
+
*/
|
|
14
|
+
export declare function createProtoBasedResourcesAsync(options: NormalizedOptions): Promise<{
|
|
15
|
+
androidManiestFilePath: string;
|
|
16
|
+
resourcesPbFilePath: string;
|
|
17
|
+
}>;
|
|
18
|
+
/**
|
|
19
|
+
* Create an APK with binary-based resources.
|
|
20
|
+
*/
|
|
21
|
+
export declare function createBinaryBasedResourcesAsync(options: NormalizedOptions): Promise<string>;
|
|
22
|
+
/**
|
|
23
|
+
* Create a resigned & updated APK.
|
|
24
|
+
* @param binaryApkPath The path to the binary-based resources APK returned from `createBinaryBasedResourcesAsync()`.
|
|
25
|
+
* @param appConfigPath The path to the app.config file returned from `generateAppConfigAsync()`.
|
|
26
|
+
* @returns
|
|
27
|
+
*/
|
|
28
|
+
export declare function createResignedApkAsync(binaryApkPath: string, appConfigPath: string, options: NormalizedOptions, signingOptions: AndroidSigningOptions): Promise<string>;
|
|
29
|
+
/**
|
|
30
|
+
* Find the latest build-tools directory in the `ANDROID_SDK_ROOT` directory.
|
|
31
|
+
*/
|
|
32
|
+
export declare function findLatestBuildToolsDirAsync(): Promise<string | null>;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import spawnAsync from '@expo/turtle-spawn';
|
|
2
|
+
import { glob } from 'glob';
|
|
3
|
+
import assert from 'node:assert';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
let cachedAndroidTools = null;
|
|
7
|
+
/**
|
|
8
|
+
* Get the paths to the Android build-tools.
|
|
9
|
+
*/
|
|
10
|
+
export async function getAndroidBuildToolsAsync(options) {
|
|
11
|
+
if (cachedAndroidTools == null) {
|
|
12
|
+
const androidBuildToolsDir = options?.androidBuildToolsDir ?? (await findLatestBuildToolsDirAsync());
|
|
13
|
+
assert(androidBuildToolsDir != null, 'Unable to find the Android build-tools directory.');
|
|
14
|
+
cachedAndroidTools = {
|
|
15
|
+
aapt2Path: path.join(androidBuildToolsDir, 'aapt2'),
|
|
16
|
+
apksignerPath: path.join(androidBuildToolsDir, 'apksigner'),
|
|
17
|
+
zipalignPath: path.join(androidBuildToolsDir, 'zipalign'),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
return cachedAndroidTools;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Create an APK with proto-based resources.
|
|
24
|
+
*/
|
|
25
|
+
export async function createProtoBasedResourcesAsync(options) {
|
|
26
|
+
const { sourceAppPath, workingDirectory } = options;
|
|
27
|
+
const { aapt2Path } = await getAndroidBuildToolsAsync(options);
|
|
28
|
+
const protoApkPath = path.join(workingDirectory, 'proto.apk');
|
|
29
|
+
await spawnAsync(aapt2Path, ['convert', '-o', protoApkPath, '--output-format', 'proto', '--keep-raw-values', sourceAppPath], {
|
|
30
|
+
stdio: options.verbose ? 'inherit' : 'ignore',
|
|
31
|
+
});
|
|
32
|
+
await spawnAsync('unzip', [protoApkPath, '-d', workingDirectory], {
|
|
33
|
+
stdio: options.verbose ? 'inherit' : 'ignore',
|
|
34
|
+
});
|
|
35
|
+
const removeFiles = await glob('**/*', {
|
|
36
|
+
cwd: workingDirectory,
|
|
37
|
+
maxDepth: 1,
|
|
38
|
+
ignore: ['resources.pb', 'AndroidManifest.xml', 'res/**'],
|
|
39
|
+
absolute: true,
|
|
40
|
+
});
|
|
41
|
+
await Promise.all(removeFiles.map((file) => fs.rm(file, { recursive: true })));
|
|
42
|
+
return {
|
|
43
|
+
androidManiestFilePath: path.join(workingDirectory, 'AndroidManifest.xml'),
|
|
44
|
+
resourcesPbFilePath: path.join(workingDirectory, 'resources.pb'),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Create an APK with binary-based resources.
|
|
49
|
+
*/
|
|
50
|
+
export async function createBinaryBasedResourcesAsync(options) {
|
|
51
|
+
const { workingDirectory } = options;
|
|
52
|
+
const { aapt2Path } = await getAndroidBuildToolsAsync(options);
|
|
53
|
+
const protoUpdatedApkPath = path.join(workingDirectory, 'proto-updated.apk');
|
|
54
|
+
const binaryApkPath = path.join(workingDirectory, 'binary.apk');
|
|
55
|
+
await spawnAsync('zip', ['-r', '-0', protoUpdatedApkPath, 'resources.pb', 'AndroidManifest.xml', 'res'], {
|
|
56
|
+
cwd: workingDirectory,
|
|
57
|
+
stdio: options.verbose ? 'inherit' : 'ignore',
|
|
58
|
+
});
|
|
59
|
+
await spawnAsync(aapt2Path, [
|
|
60
|
+
'convert',
|
|
61
|
+
'-o',
|
|
62
|
+
binaryApkPath,
|
|
63
|
+
'--output-format',
|
|
64
|
+
'binary',
|
|
65
|
+
'--keep-raw-values',
|
|
66
|
+
protoUpdatedApkPath,
|
|
67
|
+
], {
|
|
68
|
+
stdio: options.verbose ? 'inherit' : 'ignore',
|
|
69
|
+
});
|
|
70
|
+
return binaryApkPath;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Create a resigned & updated APK.
|
|
74
|
+
* @param binaryApkPath The path to the binary-based resources APK returned from `createBinaryBasedResourcesAsync()`.
|
|
75
|
+
* @param appConfigPath The path to the app.config file returned from `generateAppConfigAsync()`.
|
|
76
|
+
* @returns
|
|
77
|
+
*/
|
|
78
|
+
export async function createResignedApkAsync(binaryApkPath, appConfigPath, options, signingOptions) {
|
|
79
|
+
const { workingDirectory } = options;
|
|
80
|
+
const { apksignerPath, zipalignPath } = await getAndroidBuildToolsAsync(options);
|
|
81
|
+
const unzipWorkingDirectory = path.join(workingDirectory, 'unzip');
|
|
82
|
+
const resignedApkPath = path.join(workingDirectory, 'resigned.apk');
|
|
83
|
+
const resignedApkUnalignedPath = path.join(workingDirectory, 'resigned-unaligned.apk');
|
|
84
|
+
await fs.mkdir(unzipWorkingDirectory, { recursive: true });
|
|
85
|
+
await spawnAsync('unzip', [options.sourceAppPath, '-d', unzipWorkingDirectory], {
|
|
86
|
+
stdio: options.verbose ? 'inherit' : 'ignore',
|
|
87
|
+
});
|
|
88
|
+
await spawnAsync('unzip', ['-o', binaryApkPath, '-d', unzipWorkingDirectory], {
|
|
89
|
+
stdio: options.verbose ? 'inherit' : 'ignore',
|
|
90
|
+
});
|
|
91
|
+
await fs.copyFile(appConfigPath, path.join(unzipWorkingDirectory, 'assets', 'app.config'));
|
|
92
|
+
await spawnAsync('zip', ['-r', '-0', resignedApkUnalignedPath, 'lib', 'resources.arsc'], {
|
|
93
|
+
cwd: unzipWorkingDirectory,
|
|
94
|
+
stdio: options.verbose ? 'inherit' : 'ignore',
|
|
95
|
+
});
|
|
96
|
+
await spawnAsync('zip', ['-r', resignedApkUnalignedPath, '.', '-x', 'lib/*', '-x', 'resources.arsc'], {
|
|
97
|
+
cwd: unzipWorkingDirectory,
|
|
98
|
+
stdio: options.verbose ? 'inherit' : 'ignore',
|
|
99
|
+
});
|
|
100
|
+
await spawnAsync(zipalignPath, ['-v', '-p', '4', resignedApkUnalignedPath, resignedApkPath], {
|
|
101
|
+
stdio: options.verbose ? 'inherit' : 'ignore',
|
|
102
|
+
});
|
|
103
|
+
const signerArgs = [
|
|
104
|
+
'sign',
|
|
105
|
+
'--ks',
|
|
106
|
+
signingOptions.keyStorePath,
|
|
107
|
+
'--ks-pass',
|
|
108
|
+
signingOptions.keyStorePassword,
|
|
109
|
+
];
|
|
110
|
+
if (signingOptions.keyAlias) {
|
|
111
|
+
signerArgs.push('--ks-key-alias', signingOptions.keyAlias);
|
|
112
|
+
}
|
|
113
|
+
if (signingOptions.keyPassword) {
|
|
114
|
+
signerArgs.push('--key-pass', signingOptions.keyPassword);
|
|
115
|
+
}
|
|
116
|
+
signerArgs.push(resignedApkPath);
|
|
117
|
+
await spawnAsync(apksignerPath, signerArgs, {
|
|
118
|
+
stdio: options.verbose ? 'inherit' : 'ignore',
|
|
119
|
+
});
|
|
120
|
+
return resignedApkPath;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Find the latest build-tools directory in the `ANDROID_SDK_ROOT` directory.
|
|
124
|
+
*/
|
|
125
|
+
export async function findLatestBuildToolsDirAsync() {
|
|
126
|
+
const androidSdkRoot = process.env['ANDROID_SDK_ROOT'];
|
|
127
|
+
assert(androidSdkRoot != null, 'ANDROID_SDK_ROOT environment variable is not set');
|
|
128
|
+
const buildToolsDir = path.join(androidSdkRoot, 'build-tools');
|
|
129
|
+
try {
|
|
130
|
+
const entries = await fs.readdir(buildToolsDir, { withFileTypes: true });
|
|
131
|
+
const dirs = entries
|
|
132
|
+
.filter((entry) => entry.isDirectory())
|
|
133
|
+
.map((dir) => dir.name)
|
|
134
|
+
.sort((a, b) => {
|
|
135
|
+
// Convert version strings to numbers and compare
|
|
136
|
+
const versionA = a.split('.').map(Number);
|
|
137
|
+
const versionB = b.split('.').map(Number);
|
|
138
|
+
for (let i = 0; i < Math.min(versionA.length, versionB.length); i++) {
|
|
139
|
+
if (versionA[i] !== versionB[i]) {
|
|
140
|
+
return versionB[i] - versionA[i];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return versionB.length - versionA.length;
|
|
144
|
+
});
|
|
145
|
+
if (dirs.length > 0) {
|
|
146
|
+
return path.join(buildToolsDir, dirs[0]);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
console.error('No build-tools directories found.');
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
console.error(`Failed to read the build-tools directory: ${error}`);
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { getConfig } from '@expo/config';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import pico from 'picocolors';
|
|
5
|
+
import { createBinaryBasedResourcesAsync, createProtoBasedResourcesAsync, createResignedApkAsync, } from './build-tools.js';
|
|
6
|
+
import { updateAndroidManifestAsync, updateResourcesAsync } from './resources.js';
|
|
7
|
+
import { generateAppConfigAsync, resolveRuntimeVersionAsync } from '../expo.js';
|
|
8
|
+
import logger from '../log.js';
|
|
9
|
+
import { normalizeOptionAsync } from '../utils.js';
|
|
10
|
+
/**
|
|
11
|
+
* Repack an Android app.
|
|
12
|
+
*/
|
|
13
|
+
export async function repackAppAndroidAsync(_options) {
|
|
14
|
+
const options = await normalizeOptionAsync(_options);
|
|
15
|
+
const { workingDirectory } = options;
|
|
16
|
+
await fs.mkdir(workingDirectory, { recursive: true });
|
|
17
|
+
const { exp } = getConfig(options.projectRoot, {
|
|
18
|
+
isPublicConfig: true,
|
|
19
|
+
skipSDKVersionRequirement: true,
|
|
20
|
+
});
|
|
21
|
+
const updatesRuntimeVersion = await resolveRuntimeVersionAsync(options, exp);
|
|
22
|
+
logger.log(pico.dim(`Resolved runtime version: ${updatesRuntimeVersion}`));
|
|
23
|
+
logger.time(`Unzipping APK and creating proto based resources`);
|
|
24
|
+
const { androidManiestFilePath, resourcesPbFilePath } = await createProtoBasedResourcesAsync(options);
|
|
25
|
+
logger.timeEnd(`Unzipping APK and creating proto based resources`);
|
|
26
|
+
logger.time(`Updating Androidmanifest.xml`);
|
|
27
|
+
await updateAndroidManifestAsync(exp, androidManiestFilePath, options, updatesRuntimeVersion);
|
|
28
|
+
logger.timeEnd(`Updating Androidmanifest.xml`);
|
|
29
|
+
logger.time(`Updating resources.pb`);
|
|
30
|
+
await updateResourcesAsync(exp, resourcesPbFilePath);
|
|
31
|
+
logger.timeEnd(`Updating resources.pb`);
|
|
32
|
+
logger.time(`Creating binary based resources`);
|
|
33
|
+
const binaryApkPath = await createBinaryBasedResourcesAsync(options);
|
|
34
|
+
logger.timeEnd(`Creating binary based resources`);
|
|
35
|
+
logger.time(`Generating app.config`);
|
|
36
|
+
const appConfigPath = await generateAppConfigAsync(options, exp);
|
|
37
|
+
logger.timeEnd(`Generating app.config`);
|
|
38
|
+
logger.time(`Creating updated apk`);
|
|
39
|
+
const outputApk = await createResignedApkAsync(binaryApkPath, appConfigPath, options, {
|
|
40
|
+
keyStorePath: options.androidSigningOptions?.keyStorePath ??
|
|
41
|
+
path.resolve(__dirname, '../assets/debug.keystore'),
|
|
42
|
+
keyStorePassword: options.androidSigningOptions?.keyStorePassword ?? 'android',
|
|
43
|
+
keyAlias: options.androidSigningOptions?.keyAlias,
|
|
44
|
+
keyPassword: options.androidSigningOptions?.keyPassword,
|
|
45
|
+
});
|
|
46
|
+
logger.timeEnd(`Creating updated apk`);
|
|
47
|
+
await fs.rename(outputApk, options.outputPath);
|
|
48
|
+
try {
|
|
49
|
+
await fs.rmdir(workingDirectory, { recursive: true });
|
|
50
|
+
}
|
|
51
|
+
catch { }
|
|
52
|
+
return options.outputPath;
|
|
53
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ExpoConfig } from '@expo/config';
|
|
2
|
+
import type { NormalizedOptions } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Update resources inside the **resources.pb** file.
|
|
5
|
+
*/
|
|
6
|
+
export declare function updateResourcesAsync(config: ExpoConfig, resourcesPbFilePath: string): Promise<void>;
|
|
7
|
+
/**
|
|
8
|
+
* Update the proto-based AndroidManiest.xml.
|
|
9
|
+
*/
|
|
10
|
+
export declare function updateAndroidManifestAsync(config: ExpoConfig, androidManiestFilePath: string, options: NormalizedOptions, updatesRuntimeVersion: string): Promise<void>;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import protobuf from 'protobufjs';
|
|
5
|
+
import { requireNotNull } from '../utils.js';
|
|
6
|
+
/**
|
|
7
|
+
* Update resources inside the **resources.pb** file.
|
|
8
|
+
*/
|
|
9
|
+
export async function updateResourcesAsync(config, resourcesPbFilePath) {
|
|
10
|
+
const root = await protobuf.load(path.join(__dirname, '../../assets', 'Resources.proto'));
|
|
11
|
+
const resourceTableType = root.lookupType('aapt.pb.ResourceTable');
|
|
12
|
+
const resourceTable = await decodeProtoFile(resourceTableType, resourcesPbFilePath);
|
|
13
|
+
// [0] Update the package name
|
|
14
|
+
assert(resourceTable.package.length === 1, 'Expected only one package');
|
|
15
|
+
assert(config.android?.package, 'Expected android.package to be defined');
|
|
16
|
+
resourceTable.package[0].packageName = config.android.package;
|
|
17
|
+
const stringType = resourceTable.package[0].type.find((type) => type.name === 'string');
|
|
18
|
+
const stringEntries = stringType?.entry;
|
|
19
|
+
// [1] Update the `app_name` in **res/values/strings.xml**.
|
|
20
|
+
const appNameEntry = stringEntries?.find((entry) => entry.name === 'app_name');
|
|
21
|
+
assert(appNameEntry?.configValue?.[0].value?.item?.str?.value === 'HelloWorld', 'Expected app_name to be predefined "HelloWorld"');
|
|
22
|
+
appNameEntry.configValue[0].value.item.str.value = config.name;
|
|
23
|
+
await encodeProtoFile(resourceTableType, resourcesPbFilePath, resourceTable);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Update the proto-based AndroidManiest.xml.
|
|
27
|
+
*/
|
|
28
|
+
export async function updateAndroidManifestAsync(config, androidManiestFilePath, options, updatesRuntimeVersion) {
|
|
29
|
+
const root = await protobuf.load(path.join(__dirname, '../../assets', 'Resources.proto'));
|
|
30
|
+
const xmlNodeType = root.lookupType('aapt.pb.XmlNode');
|
|
31
|
+
const rootNode = await decodeProtoFile(xmlNodeType, androidManiestFilePath);
|
|
32
|
+
// [0] Update the package name
|
|
33
|
+
replaceXmlAttributeValue(rootNode, (value) => value.replace(/dev\.expo\.templatedefault\.appid/g, requireNotNull(config.android?.package)));
|
|
34
|
+
// [1] Update the scheme in the intent-filters
|
|
35
|
+
const intentFilterViewActionNodes = findXmlNodes(rootNode, (node) => node.element?.name === 'intent-filter' &&
|
|
36
|
+
findXmlNodes(node, (node) => node.element?.name === 'action' &&
|
|
37
|
+
node.element?.attribute[0]?.value === 'android.intent.action.VIEW').length > 0);
|
|
38
|
+
const firstScheme = Array.isArray(config.scheme) ? config.scheme[0] : config.scheme;
|
|
39
|
+
for (const node of intentFilterViewActionNodes) {
|
|
40
|
+
replaceXmlAttributeValue(node, (value) => value
|
|
41
|
+
// scheme in app.json
|
|
42
|
+
.replace(/^myapp$/g, requireNotNull(firstScheme))
|
|
43
|
+
// android.package in app.json
|
|
44
|
+
.replace(/^dev\.expo\.templatedefault$/g, requireNotNull(config.android?.package))
|
|
45
|
+
// default scheme generated from slug in app.json
|
|
46
|
+
.replace(/^exp\+expo-template-default$/g, `exp+${requireNotNull(config.slug)}`));
|
|
47
|
+
}
|
|
48
|
+
// [2] expo-updates configuration
|
|
49
|
+
const mainApplicationNode = findXmlNodes(rootNode, (node) => node.element?.name === 'application' &&
|
|
50
|
+
node.element?.attribute.find((attr) => attr.name === 'name' && attr.value.endsWith('.MainApplication')) != null)[0];
|
|
51
|
+
assert(mainApplicationNode != null, 'Expected application node to be present');
|
|
52
|
+
mutateExpoUpdatesConfigAsync(mainApplicationNode, config, updatesRuntimeVersion);
|
|
53
|
+
await encodeProtoFile(xmlNodeType, androidManiestFilePath, rootNode);
|
|
54
|
+
}
|
|
55
|
+
//#region Internals
|
|
56
|
+
/**
|
|
57
|
+
* Update the `expo-updates` configuration in the Android project.
|
|
58
|
+
*/
|
|
59
|
+
function mutateExpoUpdatesConfigAsync(mainApplicationNode, config, runtimeVersion) {
|
|
60
|
+
const updateEnabledNodeIndex = mainApplicationNode.element?.child.findIndex((child) => child.element?.name === 'meta-data' &&
|
|
61
|
+
child.element?.attribute.find((attr) => attr.name === 'name' && attr.value === 'expo.modules.updates.ENABLED'));
|
|
62
|
+
assert(updateEnabledNodeIndex != null && updateEnabledNodeIndex >= 0, `Expected 'expo.modules.updates.ENABLED' node to be present`);
|
|
63
|
+
const updateEnabledNode = requireNotNull(mainApplicationNode.element?.child[updateEnabledNodeIndex]);
|
|
64
|
+
// [0] expo.modules.updates.ENABLED
|
|
65
|
+
const updateEnabledPrimValue = updateEnabledNode.element?.attribute?.[1]?.compiledItem?.prim;
|
|
66
|
+
assert(updateEnabledPrimValue != null, 'Expected updateEnabledPrimValue to be present');
|
|
67
|
+
updateEnabledPrimValue.booleanValue = true;
|
|
68
|
+
// [1] expo.modules.updates.EXPO_RUNTIME_VERSION
|
|
69
|
+
const updateRuntimeVersionNode = mainApplicationNode.element?.child.find((child) => child.element?.name === 'meta-data' &&
|
|
70
|
+
child.element?.attribute.find((attr) => attr.name === 'name' && attr.value === 'expo.modules.updates.EXPO_RUNTIME_VERSION'));
|
|
71
|
+
if (updateRuntimeVersionNode == null) {
|
|
72
|
+
mainApplicationNode.element?.child.splice(updateEnabledNodeIndex + 1, 0, createUpdateStringNode(updateEnabledNode, 'expo.modules.updates.EXPO_RUNTIME_VERSION', runtimeVersion));
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
assert(updateRuntimeVersionNode.element != null);
|
|
76
|
+
updateRuntimeVersionNode.element.attribute[1].value = runtimeVersion;
|
|
77
|
+
}
|
|
78
|
+
// [2] expo.modules.updates.EXPO_UPDATE_URL
|
|
79
|
+
const updateUrlNode = mainApplicationNode.element?.child.find((child) => child.element?.name === 'meta-data' &&
|
|
80
|
+
child.element?.attribute.find((attr) => attr.name === 'name' && attr.value === 'expo.modules.updates.EXPO_UPDATE_URL'));
|
|
81
|
+
const updateUrl = config.updates?.url;
|
|
82
|
+
assert(updateUrl);
|
|
83
|
+
if (updateUrlNode == null) {
|
|
84
|
+
mainApplicationNode.element?.child.splice(updateEnabledNodeIndex + 1, 0, createUpdateStringNode(updateEnabledNode, 'expo.modules.updates.EXPO_UPDATE_URL', updateUrl));
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
assert(updateUrlNode.element != null);
|
|
88
|
+
updateUrlNode.element.attribute[1].value = updateUrl;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Decode the proto-encoded file from `filePath` with the given `protoTypeString` and return as JSON object.
|
|
93
|
+
*/
|
|
94
|
+
async function decodeProtoFile(protoType, filePath) {
|
|
95
|
+
const buffer = await fs.readFile(filePath);
|
|
96
|
+
const message = await protoType.decode(buffer);
|
|
97
|
+
const json = protoType.toObject(message, {
|
|
98
|
+
longs: String,
|
|
99
|
+
enums: String,
|
|
100
|
+
bytes: String,
|
|
101
|
+
defaults: true,
|
|
102
|
+
arrays: true,
|
|
103
|
+
objects: true,
|
|
104
|
+
oneofs: true,
|
|
105
|
+
});
|
|
106
|
+
return json;
|
|
107
|
+
}
|
|
108
|
+
async function encodeProtoFile(protoType, filePath, json) {
|
|
109
|
+
const updatedMessage = protoType.fromObject(json);
|
|
110
|
+
const updatedBuffer = protoType.encode(updatedMessage).finish();
|
|
111
|
+
await fs.writeFile(filePath, updatedBuffer);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Recursively find XML nodes that satisfy the given `predicate`.
|
|
115
|
+
*/
|
|
116
|
+
function findXmlNodes(rootNode, predicate) {
|
|
117
|
+
if (rootNode.element == null) {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
if (predicate(rootNode)) {
|
|
121
|
+
return [rootNode];
|
|
122
|
+
}
|
|
123
|
+
return rootNode.element.child.flatMap((child) => findXmlNodes(child, predicate));
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Recursively replace the attribute values in the XML nodes's attributes.
|
|
127
|
+
*/
|
|
128
|
+
function replaceXmlAttributeValue(node, replacer) {
|
|
129
|
+
if (node.element == null) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const attrs = node.element.attribute;
|
|
133
|
+
for (const attr of attrs) {
|
|
134
|
+
attr.value = replacer(attr.value);
|
|
135
|
+
}
|
|
136
|
+
for (const child of node.element.child) {
|
|
137
|
+
replaceXmlAttributeValue(child, replacer);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Create a new `meta-data` node with the given `name` and `value`.
|
|
142
|
+
* We use the `updateEnabledNode` as the source to clone most of the properties.
|
|
143
|
+
*/
|
|
144
|
+
function createUpdateStringNode(updateEnabledNode, name, value) {
|
|
145
|
+
return {
|
|
146
|
+
...updateEnabledNode,
|
|
147
|
+
element: {
|
|
148
|
+
...requireNotNull(updateEnabledNode.element),
|
|
149
|
+
attribute: [
|
|
150
|
+
{
|
|
151
|
+
namespaceUri: 'http://schemas.android.com/apk/res/android',
|
|
152
|
+
name: 'name',
|
|
153
|
+
value: name,
|
|
154
|
+
source: null,
|
|
155
|
+
resourceId: 16842755,
|
|
156
|
+
compiledItem: null,
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
namespaceUri: 'http://schemas.android.com/apk/res/android',
|
|
160
|
+
name: 'value',
|
|
161
|
+
value,
|
|
162
|
+
source: null,
|
|
163
|
+
resourceId: 16842788,
|
|
164
|
+
compiledItem: null,
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
//#endregion
|
package/build/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/build/cli.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import pico from 'picocolors';
|
|
3
|
+
import { repackAppAndroidAsync, repackAppIosAsync } from './index.js';
|
|
4
|
+
import { resignIpaAsync } from './ios/index.js';
|
|
5
|
+
import logger from './log.js';
|
|
6
|
+
const program = new Command('repack-app')
|
|
7
|
+
.requiredOption('-p, --platform <platform>', 'Platform to repack the app for')
|
|
8
|
+
.requiredOption('--source-app <path>', 'Path to the source app file')
|
|
9
|
+
.option('--android-build-tools-dir <path>', 'Path to the Android build tools directory')
|
|
10
|
+
.option('-w, --working-directory <path>', 'Path to the working directory')
|
|
11
|
+
.option('-o, --output <path>', 'Path to the output APK file')
|
|
12
|
+
// Android signing options
|
|
13
|
+
.option('--ks <path>', 'Path to the keystore file')
|
|
14
|
+
.option('--ks-pass <password>', 'Keystore password', 'pass:android')
|
|
15
|
+
.option('--ks-key-alias <alias>', 'Keystore key alias')
|
|
16
|
+
.option('--ks-key-pass <password>', 'Keystore key password')
|
|
17
|
+
.option('-v, --verbose', 'Enable verbose logging')
|
|
18
|
+
// iOS signing options
|
|
19
|
+
.option('--signing-identity <identity>', 'Signing identity')
|
|
20
|
+
.option('--provisioning-profile <path>', 'Path to the provisioning profile')
|
|
21
|
+
// arguments
|
|
22
|
+
.argument('<project-root>', 'Path to the project root')
|
|
23
|
+
.parse(process.argv);
|
|
24
|
+
async function runAsync() {
|
|
25
|
+
const platform = program.opts().platform;
|
|
26
|
+
const projectRoot = program.args[0];
|
|
27
|
+
if (platform === 'android') {
|
|
28
|
+
const outputPath = await repackAppAndroidAsync({
|
|
29
|
+
platform: program.opts().platform,
|
|
30
|
+
projectRoot,
|
|
31
|
+
verbose: !!program.opts().verbose,
|
|
32
|
+
sourceAppPath: program.opts().sourceApp,
|
|
33
|
+
workingDirectory: program.opts().workingDirectory,
|
|
34
|
+
outputPath: program.opts().output,
|
|
35
|
+
androidSigningOptions: {
|
|
36
|
+
keyStorePath: program.opts().ks,
|
|
37
|
+
keyStorePassword: program.opts().ksPass,
|
|
38
|
+
keyAlias: program.opts().ksKeyAlias,
|
|
39
|
+
keyPassword: program.opts().ksKeyPass,
|
|
40
|
+
},
|
|
41
|
+
androidBuildToolsDir: program.opts().androidBuildToolsDir,
|
|
42
|
+
});
|
|
43
|
+
logger.log(pico.green(`Updated APK created at ${outputPath}`));
|
|
44
|
+
}
|
|
45
|
+
else if (platform === 'ios') {
|
|
46
|
+
const options = {
|
|
47
|
+
platform: program.opts().platform,
|
|
48
|
+
projectRoot,
|
|
49
|
+
verbose: !!program.opts().verbose,
|
|
50
|
+
sourceAppPath: program.opts().sourceApp,
|
|
51
|
+
workingDirectory: program.opts().workingDirectory,
|
|
52
|
+
outputPath: program.opts().output,
|
|
53
|
+
iosSigningOptions: {
|
|
54
|
+
signingIdentity: program.opts().signingIdentity,
|
|
55
|
+
provisioningProfile: program.opts().provisioningProfile,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
const outputPath = await repackAppIosAsync(options);
|
|
59
|
+
logger.log(pico.green(`Updated IPA created at ${outputPath}`));
|
|
60
|
+
if (options.iosSigningOptions?.signingIdentity &&
|
|
61
|
+
options.iosSigningOptions?.provisioningProfile) {
|
|
62
|
+
logger.log(pico.green(`Resigning the IPA at ${outputPath}`));
|
|
63
|
+
await resignIpaAsync(outputPath, options.iosSigningOptions, options);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
throw new Error(`Unsupported platform: ${platform}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
(async () => {
|
|
71
|
+
try {
|
|
72
|
+
await runAsync();
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
console.error('Uncaught Error', e);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
})();
|
package/build/expo.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ExpoConfig } from '@expo/config';
|
|
2
|
+
import type { NormalizedOptions } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Generate the app.config file for the Android app.
|
|
5
|
+
*/
|
|
6
|
+
export declare function generateAppConfigAsync(options: NormalizedOptions, config: ExpoConfig): Promise<string>;
|
|
7
|
+
/**
|
|
8
|
+
* Resolve the `runtimeVersion` for expo-updates.
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolveRuntimeVersionAsync(options: NormalizedOptions, config: ExpoConfig): Promise<string>;
|
package/build/expo.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import spawnAsync from '@expo/turtle-spawn';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import resolveFrom from 'resolve-from';
|
|
5
|
+
/**
|
|
6
|
+
* Generate the app.config file for the Android app.
|
|
7
|
+
*/
|
|
8
|
+
export async function generateAppConfigAsync(options, config) {
|
|
9
|
+
const { workingDirectory } = options;
|
|
10
|
+
const appConfigPath = path.join(workingDirectory, 'app.config');
|
|
11
|
+
await fs.writeFile(appConfigPath, JSON.stringify(config));
|
|
12
|
+
return appConfigPath;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the `runtimeVersion` for expo-updates.
|
|
16
|
+
*/
|
|
17
|
+
export async function resolveRuntimeVersionAsync(options, config) {
|
|
18
|
+
const { projectRoot } = options;
|
|
19
|
+
const cli = resolveFrom.silent(projectRoot, 'expo-updates/bin/cli') ??
|
|
20
|
+
resolveFrom(projectRoot, 'expo-updates/bin/cli.js');
|
|
21
|
+
const proc = await spawnAsync(cli, ['runtimeversion:resolve', '--platform', 'android', '--workflow', 'managed'], {
|
|
22
|
+
cwd: projectRoot,
|
|
23
|
+
});
|
|
24
|
+
const runtimeVersion = JSON.parse(proc.stdout).runtimeVersion;
|
|
25
|
+
return runtimeVersion ?? config.version ?? '1.0.0';
|
|
26
|
+
}
|
package/build/index.d.ts
ADDED
package/build/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ExpoConfig } from '@expo/config';
|
|
2
|
+
import type { NormalizedOptions } from '../types';
|
|
3
|
+
/**
|
|
4
|
+
* Unzip the IPA file.
|
|
5
|
+
*/
|
|
6
|
+
export declare function unzipIpaAsync(options: NormalizedOptions): Promise<string>;
|
|
7
|
+
/**
|
|
8
|
+
* Update some binary files.
|
|
9
|
+
*/
|
|
10
|
+
export declare function updateFilesAsync(config: ExpoConfig, appWorkingDirectory: string): Promise<string>;
|
|
11
|
+
/**
|
|
12
|
+
* From the given working .app directory, create a new .ipa file.
|
|
13
|
+
*/
|
|
14
|
+
export declare function createIpaAsync(options: NormalizedOptions, appWorkingDirectory: string): Promise<string>;
|