import type { AppExecutor } from "@akanjs/devkit/executors"; import { FileSys } from "@akanjs/devkit/fileSys"; import type { CapacitorConfig } from "@capacitor/cli"; import { MobileProject } from "@trapezedev/project"; import type { AndroidProject } from "@trapezedev/project/dist/android/project"; import type { IosProject } from "@trapezedev/project/dist/ios/project"; import { capitalize } from "akanjs/common"; import { FileEditor } from "./fileEditor"; interface RunConfig extends CapacitorConfig { operation: "local" | "release"; version: string; buildNum: number; appId?: string; host?: "local" | "debug" | "develop" | "main"; } export class CapacitorApp { project: MobileProject & { ios: IosProject; android: AndroidProject }; iosTargetName = "App"; constructor(private readonly app: AppExecutor) { this.project = new MobileProject(this.app.cwdPath, { android: { path: "android" }, ios: { path: "ios/App" }, }) as MobileProject & { ios: IosProject; android: AndroidProject }; } async init() { const project = this.project as MobileProject; await this.project.load(); const hasAndroid = await FileSys.fileExists(`${this.app.cwdPath}/android/app/build.gradle`); const hasIos = await FileSys.fileExists(`${this.app.cwdPath}/ios/App/App.xcodeproj/project.pbxproj`); if (!project.android && !hasAndroid) { await this.app.spawn("npx", ["cap", "add", "android"]); await this.project.load(); } if (!project.ios && !hasIos) { await this.app.spawn("npx", ["cap", "add", "ios"]); await this.project.load(); } return this; } async save() { await this.project.commit(); } async #prepareIos() { const isAdded = await FileSys.fileExists(`${this.app.cwdPath}/ios/App/App.xcodeproj/project.pbxproj`); if (!isAdded) { await this.app.spawn("npx", ["cap", "add", "ios"]); await this.app.spawn("npx", ["@capacitor/assets", "generate"]); } else this.app.verbose(`iOS already added, skip adding process`); this.app.verbose(`syncing iOS`); await this.app.spawn("npx", ["cap", "sync", "ios"]); this.app.verbose(`sync completed.`); } async buildIos() { await this.#prepareIos(); this.app.verbose(`build completed iOS.`); return; } async syncIos() { await this.app.spawn("npx", ["cap", "sync", "ios"]); } async openIos() { await this.app.spawn("npx", ["cap", "open", "ios"]); } async runIos({ operation, appId, version = "0.0.1", buildNum = 1, host = "local" }: RunConfig) { const defaultAppId = `com.${this.app.name}.app`; await this.#prepareIos(); this.project.ios.setBundleId("App", "Debug", appId ?? defaultAppId); this.project.ios.setBundleId("App", "Release", appId ?? defaultAppId); await this.project.ios.setVersion("App", "Debug", version); await this.project.ios.setVersion("App", "Release", version); await this.project.ios.setBuild("App", "Debug", buildNum); await this.project.ios.setBuild("App", "Release", buildNum); await this.project.commit(); await this.app.spawn( "npx", ["cross-env", `APP_OPERATION_MODE=${operation}`, `BUN_PUBLIC_ENV=${host}`, "npx", "cap", "run", "ios"], { stdio: "inherit", }, ); // this.project.ios.incrementBuild("App", "Debug"); // this.project.ios.incrementBuild("App", "Release"); } async #prepareAndroid() { const isAdded = await Bun.file(`${this.app.cwdPath}/android/app/build.gradle`).exists(); if (!isAdded) { await this.app.spawn("npx", ["cap", "add", "android"]); } else this.app.verbose(`Android already added, skip adding process`); await this.app.spawn("npx", ["@capacitor/assets", "generate"]); await this.app.spawn("npx", ["cap", "sync", "android"]); } async #updateAndroidBuildTypes() { //keystore 기본 설정 및 debug, release 설정 const appGradle = await FileEditor.create(`${this.app.cwdPath}/android/app/build.gradle`); const buildTypesBlock = ` debug { applicationIdSuffix ".debug" versionNameSuffix "-DEBUG" debuggable true minifyEnabled false } `; const singinConfigBlock = ` signingConfigs { debug { storeFile file('debug.keystore') storePassword 'android' keyAlias 'androiddebugkey' keyPassword 'android' } release { if (project.hasProperty('MYAPP_RELEASE_STORE_FILE')) { storeFile file(MYAPP_RELEASE_STORE_FILE) storePassword MYAPP_RELEASE_STORE_PASSWORD keyAlias MYAPP_RELEASE_KEY_ALIAS keyPassword MYAPP_RELEASE_KEY_PASSWORD } } } `; if (appGradle.find("signingConfigs {") === -1) { appGradle.insertBefore("buildTypes {", singinConfigBlock); } if (appGradle.find(`applicationIdSuffix ".debug"`) === -1) { appGradle.insertAfter("buildTypes {", buildTypesBlock); } await appGradle.save(); } async buildAndroid(assembleType: "apk" | "aab") { await this.#prepareAndroid(); await this.#updateAndroidBuildTypes(); //윈도우는 gradlew.bat 사용 const isWindows = process.platform === "win32"; const gradleCommand = isWindows ? "gradlew.bat" : "./gradlew"; await this.app.spawn(gradleCommand, [assembleType === "apk" ? "assembleRelease" : "bundleRelease"], { stdio: "inherit", cwd: `${this.app.cwdPath}/android`, }); } async openAndroid() { await this.app.spawn("npx", ["cap", "open", "android"]); } async syncAndroid() { await this.#prepareAndroid(); this.app.log(`Sync Android Completed.`); } async runAndroid({ operation, appName, appId, version = "0.0.1", buildNum = 1, host = "local" }: RunConfig) { const defaultAppId = `com.${this.app.name}.app`; const defaultAppName = this.app.name; await this.project.android.setVersionName(version); await this.project.android.setPackageName(appId ?? defaultAppId); await this.project.android.setVersionCode(buildNum); const versionName = await this.project.android.getVersionName(); const versionCode = await this.project.android.getVersionCode(); await this.project.android.setAppName(appName ?? defaultAppName); await this.project.commit(); await this.#prepareAndroid(); this.app.logger.info(`Running Android in ${operation} mode on ${host} host`); await this.app.spawn( "npx", ["cross-env", `BUN_PUBLIC_ENV=${host}`, `APP_OPERATION_MODE=${operation}`, "npx", "cap", "run", "android"], { stdio: "inherit", }, ); } //? 릴리즈시 buildNum +1 version 파라미터 받아서 업데이트 async updateAndroidVersion(version: string, buildNum: number) { //TODO: 테스트 확인 필요 await this.project.android.setVersionName(version); await this.project.android.setVersionCode(buildNum); const versionName = await this.project.android.getVersionName(); const versionCode = await this.project.android.getVersionCode(); await this.project.commit(); } async releaseIos() { //TODO: 작업 필요 const isAdded = await Bun.file(`${this.app.cwdPath}/ios/App/App.xcodeproj/project.pbxproj`).exists(); if (!isAdded) { await this.app.spawn("npx cap add ios"); await this.app.spawn("npx @capacitor/assets generate"); } else this.app.log(`iOS already added, skip adding process`); await this.app.spawn("cross-env", ["APP_OPERATION_MODE=release", "npx", "cap", "sync", "ios"]); } async releaseAndroid() { //TODO: 작업 필요 const isAdded = await Bun.file(`${this.app.cwdPath}/android/app/build.gradle`).exists(); if (!isAdded) { await this.app.spawn("npx cap add android"); await this.app.spawn("npx @capacitor/assets generate"); } else this.app.log(`android already added, skip adding process`); await this.app.spawn("cross-env", ["APP_OPERATION_MODE=release", "npx", "cap", "sync", "android"]); } async addCamera() { await this.#setPermissionInIos({ cameraUsageDescription: "$(PRODUCT_NAME) requires access to the camera to take photos.", photoAddUsageDescription: "$(PRODUCT_NAME) requires access to the photo library to take photos.", photoUsageDescription: "$(PRODUCT_NAME) requires access to the photo library to take photos.", }); this.#setPermissionsInAndroid(["READ_MEDIA_IMAGES", "READ_EXTERNAL_STORAGE", "WRITE_EXTERNAL_STORAGE"]); } async addContact() { await this.#setPermissionInIos({ contactsUsageDescription: "$(PRODUCT_NAME) requires access to the contacts to add new contacts.", }); this.#setPermissionsInAndroid(["READ_CONTACTS", "WRITE_CONTACTS"]); } async addLocation() { await this.#setPermissionInIos({ locationAlwaysUsageDescription: "$(PRODUCT_NAME) requires access to the location to get the user's location.", locationWhenInUseUsageDescription: "$(PRODUCT_NAME) requires access to the location to get the user's location.", }); this.#setPermissionsInAndroid(["ACCESS_COARSE_LOCATION", "ACCESS_FINE_LOCATION"]); this.#setFeaturesInAndroid(["android.hardware.location.gps"]); } async #setPermissionInIos(permissions: { [key: string]: string }) { const updateNs = Object.fromEntries( Object.entries(permissions).map(([key, value]) => [`NS${capitalize(key)}`, value]), ); await Promise.all([ this.project.ios.updateInfoPlist(this.iosTargetName, "Debug", updateNs), this.project.ios.updateInfoPlist(this.iosTargetName, "Release", updateNs), ]); } #setFeaturesInAndroid(features: string[]) { for (const feature of features) { if (this.#hasFeatureInAndroid(feature)) { this.app.logger.info(`${feature} already exists in android`); return this; } this.app.logger.info(`Adding ${feature} to android`); this.project.android .getAndroidManifest() .injectFragment("manifest", ``); } return this; } #getFeaturesInAndroid() { const androidManifest = this.project.android.getAndroidManifest(); const element = androidManifest.getDocumentElement(); if (!element) throw new Error("manifest not found"); const usesFeature = element.getElementsByTagName("uses-feature"); return Array.from(usesFeature).map((feature) => feature.getAttribute("android:name")); } #hasFeatureInAndroid(feature: string) { return this.#getFeaturesInAndroid().includes(feature); } #setPermissionsInAndroid(permissions: string[]) { for (const permission of permissions) { if (this.#hasPermissionInAndroid(permission)) { this.app.logger.info(`${permission} already exists in android`); return this; } this.app.logger.info(`Adding ${permission} to android`); this.project.android .getAndroidManifest() .injectFragment("manifest", ``); } return this; } #getPermissionsInAndroid() { const androidManifest = this.project.android.getAndroidManifest(); const element = androidManifest.getDocumentElement(); if (!element) throw new Error("manifest not found"); const usesPermission = element.getElementsByTagName("uses-permission"); return Array.from(usesPermission).map((permission) => permission.getAttribute("android:name")); } #hasPermissionInAndroid(permission: string) { return this.#getPermissionsInAndroid().includes(permission); } }