@simplysm/sd-cli 12.16.4 → 12.16.6

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.
@@ -15,20 +15,26 @@ export declare class SdCliCapacitor {
15
15
  private static _execAsync;
16
16
  initializeAsync(): Promise<void>;
17
17
  private _initializeCapacitorProjectAsync;
18
- private _syncVersion;
19
- private _createCapacitorConfig;
18
+ private _syncVersionAsync;
19
+ private _createCapacitorConfigAsync;
20
20
  private _managePlatformsAsync;
21
21
  private _managePluginsAsync;
22
- private _setupAndroidSign;
22
+ private _setupAndroidSignAsync;
23
23
  private _setupIconAndSplashScreenAsync;
24
- private _configureAndroidNative;
25
- private _configureAndroidManifest;
26
- private _configureAndroidBuildGradle;
27
- private _configureAndroidStrings;
24
+ private _createPaddedIconAsync;
25
+ private _configureAndroidNativeAsync;
26
+ private _configureAndroidStylesAsync;
27
+ private _configureAndroidGradlePropertiesAsync;
28
+ private _findJava21;
29
+ private _configureSdkPathAsync;
30
+ private _configureAndroidManifestAsync;
31
+ private _configureAndroidBuildGradleAsync;
32
+ private _configureAndroidStringsAsync;
28
33
  buildAsync(outPath: string): Promise<void>;
29
34
  private _buildPlatformAsync;
30
35
  private _buildAndroidAsync;
31
- private _copyAndroidBuildOutput;
36
+ private _copyAndroidBuildOutputAsync;
37
+ private _findAndroidSdk;
32
38
  static runWebviewOnDeviceAsync(opt: {
33
39
  platform: string;
34
40
  package: string;
@@ -1,6 +1,7 @@
1
1
  import * as path from "path";
2
2
  import { FsUtils, PathUtils, SdLogger, SdProcess } from "@simplysm/sd-core-node";
3
3
  import { StringUtils, typescript } from "@simplysm/sd-core-common";
4
+ import sharp from "sharp";
4
5
  export class SdCliCapacitor {
5
6
  constructor(_opt) {
6
7
  this._opt = _opt;
@@ -9,7 +10,7 @@ export class SdCliCapacitor {
9
10
  this._CONFIG_FILE_NAME = "capacitor.config.ts";
10
11
  this._KEYSTORE_FILE_NAME = "android.keystore";
11
12
  this._ICON_DIR_PATH = "resources";
12
- this._platforms = Object.keys(this._opt.config.platform ?? { android: {} });
13
+ this._platforms = Object.keys(this._opt.config.platform ?? {});
13
14
  this._npmConfig = FsUtils.readJson(path.resolve(this._opt.pkgPath, "package.json"));
14
15
  }
15
16
  static { this._logger = SdLogger.get(["simplysm", "sd-cli", "SdCliCapacitor"]); }
@@ -23,65 +24,77 @@ export class SdCliCapacitor {
23
24
  // 1. Capacitor 프로젝트 초기화
24
25
  await this._initializeCapacitorProjectAsync(capacitorPath);
25
26
  // 2. Capacitor 설정 파일 생성
26
- this._createCapacitorConfig(capacitorPath);
27
+ await this._createCapacitorConfigAsync(capacitorPath);
27
28
  // 3. 플랫폼 관리
28
29
  await this._managePlatformsAsync(capacitorPath);
29
30
  // 4. 플러그인 관리
30
31
  await this._managePluginsAsync(capacitorPath);
31
32
  // 5. 안드로이드 서명 설정
32
- this._setupAndroidSign(capacitorPath);
33
+ await this._setupAndroidSignAsync(capacitorPath);
33
34
  // 6. 아이콘 및 스플래시 스크린 설정
34
35
  await this._setupIconAndSplashScreenAsync(capacitorPath);
35
36
  // 7. Android 네이티브 설정 (AndroidManifest.xml, build.gradle 등)
36
37
  if (this._platforms.includes("android")) {
37
- this._configureAndroidNative(capacitorPath);
38
+ await this._configureAndroidNativeAsync(capacitorPath);
38
39
  }
39
40
  // 8. 웹 자산 동기화
40
41
  await SdCliCapacitor._execAsync("npx", ["cap", "sync"], capacitorPath);
41
42
  }
42
43
  // 1. Capacitor 프로젝트 초기화
43
44
  async _initializeCapacitorProjectAsync(capacitorPath) {
44
- if (FsUtils.exists(capacitorPath)) {
45
+ if (FsUtils.exists(path.resolve(capacitorPath, "www"))) {
45
46
  SdCliCapacitor._logger.log("이미 생성되어있는 '.capacitor'를 사용합니다.");
46
47
  // 버전 동기화
47
- this._syncVersion(capacitorPath);
48
+ await this._syncVersionAsync(capacitorPath);
48
49
  }
49
50
  else {
50
- FsUtils.mkdirs(capacitorPath);
51
+ await FsUtils.mkdirsAsync(capacitorPath);
51
52
  // package.json 생성
53
+ const projNpmConfig = await FsUtils.readJsonAsync(path.resolve(this._opt.pkgPath, "../../package.json"));
52
54
  const pkgJson = {
53
55
  name: this._opt.config.appId,
54
56
  version: this._npmConfig.version,
55
57
  private: true,
58
+ volta: projNpmConfig.volta,
56
59
  dependencies: {
57
60
  "@capacitor/core": "^7.0.0",
58
61
  },
59
62
  devDependencies: {
60
63
  "@capacitor/cli": "^7.0.0",
61
64
  "@capacitor/assets": "^3.0.0",
65
+ ...this._platforms.toObject((item) => `@capacitor/${item}`, () => "^7.0.0"),
62
66
  },
63
67
  };
64
- FsUtils.writeJson(path.resolve(capacitorPath, "package.json"), pkgJson, { space: 2 });
68
+ await FsUtils.writeJsonAsync(path.resolve(capacitorPath, "package.json"), pkgJson, {
69
+ space: 2,
70
+ });
71
+ // .yarnrc.yml 작성
72
+ await FsUtils.writeFileAsync(path.resolve(capacitorPath, ".yarnrc.yml"), "nodeLinker: node-modules");
73
+ // yarn.lock 작성
74
+ await FsUtils.writeFileAsync(path.resolve(capacitorPath, "yarn.lock"), "");
65
75
  // yarn install
66
76
  await SdCliCapacitor._execAsync("yarn", ["install"], capacitorPath);
67
77
  // capacitor init
68
78
  await SdCliCapacitor._execAsync("npx", ["cap", "init", this._opt.config.appName, this._opt.config.appId], capacitorPath);
69
79
  }
80
+ // www/index.html 생성
81
+ const wwwPath = path.resolve(capacitorPath, "www");
82
+ await FsUtils.writeFileAsync(path.resolve(wwwPath, "index.html"), "<!DOCTYPE html><html><head></head><body></body></html>");
70
83
  }
71
84
  // 버전 동기화
72
- _syncVersion(capacitorPath) {
85
+ async _syncVersionAsync(capacitorPath) {
73
86
  const pkgJsonPath = path.resolve(capacitorPath, "package.json");
74
87
  if (FsUtils.exists(pkgJsonPath)) {
75
- const pkgJson = FsUtils.readJson(pkgJsonPath);
88
+ const pkgJson = (await FsUtils.readJsonAsync(pkgJsonPath));
76
89
  if (pkgJson.version !== this._npmConfig.version) {
77
90
  pkgJson.version = this._npmConfig.version;
78
- FsUtils.writeJson(pkgJsonPath, pkgJson, { space: 2 });
79
- SdCliCapacitor._logger.log(`버전 동기화: ${this._npmConfig.version}`);
91
+ await FsUtils.writeJsonAsync(pkgJsonPath, pkgJson, { space: 2 });
92
+ SdCliCapacitor._logger.debug(`버전 동기화: ${this._npmConfig.version}`);
80
93
  }
81
94
  }
82
95
  }
83
96
  // 2. Capacitor 설정 파일 생성
84
- _createCapacitorConfig(capacitorPath) {
97
+ async _createCapacitorConfigAsync(capacitorPath) {
85
98
  const configFilePath = path.resolve(capacitorPath, this._CONFIG_FILE_NAME);
86
99
  // 플러그인 옵션 생성
87
100
  const pluginOptions = {};
@@ -108,13 +121,14 @@ export class SdCliCapacitor {
108
121
  },
109
122
  android: {
110
123
  allowMixedContent: true,
124
+ statusBarOverlaysWebView: false,
111
125
  },
112
126
  plugins: ${pluginsConfigStr},
113
127
  };
114
128
 
115
129
  export default config;
116
130
  `;
117
- FsUtils.writeFile(configFilePath, configContent);
131
+ await FsUtils.writeFileAsync(configFilePath, configContent);
118
132
  }
119
133
  // 3. 플랫폼 관리
120
134
  async _managePlatformsAsync(capacitorPath) {
@@ -127,7 +141,7 @@ export class SdCliCapacitor {
127
141
  // 4. 플러그인 관리
128
142
  async _managePluginsAsync(capacitorPath) {
129
143
  const pkgJsonPath = path.resolve(capacitorPath, "package.json");
130
- const pkgJson = FsUtils.readJson(pkgJsonPath);
144
+ const pkgJson = (await FsUtils.readJsonAsync(pkgJsonPath));
131
145
  const currentDeps = Object.keys(pkgJson.dependencies ?? {});
132
146
  const usePlugins = Object.keys(this._opt.config.plugins ?? {});
133
147
  // 사용하지 않는 플러그인 제거
@@ -141,50 +155,51 @@ export class SdCliCapacitor {
141
155
  if (!usePlugins.includes(dep)) {
142
156
  // Capacitor 관련 플러그인만 제거
143
157
  if (dep.startsWith("@capacitor/") || dep.includes("capacitor-plugin")) {
144
- try {
145
- await SdCliCapacitor._execAsync("yarn", ["remove", dep], capacitorPath);
146
- SdCliCapacitor._logger.log(`플러그인 제거: ${dep}`);
147
- }
148
- catch {
149
- SdCliCapacitor._logger.warn(`플러그인 제거 실패: ${dep}`);
150
- }
158
+ await SdCliCapacitor._execAsync("yarn", ["remove", dep], capacitorPath);
159
+ SdCliCapacitor._logger.debug(`플러그인 제거: ${dep}`);
151
160
  }
152
161
  }
153
162
  }
154
163
  // 새 플러그인 설치
164
+ const mainPkgJson = this._npmConfig;
165
+ const mainDeps = {
166
+ ...mainPkgJson.dependencies,
167
+ ...mainPkgJson.devDependencies,
168
+ ...mainPkgJson.peerDependencies,
169
+ };
155
170
  for (const plugin of usePlugins) {
156
171
  if (!currentDeps.includes(plugin)) {
157
- try {
158
- await SdCliCapacitor._execAsync("yarn", ["add", plugin], capacitorPath);
159
- SdCliCapacitor._logger.log(`플러그인 설치: ${plugin}`);
160
- }
161
- catch {
162
- SdCliCapacitor._logger.warn(`플러그인 설치 실패: ${plugin}`);
163
- }
172
+ // 메인 프로젝트에 버전이 있으면 그 버전으로 설치
173
+ const version = mainDeps[plugin];
174
+ const pluginWithVersion = version ? `${plugin}@${version}` : plugin;
175
+ await SdCliCapacitor._execAsync("yarn", ["add", pluginWithVersion], capacitorPath);
176
+ SdCliCapacitor._logger.debug(`플러그인 설치: ${pluginWithVersion}`);
164
177
  }
165
178
  }
166
179
  }
167
180
  // 5. 안드로이드 서명 설정
168
- _setupAndroidSign(capacitorPath) {
181
+ async _setupAndroidSignAsync(capacitorPath) {
169
182
  const keystorePath = path.resolve(capacitorPath, this._KEYSTORE_FILE_NAME);
170
183
  if (this._opt.config.platform?.android?.sign) {
171
- FsUtils.copy(path.resolve(this._opt.pkgPath, this._opt.config.platform.android.sign.keystore), keystorePath);
184
+ await FsUtils.copyAsync(path.resolve(this._opt.pkgPath, this._opt.config.platform.android.sign.keystore), keystorePath);
172
185
  }
173
186
  else {
174
- FsUtils.remove(keystorePath);
187
+ await FsUtils.removeAsync(keystorePath);
175
188
  }
176
189
  }
177
190
  // 6. 아이콘 및 스플래시 스크린 설정
178
191
  async _setupIconAndSplashScreenAsync(capacitorPath) {
179
192
  const iconDirPath = path.resolve(capacitorPath, this._ICON_DIR_PATH);
180
- // ICON 파일 복사
181
193
  if (this._opt.config.icon != null) {
182
- FsUtils.mkdirs(iconDirPath);
194
+ await FsUtils.mkdirsAsync(iconDirPath);
183
195
  const iconSource = path.resolve(this._opt.pkgPath, this._opt.config.icon);
184
- // icon.png, splash.png 같은 파일 사용
185
- FsUtils.copy(iconSource, path.resolve(iconDirPath, "icon.png"));
186
- FsUtils.copy(iconSource, path.resolve(iconDirPath, "splash.png"));
187
- // @capacitor/assets로 아이콘/스플래시 리사이징
196
+ // Adaptive Icon용 여백 추가된 이미지 생성
197
+ // 1024x1024 중 680x680이 safe zone ( 66%)
198
+ const paddedIconPath = path.resolve(iconDirPath, "icon-only.png");
199
+ await this._createPaddedIconAsync(iconSource, paddedIconPath);
200
+ // splash, icon 복사
201
+ await FsUtils.copyAsync(iconSource, path.resolve(iconDirPath, "icon.png"));
202
+ await FsUtils.copyAsync(iconSource, path.resolve(iconDirPath, "splash.png"));
188
203
  try {
189
204
  await SdCliCapacitor._execAsync("npx", ["@capacitor/assets", "generate", "--android"], capacitorPath);
190
205
  }
@@ -193,28 +208,102 @@ export class SdCliCapacitor {
193
208
  }
194
209
  }
195
210
  else {
196
- FsUtils.remove(iconDirPath);
211
+ await FsUtils.removeAsync(iconDirPath);
197
212
  }
198
213
  }
214
+ async _createPaddedIconAsync(sourcePath, outputPath) {
215
+ const size = 1024;
216
+ const iconSize = 680; // safe zone
217
+ const padding = Math.floor((size - iconSize) / 2); // 172px
218
+ await sharp(sourcePath)
219
+ .resize(iconSize, iconSize, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
220
+ .extend({
221
+ top: padding,
222
+ bottom: padding,
223
+ left: padding,
224
+ right: padding,
225
+ background: { r: 0, g: 0, b: 0, alpha: 0 }, // 투명
226
+ })
227
+ .toFile(outputPath);
228
+ }
199
229
  // 7. Android 네이티브 설정
200
- _configureAndroidNative(capacitorPath) {
230
+ async _configureAndroidNativeAsync(capacitorPath) {
201
231
  const androidPath = path.resolve(capacitorPath, "android");
202
232
  if (!FsUtils.exists(androidPath)) {
203
233
  return;
204
234
  }
235
+ // JAVA_HOME 찾기
236
+ await this._configureAndroidGradlePropertiesAsync(androidPath);
237
+ // local.properties 생성
238
+ await this._configureSdkPathAsync(androidPath);
205
239
  // AndroidManifest.xml 수정
206
- this._configureAndroidManifest(androidPath);
240
+ await this._configureAndroidManifestAsync(androidPath);
207
241
  // build.gradle 수정 (필요시)
208
- this._configureAndroidBuildGradle(androidPath);
242
+ await this._configureAndroidBuildGradleAsync(androidPath);
209
243
  // strings.xml 앱 이름 수정
210
- this._configureAndroidStrings(androidPath);
244
+ await this._configureAndroidStringsAsync(androidPath);
245
+ // styles.xml 수정
246
+ await this._configureAndroidStylesAsync(androidPath);
247
+ }
248
+ async _configureAndroidStylesAsync(androidPath) {
249
+ const stylesPath = path.resolve(androidPath, "app/src/main/res/values/styles.xml");
250
+ if (!FsUtils.exists(stylesPath)) {
251
+ return;
252
+ }
253
+ let stylesContent = await FsUtils.readFileAsync(stylesPath);
254
+ // Edge-to-Edge 비활성화
255
+ if (!stylesContent.includes("android:windowOptOutEdgeToEdgeEnforcement")) {
256
+ stylesContent = stylesContent.replace(/(<style[^>]*AppTheme[^>]*>)/, `$1\n <item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>`);
257
+ }
258
+ await FsUtils.writeFileAsync(stylesPath, stylesContent);
259
+ }
260
+ async _configureAndroidGradlePropertiesAsync(androidPath) {
261
+ const gradlePropsPath = path.resolve(androidPath, "gradle.properties");
262
+ if (!FsUtils.exists(gradlePropsPath)) {
263
+ return;
264
+ }
265
+ let content = await FsUtils.readFileAsync(gradlePropsPath);
266
+ // Java 21 경로 자동 탐색
267
+ const java21Path = this._findJava21();
268
+ if (java21Path != null && !content.includes("org.gradle.java.home")) {
269
+ content += `\norg.gradle.java.home=${java21Path.replace(/\\/g, "\\\\")}\n`;
270
+ FsUtils.writeFile(gradlePropsPath, content);
271
+ }
272
+ }
273
+ _findJava21() {
274
+ const patterns = [
275
+ "C:/Program Files/Amazon Corretto/jdk21*",
276
+ "C:/Program Files/Eclipse Adoptium/jdk-21*",
277
+ "C:/Program Files/Java/jdk-21*",
278
+ "C:/Program Files/Microsoft/jdk-21*",
279
+ ];
280
+ for (const pattern of patterns) {
281
+ const matches = FsUtils.glob(pattern);
282
+ if (matches.length > 0) {
283
+ // 가장 최신 버전 선택 (정렬 후 마지막)
284
+ return matches.sort().at(-1);
285
+ }
286
+ }
287
+ return undefined;
211
288
  }
212
- _configureAndroidManifest(androidPath) {
289
+ async _configureSdkPathAsync(androidPath) {
290
+ // local.properties 생성
291
+ const localPropsPath = path.resolve(androidPath, "local.properties");
292
+ if (FsUtils.exists(localPropsPath)) {
293
+ return;
294
+ }
295
+ // SDK 경로 탐색 (Cordova 방식과 유사)
296
+ const sdkPath = this._findAndroidSdk();
297
+ if (sdkPath != null) {
298
+ await FsUtils.writeFileAsync(localPropsPath, `sdk.dir=${sdkPath.replace(/\\/g, "/")}\n`);
299
+ }
300
+ }
301
+ async _configureAndroidManifestAsync(androidPath) {
213
302
  const manifestPath = path.resolve(androidPath, "app/src/main/AndroidManifest.xml");
214
303
  if (!FsUtils.exists(manifestPath)) {
215
304
  return;
216
305
  }
217
- let manifestContent = FsUtils.readFile(manifestPath);
306
+ let manifestContent = await FsUtils.readFileAsync(manifestPath);
218
307
  // usesCleartextTraffic 설정
219
308
  if (!manifestContent.includes("android:usesCleartextTraffic")) {
220
309
  manifestContent = manifestContent.replace("<application", '<application android:usesCleartextTraffic="true"');
@@ -244,14 +333,28 @@ export class SdCliCapacitor {
244
333
  }
245
334
  }
246
335
  }
247
- FsUtils.writeFile(manifestPath, manifestContent);
336
+ // intentFilters 설정
337
+ const intentFilters = this._opt.config.platform?.android?.intentFilters ?? [];
338
+ for (const filter of intentFilters) {
339
+ const filterKey = filter.action ?? filter.category ?? "";
340
+ if (filterKey && !manifestContent.includes(filterKey)) {
341
+ const actionLine = filter.action != null ? `<action android:name="${filter.action}"/>` : "";
342
+ const categoryLine = filter.category != null ? `<category android:name="${filter.category}"/>` : "";
343
+ manifestContent = manifestContent.replace(/(<activity[\s\S]*?android:name="\.MainActivity"[\s\S]*?>)/, `$1
344
+ <intent-filter>
345
+ ${actionLine}
346
+ ${categoryLine}
347
+ </intent-filter>`);
348
+ }
349
+ }
350
+ await FsUtils.writeFileAsync(manifestPath, manifestContent);
248
351
  }
249
- _configureAndroidBuildGradle(androidPath) {
352
+ async _configureAndroidBuildGradleAsync(androidPath) {
250
353
  const buildGradlePath = path.resolve(androidPath, "app/build.gradle");
251
354
  if (!FsUtils.exists(buildGradlePath)) {
252
355
  return;
253
356
  }
254
- let gradleContent = FsUtils.readFile(buildGradlePath);
357
+ let gradleContent = await FsUtils.readFileAsync(buildGradlePath);
255
358
  // versionName, versionCode 설정
256
359
  const version = this._npmConfig.version;
257
360
  const versionParts = version.split(".");
@@ -292,19 +395,19 @@ export class SdCliCapacitor {
292
395
  gradleContent = gradleContent.replace(/(buildTypes\s*\{[\s\S]*?release\s*\{)/, `$1\n signingConfig signingConfigs.release`);
293
396
  }
294
397
  }
295
- FsUtils.writeFile(buildGradlePath, gradleContent);
398
+ await FsUtils.writeFileAsync(buildGradlePath, gradleContent);
296
399
  }
297
- _configureAndroidStrings(androidPath) {
400
+ async _configureAndroidStringsAsync(androidPath) {
298
401
  const stringsPath = path.resolve(androidPath, "app/src/main/res/values/strings.xml");
299
402
  if (!FsUtils.exists(stringsPath)) {
300
403
  return;
301
404
  }
302
- let stringsContent = FsUtils.readFile(stringsPath);
405
+ let stringsContent = await FsUtils.readFileAsync(stringsPath);
303
406
  stringsContent = stringsContent.replace(/<string name="app_name">[^<]+<\/string>/, `<string name="app_name">${this._opt.config.appName}</string>`);
304
407
  stringsContent = stringsContent.replace(/<string name="title_activity_main">[^<]+<\/string>/, `<string name="title_activity_main">${this._opt.config.appName}</string>`);
305
408
  stringsContent = stringsContent.replace(/<string name="package_name">[^<]+<\/string>/, `<string name="package_name">${this._opt.config.appId}</string>`);
306
409
  stringsContent = stringsContent.replace(/<string name="custom_url_scheme">[^<]+<\/string>/, `<string name="custom_url_scheme">${this._opt.config.appId}</string>`);
307
- FsUtils.writeFile(stringsPath, stringsContent);
410
+ await FsUtils.writeFileAsync(stringsPath, stringsContent);
308
411
  }
309
412
  async buildAsync(outPath) {
310
413
  const capacitorPath = path.resolve(this._opt.pkgPath, this._CAPACITOR_DIR_NAME);
@@ -340,9 +443,9 @@ export class SdCliCapacitor {
340
443
  const gradleCmd = process.platform === "win32" ? "gradlew.bat" : "./gradlew";
341
444
  await SdCliCapacitor._execAsync(gradleCmd, [gradleTask, "--no-daemon"], androidPath);
342
445
  // 빌드 결과물 복사
343
- this._copyAndroidBuildOutput(androidPath, targetOutPath, buildType);
446
+ await this._copyAndroidBuildOutputAsync(androidPath, targetOutPath, buildType);
344
447
  }
345
- _copyAndroidBuildOutput(androidPath, targetOutPath, buildType) {
448
+ async _copyAndroidBuildOutputAsync(androidPath, targetOutPath, buildType) {
346
449
  const isBundle = this._opt.config.platform?.android?.bundle;
347
450
  const isSigned = !!this._opt.config.platform?.android?.sign;
348
451
  const ext = isBundle ? "aab" : "apk";
@@ -357,21 +460,41 @@ export class SdCliCapacitor {
357
460
  return;
358
461
  }
359
462
  const outputFileName = `${this._opt.config.appName}${isSigned ? "" : "-unsigned"}-latest.${ext}`;
360
- FsUtils.mkdirs(targetOutPath);
361
- FsUtils.copy(actualPath, path.resolve(targetOutPath, outputFileName));
463
+ await FsUtils.mkdirsAsync(targetOutPath);
464
+ await FsUtils.copyAsync(actualPath, path.resolve(targetOutPath, outputFileName));
362
465
  const updatesPath = path.resolve(targetOutPath, "updates");
363
- FsUtils.mkdirs(updatesPath);
364
- FsUtils.copy(actualPath, path.resolve(updatesPath, `${this._npmConfig.version}.${ext}`));
466
+ await FsUtils.mkdirsAsync(updatesPath);
467
+ await FsUtils.copyAsync(actualPath, path.resolve(updatesPath, `${this._npmConfig.version}.${ext}`));
468
+ }
469
+ _findAndroidSdk() {
470
+ // 1. 환경변수 확인
471
+ const fromEnv = process.env["ANDROID_HOME"] ?? process.env["ANDROID_SDK_ROOT"];
472
+ if (fromEnv != null && FsUtils.exists(fromEnv)) {
473
+ return fromEnv;
474
+ }
475
+ // 2. 일반적인 설치 경로 탐색
476
+ const candidates = [
477
+ path.resolve(process.env["LOCALAPPDATA"] ?? "", "Android/Sdk"),
478
+ path.resolve(process.env["HOME"] ?? "", "Android/Sdk"),
479
+ "C:/Program Files/Android/Sdk",
480
+ "C:/Android/Sdk",
481
+ ];
482
+ for (const candidate of candidates) {
483
+ if (FsUtils.exists(candidate)) {
484
+ return candidate;
485
+ }
486
+ }
487
+ return undefined;
365
488
  }
366
489
  static async runWebviewOnDeviceAsync(opt) {
367
- const projNpmConf = FsUtils.readJson(path.resolve(process.cwd(), "package.json"));
368
- const allPkgPaths = projNpmConf.workspaces.mapMany((item) => FsUtils.glob(PathUtils.posix(process.cwd(), item)));
490
+ const projNpmConf = (await FsUtils.readJsonAsync(path.resolve(process.cwd(), "package.json")));
491
+ const allPkgPaths = await projNpmConf.workspaces.mapManyAsync(async (item) => await FsUtils.globAsync(PathUtils.posix(process.cwd(), item)));
369
492
  const capacitorPath = path.resolve(allPkgPaths.single((item) => item.endsWith(opt.package)), ".capacitor");
370
493
  if (opt.url !== undefined) {
371
494
  // capacitor.config.ts의 server.url 설정 업데이트
372
495
  const configPath = path.resolve(capacitorPath, "capacitor.config.ts");
373
496
  if (FsUtils.exists(configPath)) {
374
- let configContent = FsUtils.readFile(configPath);
497
+ let configContent = await FsUtils.readFileAsync(configPath);
375
498
  const serverUrl = `${opt.url.replace(/\/$/, "")}/${opt.package}/capacitor/`;
376
499
  // 기존 url 설정이 있으면 교체, 없으면 server 블록 첫 줄에 추가
377
500
  if (configContent.includes("url:")) {
@@ -380,11 +503,16 @@ export class SdCliCapacitor {
380
503
  else if (configContent.includes("server:")) {
381
504
  configContent = configContent.replace(/server:\s*\{/, `server: {\n url: "${serverUrl}",`);
382
505
  }
383
- FsUtils.writeFile(configPath, configContent);
506
+ await FsUtils.writeFileAsync(configPath, configContent);
384
507
  }
385
508
  }
386
509
  // cap sync 후 run
387
510
  await this._execAsync("npx", ["cap", "sync", opt.platform], capacitorPath);
388
- await this._execAsync("npx", ["cap", "run", opt.platform, "--target", "device"], capacitorPath);
511
+ try {
512
+ await this._execAsync("npx", ["cap", "run", opt.platform], capacitorPath);
513
+ }
514
+ catch {
515
+ await SdProcess.spawnAsync("adb", ["kill-server"]);
516
+ }
389
517
  }
390
518
  }
@@ -114,6 +114,10 @@ export interface ISdClientBuilderCapacitorConfig {
114
114
  android?: {
115
115
  config?: Record<string, string>;
116
116
  bundle?: boolean;
117
+ intentFilters?: {
118
+ action?: string;
119
+ category?: string;
120
+ }[];
117
121
  sign?: {
118
122
  keystore: string;
119
123
  storePassword: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simplysm/sd-cli",
3
- "version": "12.16.4",
3
+ "version": "12.16.6",
4
4
  "description": "심플리즘 패키지 - CLI",
5
5
  "author": "김석래",
6
6
  "repository": {
@@ -17,10 +17,10 @@
17
17
  "@angular/compiler-cli": "^20.3.15",
18
18
  "@anthropic-ai/sdk": "^0.71.2",
19
19
  "@electron/rebuild": "^4.0.2",
20
- "@simplysm/sd-core-common": "12.16.4",
21
- "@simplysm/sd-core-node": "12.16.4",
22
- "@simplysm/sd-service-server": "12.16.4",
23
- "@simplysm/sd-storage": "12.16.4",
20
+ "@simplysm/sd-core-common": "12.16.6",
21
+ "@simplysm/sd-core-node": "12.16.6",
22
+ "@simplysm/sd-service-server": "12.16.6",
23
+ "@simplysm/sd-storage": "12.16.6",
24
24
  "browserslist": "^4.28.1",
25
25
  "cordova": "^13.0.0",
26
26
  "electron": "^33.4.11",
@@ -33,6 +33,7 @@
33
33
  "rxjs": "^7.8.2",
34
34
  "sass-embedded": "^1.96.0",
35
35
  "semver": "^7.7.3",
36
+ "sharp": "^0.34.5",
36
37
  "specifier-resolution-node": "^1.1.4",
37
38
  "ts-morph": "^27.0.2",
38
39
  "tslib": "^2.8.1",
@@ -3,6 +3,7 @@ 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
5
  import { StringUtils, typescript } from "@simplysm/sd-core-common";
6
+ import sharp from "sharp";
6
7
 
7
8
  export class SdCliCapacitor {
8
9
  // 상수 정의
@@ -18,7 +19,7 @@ export class SdCliCapacitor {
18
19
  private readonly _npmConfig: INpmConfig;
19
20
 
20
21
  constructor(private readonly _opt: { pkgPath: string; config: ISdClientBuilderCapacitorConfig }) {
21
- this._platforms = Object.keys(this._opt.config.platform ?? { android: {} });
22
+ this._platforms = Object.keys(this._opt.config.platform ?? {});
22
23
  this._npmConfig = FsUtils.readJson(path.resolve(this._opt.pkgPath, "package.json"));
23
24
  }
24
25
 
@@ -37,7 +38,7 @@ export class SdCliCapacitor {
37
38
  await this._initializeCapacitorProjectAsync(capacitorPath);
38
39
 
39
40
  // 2. Capacitor 설정 파일 생성
40
- this._createCapacitorConfig(capacitorPath);
41
+ await this._createCapacitorConfigAsync(capacitorPath);
41
42
 
42
43
  // 3. 플랫폼 관리
43
44
  await this._managePlatformsAsync(capacitorPath);
@@ -46,14 +47,14 @@ export class SdCliCapacitor {
46
47
  await this._managePluginsAsync(capacitorPath);
47
48
 
48
49
  // 5. 안드로이드 서명 설정
49
- this._setupAndroidSign(capacitorPath);
50
+ await this._setupAndroidSignAsync(capacitorPath);
50
51
 
51
52
  // 6. 아이콘 및 스플래시 스크린 설정
52
53
  await this._setupIconAndSplashScreenAsync(capacitorPath);
53
54
 
54
55
  // 7. Android 네이티브 설정 (AndroidManifest.xml, build.gradle 등)
55
56
  if (this._platforms.includes("android")) {
56
- this._configureAndroidNative(capacitorPath);
57
+ await this._configureAndroidNativeAsync(capacitorPath);
57
58
  }
58
59
 
59
60
  // 8. 웹 자산 동기화
@@ -62,28 +63,47 @@ export class SdCliCapacitor {
62
63
 
63
64
  // 1. Capacitor 프로젝트 초기화
64
65
  private async _initializeCapacitorProjectAsync(capacitorPath: string): Promise<void> {
65
- if (FsUtils.exists(capacitorPath)) {
66
+ if (FsUtils.exists(path.resolve(capacitorPath, "www"))) {
66
67
  SdCliCapacitor._logger.log("이미 생성되어있는 '.capacitor'를 사용합니다.");
67
68
 
68
69
  // 버전 동기화
69
- this._syncVersion(capacitorPath);
70
+ await this._syncVersionAsync(capacitorPath);
70
71
  } else {
71
- FsUtils.mkdirs(capacitorPath);
72
+ await FsUtils.mkdirsAsync(capacitorPath);
72
73
 
73
74
  // package.json 생성
75
+ const projNpmConfig = await FsUtils.readJsonAsync(
76
+ path.resolve(this._opt.pkgPath, "../../package.json"),
77
+ );
74
78
  const pkgJson = {
75
79
  name: this._opt.config.appId,
76
80
  version: this._npmConfig.version,
77
81
  private: true,
82
+ volta: projNpmConfig.volta,
78
83
  dependencies: {
79
84
  "@capacitor/core": "^7.0.0",
80
85
  },
81
86
  devDependencies: {
82
87
  "@capacitor/cli": "^7.0.0",
83
88
  "@capacitor/assets": "^3.0.0",
89
+ ...this._platforms.toObject(
90
+ (item) => `@capacitor/${item}`,
91
+ () => "^7.0.0",
92
+ ),
84
93
  },
85
94
  };
86
- FsUtils.writeJson(path.resolve(capacitorPath, "package.json"), pkgJson, { space: 2 });
95
+ await FsUtils.writeJsonAsync(path.resolve(capacitorPath, "package.json"), pkgJson, {
96
+ space: 2,
97
+ });
98
+
99
+ // .yarnrc.yml 작성
100
+ await FsUtils.writeFileAsync(
101
+ path.resolve(capacitorPath, ".yarnrc.yml"),
102
+ "nodeLinker: node-modules",
103
+ );
104
+
105
+ // yarn.lock 작성
106
+ await FsUtils.writeFileAsync(path.resolve(capacitorPath, "yarn.lock"), "");
87
107
 
88
108
  // yarn install
89
109
  await SdCliCapacitor._execAsync("yarn", ["install"], capacitorPath);
@@ -95,25 +115,32 @@ export class SdCliCapacitor {
95
115
  capacitorPath,
96
116
  );
97
117
  }
118
+
119
+ // www/index.html 생성
120
+ const wwwPath = path.resolve(capacitorPath, "www");
121
+ await FsUtils.writeFileAsync(
122
+ path.resolve(wwwPath, "index.html"),
123
+ "<!DOCTYPE html><html><head></head><body></body></html>",
124
+ );
98
125
  }
99
126
 
100
127
  // 버전 동기화
101
- private _syncVersion(capacitorPath: string): void {
128
+ private async _syncVersionAsync(capacitorPath: string) {
102
129
  const pkgJsonPath = path.resolve(capacitorPath, "package.json");
103
130
 
104
131
  if (FsUtils.exists(pkgJsonPath)) {
105
- const pkgJson = FsUtils.readJson(pkgJsonPath) as INpmConfig;
132
+ const pkgJson = (await FsUtils.readJsonAsync(pkgJsonPath)) as INpmConfig;
106
133
 
107
134
  if (pkgJson.version !== this._npmConfig.version) {
108
135
  pkgJson.version = this._npmConfig.version;
109
- FsUtils.writeJson(pkgJsonPath, pkgJson, { space: 2 });
110
- SdCliCapacitor._logger.log(`버전 동기화: ${this._npmConfig.version}`);
136
+ await FsUtils.writeJsonAsync(pkgJsonPath, pkgJson, { space: 2 });
137
+ SdCliCapacitor._logger.debug(`버전 동기화: ${this._npmConfig.version}`);
111
138
  }
112
139
  }
113
140
  }
114
141
 
115
142
  // 2. Capacitor 설정 파일 생성
116
- private _createCapacitorConfig(capacitorPath: string): void {
143
+ private async _createCapacitorConfigAsync(capacitorPath: string) {
117
144
  const configFilePath = path.resolve(capacitorPath, this._CONFIG_FILE_NAME);
118
145
 
119
146
  // 플러그인 옵션 생성
@@ -144,6 +171,7 @@ export class SdCliCapacitor {
144
171
  },
145
172
  android: {
146
173
  allowMixedContent: true,
174
+ statusBarOverlaysWebView: false,
147
175
  },
148
176
  plugins: ${pluginsConfigStr},
149
177
  };
@@ -151,7 +179,7 @@ export class SdCliCapacitor {
151
179
  export default config;
152
180
  `;
153
181
 
154
- FsUtils.writeFile(configFilePath, configContent);
182
+ await FsUtils.writeFileAsync(configFilePath, configContent);
155
183
  }
156
184
 
157
185
  // 3. 플랫폼 관리
@@ -166,7 +194,7 @@ export class SdCliCapacitor {
166
194
  // 4. 플러그인 관리
167
195
  private async _managePluginsAsync(capacitorPath: string): Promise<void> {
168
196
  const pkgJsonPath = path.resolve(capacitorPath, "package.json");
169
- const pkgJson = FsUtils.readJson(pkgJsonPath) as INpmConfig;
197
+ const pkgJson = (await FsUtils.readJsonAsync(pkgJsonPath)) as INpmConfig;
170
198
  const currentDeps = Object.keys(pkgJson.dependencies ?? {});
171
199
 
172
200
  const usePlugins = Object.keys(this._opt.config.plugins ?? {});
@@ -185,40 +213,43 @@ export class SdCliCapacitor {
185
213
  if (!usePlugins.includes(dep)) {
186
214
  // Capacitor 관련 플러그인만 제거
187
215
  if (dep.startsWith("@capacitor/") || dep.includes("capacitor-plugin")) {
188
- try {
189
- await SdCliCapacitor._execAsync("yarn", ["remove", dep], capacitorPath);
190
- SdCliCapacitor._logger.log(`플러그인 제거: ${dep}`);
191
- } catch {
192
- SdCliCapacitor._logger.warn(`플러그인 제거 실패: ${dep}`);
193
- }
216
+ await SdCliCapacitor._execAsync("yarn", ["remove", dep], capacitorPath);
217
+ SdCliCapacitor._logger.debug(`플러그인 제거: ${dep}`);
194
218
  }
195
219
  }
196
220
  }
197
221
 
198
222
  // 새 플러그인 설치
223
+ const mainPkgJson = this._npmConfig;
224
+ const mainDeps = {
225
+ ...mainPkgJson.dependencies,
226
+ ...mainPkgJson.devDependencies,
227
+ ...mainPkgJson.peerDependencies,
228
+ };
229
+
199
230
  for (const plugin of usePlugins) {
200
231
  if (!currentDeps.includes(plugin)) {
201
- try {
202
- await SdCliCapacitor._execAsync("yarn", ["add", plugin], capacitorPath);
203
- SdCliCapacitor._logger.log(`플러그인 설치: ${plugin}`);
204
- } catch {
205
- SdCliCapacitor._logger.warn(`플러그인 설치 실패: ${plugin}`);
206
- }
232
+ // 메인 프로젝트에 버전이 있으면 그 버전으로 설치
233
+ const version = mainDeps[plugin];
234
+ const pluginWithVersion = version ? `${plugin}@${version}` : plugin;
235
+
236
+ await SdCliCapacitor._execAsync("yarn", ["add", pluginWithVersion], capacitorPath);
237
+ SdCliCapacitor._logger.debug(`플러그인 설치: ${pluginWithVersion}`);
207
238
  }
208
239
  }
209
240
  }
210
241
 
211
242
  // 5. 안드로이드 서명 설정
212
- private _setupAndroidSign(capacitorPath: string): void {
243
+ private async _setupAndroidSignAsync(capacitorPath: string) {
213
244
  const keystorePath = path.resolve(capacitorPath, this._KEYSTORE_FILE_NAME);
214
245
 
215
246
  if (this._opt.config.platform?.android?.sign) {
216
- FsUtils.copy(
247
+ await FsUtils.copyAsync(
217
248
  path.resolve(this._opt.pkgPath, this._opt.config.platform.android.sign.keystore),
218
249
  keystorePath,
219
250
  );
220
251
  } else {
221
- FsUtils.remove(keystorePath);
252
+ await FsUtils.removeAsync(keystorePath);
222
253
  }
223
254
  }
224
255
 
@@ -226,17 +257,20 @@ export class SdCliCapacitor {
226
257
  private async _setupIconAndSplashScreenAsync(capacitorPath: string): Promise<void> {
227
258
  const iconDirPath = path.resolve(capacitorPath, this._ICON_DIR_PATH);
228
259
 
229
- // ICON 파일 복사
230
260
  if (this._opt.config.icon != null) {
231
- FsUtils.mkdirs(iconDirPath);
261
+ await FsUtils.mkdirsAsync(iconDirPath);
232
262
 
233
263
  const iconSource = path.resolve(this._opt.pkgPath, this._opt.config.icon);
234
264
 
235
- // icon.png, splash.png 같은 파일 사용
236
- FsUtils.copy(iconSource, path.resolve(iconDirPath, "icon.png"));
237
- FsUtils.copy(iconSource, path.resolve(iconDirPath, "splash.png"));
265
+ // Adaptive Icon용 여백 추가된 이미지 생성
266
+ // 1024x1024 중 680x680이 safe zone ( 66%)
267
+ const paddedIconPath = path.resolve(iconDirPath, "icon-only.png");
268
+ await this._createPaddedIconAsync(iconSource, paddedIconPath);
269
+
270
+ // splash, icon 복사
271
+ await FsUtils.copyAsync(iconSource, path.resolve(iconDirPath, "icon.png"));
272
+ await FsUtils.copyAsync(iconSource, path.resolve(iconDirPath, "splash.png"));
238
273
 
239
- // @capacitor/assets로 아이콘/스플래시 리사이징
240
274
  try {
241
275
  await SdCliCapacitor._execAsync(
242
276
  "npx",
@@ -247,36 +281,133 @@ export class SdCliCapacitor {
247
281
  SdCliCapacitor._logger.warn("아이콘 리사이징 실패, 기본 아이콘 사용");
248
282
  }
249
283
  } else {
250
- FsUtils.remove(iconDirPath);
284
+ await FsUtils.removeAsync(iconDirPath);
251
285
  }
252
286
  }
253
287
 
288
+ private async _createPaddedIconAsync(sourcePath: string, outputPath: string): Promise<void> {
289
+ const size = 1024;
290
+ const iconSize = 680; // safe zone
291
+ const padding = Math.floor((size - iconSize) / 2); // 172px
292
+
293
+ await sharp(sourcePath)
294
+ .resize(iconSize, iconSize, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
295
+ .extend({
296
+ top: padding,
297
+ bottom: padding,
298
+ left: padding,
299
+ right: padding,
300
+ background: { r: 0, g: 0, b: 0, alpha: 0 }, // 투명
301
+ })
302
+ .toFile(outputPath);
303
+ }
304
+
254
305
  // 7. Android 네이티브 설정
255
- private _configureAndroidNative(capacitorPath: string): void {
306
+ private async _configureAndroidNativeAsync(capacitorPath: string) {
256
307
  const androidPath = path.resolve(capacitorPath, "android");
257
308
 
258
309
  if (!FsUtils.exists(androidPath)) {
259
310
  return;
260
311
  }
261
312
 
313
+ // JAVA_HOME 찾기
314
+ await this._configureAndroidGradlePropertiesAsync(androidPath);
315
+
316
+ // local.properties 생성
317
+ await this._configureSdkPathAsync(androidPath);
318
+
262
319
  // AndroidManifest.xml 수정
263
- this._configureAndroidManifest(androidPath);
320
+ await this._configureAndroidManifestAsync(androidPath);
264
321
 
265
322
  // build.gradle 수정 (필요시)
266
- this._configureAndroidBuildGradle(androidPath);
323
+ await this._configureAndroidBuildGradleAsync(androidPath);
267
324
 
268
325
  // strings.xml 앱 이름 수정
269
- this._configureAndroidStrings(androidPath);
326
+ await this._configureAndroidStringsAsync(androidPath);
327
+
328
+ // styles.xml 수정
329
+ await this._configureAndroidStylesAsync(androidPath);
330
+ }
331
+
332
+ private async _configureAndroidStylesAsync(androidPath: string) {
333
+ const stylesPath = path.resolve(androidPath, "app/src/main/res/values/styles.xml");
334
+
335
+ if (!FsUtils.exists(stylesPath)) {
336
+ return;
337
+ }
338
+
339
+ let stylesContent = await FsUtils.readFileAsync(stylesPath);
340
+
341
+ // Edge-to-Edge 비활성화
342
+ if (!stylesContent.includes("android:windowOptOutEdgeToEdgeEnforcement")) {
343
+ stylesContent = stylesContent.replace(
344
+ /(<style[^>]*AppTheme[^>]*>)/,
345
+ `$1\n <item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>`,
346
+ );
347
+ }
348
+
349
+ await FsUtils.writeFileAsync(stylesPath, stylesContent);
350
+ }
351
+
352
+ private async _configureAndroidGradlePropertiesAsync(androidPath: string) {
353
+ const gradlePropsPath = path.resolve(androidPath, "gradle.properties");
354
+
355
+ if (!FsUtils.exists(gradlePropsPath)) {
356
+ return;
357
+ }
358
+
359
+ let content = await FsUtils.readFileAsync(gradlePropsPath);
360
+
361
+ // Java 21 경로 자동 탐색
362
+ const java21Path = this._findJava21();
363
+ if (java21Path != null && !content.includes("org.gradle.java.home")) {
364
+ content += `\norg.gradle.java.home=${java21Path.replace(/\\/g, "\\\\")}\n`;
365
+ FsUtils.writeFile(gradlePropsPath, content);
366
+ }
367
+ }
368
+
369
+ private _findJava21(): string | undefined {
370
+ const patterns = [
371
+ "C:/Program Files/Amazon Corretto/jdk21*",
372
+ "C:/Program Files/Eclipse Adoptium/jdk-21*",
373
+ "C:/Program Files/Java/jdk-21*",
374
+ "C:/Program Files/Microsoft/jdk-21*",
375
+ ];
376
+
377
+ for (const pattern of patterns) {
378
+ const matches = FsUtils.glob(pattern);
379
+ if (matches.length > 0) {
380
+ // 가장 최신 버전 선택 (정렬 후 마지막)
381
+ return matches.sort().at(-1);
382
+ }
383
+ }
384
+
385
+ return undefined;
386
+ }
387
+
388
+ private async _configureSdkPathAsync(androidPath: string) {
389
+ // local.properties 생성
390
+ const localPropsPath = path.resolve(androidPath, "local.properties");
391
+
392
+ if (FsUtils.exists(localPropsPath)) {
393
+ return;
394
+ }
395
+
396
+ // SDK 경로 탐색 (Cordova 방식과 유사)
397
+ const sdkPath = this._findAndroidSdk();
398
+ if (sdkPath != null) {
399
+ await FsUtils.writeFileAsync(localPropsPath, `sdk.dir=${sdkPath.replace(/\\/g, "/")}\n`);
400
+ }
270
401
  }
271
402
 
272
- private _configureAndroidManifest(androidPath: string): void {
403
+ private async _configureAndroidManifestAsync(androidPath: string) {
273
404
  const manifestPath = path.resolve(androidPath, "app/src/main/AndroidManifest.xml");
274
405
 
275
406
  if (!FsUtils.exists(manifestPath)) {
276
407
  return;
277
408
  }
278
409
 
279
- let manifestContent = FsUtils.readFile(manifestPath);
410
+ let manifestContent = await FsUtils.readFileAsync(manifestPath);
280
411
 
281
412
  // usesCleartextTraffic 설정
282
413
  if (!manifestContent.includes("android:usesCleartextTraffic")) {
@@ -319,17 +450,37 @@ export class SdCliCapacitor {
319
450
  }
320
451
  }
321
452
 
322
- FsUtils.writeFile(manifestPath, manifestContent);
453
+ // intentFilters 설정
454
+ const intentFilters = this._opt.config.platform?.android?.intentFilters ?? [];
455
+ for (const filter of intentFilters) {
456
+ const filterKey = filter.action ?? filter.category ?? "";
457
+ if (filterKey && !manifestContent.includes(filterKey)) {
458
+ const actionLine = filter.action != null ? `<action android:name="${filter.action}"/>` : "";
459
+ const categoryLine =
460
+ filter.category != null ? `<category android:name="${filter.category}"/>` : "";
461
+
462
+ manifestContent = manifestContent.replace(
463
+ /(<activity[\s\S]*?android:name="\.MainActivity"[\s\S]*?>)/,
464
+ `$1
465
+ <intent-filter>
466
+ ${actionLine}
467
+ ${categoryLine}
468
+ </intent-filter>`,
469
+ );
470
+ }
471
+ }
472
+
473
+ await FsUtils.writeFileAsync(manifestPath, manifestContent);
323
474
  }
324
475
 
325
- private _configureAndroidBuildGradle(androidPath: string): void {
476
+ private async _configureAndroidBuildGradleAsync(androidPath: string) {
326
477
  const buildGradlePath = path.resolve(androidPath, "app/build.gradle");
327
478
 
328
479
  if (!FsUtils.exists(buildGradlePath)) {
329
480
  return;
330
481
  }
331
482
 
332
- let gradleContent = FsUtils.readFile(buildGradlePath);
483
+ let gradleContent = await FsUtils.readFileAsync(buildGradlePath);
333
484
 
334
485
  // versionName, versionCode 설정
335
486
  const version = this._npmConfig.version;
@@ -384,17 +535,17 @@ export class SdCliCapacitor {
384
535
  }
385
536
  }
386
537
 
387
- FsUtils.writeFile(buildGradlePath, gradleContent);
538
+ await FsUtils.writeFileAsync(buildGradlePath, gradleContent);
388
539
  }
389
540
 
390
- private _configureAndroidStrings(androidPath: string): void {
541
+ private async _configureAndroidStringsAsync(androidPath: string) {
391
542
  const stringsPath = path.resolve(androidPath, "app/src/main/res/values/strings.xml");
392
543
 
393
544
  if (!FsUtils.exists(stringsPath)) {
394
545
  return;
395
546
  }
396
547
 
397
- let stringsContent = FsUtils.readFile(stringsPath);
548
+ let stringsContent = await FsUtils.readFileAsync(stringsPath);
398
549
  stringsContent = stringsContent.replace(
399
550
  /<string name="app_name">[^<]+<\/string>/,
400
551
  `<string name="app_name">${this._opt.config.appName}</string>`,
@@ -412,7 +563,7 @@ export class SdCliCapacitor {
412
563
  `<string name="custom_url_scheme">${this._opt.config.appId}</string>`,
413
564
  );
414
565
 
415
- FsUtils.writeFile(stringsPath, stringsContent);
566
+ await FsUtils.writeFileAsync(stringsPath, stringsContent);
416
567
  }
417
568
 
418
569
  async buildAsync(outPath: string): Promise<void> {
@@ -470,14 +621,14 @@ export class SdCliCapacitor {
470
621
  await SdCliCapacitor._execAsync(gradleCmd, [gradleTask, "--no-daemon"], androidPath);
471
622
 
472
623
  // 빌드 결과물 복사
473
- this._copyAndroidBuildOutput(androidPath, targetOutPath, buildType);
624
+ await this._copyAndroidBuildOutputAsync(androidPath, targetOutPath, buildType);
474
625
  }
475
626
 
476
- private _copyAndroidBuildOutput(
627
+ private async _copyAndroidBuildOutputAsync(
477
628
  androidPath: string,
478
629
  targetOutPath: string,
479
630
  buildType: string,
480
- ): void {
631
+ ) {
481
632
  const isBundle = this._opt.config.platform?.android?.bundle;
482
633
  const isSigned = !!this._opt.config.platform?.android?.sign;
483
634
 
@@ -510,12 +661,39 @@ export class SdCliCapacitor {
510
661
 
511
662
  const outputFileName = `${this._opt.config.appName}${isSigned ? "" : "-unsigned"}-latest.${ext}`;
512
663
 
513
- FsUtils.mkdirs(targetOutPath);
514
- FsUtils.copy(actualPath, path.resolve(targetOutPath, outputFileName));
664
+ await FsUtils.mkdirsAsync(targetOutPath);
665
+ await FsUtils.copyAsync(actualPath, path.resolve(targetOutPath, outputFileName));
515
666
 
516
667
  const updatesPath = path.resolve(targetOutPath, "updates");
517
- FsUtils.mkdirs(updatesPath);
518
- FsUtils.copy(actualPath, path.resolve(updatesPath, `${this._npmConfig.version}.${ext}`));
668
+ await FsUtils.mkdirsAsync(updatesPath);
669
+ await FsUtils.copyAsync(
670
+ actualPath,
671
+ path.resolve(updatesPath, `${this._npmConfig.version}.${ext}`),
672
+ );
673
+ }
674
+
675
+ private _findAndroidSdk(): string | undefined {
676
+ // 1. 환경변수 확인
677
+ const fromEnv = process.env["ANDROID_HOME"] ?? process.env["ANDROID_SDK_ROOT"];
678
+ if (fromEnv != null && FsUtils.exists(fromEnv)) {
679
+ return fromEnv;
680
+ }
681
+
682
+ // 2. 일반적인 설치 경로 탐색
683
+ const candidates = [
684
+ path.resolve(process.env["LOCALAPPDATA"] ?? "", "Android/Sdk"),
685
+ path.resolve(process.env["HOME"] ?? "", "Android/Sdk"),
686
+ "C:/Program Files/Android/Sdk",
687
+ "C:/Android/Sdk",
688
+ ];
689
+
690
+ for (const candidate of candidates) {
691
+ if (FsUtils.exists(candidate)) {
692
+ return candidate;
693
+ }
694
+ }
695
+
696
+ return undefined;
519
697
  }
520
698
 
521
699
  static async runWebviewOnDeviceAsync(opt: {
@@ -523,9 +701,11 @@ export class SdCliCapacitor {
523
701
  package: string;
524
702
  url?: string;
525
703
  }): Promise<void> {
526
- const projNpmConf = FsUtils.readJson(path.resolve(process.cwd(), "package.json")) as INpmConfig;
527
- const allPkgPaths = projNpmConf.workspaces!.mapMany((item) =>
528
- FsUtils.glob(PathUtils.posix(process.cwd(), item)),
704
+ const projNpmConf = (await FsUtils.readJsonAsync(
705
+ path.resolve(process.cwd(), "package.json"),
706
+ )) as INpmConfig;
707
+ const allPkgPaths = await projNpmConf.workspaces!.mapManyAsync(
708
+ async (item) => await FsUtils.globAsync(PathUtils.posix(process.cwd(), item)),
529
709
  );
530
710
 
531
711
  const capacitorPath = path.resolve(
@@ -537,7 +717,7 @@ export class SdCliCapacitor {
537
717
  // capacitor.config.ts의 server.url 설정 업데이트
538
718
  const configPath = path.resolve(capacitorPath, "capacitor.config.ts");
539
719
  if (FsUtils.exists(configPath)) {
540
- let configContent = FsUtils.readFile(configPath);
720
+ let configContent = await FsUtils.readFileAsync(configPath);
541
721
  const serverUrl = `${opt.url.replace(/\/$/, "")}/${opt.package}/capacitor/`;
542
722
 
543
723
  // 기존 url 설정이 있으면 교체, 없으면 server 블록 첫 줄에 추가
@@ -549,12 +729,17 @@ export class SdCliCapacitor {
549
729
  `server: {\n url: "${serverUrl}",`,
550
730
  );
551
731
  }
552
- FsUtils.writeFile(configPath, configContent);
732
+ await FsUtils.writeFileAsync(configPath, configContent);
553
733
  }
554
734
  }
555
735
 
556
736
  // cap sync 후 run
557
737
  await this._execAsync("npx", ["cap", "sync", opt.platform], capacitorPath);
558
- await this._execAsync("npx", ["cap", "run", opt.platform, "--target", "device"], capacitorPath);
738
+
739
+ try {
740
+ await this._execAsync("npx", ["cap", "run", opt.platform], capacitorPath);
741
+ } catch {
742
+ await SdProcess.spawnAsync("adb", ["kill-server"]);
743
+ }
559
744
  }
560
745
  }
@@ -132,7 +132,6 @@ export interface ISdClientBuilderCordovaConfig {
132
132
  browserslist?: string[];
133
133
  }
134
134
 
135
-
136
135
  export interface ISdClientBuilderCapacitorConfig {
137
136
  appId: string;
138
137
  appName: string;
@@ -143,6 +142,10 @@ export interface ISdClientBuilderCapacitorConfig {
143
142
  android?: {
144
143
  config?: Record<string, string>;
145
144
  bundle?: boolean;
145
+ intentFilters?: {
146
+ action?: string;
147
+ category?: string;
148
+ }[];
146
149
  sign?: {
147
150
  keystore: string;
148
151
  storePassword: string;