diff --git a/apps/mobile/package.json b/apps/mobile/package.json index a5017f4..a9ab608 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -8,6 +8,8 @@ "start": "expo start", "android": "expo run:android", "android:release": "expo run:android --variant release", + "build:signed:aab": "tsx scripts/signed-build.ts", + "build:signed:apk": "tsx scripts/signed-build.ts --format apk", "ios": "expo run:ios", "web": "expo start --web", "prebuild": "expo prebuild", @@ -59,6 +61,7 @@ "devDependencies": { "@epic-web/config": "^1.21.3", "@types/react": "~19.1.12", + "cmd-ts": "^0.14.1", "eslint": "^9.35.0", "eslint-config-expo": "~10.0.0", "jiti": "^2.5.1", diff --git a/apps/mobile/scripts/script-lib.ts b/apps/mobile/scripts/script-lib.ts new file mode 100644 index 0000000..6431d07 --- /dev/null +++ b/apps/mobile/scripts/script-lib.ts @@ -0,0 +1,43 @@ +import * as child from 'child_process'; +import * as path from 'path'; + +export const cmd = ( + command: string, + options: { relativeCwd?: string; stdio?: child.StdioOptions } = {}, +) => + new Promise<{ exitCode: number; stdout: string; stderr: string }>( + (resolve, reject) => { + console.log(`cmd: ${command}`); + const proc = child.spawn(command, { + shell: true, + stdio: options.stdio ?? 'inherit', + cwd: options.relativeCwd + ? path.resolve(process.cwd(), options.relativeCwd) + : process.cwd(), + }); + + let stdout = ''; + let stderr = ''; + + proc.stdout?.on('data', (data) => { + stdout += data; + }); + proc.stderr?.on('data', (data) => { + stderr += data; + }); + + process.once('SIGTERM', () => { + proc.kill('SIGTERM'); + }); + process.once('SIGINT', () => { + proc.kill('SIGINT'); + }); + proc.on('close', (code) => { + console.log(`cmd: ${command} closed with code ${code}`); + resolve({ exitCode: code ?? 0, stdout, stderr }); + }); + proc.on('error', (error) => { + reject(error); + }); + }, + ); diff --git a/apps/mobile/scripts/signed-build.ts b/apps/mobile/scripts/signed-build.ts new file mode 100644 index 0000000..c554613 --- /dev/null +++ b/apps/mobile/scripts/signed-build.ts @@ -0,0 +1,157 @@ +/** + * https://docs.expo.dev/guides/local-app-production/ + */ +import * as fsp from 'fs/promises'; +import * as path from 'path'; +import { command, run, option, oneOf } from 'cmd-ts'; +import { z } from 'zod'; +import { cmd } from './script-lib'; + +async function getSecrets(): Promise<{ + keystoreBase64: string; + keystoreAlias: string; + keystorePassword: string; +}> { + const { stdout: rawBwItemString } = await cmd( + `bw get item "fressh keystore" --raw`, + { + stdio: 'pipe', + }, + ); + const rawBwItem = JSON.parse(rawBwItemString); + const bwItemSchema = z.looseObject({ + login: z.looseObject({ + username: z.string(), + password: z.string(), + }), + fields: z.array( + z.looseObject({ + name: z.string(), + value: z.string(), + }), + ), + }); + const bwItem = bwItemSchema.parse(rawBwItem, { + reportInput: true, + }); + const keystoreBase64 = bwItem.fields.find( + (field) => field.name === 'keystore', + )?.value; + if (!keystoreBase64) throw new Error('Keystore not found'); + return { + keystoreBase64, + keystoreAlias: bwItem.login.username, + keystorePassword: bwItem.login.password, + }; +} + +const signedBuildCommand = command({ + name: 'signed-build', + description: 'Build a signed release build of the app', + args: { + format: option({ + long: 'format', + type: oneOf(['aab', 'apk']), + short: 'f', + description: 'The format of the build to build', + defaultValue: () => 'aab', + }), + }, + handler: async ({ format }) => { + { + const secrets = await getSecrets(); + await cmd(`pnpm run prebuild:clean`); + + // Ensure keystore is in the right place + // https://docs.expo.dev/guides/local-app-production/#create-an-upload-key + // Generated with: + // sudo keytool -genkey -v -keystore fressh-upload-key.keystore -alias fressh-key-alias -keyalg RSA -keysize 2048 -validity 10000 + const keystorePath = `./android/app/fressh-upload-key.keystore`; + const keystoreFileName = path.basename(keystorePath); + // const bufferShouldEqual = await fsp.readFile(keystoreFileName, 'base64'); + // await fsp.writeFile( + // './debug.log', + // JSON.stringify( + // { + // ...secrets, + // bufferShouldEqual, + // }, + // null, + // 2, + // ), + // ); + await fsp.writeFile( + keystorePath, + Buffer.from(secrets.keystoreBase64, 'base64'), + 'base64', + ); + console.log(`Keystore written to ${keystorePath}`); + + // Ensure gradle.properties is configured + // https://docs.expo.dev/guides/local-app-production/#update-gradle-variables + const gradlePropertiesSuffix = ` + FRESSH_UPLOAD_STORE_FILE=${keystoreFileName} + FRESSH_UPLOAD_KEY_ALIAS=${secrets.keystoreAlias} + FRESSH_UPLOAD_STORE_PASSWORD=${secrets.keystorePassword} + FRESSH_UPLOAD_KEY_PASSWORD=${secrets.keystorePassword} + `; + const currentGradleProperties = await fsp.readFile( + './android/gradle.properties', + 'utf8', + ); + + if (!currentGradleProperties.includes(gradlePropertiesSuffix.trim())) { + await fsp.writeFile( + './android/gradle.properties', + `${currentGradleProperties}\n\n${gradlePropertiesSuffix}`, + ); + console.log(`Gradle properties written to ./android/gradle.properties`); + } + + // Ensure there is a release signing config in android/app/build.gradle + // https://docs.expo.dev/guides/local-app-production/#add-signing-config-to-buildgradle + const releaseSigningConfig = ` + release { + if (project.hasProperty('FRESSH_UPLOAD_STORE_FILE')) { + storeFile file(FRESSH_UPLOAD_STORE_FILE) + storePassword FRESSH_UPLOAD_STORE_PASSWORD + keyAlias FRESSH_UPLOAD_KEY_ALIAS + keyPassword FRESSH_UPLOAD_KEY_PASSWORD + } + }`; + const currentBuildGradle = await fsp.readFile( + './android/app/build.gradle', + 'utf8', + ); + if (!currentBuildGradle.includes(releaseSigningConfig.trim())) { + const newBuildGradle = currentBuildGradle + .replace( + /signingConfigs \{([\s\S]*?)\}/, // Modify existing signingConfigs without removing debug + (match) => { + if (/release \{/.test(match)) { + return match.replace( + /release \{([\s\S]*?)\}/, + releaseSigningConfig, + ); + } + return match.trim() + releaseSigningConfig; + }, + ) + .replace( + /buildTypes \{([\s\S]*?)release \{([\s\S]*?)signingConfig signingConfigs\.debug/, // Ensure release config uses signingConfigs.release + `buildTypes { $1release { $2signingConfig signingConfigs.release`, + ); + await fsp.writeFile('./android/app/build.gradle', newBuildGradle); + console.log(`Build gradle written to ./android/app/build.gradle`); + } + + const bundleCommand = + format === 'aab' ? 'bundleRelease' : 'assembleRelease'; + await cmd(`./gradlew app:${bundleCommand}`, { + relativeCwd: './android', + }); + } + }, +}); + +void run(signedBuildCommand, process.argv.slice(2)); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 305b0e1..21100da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,6 +141,9 @@ importers: '@types/react': specifier: ~19.1.12 version: 19.1.12 + cmd-ts: + specifier: ^0.14.1 + version: 0.14.1 eslint: specifier: ^9.35.0 version: 9.35.0(jiti@2.5.1) @@ -2271,6 +2274,9 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + cmd-ts@0.14.1: + resolution: {integrity: sha512-OXnLTdj0zKjOPAHLktkZUxyEquigm4o5LMufjFf+Eu3IJVxEZrG6oku69xAULaTS83p6bUb9iBP1gPXxomiCsg==} + color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -2474,6 +2480,9 @@ packages: devalue@5.3.2: resolution: {integrity: sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==} + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -7920,6 +7929,15 @@ snapshots: clone@1.0.4: {} + cmd-ts@0.14.1: + dependencies: + chalk: 5.6.2 + debug: 4.4.1 + didyoumean: 1.2.2 + strip-ansi: 7.1.0 + transitivePeerDependencies: + - supports-color + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -8113,6 +8131,8 @@ snapshots: devalue@5.3.2: {} + didyoumean@1.2.2: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3