@simplysm/sd-cli 12.16.13 → 12.16.15

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.
@@ -1,116 +1,162 @@
1
1
  import * as path from "path";
2
2
  import { FsUtils, PathUtils, SdLogger, SdProcess } from "@simplysm/sd-core-node";
3
- import { StringUtils, typescript } from "@simplysm/sd-core-common";
3
+ import { NotImplementError, ObjectUtils, StringUtils, typescript } from "@simplysm/sd-core-common";
4
4
  import sharp from "sharp";
5
5
  export class SdCliCapacitor {
6
6
  constructor(_opt) {
7
7
  this._opt = _opt;
8
8
  // 상수 정의
9
- this._CAPACITOR_DIR_NAME = ".capacitor";
10
- this._CONFIG_FILE_NAME = "capacitor.config.ts";
11
- this._KEYSTORE_FILE_NAME = "android.keystore";
12
- this._ICON_DIR_NAME = "resources";
9
+ this._ANDROID_KEYSTORE_FILE_NAME = "android.keystore";
13
10
  this._platforms = Object.keys(this._opt.config.platform ?? {});
14
11
  this._npmConfig = FsUtils.readJson(path.resolve(this._opt.pkgPath, "package.json"));
12
+ this._capPath = path.resolve(this._opt.pkgPath, ".capacitor");
15
13
  }
16
14
  static { this._logger = SdLogger.get(["simplysm", "sd-cli", "SdCliCapacitor"]); }
17
15
  static async _execAsync(cmd, args, cwd) {
18
16
  this._logger.debug(`실행 명령: ${cmd + " " + args.join(" ")}`);
19
- const msg = await SdProcess.spawnAsync(cmd, args, { cwd });
17
+ const msg = await SdProcess.spawnAsync(cmd, args, {
18
+ cwd,
19
+ env: {
20
+ FORCE_COLOR: "1", // chalk, supports-color 계열
21
+ CLICOLOR_FORCE: "1", // 일부 Unix 도구
22
+ COLORTERM: "truecolor", // 추가 힌트
23
+ },
24
+ });
20
25
  this._logger.debug(`실행 결과: ${msg}`);
26
+ return msg;
21
27
  }
22
28
  async initializeAsync() {
23
- const capacitorPath = path.resolve(this._opt.pkgPath, this._CAPACITOR_DIR_NAME);
24
29
  // 1. Capacitor 프로젝트 초기화
25
- const isNewProject = await this._initializeCapacitorProjectAsync(capacitorPath);
30
+ const changed = await this._initCapAsync();
26
31
  // 2. Capacitor 설정 파일 생성
27
- await this._createCapacitorConfigAsync(capacitorPath);
32
+ await this._writeCapConfAsync();
28
33
  // 3. 플랫폼 관리
29
- await this._managePlatformsAsync(capacitorPath);
30
- // 4. 플러그인 관리
31
- const pluginsChanged = await this._managePluginsAsync(capacitorPath);
32
- // 5. 안드로이드 서명 설정
33
- await this._setupAndroidSignAsync(capacitorPath);
34
- // 6. 아이콘 및 스플래시 스크린 설정
35
- await this._setupIconAndSplashScreenAsync(capacitorPath);
36
- // 7. Android 네이티브 설정 (AndroidManifest.xml, build.gradle 등)
34
+ await this._addPlatformsAsync();
35
+ // 4. 아이콘 설정
36
+ await this._setupIconAsync();
37
+ // 5. Android 네이티브 설정
37
38
  if (this._platforms.includes("android")) {
38
- await this._configureAndroidNativeAsync(capacitorPath);
39
+ await this._configureAndroidAsync();
39
40
  }
40
- // 8. 웹 자산 동기화
41
- if (isNewProject || pluginsChanged) {
42
- await SdCliCapacitor._execAsync("npx", ["cap", "sync"], capacitorPath);
41
+ // 6. 웹 자산 동기화
42
+ if (changed) {
43
+ await SdCliCapacitor._execAsync("npx", ["cap", "sync"], this._capPath);
43
44
  }
44
45
  else {
45
- await SdCliCapacitor._execAsync("npx", ["cap", "copy"], capacitorPath);
46
+ await SdCliCapacitor._execAsync("npx", ["cap", "copy"], this._capPath);
46
47
  }
47
48
  }
48
49
  // 1. Capacitor 프로젝트 초기화
49
- async _initializeCapacitorProjectAsync(capacitorPath) {
50
- const wwwPath = path.resolve(capacitorPath, "www");
51
- if (FsUtils.exists(wwwPath)) {
52
- SdCliCapacitor._logger.log("이미 생성되어있는 '.capacitor'를 사용합니다.");
53
- // 버전 동기화
54
- const pkgJsonPath = path.resolve(capacitorPath, "package.json");
55
- if (FsUtils.exists(pkgJsonPath)) {
56
- const pkgJson = (await FsUtils.readJsonAsync(pkgJsonPath));
57
- if (pkgJson.version !== this._npmConfig.version) {
58
- pkgJson.version = this._npmConfig.version;
59
- await FsUtils.writeJsonAsync(pkgJsonPath, pkgJson, { space: 2 });
60
- SdCliCapacitor._logger.debug(`버전 동기화: ${this._npmConfig.version}`);
61
- }
62
- }
50
+ async _initCapAsync() {
51
+ // package.json 파일 구성
52
+ const depChanged = await this._setupNpmConfAsync();
53
+ if (!depChanged)
63
54
  return false;
64
- }
65
- // www 폴더 생성
66
- await FsUtils.mkdirsAsync(wwwPath);
67
- // package.json 생성
68
- const projNpmConfig = await FsUtils.readJsonAsync(path.resolve(this._opt.pkgPath, "../../package.json"));
69
- const pkgJson = {
70
- name: this._opt.config.appId,
71
- version: this._npmConfig.version,
72
- private: true,
73
- volta: projNpmConfig.volta,
74
- dependencies: {
75
- "@capacitor/core": "^7.0.0",
76
- "@capacitor/app": "^7.0.0",
77
- },
78
- devDependencies: {
79
- "@capacitor/cli": "^7.0.0",
80
- "@capacitor/assets": "^3.0.0",
81
- ...this._platforms.toObject((item) => `@capacitor/${item}`, () => "^7.0.0"),
82
- },
83
- };
84
- await FsUtils.writeJsonAsync(path.resolve(capacitorPath, "package.json"), pkgJson, {
85
- space: 2,
86
- });
87
55
  // .yarnrc.yml 작성
88
- await FsUtils.writeFileAsync(path.resolve(capacitorPath, ".yarnrc.yml"), "nodeLinker: node-modules");
56
+ await FsUtils.writeFileAsync(path.resolve(this._capPath, ".yarnrc.yml"), "nodeLinker: node-modules");
89
57
  // 빈 yarn.lock 작성
90
- await FsUtils.writeFileAsync(path.resolve(capacitorPath, "yarn.lock"), "");
58
+ await FsUtils.writeFileAsync(path.resolve(this._capPath, "yarn.lock"), "");
91
59
  // yarn install
92
- await SdCliCapacitor._execAsync("yarn", ["dlx", "npm-check-updates", "-u", "--target", "semver"], capacitorPath);
93
- await SdCliCapacitor._execAsync("yarn", ["install"], capacitorPath);
94
- // capacitor init
95
- await SdCliCapacitor._execAsync("npx", ["cap", "init", this._opt.config.appName, this._opt.config.appId], capacitorPath);
96
- // www/index.html 생성
60
+ const installResult = await SdCliCapacitor._execAsync("yarn", ["install"], this._capPath);
61
+ const errorLines = installResult.split("\n").filter((item) => item.includes("YN0002"));
62
+ // peer dependency 경고 감지
63
+ if (errorLines.length > 0) {
64
+ throw new Error(errorLines.join("\n"));
65
+ }
66
+ // cap init
67
+ await SdCliCapacitor._execAsync("npx", ["cap", "init", this._opt.config.appName, this._opt.config.appId], this._capPath);
68
+ // 기본 www/index.html 생성
69
+ const wwwPath = path.resolve(this._capPath, "www");
97
70
  await FsUtils.writeFileAsync(path.resolve(wwwPath, "index.html"), "<!DOCTYPE html><html><head></head><body></body></html>");
98
71
  return true;
99
72
  }
73
+ async _setupNpmConfAsync() {
74
+ const projNpmConfig = await FsUtils.readJsonAsync(path.resolve(this._opt.pkgPath, "../../package.json"));
75
+ // -----------------------------
76
+ // 기본설정
77
+ // -----------------------------
78
+ const capNpmConfPath = path.resolve(this._capPath, "package.json");
79
+ const orgCapNpmConf = FsUtils.exists(capNpmConfPath)
80
+ ? await FsUtils.readJsonAsync(path.resolve(this._capPath, "package.json"))
81
+ : {};
82
+ const capNpmConf = ObjectUtils.clone(orgCapNpmConf);
83
+ capNpmConf.name = this._opt.config.appId;
84
+ capNpmConf.version = this._npmConfig.version;
85
+ capNpmConf.volta = projNpmConfig.volta;
86
+ capNpmConf.dependencies = capNpmConf.dependencies ?? {};
87
+ capNpmConf.dependencies["@capacitor/core"] = "^7.0.0";
88
+ capNpmConf.dependencies["@capacitor/app"] = "^7.0.0";
89
+ for (const platform of this._platforms) {
90
+ capNpmConf.dependencies[`@capacitor/${platform}`] = "^7.0.0";
91
+ }
92
+ capNpmConf.devDependencies = capNpmConf.devDependencies ?? {};
93
+ capNpmConf.devDependencies["@capacitor/cli"] = "^7.0.0";
94
+ capNpmConf.devDependencies["@capacitor/assets"] = "^3.0.0";
95
+ // -----------------------------
96
+ // 플러그인 패키지 설정
97
+ // -----------------------------
98
+ const mainDeps = {
99
+ ...this._npmConfig.dependencies,
100
+ ...this._npmConfig.devDependencies,
101
+ ...this._npmConfig.peerDependencies,
102
+ };
103
+ const usePlugins = Object.keys(this._opt.config.plugins ?? {});
104
+ const prevPlugins = Object.keys(capNpmConf.dependencies).filter((item) => !["@capacitor/core", "@capacitor/android", "@capacitor/ios", "@capacitor/app"].includes(item));
105
+ // 사용하지 않는 플러그인 제거
106
+ for (const prevPlugin of prevPlugins) {
107
+ if (!usePlugins.includes(prevPlugin)) {
108
+ delete capNpmConf.dependencies[prevPlugin];
109
+ SdCliCapacitor._logger.debug(`플러그인 제거: ${prevPlugin}`);
110
+ }
111
+ }
112
+ // 새 플러그인 추가
113
+ for (const plugin of usePlugins) {
114
+ if (!(plugin in capNpmConf.dependencies)) {
115
+ const version = mainDeps[plugin] ?? "*";
116
+ capNpmConf.dependencies[plugin] = version;
117
+ SdCliCapacitor._logger.debug(`플러그인 추가: ${plugin}@${version}`);
118
+ }
119
+ }
120
+ // -----------------------------
121
+ // 저장
122
+ // -----------------------------
123
+ await FsUtils.writeJsonAsync(capNpmConfPath, capNpmConf, { space: 2 });
124
+ return (!ObjectUtils.equal(orgCapNpmConf.volta, capNpmConf.volta) ||
125
+ !ObjectUtils.equal(orgCapNpmConf.dependencies, capNpmConf.dependencies) ||
126
+ !ObjectUtils.equal(orgCapNpmConf.devDependencies, capNpmConf.devDependencies));
127
+ /*// volta, dep, devDep 이 변한 경우에만, yarn install
128
+ if (
129
+ !ObjectUtils.equal(orgCapNpmConf.volta, capNpmConf.volta) ||
130
+ !ObjectUtils.equal(orgCapNpmConf.dependencies, capNpmConf.dependencies) ||
131
+ !ObjectUtils.equal(orgCapNpmConf.devDependencies, capNpmConf.devDependencies)
132
+ ) {
133
+ const installResult = await SdCliCapacitor._execAsync("yarn", ["install"], this._capPath);
134
+
135
+ const errorLines = installResult.split("\n").filter((item) => item.includes("YN0002"));
136
+
137
+ // peer dependency 경고 감지
138
+ if (errorLines.length > 0) {
139
+ throw new Error(errorLines.join("\n"));
140
+ }
141
+
142
+ return true;
143
+ }
144
+ // 변경 없으면 아무것도 안 함 → 오프라인 OK
145
+ return false;*/
146
+ }
100
147
  // 2. Capacitor 설정 파일 생성
101
- async _createCapacitorConfigAsync(capacitorPath) {
102
- const configFilePath = path.resolve(capacitorPath, this._CONFIG_FILE_NAME);
148
+ async _writeCapConfAsync() {
149
+ const confPath = path.resolve(this._capPath, "capacitor.config.ts");
103
150
  // 플러그인 옵션 생성
104
151
  const pluginOptions = {};
105
152
  for (const [pluginName, options] of Object.entries(this._opt.config.plugins ?? {})) {
106
153
  if (options !== true) {
107
- // @capacitor/splash-screen → SplashScreen 형태로 변환
108
154
  const configKey = StringUtils.toPascalCase(pluginName.split("/").last());
109
155
  pluginOptions[configKey] = options;
110
156
  }
111
157
  }
112
158
  const pluginsConfigStr = Object.keys(pluginOptions).length > 0
113
- ? JSON.stringify(pluginOptions, null, 4).replace(/^/gm, " ").trim()
159
+ ? JSON.stringify(pluginOptions, null, 2).replace(/^/gm, " ").trim()
114
160
  : "{}";
115
161
  const configContent = typescript `
116
162
  import type { CapacitorConfig } from "@capacitor/cli";
@@ -120,207 +166,114 @@ export class SdCliCapacitor {
120
166
  appName: "${this._opt.config.appName}",
121
167
  server: {
122
168
  androidScheme: "http",
123
- cleartext: true,
124
- allowNavigation: ["*"],
125
- },
126
- android: {
127
- allowMixedContent: true,
128
- statusBarOverlaysWebView: false,
169
+ cleartext: true
129
170
  },
171
+ android: {},
130
172
  plugins: ${pluginsConfigStr},
131
173
  };
132
174
 
133
175
  export default config;
134
176
  `;
135
- await FsUtils.writeFileAsync(configFilePath, configContent);
177
+ await FsUtils.writeFileAsync(confPath, configContent);
136
178
  }
137
- // 3. 플랫폼 관리
138
- async _managePlatformsAsync(capacitorPath) {
179
+ // 3. 플랫폼 추가
180
+ async _addPlatformsAsync() {
139
181
  for (const platform of this._platforms) {
140
- if (FsUtils.exists(path.resolve(capacitorPath, platform)))
182
+ if (FsUtils.exists(path.resolve(this._capPath, platform)))
141
183
  continue;
142
- await SdCliCapacitor._execAsync("npx", ["cap", "add", platform], capacitorPath);
184
+ await SdCliCapacitor._execAsync("npx", ["cap", "add", platform], this._capPath);
143
185
  }
144
186
  }
145
- async _managePluginsAsync(capacitorPath) {
146
- const pkgJsonPath = path.resolve(capacitorPath, "package.json");
147
- const pkgJson = (await FsUtils.readJsonAsync(pkgJsonPath));
148
- const mainDeps = {
149
- ...this._npmConfig.dependencies,
150
- ...this._npmConfig.devDependencies,
151
- ...this._npmConfig.peerDependencies,
152
- };
153
- const usePlugins = Object.keys(this._opt.config.plugins ?? {});
154
- const currentDeps = pkgJson.dependencies ?? {};
155
- let changed = false;
156
- // 사용하지 않는 플러그인 제거
157
- for (const dep of Object.keys(currentDeps)) {
158
- if (this._isCapacitorPlugin(dep) && !usePlugins.includes(dep)) {
159
- delete currentDeps[dep];
160
- changed = true;
161
- SdCliCapacitor._logger.debug(`플러그인 제거: ${dep}`);
162
- }
163
- }
164
- // 새 플러그인 추가
165
- for (const plugin of usePlugins) {
166
- if (!(plugin in currentDeps)) {
167
- const version = mainDeps[plugin] ?? "^7.0.0";
168
- currentDeps[plugin] = version;
169
- changed = true;
170
- SdCliCapacitor._logger.debug(`플러그인 추가: ${plugin}@${version}`);
171
- }
172
- }
173
- // 변경사항 있을 때만 저장 & install
174
- if (changed) {
175
- pkgJson.dependencies = currentDeps;
176
- await FsUtils.writeJsonAsync(pkgJsonPath, pkgJson, { space: 2 });
177
- await SdCliCapacitor._execAsync("yarn", ["install"], capacitorPath);
178
- return true;
179
- }
180
- // 변경 없으면 아무것도 안 함 → 오프라인 OK
181
- return false;
182
- }
183
- _isCapacitorPlugin(dep) {
184
- // 기본 패키지 제외
185
- const corePackages = [
186
- "@capacitor/core",
187
- "@capacitor/android",
188
- "@capacitor/ios",
189
- "@capacitor/app",
190
- ];
191
- if (corePackages.includes(dep))
192
- return false;
193
- return dep.startsWith("@capacitor/") || dep.includes("capacitor-plugin");
194
- }
195
- // 5. 안드로이드 서명 설정
196
- async _setupAndroidSignAsync(capacitorPath) {
197
- const keystorePath = path.resolve(capacitorPath, this._KEYSTORE_FILE_NAME);
198
- if (this._opt.config.platform?.android?.sign) {
199
- await FsUtils.copyAsync(path.resolve(this._opt.pkgPath, this._opt.config.platform.android.sign.keystore), keystorePath);
200
- }
201
- else {
202
- await FsUtils.removeAsync(keystorePath);
203
- }
204
- }
205
- async _setupIconAndSplashScreenAsync(capacitorPath) {
206
- const resourcesDirPath = path.resolve(capacitorPath, this._ICON_DIR_NAME);
187
+ // 4. 아이콘 설정
188
+ async _setupIconAsync() {
189
+ const assetsDirPath = path.resolve(this._capPath, "assets");
207
190
  if (this._opt.config.icon != null) {
208
- await FsUtils.mkdirsAsync(resourcesDirPath);
191
+ await FsUtils.mkdirsAsync(assetsDirPath);
209
192
  const iconSource = path.resolve(this._opt.pkgPath, this._opt.config.icon);
210
- // 아이콘만 생성
211
- const logoPath = path.resolve(resourcesDirPath, "logo.png");
212
- await this._createCenteredImageAsync(iconSource, logoPath, 1024, 0.6);
213
- await this._cleanupExistingIconsAsync(capacitorPath);
214
- try {
215
- await SdCliCapacitor._execAsync("npx", [
216
- "@capacitor/assets",
217
- "generate",
218
- "--android",
219
- "--iconBackgroundColor",
220
- "#ffffff",
221
- "--splashBackgroundColor",
222
- "#ffffff",
223
- ], capacitorPath);
224
- }
225
- catch (e) {
226
- SdCliCapacitor._logger.warn("아이콘 생성 실패, 기본 아이콘 사용", e);
227
- }
193
+ // 아이콘 생성
194
+ const logoPath = path.resolve(assetsDirPath, "logo.png");
195
+ const logoSize = Math.floor(1024 * 0.6);
196
+ const padding = Math.floor((1024 - logoSize) / 2);
197
+ await sharp(iconSource)
198
+ .resize(logoSize, logoSize, {
199
+ fit: "contain",
200
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
201
+ })
202
+ .extend({
203
+ top: padding,
204
+ bottom: padding,
205
+ left: padding,
206
+ right: padding,
207
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
208
+ })
209
+ .toFile(logoPath);
210
+ // await this._cleanupExistingIconsAsync();
211
+ await SdCliCapacitor._execAsync("npx", [
212
+ "@capacitor/assets",
213
+ "generate",
214
+ "--iconBackgroundColor",
215
+ "#ffffff",
216
+ "--splashBackgroundColor",
217
+ "#ffffff",
218
+ ], this._capPath);
228
219
  }
229
220
  else {
230
- await FsUtils.removeAsync(resourcesDirPath);
221
+ await FsUtils.removeAsync(assetsDirPath);
231
222
  }
232
223
  }
233
- // 중앙에 로고를 배치한 이미지 생성
234
- async _createCenteredImageAsync(sourcePath, outputPath, outputSize, logoRatio) {
235
- const logoSize = Math.floor(outputSize * logoRatio);
236
- const padding = Math.floor((outputSize - logoSize) / 2);
237
- await sharp(sourcePath)
238
- .resize(logoSize, logoSize, {
239
- fit: "contain",
240
- background: { r: 0, g: 0, b: 0, alpha: 0 },
241
- })
242
- .extend({
243
- top: padding,
244
- bottom: padding,
245
- left: padding,
246
- right: padding,
247
- background: { r: 0, g: 0, b: 0, alpha: 0 },
248
- })
249
- .toFile(outputPath);
250
- }
251
224
  // 기존 아이콘 파일 삭제
252
- async _cleanupExistingIconsAsync(capacitorPath) {
253
- const androidResPath = path.resolve(capacitorPath, "android/app/src/main/res");
254
- if (!FsUtils.exists(androidResPath))
255
- return;
256
- // mipmap 폴더의 모든 ic_launcher 관련 파일 삭제 (png + xml)
257
- const mipmapDirs = await FsUtils.globAsync(path.resolve(androidResPath, "mipmap-*"));
258
- for (const dir of mipmapDirs) {
259
- const iconFiles = await FsUtils.globAsync(path.resolve(dir, "ic_launcher*")); // 확장자 제거
260
- for (const file of iconFiles) {
261
- await FsUtils.removeAsync(file);
262
- }
263
- }
264
- // drawable 폴더의 splash/icon 관련 파일도 삭제
265
- const drawableDirs = await FsUtils.globAsync(path.resolve(androidResPath, "drawable*"));
266
- for (const dir of drawableDirs) {
267
- const splashFiles = await FsUtils.globAsync(path.resolve(dir, "splash*"));
268
- const iconFiles = await FsUtils.globAsync(path.resolve(dir, "ic_launcher*"));
269
- for (const file of [...splashFiles, ...iconFiles]) {
270
- await FsUtils.removeAsync(file);
271
- }
272
- }
273
- }
274
- // 7. Android 네이티브 설정
275
- async _configureAndroidNativeAsync(capacitorPath) {
276
- const androidPath = path.resolve(capacitorPath, "android");
277
- if (!FsUtils.exists(androidPath)) {
278
- return;
279
- }
280
- // JAVA_HOME 찾기
281
- await this._configureAndroidGradlePropertiesAsync(androidPath);
282
- // local.properties 생성
283
- await this._configureSdkPathAsync(androidPath);
225
+ // private async _cleanupExistingIconsAsync(): Promise<void> {
226
+ // const androidResPath = path.resolve(capacitorPath, "android/app/src/main/res");
227
+ //
228
+ // if (!FsUtils.exists(androidResPath)) return;
229
+ //
230
+ // // mipmap 폴더의 모든 ic_launcher 관련 파일 삭제 (png + xml)
231
+ // const mipmapDirs = await FsUtils.globAsync(path.resolve(androidResPath, "mipmap-*"));
232
+ // for (const dir of mipmapDirs) {
233
+ // const iconFiles = await FsUtils.globAsync(path.resolve(dir, "ic_launcher*")); // 확장자 제거
234
+ // for (const file of iconFiles) {
235
+ // await FsUtils.removeAsync(file);
236
+ // }
237
+ // }
238
+ //
239
+ // // drawable 폴더의 splash/icon 관련 파일도 삭제
240
+ // const drawableDirs = await FsUtils.globAsync(path.resolve(androidResPath, "drawable*"));
241
+ // for (const dir of drawableDirs) {
242
+ // const splashFiles = await FsUtils.globAsync(path.resolve(dir, "splash*"));
243
+ // const iconFiles = await FsUtils.globAsync(path.resolve(dir, "ic_launcher*"));
244
+ // for (const file of [...splashFiles, ...iconFiles]) {
245
+ // await FsUtils.removeAsync(file);
246
+ // }
247
+ // }
248
+ // }
249
+ // 5. Android 네이티브 설정
250
+ async _configureAndroidAsync() {
251
+ const androidPath = path.resolve(this._capPath, "android");
252
+ // JAVA_HOME 경로 설정
253
+ await this._configureAndroidJavaHomePathAsync(androidPath);
254
+ // SDK 경로 설정
255
+ await this._configureAndroidSdkPathAsync(androidPath);
284
256
  // AndroidManifest.xml 수정
285
257
  await this._configureAndroidManifestAsync(androidPath);
286
258
  // build.gradle 수정 (필요시)
287
259
  await this._configureAndroidBuildGradleAsync(androidPath);
288
- // strings.xml 앱 이름 수정
289
- await this._configureAndroidStringsAsync(androidPath);
290
- // styles.xml 수정
291
- await this._configureAndroidStylesAsync(androidPath);
292
- }
293
- async _configureAndroidStylesAsync(androidPath) {
294
- const stylesPath = path.resolve(androidPath, "app/src/main/res/values/styles.xml");
295
- if (!FsUtils.exists(stylesPath)) {
296
- return;
297
- }
298
- let stylesContent = await FsUtils.readFileAsync(stylesPath);
299
- // Edge-to-Edge 비활성화만
300
- if (!stylesContent.includes("android:windowOptOutEdgeToEdgeEnforcement")) {
301
- stylesContent = stylesContent.replace(/(<style[^>]*name="AppTheme"[^>]*>)/, `$1\n <item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>`);
302
- }
303
- // NoActionBarLaunch를 단순 NoActionBar로
304
- stylesContent = stylesContent.replace(/(<style\s+name="AppTheme\.NoActionBarLaunch"\s+parent=")[^"]+(")/, `$1Theme.AppCompat.Light.NoActionBar$2`);
305
- // splash 관련 전부 제거
306
- stylesContent = stylesContent.replace(/\s*<item name="android:background">@drawable\/splash<\/item>/g, "");
307
- stylesContent = stylesContent.replace(/\s*<item name="android:windowSplashScreen[^"]*">[^<]*<\/item>/g, "");
308
- await FsUtils.writeFileAsync(stylesPath, stylesContent);
260
+ // TODO: strings.xml 앱 이름 수정?? WHY?
261
+ // await this._configureAndroidStringsAsync(androidPath);
262
+ // TODO: styles.xml 수정?? WHY?
263
+ // await this._configureAndroidStylesAsync(androidPath);
309
264
  }
310
- async _configureAndroidGradlePropertiesAsync(androidPath) {
265
+ // JAVA_HOME 경로 설정
266
+ async _configureAndroidJavaHomePathAsync(androidPath) {
311
267
  const gradlePropsPath = path.resolve(androidPath, "gradle.properties");
312
- if (!FsUtils.exists(gradlePropsPath)) {
313
- return;
314
- }
315
268
  let content = await FsUtils.readFileAsync(gradlePropsPath);
316
269
  // Java 21 경로 자동 탐색
317
- const java21Path = this._findJava21();
270
+ const java21Path = await this._findJava21Async();
318
271
  if (java21Path != null && !content.includes("org.gradle.java.home")) {
319
272
  content += `\norg.gradle.java.home=${java21Path.replace(/\\/g, "\\\\")}\n`;
320
273
  FsUtils.writeFile(gradlePropsPath, content);
321
274
  }
322
275
  }
323
- _findJava21() {
276
+ async _findJava21Async() {
324
277
  const patterns = [
325
278
  "C:/Program Files/Amazon Corretto/jdk21*",
326
279
  "C:/Program Files/Eclipse Adoptium/jdk-21*",
@@ -328,7 +281,7 @@ export class SdCliCapacitor {
328
281
  "C:/Program Files/Microsoft/jdk-21*",
329
282
  ];
330
283
  for (const pattern of patterns) {
331
- const matches = FsUtils.glob(pattern);
284
+ const matches = await FsUtils.globAsync(pattern);
332
285
  if (matches.length > 0) {
333
286
  // 가장 최신 버전 선택 (정렬 후 마지막)
334
287
  return matches.sort().at(-1);
@@ -336,23 +289,38 @@ export class SdCliCapacitor {
336
289
  }
337
290
  return undefined;
338
291
  }
339
- async _configureSdkPathAsync(androidPath) {
340
- // local.properties 생성
292
+ // SDK 경로 설정
293
+ async _configureAndroidSdkPathAsync(androidPath) {
341
294
  const localPropsPath = path.resolve(androidPath, "local.properties");
342
- if (FsUtils.exists(localPropsPath)) {
343
- return;
344
- }
345
295
  // SDK 경로 탐색 (Cordova 방식과 유사)
346
296
  const sdkPath = this._findAndroidSdk();
347
297
  if (sdkPath != null) {
348
298
  await FsUtils.writeFileAsync(localPropsPath, `sdk.dir=${sdkPath.replace(/\\/g, "/")}\n`);
349
299
  }
350
300
  }
301
+ _findAndroidSdk() {
302
+ // 1. 환경변수 확인
303
+ const fromEnv = process.env["ANDROID_HOME"] ?? process.env["ANDROID_SDK_ROOT"];
304
+ if (fromEnv != null && FsUtils.exists(fromEnv)) {
305
+ return fromEnv;
306
+ }
307
+ // 2. 일반적인 설치 경로 탐색
308
+ const candidates = [
309
+ path.resolve(process.env["LOCALAPPDATA"] ?? "", "Android/Sdk"),
310
+ path.resolve(process.env["HOME"] ?? "", "Android/Sdk"),
311
+ "C:/Program Files/Android/Sdk",
312
+ "C:/Android/Sdk",
313
+ ];
314
+ for (const candidate of candidates) {
315
+ if (FsUtils.exists(candidate)) {
316
+ return candidate;
317
+ }
318
+ }
319
+ return undefined;
320
+ }
321
+ // AndroidManifest.xml 수정
351
322
  async _configureAndroidManifestAsync(androidPath) {
352
323
  const manifestPath = path.resolve(androidPath, "app/src/main/AndroidManifest.xml");
353
- if (!FsUtils.exists(manifestPath)) {
354
- return;
355
- }
356
324
  let manifestContent = await FsUtils.readFileAsync(manifestPath);
357
325
  // usesCleartextTraffic 설정
358
326
  if (!manifestContent.includes("android:usesCleartextTraffic")) {
@@ -399,11 +367,9 @@ export class SdCliCapacitor {
399
367
  }
400
368
  await FsUtils.writeFileAsync(manifestPath, manifestContent);
401
369
  }
370
+ // build.gradle 수정 (필요시)
402
371
  async _configureAndroidBuildGradleAsync(androidPath) {
403
372
  const buildGradlePath = path.resolve(androidPath, "app/build.gradle");
404
- if (!FsUtils.exists(buildGradlePath)) {
405
- return;
406
- }
407
373
  let gradleContent = await FsUtils.readFileAsync(buildGradlePath);
408
374
  // versionName, versionCode 설정
409
375
  const version = this._npmConfig.version;
@@ -416,13 +382,21 @@ export class SdCliCapacitor {
416
382
  // SDK 버전 설정
417
383
  if (this._opt.config.platform?.android?.sdkVersion != null) {
418
384
  const sdkVersion = this._opt.config.platform.android.sdkVersion;
419
- gradleContent = gradleContent.replace(/minSdkVersion \d+/, `minSdkVersion ${sdkVersion}`);
420
- gradleContent = gradleContent.replace(/targetSdkVersion \d+/, `targetSdkVersion ${sdkVersion}`);
385
+ gradleContent = gradleContent.replace(/minSdkVersion .+/, `minSdkVersion ${sdkVersion}`);
386
+ gradleContent = gradleContent.replace(/targetSdkVersion .+/, `targetSdkVersion ${sdkVersion}`);
387
+ }
388
+ else {
389
+ gradleContent = gradleContent.replace(/minSdkVersion .+/, `minSdkVersion rootProject.ext.minSdkVersion`);
390
+ gradleContent = gradleContent.replace(/targetSdkVersion .+/, `targetSdkVersion rootProject.ext.targetSdkVersion`);
421
391
  }
422
392
  // Signing 설정
393
+ const keystorePath = path.resolve(this._capPath, this._ANDROID_KEYSTORE_FILE_NAME);
423
394
  const signConfig = this._opt.config.platform?.android?.sign;
424
395
  if (signConfig) {
425
- const keystoreRelativePath = `../../${this._KEYSTORE_FILE_NAME}`;
396
+ await FsUtils.copyAsync(path.resolve(this._opt.pkgPath, signConfig.keystore), keystorePath);
397
+ const keystoreRelativePath = path
398
+ .relative(path.dirname(buildGradlePath), keystorePath)
399
+ .replace(/\\/g, "/");
426
400
  const keystoreType = signConfig.keystoreType ?? "jks";
427
401
  // signingConfigs 블록 추가
428
402
  if (!gradleContent.includes("signingConfigs")) {
@@ -445,34 +419,90 @@ export class SdCliCapacitor {
445
419
  gradleContent = gradleContent.replace(/(buildTypes\s*\{[\s\S]*?release\s*\{)/, `$1\n signingConfig signingConfigs.release`);
446
420
  }
447
421
  }
448
- await FsUtils.writeFileAsync(buildGradlePath, gradleContent);
449
- }
450
- async _configureAndroidStringsAsync(androidPath) {
451
- const stringsPath = path.resolve(androidPath, "app/src/main/res/values/strings.xml");
452
- if (!FsUtils.exists(stringsPath)) {
453
- return;
422
+ else {
423
+ //TODO: gradleContent에서 signingConfigs 관련 부분 삭제
424
+ await FsUtils.removeAsync(keystorePath);
454
425
  }
455
- let stringsContent = await FsUtils.readFileAsync(stringsPath);
456
- stringsContent = stringsContent.replace(/<string name="app_name">[^<]+<\/string>/, `<string name="app_name">${this._opt.config.appName}</string>`);
457
- stringsContent = stringsContent.replace(/<string name="title_activity_main">[^<]+<\/string>/, `<string name="title_activity_main">${this._opt.config.appName}</string>`);
458
- stringsContent = stringsContent.replace(/<string name="package_name">[^<]+<\/string>/, `<string name="package_name">${this._opt.config.appId}</string>`);
459
- stringsContent = stringsContent.replace(/<string name="custom_url_scheme">[^<]+<\/string>/, `<string name="custom_url_scheme">${this._opt.config.appId}</string>`);
460
- await FsUtils.writeFileAsync(stringsPath, stringsContent);
426
+ await FsUtils.writeFileAsync(buildGradlePath, gradleContent);
461
427
  }
428
+ /*private async _configureAndroidStringsAsync(androidPath: string) {
429
+ const stringsPath = path.resolve(androidPath, "app/src/main/res/values/strings.xml");
430
+
431
+ if (!FsUtils.exists(stringsPath)) {
432
+ return;
433
+ }
434
+
435
+ let stringsContent = await FsUtils.readFileAsync(stringsPath);
436
+ stringsContent = stringsContent.replace(
437
+ /<string name="app_name">[^<]+<\/string>/,
438
+ `<string name="app_name">${this._opt.config.appName}</string>`,
439
+ );
440
+ stringsContent = stringsContent.replace(
441
+ /<string name="title_activity_main">[^<]+<\/string>/,
442
+ `<string name="title_activity_main">${this._opt.config.appName}</string>`,
443
+ );
444
+ stringsContent = stringsContent.replace(
445
+ /<string name="package_name">[^<]+<\/string>/,
446
+ `<string name="package_name">${this._opt.config.appId}</string>`,
447
+ );
448
+ stringsContent = stringsContent.replace(
449
+ /<string name="custom_url_scheme">[^<]+<\/string>/,
450
+ `<string name="custom_url_scheme">${this._opt.config.appId}</string>`,
451
+ );
452
+
453
+ await FsUtils.writeFileAsync(stringsPath, stringsContent);
454
+ }*/
455
+ /*private async _configureAndroidStylesAsync(androidPath: string) {
456
+ const stylesPath = path.resolve(androidPath, "app/src/main/res/values/styles.xml");
457
+
458
+ if (!FsUtils.exists(stylesPath)) {
459
+ return;
460
+ }
461
+
462
+ let stylesContent = await FsUtils.readFileAsync(stylesPath);
463
+
464
+ // Edge-to-Edge 비활성화만
465
+ if (!stylesContent.includes("android:windowOptOutEdgeToEdgeEnforcement")) {
466
+ stylesContent = stylesContent.replace(
467
+ /(<style[^>]*name="AppTheme"[^>]*>)/,
468
+ `$1\n <item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>`,
469
+ );
470
+ }
471
+
472
+ // NoActionBarLaunch를 단순 NoActionBar로
473
+ stylesContent = stylesContent.replace(
474
+ /(<style\s+name="AppTheme\.NoActionBarLaunch"\s+parent=")[^"]+(")/,
475
+ `$1Theme.AppCompat.Light.NoActionBar$2`,
476
+ );
477
+
478
+ // splash 관련 전부 제거
479
+ stylesContent = stylesContent.replace(
480
+ /\s*<item name="android:background">@drawable\/splash<\/item>/g,
481
+ "",
482
+ );
483
+ stylesContent = stylesContent.replace(
484
+ /\s*<item name="android:windowSplashScreen[^"]*">[^<]*<\/item>/g,
485
+ "",
486
+ );
487
+
488
+ await FsUtils.writeFileAsync(stylesPath, stylesContent);
489
+ }*/
462
490
  async buildAsync(outPath) {
463
- const capacitorPath = path.resolve(this._opt.pkgPath, this._CAPACITOR_DIR_NAME);
464
491
  const buildType = this._opt.config.debug ? "debug" : "release";
465
492
  // 플랫폼별 빌드
466
493
  await Promise.all(this._platforms.map(async (platform) => {
467
494
  // 해당 플랫폼만 copy
468
- await SdCliCapacitor._execAsync("npx", ["cap", "copy", platform], capacitorPath);
495
+ await SdCliCapacitor._execAsync("npx", ["cap", "copy", platform], this._capPath);
469
496
  if (platform === "android") {
470
- await this._buildAndroidAsync(capacitorPath, outPath, buildType);
497
+ await this._buildAndroidAsync(outPath, buildType);
498
+ }
499
+ else {
500
+ throw new NotImplementError();
471
501
  }
472
502
  }));
473
503
  }
474
- async _buildAndroidAsync(capacitorPath, outPath, buildType) {
475
- const androidPath = path.resolve(capacitorPath, "android");
504
+ async _buildAndroidAsync(outPath, buildType) {
505
+ const androidPath = path.resolve(this._capPath, "android");
476
506
  const targetOutPath = path.resolve(outPath, "android");
477
507
  const isBundle = this._opt.config.platform?.android?.bundle;
478
508
  const gradleTask = buildType === "release" ? (isBundle ? "bundleRelease" : "assembleRelease") : "assembleDebug";
@@ -502,26 +532,6 @@ export class SdCliCapacitor {
502
532
  await FsUtils.mkdirsAsync(updatesPath);
503
533
  await FsUtils.copyAsync(actualPath, path.resolve(updatesPath, `${this._npmConfig.version}.${ext}`));
504
534
  }
505
- _findAndroidSdk() {
506
- // 1. 환경변수 확인
507
- const fromEnv = process.env["ANDROID_HOME"] ?? process.env["ANDROID_SDK_ROOT"];
508
- if (fromEnv != null && FsUtils.exists(fromEnv)) {
509
- return fromEnv;
510
- }
511
- // 2. 일반적인 설치 경로 탐색
512
- const candidates = [
513
- path.resolve(process.env["LOCALAPPDATA"] ?? "", "Android/Sdk"),
514
- path.resolve(process.env["HOME"] ?? "", "Android/Sdk"),
515
- "C:/Program Files/Android/Sdk",
516
- "C:/Android/Sdk",
517
- ];
518
- for (const candidate of candidates) {
519
- if (FsUtils.exists(candidate)) {
520
- return candidate;
521
- }
522
- }
523
- return undefined;
524
- }
525
535
  static async runWebviewOnDeviceAsync(opt) {
526
536
  const projNpmConf = (await FsUtils.readJsonAsync(path.resolve(process.cwd(), "package.json")));
527
537
  const allPkgPaths = await projNpmConf.workspaces.mapManyAsync(async (item) => await FsUtils.globAsync(PathUtils.posix(process.cwd(), item)));
@@ -548,7 +558,7 @@ export class SdCliCapacitor {
548
558
  await this._execAsync("npx", ["cap", "run", opt.platform], capacitorPath);
549
559
  }
550
560
  catch (err) {
551
- await SdProcess.spawnAsync("adb", ["kill-server"]);
561
+ await this._execAsync("adb", ["kill-server"], capacitorPath);
552
562
  throw err;
553
563
  }
554
564
  }