@simplysm/sd-cli 12.16.14 → 12.16.16

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