pulse-js-framework 1.0.0 → 1.4.0

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.
package/cli/mobile.js ADDED
@@ -0,0 +1,1473 @@
1
+ /**
2
+ * Pulse Mobile CLI - Mobile platform commands
3
+ * Zero-dependency mobile platform for Pulse Framework
4
+ */
5
+
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync, readdirSync } from 'fs';
7
+ import { join, resolve, dirname } from 'path';
8
+ import { fileURLToPath } from 'url';
9
+ import { execSync } from 'child_process';
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = dirname(__filename);
13
+
14
+ const MOBILE_DIR = 'mobile';
15
+ const CONFIG_FILE = 'pulse.mobile.json';
16
+
17
+ /**
18
+ * Handle mobile subcommands
19
+ */
20
+ export async function handleMobileCommand(args) {
21
+ const subcommand = args[0];
22
+ const subargs = args.slice(1);
23
+
24
+ switch (subcommand) {
25
+ case 'init':
26
+ await initMobile(subargs);
27
+ break;
28
+ case 'build':
29
+ await buildMobile(subargs);
30
+ break;
31
+ case 'run':
32
+ await runMobile(subargs);
33
+ break;
34
+ case 'sync':
35
+ await syncAssets(subargs);
36
+ break;
37
+ case 'help':
38
+ default:
39
+ showMobileHelp();
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Initialize mobile platforms
45
+ */
46
+ async function initMobile(args) {
47
+ const root = process.cwd();
48
+ const mobileDir = join(root, MOBILE_DIR);
49
+ const configPath = join(root, CONFIG_FILE);
50
+
51
+ console.log('Initializing Pulse Mobile...\n');
52
+
53
+ // Check if dist exists
54
+ if (!existsSync(join(root, 'dist'))) {
55
+ console.warn('Warning: No dist/ folder found. Run "pulse build" first.\n');
56
+ }
57
+
58
+ // Create mobile directory
59
+ if (!existsSync(mobileDir)) {
60
+ mkdirSync(mobileDir, { recursive: true });
61
+ }
62
+
63
+ // Read project name from package.json
64
+ let projectName = 'PulseApp';
65
+ let packageId = 'com.pulse.app';
66
+
67
+ const packageJsonPath = join(root, 'package.json');
68
+ if (existsSync(packageJsonPath)) {
69
+ const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
70
+ projectName = toPascalCase(pkg.name || 'PulseApp');
71
+ packageId = `com.pulse.${pkg.name?.toLowerCase().replace(/[^a-z0-9]/g, '') || 'app'}`;
72
+ }
73
+
74
+ // Create default config
75
+ const config = {
76
+ name: projectName,
77
+ displayName: projectName,
78
+ packageId: packageId,
79
+ version: '1.0.0',
80
+ platforms: ['android', 'ios'],
81
+ webDir: 'dist',
82
+ android: {
83
+ minSdkVersion: 24,
84
+ targetSdkVersion: 34,
85
+ compileSdkVersion: 34
86
+ },
87
+ ios: {
88
+ deploymentTarget: '13.0'
89
+ }
90
+ };
91
+
92
+ // Write config if not exists
93
+ if (!existsSync(configPath)) {
94
+ writeFileSync(configPath, JSON.stringify(config, null, 2));
95
+ console.log(`Created ${CONFIG_FILE}`);
96
+ } else {
97
+ console.log(`${CONFIG_FILE} already exists, skipping...`);
98
+ }
99
+
100
+ // Copy Android template
101
+ const androidTemplateDir = join(__dirname, '..', 'mobile', 'templates', 'android');
102
+ const androidDir = join(mobileDir, 'android');
103
+
104
+ if (!existsSync(androidDir)) {
105
+ if (existsSync(androidTemplateDir)) {
106
+ console.log('Initializing Android project...');
107
+ copyAndProcessTemplate(androidTemplateDir, androidDir, config);
108
+ console.log('Android project created.');
109
+ } else {
110
+ console.log('Creating Android project structure...');
111
+ createAndroidProject(androidDir, config);
112
+ console.log('Android project created.');
113
+ }
114
+ } else {
115
+ console.log('Android directory exists, skipping...');
116
+ }
117
+
118
+ // Copy iOS template
119
+ const iosTemplateDir = join(__dirname, '..', 'mobile', 'templates', 'ios');
120
+ const iosDir = join(mobileDir, 'ios');
121
+
122
+ if (!existsSync(iosDir)) {
123
+ if (existsSync(iosTemplateDir)) {
124
+ console.log('Initializing iOS project...');
125
+ copyAndProcessTemplate(iosTemplateDir, iosDir, config);
126
+ console.log('iOS project created.');
127
+ } else {
128
+ console.log('Creating iOS project structure...');
129
+ createIOSProject(iosDir, config);
130
+ console.log('iOS project created.');
131
+ }
132
+ } else {
133
+ console.log('iOS directory exists, skipping...');
134
+ }
135
+
136
+ // Copy bridge script to dist if it exists
137
+ const bridgeSource = join(__dirname, '..', 'mobile', 'bridge', 'pulse-native.js');
138
+ if (existsSync(join(root, 'dist')) && existsSync(bridgeSource)) {
139
+ cpSync(bridgeSource, join(root, 'dist', 'pulse-native.js'));
140
+ console.log('Native bridge script copied to dist/');
141
+ }
142
+
143
+ console.log(`
144
+ Mobile platforms initialized!
145
+
146
+ Next steps:
147
+ 1. Run "pulse build" to build your web app
148
+ 2. Run "pulse mobile build android" or "pulse mobile build ios"
149
+ 3. Run "pulse mobile run android" to test on device/emulator
150
+
151
+ Requirements:
152
+ - Android: Android SDK with build-tools installed
153
+ - iOS: macOS with Xcode (iOS builds only work on Mac)
154
+ `);
155
+ }
156
+
157
+ /**
158
+ * Build for a mobile platform
159
+ */
160
+ async function buildMobile(args) {
161
+ const platform = args[0]?.toLowerCase();
162
+
163
+ if (!platform || !['android', 'ios'].includes(platform)) {
164
+ console.error('Please specify a platform: android or ios');
165
+ console.log('Usage: pulse mobile build <platform>');
166
+ process.exit(1);
167
+ }
168
+
169
+ const root = process.cwd();
170
+ const config = loadConfig(root);
171
+
172
+ // First, ensure web build exists
173
+ if (!existsSync(join(root, config.webDir))) {
174
+ console.log('Building web app first...');
175
+ const { buildProject } = await import('./build.js');
176
+ await buildProject([]);
177
+ }
178
+
179
+ // Sync web assets to native project
180
+ await syncWebAssets(root, platform, config);
181
+
182
+ // Build native app
183
+ if (platform === 'android') {
184
+ await buildAndroid(root, config);
185
+ } else if (platform === 'ios') {
186
+ await buildIOS(root, config);
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Build and run on device/emulator
192
+ */
193
+ async function runMobile(args) {
194
+ const platform = args[0]?.toLowerCase();
195
+
196
+ if (!platform || !['android', 'ios'].includes(platform)) {
197
+ console.error('Please specify a platform: android or ios');
198
+ console.log('Usage: pulse mobile run <platform>');
199
+ process.exit(1);
200
+ }
201
+
202
+ // Build first
203
+ await buildMobile([platform]);
204
+
205
+ const root = process.cwd();
206
+ const config = loadConfig(root);
207
+
208
+ // Run on device/emulator
209
+ if (platform === 'android') {
210
+ await runAndroid(root, config);
211
+ } else if (platform === 'ios') {
212
+ await runIOS(root, config);
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Sync assets only
218
+ */
219
+ async function syncAssets(args) {
220
+ const platform = args[0]?.toLowerCase();
221
+ const root = process.cwd();
222
+ const config = loadConfig(root);
223
+
224
+ if (platform) {
225
+ await syncWebAssets(root, platform, config);
226
+ } else {
227
+ await syncWebAssets(root, 'android', config);
228
+ await syncWebAssets(root, 'ios', config);
229
+ }
230
+ console.log('Assets synced successfully!');
231
+ }
232
+
233
+ /**
234
+ * Sync web assets to native project
235
+ */
236
+ async function syncWebAssets(root, platform, config) {
237
+ const webDir = join(root, config.webDir);
238
+
239
+ if (!existsSync(webDir)) {
240
+ console.error(`Web directory "${config.webDir}" not found. Run "pulse build" first.`);
241
+ process.exit(1);
242
+ }
243
+
244
+ let assetsDir;
245
+ if (platform === 'android') {
246
+ assetsDir = join(root, MOBILE_DIR, 'android', 'app', 'src', 'main', 'assets', 'www');
247
+ } else {
248
+ assetsDir = join(root, MOBILE_DIR, 'ios', 'PulseApp', 'www');
249
+ }
250
+
251
+ // Create assets directory
252
+ mkdirSync(assetsDir, { recursive: true });
253
+
254
+ // Copy web files
255
+ cpSync(webDir, assetsDir, { recursive: true });
256
+
257
+ // Copy native bridge
258
+ const bridgeSource = join(__dirname, '..', 'mobile', 'bridge', 'pulse-native.js');
259
+ if (existsSync(bridgeSource)) {
260
+ cpSync(bridgeSource, join(assetsDir, 'pulse-native.js'));
261
+ }
262
+
263
+ console.log(`Web assets synced to ${platform}`);
264
+ }
265
+
266
+ /**
267
+ * Build Android APK
268
+ */
269
+ async function buildAndroid(root, config) {
270
+ const androidDir = join(root, MOBILE_DIR, 'android');
271
+
272
+ if (!existsSync(androidDir)) {
273
+ console.error('Android project not found. Run "pulse mobile init" first.');
274
+ process.exit(1);
275
+ }
276
+
277
+ console.log('Building Android APK...\n');
278
+
279
+ // Determine gradle executable
280
+ const isWindows = process.platform === 'win32';
281
+ const gradlew = isWindows ? 'gradlew.bat' : './gradlew';
282
+
283
+ try {
284
+ execSync(`${gradlew} assembleDebug`, {
285
+ cwd: androidDir,
286
+ stdio: 'inherit'
287
+ });
288
+
289
+ const apkPath = join(androidDir, 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk');
290
+ console.log(`
291
+ Build successful!
292
+ APK location: ${apkPath}
293
+ `);
294
+ } catch (error) {
295
+ console.error('Android build failed.');
296
+ console.error('Make sure Android SDK is installed and ANDROID_HOME is set.');
297
+ process.exit(1);
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Build iOS app
303
+ */
304
+ async function buildIOS(root, config) {
305
+ if (process.platform !== 'darwin') {
306
+ console.error('iOS builds are only supported on macOS');
307
+ process.exit(1);
308
+ }
309
+
310
+ const iosDir = join(root, MOBILE_DIR, 'ios');
311
+
312
+ if (!existsSync(iosDir)) {
313
+ console.error('iOS project not found. Run "pulse mobile init" first.');
314
+ process.exit(1);
315
+ }
316
+
317
+ console.log('Building iOS app...\n');
318
+
319
+ try {
320
+ execSync(`xcodebuild -project PulseApp.xcodeproj -scheme PulseApp -configuration Debug -destination 'generic/platform=iOS Simulator' build`, {
321
+ cwd: iosDir,
322
+ stdio: 'inherit'
323
+ });
324
+
325
+ console.log('\niOS build successful!');
326
+ } catch (error) {
327
+ console.error('iOS build failed.');
328
+ console.error('Make sure Xcode is installed and command line tools are configured.');
329
+ process.exit(1);
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Run on Android device/emulator
335
+ */
336
+ async function runAndroid(root, config) {
337
+ const androidDir = join(root, MOBILE_DIR, 'android');
338
+ const isWindows = process.platform === 'win32';
339
+ const gradlew = isWindows ? 'gradlew.bat' : './gradlew';
340
+
341
+ console.log('Installing and running on Android...\n');
342
+
343
+ try {
344
+ execSync(`${gradlew} installDebug`, {
345
+ cwd: androidDir,
346
+ stdio: 'inherit'
347
+ });
348
+
349
+ // Launch the app
350
+ const packageId = config.packageId;
351
+ execSync(`adb shell am start -n ${packageId}/${packageId}.MainActivity`, {
352
+ stdio: 'inherit'
353
+ });
354
+
355
+ console.log('\nApp launched on Android device/emulator');
356
+ } catch (error) {
357
+ console.error('Failed to run on Android.');
358
+ console.error('Make sure a device/emulator is connected (check with "adb devices").');
359
+ process.exit(1);
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Run on iOS simulator
365
+ */
366
+ async function runIOS(root, config) {
367
+ if (process.platform !== 'darwin') {
368
+ console.error('iOS development is only supported on macOS');
369
+ process.exit(1);
370
+ }
371
+
372
+ const iosDir = join(root, MOBILE_DIR, 'ios');
373
+
374
+ console.log('Running on iOS Simulator...\n');
375
+
376
+ try {
377
+ // Build for simulator
378
+ execSync(`xcodebuild -project PulseApp.xcodeproj -scheme PulseApp -destination 'platform=iOS Simulator,name=iPhone 15' -configuration Debug build`, {
379
+ cwd: iosDir,
380
+ stdio: 'inherit'
381
+ });
382
+
383
+ // Boot simulator if needed
384
+ try {
385
+ execSync('xcrun simctl boot "iPhone 15"', { stdio: 'pipe' });
386
+ } catch (e) {
387
+ // Simulator might already be booted
388
+ }
389
+
390
+ // Get app path and install
391
+ const buildDir = join(iosDir, 'build', 'Debug-iphonesimulator');
392
+ execSync(`xcrun simctl install booted "${join(buildDir, 'PulseApp.app')}"`, {
393
+ stdio: 'inherit'
394
+ });
395
+
396
+ // Launch app
397
+ execSync(`xcrun simctl launch booted ${config.packageId}`, {
398
+ stdio: 'inherit'
399
+ });
400
+
401
+ console.log('\nApp launched on iOS Simulator');
402
+ } catch (error) {
403
+ console.error('Failed to run on iOS.');
404
+ process.exit(1);
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Load mobile config
410
+ */
411
+ function loadConfig(root) {
412
+ const configPath = join(root, CONFIG_FILE);
413
+
414
+ if (!existsSync(configPath)) {
415
+ console.error(`No ${CONFIG_FILE} found. Run "pulse mobile init" first.`);
416
+ process.exit(1);
417
+ }
418
+
419
+ return JSON.parse(readFileSync(configPath, 'utf-8'));
420
+ }
421
+
422
+ /**
423
+ * Copy and process template files
424
+ */
425
+ function copyAndProcessTemplate(src, dest, config) {
426
+ if (!existsSync(src)) {
427
+ return;
428
+ }
429
+
430
+ mkdirSync(dest, { recursive: true });
431
+
432
+ const files = readdirSync(src, { withFileTypes: true });
433
+
434
+ for (const file of files) {
435
+ const srcPath = join(src, file.name);
436
+ const destPath = join(dest, file.name);
437
+
438
+ if (file.isDirectory()) {
439
+ copyAndProcessTemplate(srcPath, destPath, config);
440
+ } else {
441
+ let content = readFileSync(srcPath, 'utf-8');
442
+
443
+ // Replace template variables
444
+ content = content
445
+ .replace(/\{\{APP_NAME\}\}/g, config.name)
446
+ .replace(/\{\{DISPLAY_NAME\}\}/g, config.displayName)
447
+ .replace(/\{\{PACKAGE_ID\}\}/g, config.packageId)
448
+ .replace(/\{\{VERSION\}\}/g, config.version)
449
+ .replace(/\{\{MIN_SDK\}\}/g, String(config.android?.minSdkVersion || 24))
450
+ .replace(/\{\{TARGET_SDK\}\}/g, String(config.android?.targetSdkVersion || 34))
451
+ .replace(/\{\{COMPILE_SDK\}\}/g, String(config.android?.compileSdkVersion || 34))
452
+ .replace(/\{\{IOS_TARGET\}\}/g, config.ios?.deploymentTarget || '13.0');
453
+
454
+ writeFileSync(destPath, content);
455
+ }
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Create Android project from scratch
461
+ */
462
+ function createAndroidProject(androidDir, config) {
463
+ const packagePath = config.packageId.replace(/\./g, '/');
464
+
465
+ // Create directory structure
466
+ const dirs = [
467
+ 'app/src/main/java/' + packagePath,
468
+ 'app/src/main/res/layout',
469
+ 'app/src/main/res/values',
470
+ 'app/src/main/res/drawable',
471
+ 'app/src/main/res/mipmap-hdpi',
472
+ 'app/src/main/res/mipmap-mdpi',
473
+ 'app/src/main/res/mipmap-xhdpi',
474
+ 'app/src/main/res/mipmap-xxhdpi',
475
+ 'app/src/main/res/mipmap-xxxhdpi',
476
+ 'app/src/main/assets/www',
477
+ 'gradle/wrapper'
478
+ ];
479
+
480
+ for (const dir of dirs) {
481
+ mkdirSync(join(androidDir, dir), { recursive: true });
482
+ }
483
+
484
+ // MainActivity.java
485
+ writeFileSync(join(androidDir, 'app/src/main/java', packagePath, 'MainActivity.java'), `
486
+ package ${config.packageId};
487
+
488
+ import android.os.Bundle;
489
+ import android.webkit.WebView;
490
+ import android.webkit.WebSettings;
491
+ import android.webkit.WebViewClient;
492
+ import android.webkit.WebChromeClient;
493
+ import android.webkit.ConsoleMessage;
494
+ import android.util.Log;
495
+ import android.app.Activity;
496
+ import android.view.View;
497
+ import android.view.Window;
498
+ import android.view.WindowManager;
499
+ import android.graphics.Color;
500
+ import android.os.Build;
501
+
502
+ public class MainActivity extends Activity {
503
+ private static final String TAG = "PulseApp";
504
+ private WebView webView;
505
+ private PulseBridge bridge;
506
+
507
+ @Override
508
+ protected void onCreate(Bundle savedInstanceState) {
509
+ super.onCreate(savedInstanceState);
510
+
511
+ // Full screen
512
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
513
+ getWindow().setFlags(
514
+ WindowManager.LayoutParams.FLAG_FULLSCREEN,
515
+ WindowManager.LayoutParams.FLAG_FULLSCREEN
516
+ );
517
+
518
+ // Create WebView
519
+ webView = new WebView(this);
520
+ setContentView(webView);
521
+
522
+ // Configure WebView
523
+ WebSettings settings = webView.getSettings();
524
+ settings.setJavaScriptEnabled(true);
525
+ settings.setDomStorageEnabled(true);
526
+ settings.setDatabaseEnabled(true);
527
+ settings.setAllowFileAccess(true);
528
+ settings.setAllowContentAccess(true);
529
+ settings.setMediaPlaybackRequiresUserGesture(false);
530
+
531
+ webView.setLayerType(View.LAYER_TYPE_HARDWARE, null);
532
+
533
+ if (BuildConfig.DEBUG) {
534
+ WebView.setWebContentsDebuggingEnabled(true);
535
+ }
536
+
537
+ // Set up native bridge
538
+ bridge = new PulseBridge(this);
539
+ webView.addJavascriptInterface(bridge, "PulseNative");
540
+
541
+ webView.setWebViewClient(new WebViewClient() {
542
+ @Override
543
+ public void onPageFinished(WebView view, String url) {
544
+ super.onPageFinished(view, url);
545
+ injectBridgeInit();
546
+ }
547
+ });
548
+
549
+ webView.setWebChromeClient(new WebChromeClient() {
550
+ @Override
551
+ public boolean onConsoleMessage(ConsoleMessage consoleMessage) {
552
+ Log.d(TAG, consoleMessage.message());
553
+ return true;
554
+ }
555
+ });
556
+
557
+ webView.loadUrl("file:///android_asset/www/index.html");
558
+ }
559
+
560
+ private void injectBridgeInit() {
561
+ String script = "if(typeof window.initPulseNative === 'function') { window.initPulseNative(); }";
562
+ webView.evaluateJavascript(script, null);
563
+ }
564
+
565
+ public void executeJS(String script) {
566
+ runOnUiThread(() -> webView.evaluateJavascript(script, null));
567
+ }
568
+
569
+ @Override
570
+ public void onBackPressed() {
571
+ if (webView.canGoBack()) {
572
+ webView.goBack();
573
+ } else {
574
+ super.onBackPressed();
575
+ }
576
+ }
577
+
578
+ @Override
579
+ protected void onResume() {
580
+ super.onResume();
581
+ webView.onResume();
582
+ }
583
+
584
+ @Override
585
+ protected void onPause() {
586
+ webView.onPause();
587
+ super.onPause();
588
+ }
589
+
590
+ @Override
591
+ protected void onDestroy() {
592
+ if (webView != null) webView.destroy();
593
+ super.onDestroy();
594
+ }
595
+ }
596
+ `.trim());
597
+
598
+ // PulseBridge.java
599
+ writeFileSync(join(androidDir, 'app/src/main/java', packagePath, 'PulseBridge.java'), `
600
+ package ${config.packageId};
601
+
602
+ import android.content.Context;
603
+ import android.content.SharedPreferences;
604
+ import android.os.Build;
605
+ import android.webkit.JavascriptInterface;
606
+ import android.provider.Settings;
607
+ import android.os.Vibrator;
608
+ import android.os.VibrationEffect;
609
+ import android.widget.Toast;
610
+ import android.content.pm.PackageManager;
611
+ import android.content.pm.PackageInfo;
612
+ import android.net.ConnectivityManager;
613
+ import android.net.NetworkInfo;
614
+ import org.json.JSONObject;
615
+ import org.json.JSONException;
616
+
617
+ public class PulseBridge {
618
+ private static final String PREFS_NAME = "PulseStorage";
619
+ private Context context;
620
+ private MainActivity activity;
621
+
622
+ public PulseBridge(MainActivity activity) {
623
+ this.activity = activity;
624
+ this.context = activity.getApplicationContext();
625
+ }
626
+
627
+ // Storage API
628
+ @JavascriptInterface
629
+ public void setItem(String key, String value) {
630
+ SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
631
+ prefs.edit().putString(key, value).apply();
632
+ }
633
+
634
+ @JavascriptInterface
635
+ public String getItem(String key) {
636
+ SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
637
+ return prefs.getString(key, null);
638
+ }
639
+
640
+ @JavascriptInterface
641
+ public void removeItem(String key) {
642
+ SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
643
+ prefs.edit().remove(key).apply();
644
+ }
645
+
646
+ @JavascriptInterface
647
+ public void clearStorage() {
648
+ SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
649
+ prefs.edit().clear().apply();
650
+ }
651
+
652
+ @JavascriptInterface
653
+ public String getAllKeys() {
654
+ SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
655
+ return String.join(",", prefs.getAll().keySet());
656
+ }
657
+
658
+ // Device Info
659
+ @JavascriptInterface
660
+ public String getDeviceInfo() {
661
+ try {
662
+ JSONObject info = new JSONObject();
663
+ info.put("platform", "android");
664
+ info.put("model", Build.MODEL);
665
+ info.put("manufacturer", Build.MANUFACTURER);
666
+ info.put("version", Build.VERSION.RELEASE);
667
+ info.put("sdkVersion", Build.VERSION.SDK_INT);
668
+ info.put("appVersion", getAppVersion());
669
+ return info.toString();
670
+ } catch (JSONException e) {
671
+ return "{}";
672
+ }
673
+ }
674
+
675
+ private String getAppVersion() {
676
+ try {
677
+ PackageInfo pInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), 0);
678
+ return pInfo.versionName;
679
+ } catch (PackageManager.NameNotFoundException e) {
680
+ return "1.0.0";
681
+ }
682
+ }
683
+
684
+ @JavascriptInterface
685
+ public String getNetworkStatus() {
686
+ try {
687
+ ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
688
+ NetworkInfo activeNetwork = cm.getActiveNetworkInfo();
689
+ JSONObject status = new JSONObject();
690
+ status.put("connected", activeNetwork != null && activeNetwork.isConnected());
691
+ status.put("type", activeNetwork != null ? activeNetwork.getTypeName() : "none");
692
+ return status.toString();
693
+ } catch (JSONException e) {
694
+ return "{\\"connected\\":false,\\"type\\":\\"none\\"}";
695
+ }
696
+ }
697
+
698
+ // UI API
699
+ @JavascriptInterface
700
+ public void showToast(String message, boolean isLong) {
701
+ int duration = isLong ? Toast.LENGTH_LONG : Toast.LENGTH_SHORT;
702
+ activity.runOnUiThread(() -> Toast.makeText(context, message, duration).show());
703
+ }
704
+
705
+ @JavascriptInterface
706
+ public void vibrate(int duration) {
707
+ Vibrator vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE);
708
+ if (vibrator != null && vibrator.hasVibrator()) {
709
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
710
+ vibrator.vibrate(VibrationEffect.createOneShot(duration, VibrationEffect.DEFAULT_AMPLITUDE));
711
+ } else {
712
+ vibrator.vibrate(duration);
713
+ }
714
+ }
715
+ }
716
+
717
+ // Clipboard
718
+ @JavascriptInterface
719
+ public void copyToClipboard(String text) {
720
+ android.content.ClipboardManager clipboard =
721
+ (android.content.ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
722
+ android.content.ClipData clip = android.content.ClipData.newPlainText("Pulse", text);
723
+ clipboard.setPrimaryClip(clip);
724
+ }
725
+
726
+ @JavascriptInterface
727
+ public String getClipboardText() {
728
+ android.content.ClipboardManager clipboard =
729
+ (android.content.ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
730
+ if (clipboard.hasPrimaryClip()) {
731
+ android.content.ClipData.Item item = clipboard.getPrimaryClip().getItemAt(0);
732
+ CharSequence text = item.getText();
733
+ return text != null ? text.toString() : "";
734
+ }
735
+ return "";
736
+ }
737
+
738
+ // App Lifecycle
739
+ @JavascriptInterface
740
+ public void exitApp() {
741
+ activity.runOnUiThread(() -> activity.finish());
742
+ }
743
+
744
+ @JavascriptInterface
745
+ public void minimizeApp() {
746
+ activity.runOnUiThread(() -> activity.moveTaskToBack(true));
747
+ }
748
+ }
749
+ `.trim());
750
+
751
+ // AndroidManifest.xml
752
+ writeFileSync(join(androidDir, 'app/src/main/AndroidManifest.xml'), `
753
+ <?xml version="1.0" encoding="utf-8"?>
754
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android"
755
+ package="${config.packageId}">
756
+
757
+ <uses-permission android:name="android.permission.INTERNET" />
758
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
759
+ <uses-permission android:name="android.permission.VIBRATE" />
760
+
761
+ <application
762
+ android:allowBackup="true"
763
+ android:icon="@mipmap/ic_launcher"
764
+ android:label="${config.displayName}"
765
+ android:supportsRtl="true"
766
+ android:theme="@style/Theme.PulseApp"
767
+ android:usesCleartextTraffic="true"
768
+ android:hardwareAccelerated="true">
769
+
770
+ <activity
771
+ android:name=".MainActivity"
772
+ android:exported="true"
773
+ android:configChanges="orientation|screenSize|keyboardHidden"
774
+ android:windowSoftInputMode="adjustResize"
775
+ android:launchMode="singleTask">
776
+
777
+ <intent-filter>
778
+ <action android:name="android.intent.action.MAIN" />
779
+ <category android:name="android.intent.category.LAUNCHER" />
780
+ </intent-filter>
781
+ </activity>
782
+ </application>
783
+ </manifest>
784
+ `.trim());
785
+
786
+ // app/build.gradle
787
+ writeFileSync(join(androidDir, 'app/build.gradle'), `
788
+ plugins {
789
+ id 'com.android.application'
790
+ }
791
+
792
+ android {
793
+ namespace '${config.packageId}'
794
+ compileSdk ${config.android?.compileSdkVersion || 34}
795
+
796
+ defaultConfig {
797
+ applicationId "${config.packageId}"
798
+ minSdk ${config.android?.minSdkVersion || 24}
799
+ targetSdk ${config.android?.targetSdkVersion || 34}
800
+ versionCode 1
801
+ versionName "${config.version}"
802
+ }
803
+
804
+ buildTypes {
805
+ release {
806
+ minifyEnabled true
807
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
808
+ }
809
+ debug {
810
+ debuggable true
811
+ }
812
+ }
813
+
814
+ compileOptions {
815
+ sourceCompatibility JavaVersion.VERSION_1_8
816
+ targetCompatibility JavaVersion.VERSION_1_8
817
+ }
818
+ }
819
+
820
+ dependencies {}
821
+ `.trim());
822
+
823
+ // build.gradle (project level)
824
+ writeFileSync(join(androidDir, 'build.gradle'), `
825
+ buildscript {
826
+ repositories {
827
+ google()
828
+ mavenCentral()
829
+ }
830
+ dependencies {
831
+ classpath 'com.android.tools.build:gradle:8.2.0'
832
+ }
833
+ }
834
+
835
+ allprojects {
836
+ repositories {
837
+ google()
838
+ mavenCentral()
839
+ }
840
+ }
841
+
842
+ task clean(type: Delete) {
843
+ delete rootProject.buildDir
844
+ }
845
+ `.trim());
846
+
847
+ // settings.gradle
848
+ writeFileSync(join(androidDir, 'settings.gradle'), `
849
+ pluginManagement {
850
+ repositories {
851
+ google()
852
+ mavenCentral()
853
+ gradlePluginPortal()
854
+ }
855
+ }
856
+
857
+ rootProject.name = "${config.name}"
858
+ include ':app'
859
+ `.trim());
860
+
861
+ // gradle-wrapper.properties
862
+ writeFileSync(join(androidDir, 'gradle/wrapper/gradle-wrapper.properties'), `
863
+ distributionBase=GRADLE_USER_HOME
864
+ distributionPath=wrapper/dists
865
+ distributionUrl=https\\://services.gradle.org/distributions/gradle-8.4-bin.zip
866
+ zipStoreBase=GRADLE_USER_HOME
867
+ zipStorePath=wrapper/dists
868
+ `.trim());
869
+
870
+ // gradlew (Unix)
871
+ writeFileSync(join(androidDir, 'gradlew'), `#!/bin/sh
872
+ exec gradle "$@"
873
+ `.trim());
874
+
875
+ // gradlew.bat (Windows)
876
+ writeFileSync(join(androidDir, 'gradlew.bat'), `@echo off
877
+ gradle %*
878
+ `.trim());
879
+
880
+ // styles.xml
881
+ writeFileSync(join(androidDir, 'app/src/main/res/values/styles.xml'), `
882
+ <?xml version="1.0" encoding="utf-8"?>
883
+ <resources>
884
+ <style name="Theme.PulseApp" parent="android:Theme.Material.Light.NoActionBar">
885
+ <item name="android:windowBackground">@android:color/white</item>
886
+ <item name="android:statusBarColor">@android:color/transparent</item>
887
+ </style>
888
+ </resources>
889
+ `.trim());
890
+
891
+ // strings.xml
892
+ writeFileSync(join(androidDir, 'app/src/main/res/values/strings.xml'), `
893
+ <?xml version="1.0" encoding="utf-8"?>
894
+ <resources>
895
+ <string name="app_name">${config.displayName}</string>
896
+ </resources>
897
+ `.trim());
898
+
899
+ // proguard-rules.pro
900
+ writeFileSync(join(androidDir, 'app/proguard-rules.pro'), `
901
+ # Pulse WebView bridge
902
+ -keepclassmembers class ${config.packageId}.PulseBridge {
903
+ @android.webkit.JavascriptInterface <methods>;
904
+ }
905
+ -keepattributes JavascriptInterface
906
+ `.trim());
907
+ }
908
+
909
+ /**
910
+ * Create iOS project from scratch
911
+ */
912
+ function createIOSProject(iosDir, config) {
913
+ // Create directory structure
914
+ const dirs = [
915
+ 'PulseApp',
916
+ 'PulseApp/www',
917
+ 'PulseApp/Assets.xcassets',
918
+ 'PulseApp.xcodeproj'
919
+ ];
920
+
921
+ for (const dir of dirs) {
922
+ mkdirSync(join(iosDir, dir), { recursive: true });
923
+ }
924
+
925
+ // AppDelegate.swift
926
+ writeFileSync(join(iosDir, 'PulseApp/AppDelegate.swift'), `
927
+ import UIKit
928
+
929
+ @main
930
+ class AppDelegate: UIResponder, UIApplicationDelegate {
931
+ var window: UIWindow?
932
+
933
+ func application(_ application: UIApplication,
934
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
935
+ return true
936
+ }
937
+
938
+ func application(_ application: UIApplication,
939
+ configurationForConnecting connectingSceneSession: UISceneSession,
940
+ options: UIScene.ConnectionOptions) -> UISceneConfiguration {
941
+ return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
942
+ }
943
+ }
944
+ `.trim());
945
+
946
+ // SceneDelegate.swift
947
+ writeFileSync(join(iosDir, 'PulseApp/SceneDelegate.swift'), `
948
+ import UIKit
949
+
950
+ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
951
+ var window: UIWindow?
952
+
953
+ func scene(_ scene: UIScene,
954
+ willConnectTo session: UISceneSession,
955
+ options connectionOptions: UIScene.ConnectionOptions) {
956
+ guard let windowScene = (scene as? UIWindowScene) else { return }
957
+
958
+ window = UIWindow(windowScene: windowScene)
959
+ window?.rootViewController = ViewController()
960
+ window?.makeKeyAndVisible()
961
+ }
962
+ }
963
+ `.trim());
964
+
965
+ // ViewController.swift
966
+ writeFileSync(join(iosDir, 'PulseApp/ViewController.swift'), `
967
+ import UIKit
968
+ import WebKit
969
+
970
+ class ViewController: UIViewController, WKNavigationDelegate {
971
+ private var webView: WKWebView!
972
+ private var bridge: PulseBridge!
973
+
974
+ override func viewDidLoad() {
975
+ super.viewDidLoad()
976
+ setupWebView()
977
+ loadApp()
978
+ }
979
+
980
+ private func setupWebView() {
981
+ let config = WKWebViewConfiguration()
982
+ config.allowsInlineMediaPlayback = true
983
+
984
+ bridge = PulseBridge(viewController: self)
985
+ config.userContentController.add(bridge, name: "PulseNative")
986
+
987
+ webView = WKWebView(frame: view.bounds, configuration: config)
988
+ webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
989
+ webView.navigationDelegate = self
990
+ webView.scrollView.bounces = false
991
+
992
+ #if DEBUG
993
+ if #available(iOS 16.4, *) {
994
+ webView.isInspectable = true
995
+ }
996
+ #endif
997
+
998
+ view.addSubview(webView)
999
+ }
1000
+
1001
+ private func loadApp() {
1002
+ guard let indexPath = Bundle.main.path(forResource: "index", ofType: "html", inDirectory: "www") else {
1003
+ print("Error: Could not find www/index.html")
1004
+ return
1005
+ }
1006
+
1007
+ let indexURL = URL(fileURLWithPath: indexPath)
1008
+ webView.loadFileURL(indexURL, allowingReadAccessTo: indexURL.deletingLastPathComponent())
1009
+ }
1010
+
1011
+ func executeJS(_ script: String) {
1012
+ webView.evaluateJavaScript(script, completionHandler: nil)
1013
+ }
1014
+
1015
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
1016
+ webView.evaluateJavaScript("if(typeof window.initPulseNative === 'function') { window.initPulseNative(); }", completionHandler: nil)
1017
+ }
1018
+
1019
+ override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent }
1020
+ }
1021
+ `.trim());
1022
+
1023
+ // PulseBridge.swift
1024
+ writeFileSync(join(iosDir, 'PulseApp/PulseBridge.swift'), `
1025
+ import UIKit
1026
+ import WebKit
1027
+
1028
+ class PulseBridge: NSObject, WKScriptMessageHandler {
1029
+ private weak var viewController: ViewController?
1030
+ private let userDefaults = UserDefaults.standard
1031
+ private let prefix = "pulse_"
1032
+
1033
+ init(viewController: ViewController) {
1034
+ self.viewController = viewController
1035
+ super.init()
1036
+ }
1037
+
1038
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
1039
+ guard let body = message.body as? [String: Any],
1040
+ let action = body["action"] as? String,
1041
+ let callbackId = body["callbackId"] as? String else { return }
1042
+
1043
+ let args = body["args"] as? [String: Any] ?? [:]
1044
+
1045
+ switch action {
1046
+ case "setItem":
1047
+ if let key = args["key"] as? String, let value = args["value"] as? String {
1048
+ userDefaults.set(value, forKey: prefix + key)
1049
+ }
1050
+ sendSuccess(callbackId: callbackId, data: nil)
1051
+
1052
+ case "getItem":
1053
+ if let key = args["key"] as? String {
1054
+ let value = userDefaults.string(forKey: prefix + key)
1055
+ sendSuccess(callbackId: callbackId, data: value)
1056
+ }
1057
+
1058
+ case "removeItem":
1059
+ if let key = args["key"] as? String {
1060
+ userDefaults.removeObject(forKey: prefix + key)
1061
+ }
1062
+ sendSuccess(callbackId: callbackId, data: nil)
1063
+
1064
+ case "clearStorage":
1065
+ let keys = userDefaults.dictionaryRepresentation().keys.filter { $0.hasPrefix(prefix) }
1066
+ for key in keys { userDefaults.removeObject(forKey: key) }
1067
+ sendSuccess(callbackId: callbackId, data: nil)
1068
+
1069
+ case "getAllKeys":
1070
+ let keys = userDefaults.dictionaryRepresentation().keys
1071
+ .filter { $0.hasPrefix(prefix) }
1072
+ .map { String($0.dropFirst(prefix.count)) }
1073
+ sendSuccess(callbackId: callbackId, data: keys)
1074
+
1075
+ case "getDeviceInfo":
1076
+ let device = UIDevice.current
1077
+ let info: [String: Any] = [
1078
+ "platform": "ios",
1079
+ "model": device.model,
1080
+ "systemVersion": device.systemVersion,
1081
+ "name": device.name,
1082
+ "appVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
1083
+ ]
1084
+ sendSuccess(callbackId: callbackId, data: info)
1085
+
1086
+ case "getNetworkStatus":
1087
+ let status: [String: Any] = ["connected": true, "type": "unknown"]
1088
+ sendSuccess(callbackId: callbackId, data: status)
1089
+
1090
+ case "showToast":
1091
+ if let message = args["message"] as? String {
1092
+ DispatchQueue.main.async {
1093
+ let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)
1094
+ self.viewController?.present(alert, animated: true) {
1095
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { alert.dismiss(animated: true) }
1096
+ }
1097
+ }
1098
+ }
1099
+ sendSuccess(callbackId: callbackId, data: nil)
1100
+
1101
+ case "vibrate":
1102
+ UIImpactFeedbackGenerator(style: .medium).impactOccurred()
1103
+ sendSuccess(callbackId: callbackId, data: nil)
1104
+
1105
+ case "copyToClipboard":
1106
+ if let text = args["text"] as? String {
1107
+ UIPasteboard.general.string = text
1108
+ }
1109
+ sendSuccess(callbackId: callbackId, data: nil)
1110
+
1111
+ case "getClipboardText":
1112
+ sendSuccess(callbackId: callbackId, data: UIPasteboard.general.string ?? "")
1113
+
1114
+ default:
1115
+ sendError(callbackId: callbackId, message: "Unknown action")
1116
+ }
1117
+ }
1118
+
1119
+ private func sendSuccess(callbackId: String, data: Any?) {
1120
+ var response: [String: Any] = ["success": true]
1121
+ if let data = data { response["data"] = data }
1122
+ sendResponse(callbackId: callbackId, response: response)
1123
+ }
1124
+
1125
+ private func sendError(callbackId: String, message: String) {
1126
+ sendResponse(callbackId: callbackId, response: ["success": false, "error": message])
1127
+ }
1128
+
1129
+ private func sendResponse(callbackId: String, response: [String: Any]) {
1130
+ guard let jsonData = try? JSONSerialization.data(withJSONObject: response),
1131
+ let jsonString = String(data: jsonData, encoding: .utf8) else { return }
1132
+
1133
+ DispatchQueue.main.async {
1134
+ self.viewController?.executeJS("window.__pulseNativeCallback('\\(callbackId)', \\(jsonString));")
1135
+ }
1136
+ }
1137
+ }
1138
+ `.trim());
1139
+
1140
+ // Info.plist
1141
+ writeFileSync(join(iosDir, 'PulseApp/Info.plist'), `
1142
+ <?xml version="1.0" encoding="UTF-8"?>
1143
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1144
+ <plist version="1.0">
1145
+ <dict>
1146
+ <key>CFBundleDevelopmentRegion</key>
1147
+ <string>$(DEVELOPMENT_LANGUAGE)</string>
1148
+ <key>CFBundleDisplayName</key>
1149
+ <string>${config.displayName}</string>
1150
+ <key>CFBundleExecutable</key>
1151
+ <string>$(EXECUTABLE_NAME)</string>
1152
+ <key>CFBundleIdentifier</key>
1153
+ <string>${config.packageId}</string>
1154
+ <key>CFBundleInfoDictionaryVersion</key>
1155
+ <string>6.0</string>
1156
+ <key>CFBundleName</key>
1157
+ <string>$(PRODUCT_NAME)</string>
1158
+ <key>CFBundlePackageType</key>
1159
+ <string>APPL</string>
1160
+ <key>CFBundleShortVersionString</key>
1161
+ <string>${config.version}</string>
1162
+ <key>CFBundleVersion</key>
1163
+ <string>1</string>
1164
+ <key>LSRequiresIPhoneOS</key>
1165
+ <true/>
1166
+ <key>UIApplicationSceneManifest</key>
1167
+ <dict>
1168
+ <key>UIApplicationSupportsMultipleScenes</key>
1169
+ <false/>
1170
+ <key>UISceneConfigurations</key>
1171
+ <dict>
1172
+ <key>UIWindowSceneSessionRoleApplication</key>
1173
+ <array>
1174
+ <dict>
1175
+ <key>UISceneConfigurationName</key>
1176
+ <string>Default Configuration</string>
1177
+ <key>UISceneDelegateClassName</key>
1178
+ <string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
1179
+ </dict>
1180
+ </array>
1181
+ </dict>
1182
+ </dict>
1183
+ <key>UILaunchStoryboardName</key>
1184
+ <string>LaunchScreen</string>
1185
+ <key>UISupportedInterfaceOrientations</key>
1186
+ <array>
1187
+ <string>UIInterfaceOrientationPortrait</string>
1188
+ <string>UIInterfaceOrientationLandscapeLeft</string>
1189
+ <string>UIInterfaceOrientationLandscapeRight</string>
1190
+ </array>
1191
+ </dict>
1192
+ </plist>
1193
+ `.trim());
1194
+
1195
+ // Minimal Xcode project file (project.pbxproj)
1196
+ writeFileSync(join(iosDir, 'PulseApp.xcodeproj/project.pbxproj'), generateXcodeProject(config));
1197
+ }
1198
+
1199
+ /**
1200
+ * Generate minimal Xcode project file
1201
+ */
1202
+ function generateXcodeProject(config) {
1203
+ return `// !$*UTF8*$!
1204
+ {
1205
+ archiveVersion = 1;
1206
+ classes = {
1207
+ };
1208
+ objectVersion = 56;
1209
+ objects = {
1210
+
1211
+ /* Begin PBXBuildFile section */
1212
+ 1A0000000000000000000001 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A0000000000000000000011; };
1213
+ 1A0000000000000000000002 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A0000000000000000000012; };
1214
+ 1A0000000000000000000003 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A0000000000000000000013; };
1215
+ 1A0000000000000000000004 /* PulseBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A0000000000000000000014; };
1216
+ 1A0000000000000000000005 /* www in Resources */ = {isa = PBXBuildFile; fileRef = 1A0000000000000000000015; };
1217
+ /* End PBXBuildFile section */
1218
+
1219
+ /* Begin PBXFileReference section */
1220
+ 1A0000000000000000000010 /* PulseApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PulseApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
1221
+ 1A0000000000000000000011 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
1222
+ 1A0000000000000000000012 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
1223
+ 1A0000000000000000000013 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = "<group>"; };
1224
+ 1A0000000000000000000014 /* PulseBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PulseBridge.swift; sourceTree = "<group>"; };
1225
+ 1A0000000000000000000015 /* www */ = {isa = PBXFileReference; lastKnownFileType = folder; path = www; sourceTree = "<group>"; };
1226
+ 1A0000000000000000000016 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
1227
+ /* End PBXFileReference section */
1228
+
1229
+ /* Begin PBXGroup section */
1230
+ 1A0000000000000000000020 = {
1231
+ isa = PBXGroup;
1232
+ children = (
1233
+ 1A0000000000000000000021 /* PulseApp */,
1234
+ 1A0000000000000000000022 /* Products */,
1235
+ );
1236
+ sourceTree = "<group>";
1237
+ };
1238
+ 1A0000000000000000000021 /* PulseApp */ = {
1239
+ isa = PBXGroup;
1240
+ children = (
1241
+ 1A0000000000000000000011,
1242
+ 1A0000000000000000000012,
1243
+ 1A0000000000000000000013,
1244
+ 1A0000000000000000000014,
1245
+ 1A0000000000000000000015,
1246
+ 1A0000000000000000000016,
1247
+ );
1248
+ path = PulseApp;
1249
+ sourceTree = "<group>";
1250
+ };
1251
+ 1A0000000000000000000022 /* Products */ = {
1252
+ isa = PBXGroup;
1253
+ children = (
1254
+ 1A0000000000000000000010,
1255
+ );
1256
+ name = Products;
1257
+ sourceTree = "<group>";
1258
+ };
1259
+ /* End PBXGroup section */
1260
+
1261
+ /* Begin PBXNativeTarget section */
1262
+ 1A0000000000000000000030 /* PulseApp */ = {
1263
+ isa = PBXNativeTarget;
1264
+ buildConfigurationList = 1A0000000000000000000050;
1265
+ buildPhases = (
1266
+ 1A0000000000000000000031,
1267
+ 1A0000000000000000000032,
1268
+ );
1269
+ buildRules = (
1270
+ );
1271
+ dependencies = (
1272
+ );
1273
+ name = PulseApp;
1274
+ productName = PulseApp;
1275
+ productReference = 1A0000000000000000000010;
1276
+ productType = "com.apple.product-type.application";
1277
+ };
1278
+ /* End PBXNativeTarget section */
1279
+
1280
+ /* Begin PBXProject section */
1281
+ 1A0000000000000000000040 /* Project object */ = {
1282
+ isa = PBXProject;
1283
+ attributes = {
1284
+ BuildIndependentTargetsInParallel = 1;
1285
+ LastSwiftUpdateCheck = 1500;
1286
+ LastUpgradeCheck = 1500;
1287
+ TargetAttributes = {
1288
+ 1A0000000000000000000030 = {
1289
+ CreatedOnToolsVersion = 15.0;
1290
+ };
1291
+ };
1292
+ };
1293
+ buildConfigurationList = 1A0000000000000000000041;
1294
+ compatibilityVersion = "Xcode 14.0";
1295
+ developmentRegion = en;
1296
+ hasScannedForEncodings = 0;
1297
+ knownRegions = (
1298
+ en,
1299
+ Base,
1300
+ );
1301
+ mainGroup = 1A0000000000000000000020;
1302
+ productRefGroup = 1A0000000000000000000022;
1303
+ projectDirPath = "";
1304
+ projectRoot = "";
1305
+ targets = (
1306
+ 1A0000000000000000000030,
1307
+ );
1308
+ };
1309
+ /* End PBXProject section */
1310
+
1311
+ /* Begin PBXResourcesBuildPhase section */
1312
+ 1A0000000000000000000032 /* Resources */ = {
1313
+ isa = PBXResourcesBuildPhase;
1314
+ buildActionMask = 2147483647;
1315
+ files = (
1316
+ 1A0000000000000000000005,
1317
+ );
1318
+ runOnlyForDeploymentPostprocessing = 0;
1319
+ };
1320
+ /* End PBXResourcesBuildPhase section */
1321
+
1322
+ /* Begin PBXSourcesBuildPhase section */
1323
+ 1A0000000000000000000031 /* Sources */ = {
1324
+ isa = PBXSourcesBuildPhase;
1325
+ buildActionMask = 2147483647;
1326
+ files = (
1327
+ 1A0000000000000000000001,
1328
+ 1A0000000000000000000002,
1329
+ 1A0000000000000000000003,
1330
+ 1A0000000000000000000004,
1331
+ );
1332
+ runOnlyForDeploymentPostprocessing = 0;
1333
+ };
1334
+ /* End PBXSourcesBuildPhase section */
1335
+
1336
+ /* Begin XCBuildConfiguration section */
1337
+ 1A0000000000000000000051 /* Debug */ = {
1338
+ isa = XCBuildConfiguration;
1339
+ buildSettings = {
1340
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
1341
+ CODE_SIGN_STYLE = Automatic;
1342
+ CURRENT_PROJECT_VERSION = 1;
1343
+ GENERATE_INFOPLIST_FILE = NO;
1344
+ INFOPLIST_FILE = PulseApp/Info.plist;
1345
+ IPHONEOS_DEPLOYMENT_TARGET = ${config.ios?.deploymentTarget || '13.0'};
1346
+ MARKETING_VERSION = ${config.version};
1347
+ PRODUCT_BUNDLE_IDENTIFIER = ${config.packageId};
1348
+ PRODUCT_NAME = "$(TARGET_NAME)";
1349
+ SWIFT_VERSION = 5.0;
1350
+ TARGETED_DEVICE_FAMILY = "1,2";
1351
+ };
1352
+ name = Debug;
1353
+ };
1354
+ 1A0000000000000000000052 /* Release */ = {
1355
+ isa = XCBuildConfiguration;
1356
+ buildSettings = {
1357
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
1358
+ CODE_SIGN_STYLE = Automatic;
1359
+ CURRENT_PROJECT_VERSION = 1;
1360
+ GENERATE_INFOPLIST_FILE = NO;
1361
+ INFOPLIST_FILE = PulseApp/Info.plist;
1362
+ IPHONEOS_DEPLOYMENT_TARGET = ${config.ios?.deploymentTarget || '13.0'};
1363
+ MARKETING_VERSION = ${config.version};
1364
+ PRODUCT_BUNDLE_IDENTIFIER = ${config.packageId};
1365
+ PRODUCT_NAME = "$(TARGET_NAME)";
1366
+ SWIFT_VERSION = 5.0;
1367
+ TARGETED_DEVICE_FAMILY = "1,2";
1368
+ };
1369
+ name = Release;
1370
+ };
1371
+ 1A0000000000000000000061 /* Debug */ = {
1372
+ isa = XCBuildConfiguration;
1373
+ buildSettings = {
1374
+ ALWAYS_SEARCH_USER_PATHS = NO;
1375
+ CLANG_ENABLE_MODULES = YES;
1376
+ CLANG_ENABLE_OBJC_ARC = YES;
1377
+ DEBUG_INFORMATION_FORMAT = dwarf;
1378
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
1379
+ GCC_OPTIMIZATION_LEVEL = 0;
1380
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
1381
+ ONLY_ACTIVE_ARCH = YES;
1382
+ SDKROOT = iphoneos;
1383
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
1384
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
1385
+ };
1386
+ name = Debug;
1387
+ };
1388
+ 1A0000000000000000000062 /* Release */ = {
1389
+ isa = XCBuildConfiguration;
1390
+ buildSettings = {
1391
+ ALWAYS_SEARCH_USER_PATHS = NO;
1392
+ CLANG_ENABLE_MODULES = YES;
1393
+ CLANG_ENABLE_OBJC_ARC = YES;
1394
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
1395
+ ENABLE_NS_ASSERTIONS = NO;
1396
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
1397
+ MTL_ENABLE_DEBUG_INFO = NO;
1398
+ SDKROOT = iphoneos;
1399
+ SWIFT_COMPILATION_MODE = wholemodule;
1400
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
1401
+ VALIDATE_PRODUCT = YES;
1402
+ };
1403
+ name = Release;
1404
+ };
1405
+ /* End XCBuildConfiguration section */
1406
+
1407
+ /* Begin XCConfigurationList section */
1408
+ 1A0000000000000000000041 /* Build configuration list for PBXProject "PulseApp" */ = {
1409
+ isa = XCConfigurationList;
1410
+ buildConfigurations = (
1411
+ 1A0000000000000000000061,
1412
+ 1A0000000000000000000062,
1413
+ );
1414
+ defaultConfigurationIsVisible = 0;
1415
+ defaultConfigurationName = Release;
1416
+ };
1417
+ 1A0000000000000000000050 /* Build configuration list for PBXNativeTarget "PulseApp" */ = {
1418
+ isa = XCConfigurationList;
1419
+ buildConfigurations = (
1420
+ 1A0000000000000000000051,
1421
+ 1A0000000000000000000052,
1422
+ );
1423
+ defaultConfigurationIsVisible = 0;
1424
+ defaultConfigurationName = Release;
1425
+ };
1426
+ /* End XCConfigurationList section */
1427
+ };
1428
+ rootObject = 1A0000000000000000000040 /* Project object */;
1429
+ }
1430
+ `;
1431
+ }
1432
+
1433
+ /**
1434
+ * Convert string to PascalCase
1435
+ */
1436
+ function toPascalCase(str) {
1437
+ return str
1438
+ .replace(/[-_](.)/g, (_, char) => char.toUpperCase())
1439
+ .replace(/^(.)/, (_, char) => char.toUpperCase());
1440
+ }
1441
+
1442
+ /**
1443
+ * Show mobile help
1444
+ */
1445
+ function showMobileHelp() {
1446
+ console.log(`
1447
+ Pulse Mobile - Zero-Dependency Mobile Platform
1448
+
1449
+ Usage: pulse mobile <command> [options]
1450
+
1451
+ Commands:
1452
+ init Initialize mobile platforms (Android & iOS)
1453
+ build <platform> Build for android or ios
1454
+ run <platform> Build and run on device/emulator
1455
+ sync [platform] Sync web assets to native projects
1456
+
1457
+ Examples:
1458
+ pulse mobile init
1459
+ pulse mobile build android
1460
+ pulse mobile build ios
1461
+ pulse mobile run android
1462
+ pulse mobile run ios
1463
+
1464
+ Configuration:
1465
+ Edit pulse.mobile.json to customize your mobile app settings.
1466
+
1467
+ Requirements:
1468
+ Android: Android SDK with build-tools
1469
+ iOS: macOS with Xcode (builds only work on Mac)
1470
+ `);
1471
+ }
1472
+
1473
+ export default { handleMobileCommand };