catalyst-core 0.1.0-canary.3 → 0.1.0-canary.4

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.
Files changed (21) hide show
  1. package/changelog.md +9 -0
  2. package/dist/native/androidProject/app/build.gradle.kts +3 -1
  3. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt +204 -0
  4. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt +12 -1
  5. package/dist/native/androidProject/gradle/libs.versions.toml +5 -1
  6. package/dist/native/bridge/constants/NativeInterfaces.js +6 -3
  7. package/dist/native/bridge/errors.js +1 -1
  8. package/dist/native/bridge/hooks.js +6 -2
  9. package/dist/native/bridge/utils/NativeBridge.js +5 -1
  10. package/dist/native/buildAppIos.js +11 -8
  11. package/dist/native/iosnativeWebView/Package.swift +5 -2
  12. package/dist/native/iosnativeWebView/Sources/CatalystNotifications/NotificationManager.swift +1 -1
  13. package/dist/native/iosnativeWebView/Sources/Core/Constants/CatalystConstants.swift +3 -0
  14. package/dist/native/iosnativeWebView/Sources/Core/Constants/ConfigConstants.swift +7 -0
  15. package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeCommandHandler.swift +264 -0
  16. package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeMessageValidator.swift +18 -0
  17. package/dist/native/iosnativeWebView/Sources/Core/WebView/NativeBridge.swift +2 -0
  18. package/dist/native/iosnativeWebView/iosnativeWebView/AppDelegate.swift +13 -0
  19. package/dist/native/iosnativeWebView/iosnativeWebView/iosnativeWebView.entitlements +4 -0
  20. package/dist/native/iosnativeWebView/iosnativeWebView/iosnativeWebViewApp.swift +10 -0
  21. package/package.json +1 -1
package/changelog.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.0-canary.4] - 2026-02-12
4
+
5
+ - Added Google Sign-In support for both Android and iOS in Catalyst, enabling a unified native authentication experience for apps built on the framework.
6
+ - Improved release/build reliability with related bridge and CI updates, making integration smoother and more consistent across platforms.
7
+
8
+ ## [0.1.0-canary.3] - 2026-02-04
9
+
10
+ - Android device security: added root, emulator, and Frida detection with launch-time checks and a Material Design 3 bottom sheet threat alert UI
11
+
3
12
  ## [0.1.0-canary.2] - 2026-01-28
4
13
 
5
14
  - Adds mono-repository support for catalyst
@@ -171,8 +171,10 @@ dependencies {
171
171
  androidTestImplementation(libs.androidx.junit)
172
172
  androidTestImplementation(libs.androidx.espresso.core)
173
173
  implementation(libs.androidx.webkit)
174
+ implementation(libs.androidx.credentials)
175
+ implementation(libs.androidx.credentials.play.services.auth)
176
+ implementation(libs.googleid)
174
177
  implementation("org.json:json:20231013")
175
-
176
178
  // Ktor Server dependencies for FrameworkServer (~200KB total)
177
179
  implementation("io.ktor:ktor-server-core:3.0.3")
178
180
  implementation("io.ktor:ktor-server-netty:3.0.3")
@@ -10,12 +10,19 @@ import android.webkit.WebView
10
10
  import androidx.activity.result.ActivityResultLauncher
11
11
  import androidx.activity.result.contract.ActivityResultContracts
12
12
  import androidx.core.app.NotificationCompat
13
+ import androidx.credentials.CredentialManager
14
+ import androidx.credentials.GetCredentialRequest
15
+ import androidx.credentials.exceptions.GetCredentialCancellationException
16
+ import androidx.credentials.exceptions.GetCredentialException
17
+ import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
18
+ import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
13
19
  import io.yourname.androidproject.MainActivity
14
20
  import io.yourname.androidproject.utils.*
15
21
  import kotlinx.coroutines.*
16
22
  import org.json.JSONArray
17
23
  import org.json.JSONObject
18
24
  import java.util.Properties
25
+ import java.util.concurrent.atomic.AtomicBoolean
19
26
 
20
27
  private data class FilePickerOptions(
21
28
  val mimeType: String = "*/*",
@@ -86,6 +93,38 @@ private fun JSONObject.optIntNullable(key: String): Int? =
86
93
  private fun JSONObject.optLongNullable(key: String): Long? =
87
94
  if (has(key) && !isNull(key)) getLong(key) else null
88
95
 
96
+ private data class GoogleSignInOptions(
97
+ val clientId: String,
98
+ val nonce: String? = null,
99
+ val autoSelect: Boolean = false,
100
+ val filterByAuthorizedAccounts: Boolean = false
101
+ ) {
102
+ companion object {
103
+ fun fromRaw(raw: String?): GoogleSignInOptions {
104
+ if (raw.isNullOrBlank()) {
105
+ // Allow fallback to config-supplied clientId
106
+ return GoogleSignInOptions(clientId = "")
107
+ }
108
+
109
+ return try {
110
+ val json = JSONObject(raw)
111
+ val resolvedClientId = json.optString("clientId")
112
+ .ifBlank { json.optString("webClientId") }
113
+
114
+ GoogleSignInOptions(
115
+ clientId = resolvedClientId,
116
+ nonce = json.optString("nonce").takeIf { it.isNotBlank() },
117
+ autoSelect = json.optBoolean("autoSelect", false),
118
+ filterByAuthorizedAccounts = json.optBoolean("filterByAuthorizedAccounts", false)
119
+ )
120
+ } catch (e: Exception) {
121
+ BridgeUtils.logDebug("NativeBridge", "Failed to parse options JSON: ${e.message}")
122
+ GoogleSignInOptions(clientId = raw.trim())
123
+ }
124
+ }
125
+ }
126
+ }
127
+
89
128
  class NativeBridge(
90
129
  private val mainActivity: MainActivity,
91
130
  private val webView: WebView,
@@ -104,6 +143,12 @@ class NativeBridge(
104
143
  private lateinit var permissionLauncher: ActivityResultLauncher<String>
105
144
  private lateinit var filePickerLauncher: ActivityResultLauncher<Intent>
106
145
  private var currentFilePickerOptions: FilePickerOptions = FilePickerOptions()
146
+ private val isGoogleSignInInProgress = AtomicBoolean(false)
147
+ private val credentialManager: CredentialManager by lazy { CredentialManager.create(mainActivity) }
148
+ private val isGoogleSignInEnabled: Boolean by lazy {
149
+ properties.getProperty("googleSignIn.enabled", "false").toBoolean()
150
+ }
151
+
107
152
  private var networkMonitor: NetworkMonitor? = null
108
153
 
109
154
  // Unified notification manager
@@ -111,6 +156,7 @@ class NativeBridge(
111
156
 
112
157
  companion object {
113
158
  private const val TAG = "NativeBridge"
159
+ private const val GOOGLE_SIGN_IN_SAFE_EXECUTE_ACTION = "start Google Sign-In"
114
160
 
115
161
  /**
116
162
  * Parse and validate bridge message
@@ -237,6 +283,55 @@ class NativeBridge(
237
283
  }
238
284
  }
239
285
 
286
+ @JavascriptInterface
287
+ fun googleSignIn(optionsRaw: String?) {
288
+ BridgeUtils.safeExecute(
289
+ webView,
290
+ BridgeUtils.WebEvents.ON_GOOGLE_SIGN_IN_ERROR,
291
+ GOOGLE_SIGN_IN_SAFE_EXECUTE_ACTION
292
+ ) {
293
+ if (!isGoogleSignInEnabled) {
294
+ BridgeUtils.notifyWebError(
295
+ webView,
296
+ BridgeUtils.WebEvents.ON_GOOGLE_SIGN_IN_ERROR,
297
+ "Google Sign-In is disabled in config"
298
+ )
299
+ return@safeExecute
300
+ }
301
+
302
+ val options = GoogleSignInOptions.fromRaw(optionsRaw)
303
+
304
+ val resolvedClientId = when {
305
+ options.clientId.isNotBlank() -> options.clientId
306
+ !properties.getProperty("googleSignIn.clientId", "").isNullOrBlank() ->
307
+ properties.getProperty("googleSignIn.clientId", "")
308
+ else -> ""
309
+ }
310
+
311
+ if (resolvedClientId.isBlank()) {
312
+ BridgeUtils.notifyWebError(
313
+ webView,
314
+ BridgeUtils.WebEvents.ON_GOOGLE_SIGN_IN_ERROR,
315
+ "clientId is required for Google Sign-In"
316
+ )
317
+ return@safeExecute
318
+ }
319
+
320
+ if (!isGoogleSignInInProgress.compareAndSet(false, true)) {
321
+ BridgeUtils.notifyWebError(
322
+ webView,
323
+ BridgeUtils.WebEvents.ON_GOOGLE_SIGN_IN_ERROR,
324
+ "A Google Sign-In request is already in progress"
325
+ )
326
+ return@safeExecute
327
+ }
328
+
329
+ BridgeUtils.logDebug(TAG, "Google Sign-In using clientId prefix: ${resolvedClientId.take(12)}...")
330
+
331
+ launchGoogleSignIn(options.copy(clientId = resolvedClientId))
332
+ }
333
+ }
334
+
240
335
  @JavascriptInterface
241
336
  fun openFileWithIntent(params: String?) {
242
337
  BridgeUtils.safeExecute(webView, BridgeUtils.WebEvents.ON_INTENT_ERROR, "open file with intent") {
@@ -626,6 +721,115 @@ class NativeBridge(
626
721
  }
627
722
  }
628
723
  }
724
+
725
+ private fun launchGoogleSignIn(options: GoogleSignInOptions) {
726
+ launch {
727
+ try {
728
+ val googleOptionBuilder = GetSignInWithGoogleOption.Builder(options.clientId)
729
+ options.nonce?.let { googleOptionBuilder.setNonce(it) }
730
+ val googleOption = googleOptionBuilder.build()
731
+
732
+ val request = GetCredentialRequest.Builder()
733
+ .addCredentialOption(googleOption)
734
+ .build()
735
+
736
+ BridgeUtils.logDebug(
737
+ TAG,
738
+ "Starting Google Sign-In via CredentialManager (hasNonce=${options.nonce != null})"
739
+ )
740
+
741
+ val response = credentialManager.getCredential(mainActivity, request)
742
+ handleCredentialResponse(response)
743
+ } catch (e: GetCredentialCancellationException) {
744
+ isGoogleSignInInProgress.set(false)
745
+ BridgeUtils.logDebug(
746
+ TAG,
747
+ "Google Sign-In cancelled: ${e.message} :: ${e.javaClass.simpleName}"
748
+ )
749
+ BridgeUtils.notifyWeb(
750
+ webView,
751
+ BridgeUtils.WebEvents.ON_GOOGLE_SIGN_IN_CANCELLED,
752
+ "User cancelled Google sign-in"
753
+ )
754
+ } catch (e: GetCredentialException) {
755
+ isGoogleSignInInProgress.set(false)
756
+ val message = e.errorMessage?.toString() ?: e.message ?: "Credential request failed"
757
+ BridgeUtils.logError(
758
+ TAG,
759
+ "Google Sign-In credential error: $message :: ${e.javaClass.simpleName}",
760
+ e
761
+ )
762
+ BridgeUtils.notifyWebError(
763
+ webView,
764
+ BridgeUtils.WebEvents.ON_GOOGLE_SIGN_IN_ERROR,
765
+ message
766
+ )
767
+ } catch (e: Exception) {
768
+ isGoogleSignInInProgress.set(false)
769
+ BridgeUtils.logError(
770
+ TAG,
771
+ "Google Sign-In unexpected error: ${e.message} :: ${e.javaClass.simpleName}",
772
+ e
773
+ )
774
+ BridgeUtils.notifyWebError(
775
+ webView,
776
+ BridgeUtils.WebEvents.ON_GOOGLE_SIGN_IN_ERROR,
777
+ "Error preparing Google sign-in: ${e.message}"
778
+ )
779
+ }
780
+ }
781
+ }
782
+
783
+ private fun handleCredentialResponse(response: androidx.credentials.GetCredentialResponse) {
784
+ try {
785
+ val credential = response.credential
786
+ if (credential.type != GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) {
787
+ BridgeUtils.logError(TAG, "Unexpected credential type: ${credential.type}")
788
+ isGoogleSignInInProgress.set(false)
789
+ BridgeUtils.notifyWebError(
790
+ webView,
791
+ BridgeUtils.WebEvents.ON_GOOGLE_SIGN_IN_ERROR,
792
+ "Unsupported credential type returned"
793
+ )
794
+ return
795
+ }
796
+
797
+ val googleCredential = GoogleIdTokenCredential.createFrom(credential.data)
798
+ val idToken = googleCredential.idToken
799
+
800
+ if (idToken.isNullOrBlank()) {
801
+ isGoogleSignInInProgress.set(false)
802
+ BridgeUtils.notifyWebError(
803
+ webView,
804
+ BridgeUtils.WebEvents.ON_GOOGLE_SIGN_IN_ERROR,
805
+ "No ID token returned from Google sign-in"
806
+ )
807
+ return
808
+ }
809
+
810
+ val payload = JSONObject().apply {
811
+ put("idToken", idToken)
812
+ googleCredential.id?.let { put("email", it) }
813
+ googleCredential.displayName?.let { put("name", it) }
814
+ googleCredential.profilePictureUri?.toString()?.let { put("photoUrl", it) }
815
+ put("provider", "google")
816
+ }
817
+
818
+ isGoogleSignInInProgress.set(false)
819
+ BridgeUtils.notifyWebJson(
820
+ webView,
821
+ BridgeUtils.WebEvents.ON_GOOGLE_SIGN_IN_SUCCESS,
822
+ payload
823
+ )
824
+ } catch (e: Exception) {
825
+ isGoogleSignInInProgress.set(false)
826
+ BridgeUtils.notifyWebError(
827
+ webView,
828
+ BridgeUtils.WebEvents.ON_GOOGLE_SIGN_IN_ERROR,
829
+ "Error handling Google credential: ${e.message}"
830
+ )
831
+ }
832
+ }
629
833
 
630
834
  private fun processSelectedFiles(uris: List<Uri>, options: FilePickerOptions) {
631
835
  launch {
@@ -4,6 +4,7 @@ import android.util.Log
4
4
  import android.webkit.WebView
5
5
  import io.yourname.androidproject.CatalystConstants
6
6
  import org.json.JSONObject
7
+ import android.os.Looper
7
8
 
8
9
  /**
9
10
  * Common utilities for native bridge operations
@@ -65,9 +66,13 @@ object BridgeUtils {
65
66
  TOPIC_SUBSCRIPTION_RESULT("TOPIC_SUBSCRIPTION_RESULT"),
66
67
  SUBSCRIBED_TOPICS_RESULT("SUBSCRIBED_TOPICS_RESULT"),
67
68
 
69
+ // Auth events
70
+ ON_GOOGLE_SIGN_IN_SUCCESS("ON_GOOGLE_SIGN_IN_SUCCESS"),
71
+ ON_GOOGLE_SIGN_IN_ERROR("ON_GOOGLE_SIGN_IN_ERROR"),
72
+ ON_GOOGLE_SIGN_IN_CANCELLED("ON_GOOGLE_SIGN_IN_CANCELLED"),
73
+
68
74
  // Network events
69
75
  NETWORK_STATUS_CHANGED("NETWORK_STATUS_CHANGED"),
70
-
71
76
  // Safe area events
72
77
  ON_SAFE_AREA_INSETS_UPDATED("ON_SAFE_AREA_INSETS_UPDATED"),
73
78
  }
@@ -80,6 +85,12 @@ object BridgeUtils {
80
85
  * @param data The data to send (can be null)
81
86
  */
82
87
  fun notifyWeb(webView: WebView, event: WebEvents, data: String? = null) {
88
+ // Ensure WebView interactions happen on the main thread
89
+ if (Looper.myLooper() != Looper.getMainLooper()) {
90
+ webView.post { notifyWeb(webView, event, data) }
91
+ return
92
+ }
93
+
83
94
  try {
84
95
  val safeData = data?.replace("'", "\\'") ?: ""
85
96
  val jsCode = if (data != null) {
@@ -10,6 +10,8 @@ material = "1.12.0"
10
10
  constraintlayout = "2.1.4"
11
11
  webkit = "1.13.0"
12
12
  googleServices = "4.4.0"
13
+ androidxCredentials = "1.3.0"
14
+ googleId = "1.1.1"
13
15
 
14
16
  [libraries]
15
17
  androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -20,9 +22,11 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version
20
22
  material = { group = "com.google.android.material", name = "material", version.ref = "material" }
21
23
  androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
22
24
  androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
25
+ androidx-credentials = { group = "androidx.credentials", name = "credentials", version.ref = "androidxCredentials" }
26
+ androidx-credentials-play-services-auth = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "androidxCredentials" }
27
+ googleid = { group = "com.google.android.libraries.identity.googleid", name = "googleid", version.ref = "googleId" }
23
28
 
24
29
  [plugins]
25
30
  android-application = { id = "com.android.application", version.ref = "agp" }
26
31
  jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
27
32
  google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }
28
-
@@ -6,7 +6,9 @@ OPEN_CAMERA:"openCamera",REQUEST_CAMERA_PERMISSION:"requestCameraPermission",//
6
6
  PICK_FILE:"pickFile",OPEN_FILE_WITH_INTENT:"openFileWithIntent",// Haptic feedback commands
7
7
  REQUEST_HAPTIC_FEEDBACK:"requestHapticFeedback",// Notification commands
8
8
  SCHEDULE_LOCAL_NOTIFICATION:"scheduleLocalNotification",CANCEL_LOCAL_NOTIFICATION:"cancelLocalNotification",REQUEST_NOTIFICATION_PERMISSION:"requestNotificationPermission",REGISTER_FOR_PUSH_NOTIFICATIONS:"registerForPushNotifications",UPDATE_BADGE_COUNT:"updateBadgeCount",SUBSCRIBE_TO_TOPIC:"subscribeToTopic",UNSUBSCRIBE_FROM_TOPIC:"unsubscribeFromTopic",GET_SUBSCRIBED_TOPICS:"getSubscribedTopics",// Device info commands
9
- GET_DEVICE_INFO:"getDeviceInfo",GET_NETWORK_STATUS:"getNetworkStatus",// Safe area commands
9
+ GET_DEVICE_INFO:"getDeviceInfo",// Network commands
10
+ GET_NETWORK_STATUS:"getNetworkStatus",// Auth commands
11
+ GOOGLE_SIGN_IN:"googleSignIn",// Safe area commands
10
12
  GET_SAFE_AREA:"getSafeArea"};// Callback Interfaces: Native → Web (incoming events/callbacks)
11
13
  const NATIVE_CALLBACKS=exports.NATIVE_CALLBACKS={// Camera callbacks
12
14
  CAMERA_PERMISSION_STATUS:"CAMERA_PERMISSION_STATUS",ON_CAMERA_CAPTURE:"ON_CAMERA_CAPTURE",ON_CAMERA_ERROR:"ON_CAMERA_ERROR",// File picker callbacks
@@ -14,10 +16,11 @@ ON_FILE_PICKED:"ON_FILE_PICKED",ON_FILE_PICK_ERROR:"ON_FILE_PICK_ERROR",ON_FILE_
14
16
  ON_INTENT_SUCCESS:"ON_INTENT_SUCCESS",ON_INTENT_ERROR:"ON_INTENT_ERROR",ON_INTENT_CANCELLED:"ON_INTENT_CANCELLED",// Haptic feedback callbacks
15
17
  HAPTIC_FEEDBACK:"HAPTIC_FEEDBACK",// Notification callbacks
16
18
  NOTIFICATION_PERMISSION_STATUS:"NOTIFICATION_PERMISSION_STATUS",LOCAL_NOTIFICATION_SCHEDULED:"LOCAL_NOTIFICATION_SCHEDULED",LOCAL_NOTIFICATION_CANCELLED:"LOCAL_NOTIFICATION_CANCELLED",PUSH_NOTIFICATION_TOKEN:"PUSH_NOTIFICATION_TOKEN",NOTIFICATION_RECEIVED:"NOTIFICATION_RECEIVED",NOTIFICATION_ACTION_PERFORMED:"NOTIFICATION_ACTION_PERFORMED",NOTIFICATION_TAPPED:"NOTIFICATION_TAPPED",TOPIC_SUBSCRIPTION_RESULT:"TOPIC_SUBSCRIPTION_RESULT",SUBSCRIBED_TOPICS_RESULT:"SUBSCRIBED_TOPICS_RESULT",// Device info callbacks
17
- ON_DEVICE_INFO_SUCCESS:"ON_DEVICE_INFO_SUCCESS",ON_DEVICE_INFO_ERROR:"ON_DEVICE_INFO_ERROR",// Network callbacks
19
+ ON_DEVICE_INFO_SUCCESS:"ON_DEVICE_INFO_SUCCESS",ON_DEVICE_INFO_ERROR:"ON_DEVICE_INFO_ERROR",// Auth callbacks
20
+ ON_GOOGLE_SIGN_IN_SUCCESS:"ON_GOOGLE_SIGN_IN_SUCCESS",ON_GOOGLE_SIGN_IN_ERROR:"ON_GOOGLE_SIGN_IN_ERROR",ON_GOOGLE_SIGN_IN_CANCELLED:"ON_GOOGLE_SIGN_IN_CANCELLED",// Network callbacks
18
21
  NETWORK_STATUS_CHANGED:"NETWORK_STATUS_CHANGED",// Safe area callbacks
19
22
  ON_SAFE_AREA_INSETS_UPDATED:"ON_SAFE_AREA_INSETS_UPDATED"};// Interface Categories for easier management
20
- const INTERFACE_CATEGORIES=exports.INTERFACE_CATEGORIES={CAMERA:{commands:[NATIVE_COMMANDS.OPEN_CAMERA,NATIVE_COMMANDS.REQUEST_CAMERA_PERMISSION],callbacks:[NATIVE_CALLBACKS.CAMERA_PERMISSION_STATUS,NATIVE_CALLBACKS.ON_CAMERA_CAPTURE,NATIVE_CALLBACKS.ON_CAMERA_ERROR]},FILE:{commands:[NATIVE_COMMANDS.PICK_FILE,NATIVE_COMMANDS.OPEN_FILE_WITH_INTENT],callbacks:[NATIVE_CALLBACKS.ON_FILE_PICKED,NATIVE_CALLBACKS.ON_FILE_PICK_ERROR,NATIVE_CALLBACKS.ON_FILE_PICK_CANCELLED,NATIVE_CALLBACKS.ON_FILE_PICK_STATE_UPDATE]},INTENT:{commands:[NATIVE_COMMANDS.OPEN_FILE_WITH_INTENT],callbacks:[NATIVE_CALLBACKS.ON_INTENT_SUCCESS,NATIVE_CALLBACKS.ON_INTENT_ERROR,NATIVE_CALLBACKS.ON_INTENT_CANCELLED]},HAPTIC:{commands:[NATIVE_COMMANDS.REQUEST_HAPTIC_FEEDBACK],callbacks:[NATIVE_CALLBACKS.HAPTIC_FEEDBACK]},NOTIFICATION:{commands:[NATIVE_COMMANDS.SCHEDULE_LOCAL_NOTIFICATION,NATIVE_COMMANDS.CANCEL_LOCAL_NOTIFICATION,NATIVE_COMMANDS.REQUEST_NOTIFICATION_PERMISSION,NATIVE_COMMANDS.REGISTER_FOR_PUSH_NOTIFICATIONS,NATIVE_COMMANDS.UPDATE_BADGE_COUNT,NATIVE_COMMANDS.SUBSCRIBE_TO_TOPIC,NATIVE_COMMANDS.UNSUBSCRIBE_FROM_TOPIC,NATIVE_COMMANDS.GET_SUBSCRIBED_TOPICS],callbacks:[NATIVE_CALLBACKS.NOTIFICATION_PERMISSION_STATUS,NATIVE_CALLBACKS.LOCAL_NOTIFICATION_SCHEDULED,NATIVE_CALLBACKS.LOCAL_NOTIFICATION_CANCELLED,NATIVE_CALLBACKS.PUSH_NOTIFICATION_TOKEN,NATIVE_CALLBACKS.NOTIFICATION_RECEIVED,NATIVE_CALLBACKS.NOTIFICATION_ACTION_PERFORMED,NATIVE_CALLBACKS.NOTIFICATION_TAPPED,NATIVE_CALLBACKS.TOPIC_SUBSCRIPTION_RESULT,NATIVE_CALLBACKS.SUBSCRIBED_TOPICS_RESULT]},DEVICE_INFO:{commands:[NATIVE_COMMANDS.GET_DEVICE_INFO],callbacks:[NATIVE_CALLBACKS.ON_DEVICE_INFO_SUCCESS,NATIVE_CALLBACKS.ON_DEVICE_INFO_ERROR]},NETWORK:{commands:[NATIVE_COMMANDS.GET_NETWORK_STATUS],callbacks:[NATIVE_CALLBACKS.NETWORK_STATUS_CHANGED]}};// All available interfaces (for validation)
23
+ const INTERFACE_CATEGORIES=exports.INTERFACE_CATEGORIES={CAMERA:{commands:[NATIVE_COMMANDS.OPEN_CAMERA,NATIVE_COMMANDS.REQUEST_CAMERA_PERMISSION],callbacks:[NATIVE_CALLBACKS.CAMERA_PERMISSION_STATUS,NATIVE_CALLBACKS.ON_CAMERA_CAPTURE,NATIVE_CALLBACKS.ON_CAMERA_ERROR]},FILE:{commands:[NATIVE_COMMANDS.PICK_FILE,NATIVE_COMMANDS.OPEN_FILE_WITH_INTENT],callbacks:[NATIVE_CALLBACKS.ON_FILE_PICKED,NATIVE_CALLBACKS.ON_FILE_PICK_ERROR,NATIVE_CALLBACKS.ON_FILE_PICK_CANCELLED,NATIVE_CALLBACKS.ON_FILE_PICK_STATE_UPDATE]},INTENT:{commands:[NATIVE_COMMANDS.OPEN_FILE_WITH_INTENT],callbacks:[NATIVE_CALLBACKS.ON_INTENT_SUCCESS,NATIVE_CALLBACKS.ON_INTENT_ERROR,NATIVE_CALLBACKS.ON_INTENT_CANCELLED]},HAPTIC:{commands:[NATIVE_COMMANDS.REQUEST_HAPTIC_FEEDBACK],callbacks:[NATIVE_CALLBACKS.HAPTIC_FEEDBACK]},NOTIFICATION:{commands:[NATIVE_COMMANDS.SCHEDULE_LOCAL_NOTIFICATION,NATIVE_COMMANDS.CANCEL_LOCAL_NOTIFICATION,NATIVE_COMMANDS.REQUEST_NOTIFICATION_PERMISSION,NATIVE_COMMANDS.REGISTER_FOR_PUSH_NOTIFICATIONS,NATIVE_COMMANDS.UPDATE_BADGE_COUNT,NATIVE_COMMANDS.SUBSCRIBE_TO_TOPIC,NATIVE_COMMANDS.UNSUBSCRIBE_FROM_TOPIC,NATIVE_COMMANDS.GET_SUBSCRIBED_TOPICS],callbacks:[NATIVE_CALLBACKS.NOTIFICATION_PERMISSION_STATUS,NATIVE_CALLBACKS.LOCAL_NOTIFICATION_SCHEDULED,NATIVE_CALLBACKS.LOCAL_NOTIFICATION_CANCELLED,NATIVE_CALLBACKS.PUSH_NOTIFICATION_TOKEN,NATIVE_CALLBACKS.NOTIFICATION_RECEIVED,NATIVE_CALLBACKS.NOTIFICATION_ACTION_PERFORMED,NATIVE_CALLBACKS.NOTIFICATION_TAPPED,NATIVE_CALLBACKS.TOPIC_SUBSCRIPTION_RESULT,NATIVE_CALLBACKS.SUBSCRIBED_TOPICS_RESULT]},DEVICE_INFO:{commands:[NATIVE_COMMANDS.GET_DEVICE_INFO],callbacks:[NATIVE_CALLBACKS.ON_DEVICE_INFO_SUCCESS,NATIVE_CALLBACKS.ON_DEVICE_INFO_ERROR]},AUTH:{commands:[NATIVE_COMMANDS.GOOGLE_SIGN_IN],callbacks:[NATIVE_CALLBACKS.ON_GOOGLE_SIGN_IN_SUCCESS,NATIVE_CALLBACKS.ON_GOOGLE_SIGN_IN_ERROR,NATIVE_CALLBACKS.ON_GOOGLE_SIGN_IN_CANCELLED]},NETWORK:{commands:[NATIVE_COMMANDS.GET_NETWORK_STATUS],callbacks:[NATIVE_CALLBACKS.NETWORK_STATUS_CHANGED]}};// All available interfaces (for validation)
21
24
  const ALL_COMMANDS=exports.ALL_COMMANDS=Object.values(NATIVE_COMMANDS);const ALL_CALLBACKS=exports.ALL_CALLBACKS=Object.values(NATIVE_CALLBACKS);const ALL_INTERFACES=exports.ALL_INTERFACES=[...ALL_COMMANDS,...ALL_CALLBACKS];// Permission status constants
22
25
  const PERMISSION_STATUS=exports.PERMISSION_STATUS={GRANTED:"GRANTED",DENIED:"DENIED",NOT_DETERMINED:"NOT_DETERMINED",RESTRICTED:"RESTRICTED"};// Haptic feedback types (matching Android implementation)
23
26
  const HAPTIC_FEEDBACK_TYPES=exports.HAPTIC_FEEDBACK_TYPES={VIRTUAL_KEY:"VIRTUAL_KEY",LONG_PRESS:"LONG_PRESS",DEFAULT:"DEFAULT",// Legacy types for backward compatibility
@@ -22,7 +22,7 @@ const ERROR_DEFINITIONS=exports.ERROR_DEFINITIONS={[ERROR_CODES.PERMISSION_DENIE
22
22
  * Translate native error to standardized error
23
23
  * @param {any} nativeError - Native error from Android/iOS
24
24
  * @returns {Object} Standardized error object
25
- */exports.createStandardError=createStandardError;const translateError=nativeError=>{if(!nativeError){return createStandardError(ERROR_CODES.INTERNAL_ERROR,null,nativeError);}const errorString=nativeError.toString().toLowerCase();// Permission errors
25
+ */exports.createStandardError=createStandardError;const translateError=nativeError=>{if(!nativeError){return createStandardError(ERROR_CODES.INTERNAL_ERROR,null,nativeError);}const errorString=nativeError.toString().toLowerCase();if(errorString.includes("disabled")&&errorString.includes("config")){return createStandardError(ERROR_CODES.FEATURE_UNSUPPORTED,nativeError.toString(),nativeError);}// Permission errors
26
26
  if(errorString.includes("permission")&&errorString.includes("denied")){if(errorString.includes("camera")){return createStandardError(ERROR_CODES.PERMISSION_DENIED,"Camera permission denied",nativeError);}if(errorString.includes("storage")||errorString.includes("external_storage")){return createStandardError(ERROR_CODES.PERMISSION_DENIED,"Storage permission denied",nativeError);}return createStandardError(ERROR_CODES.PERMISSION_DENIED,null,nativeError);}// File system errors
27
27
  if(errorString.includes("filenotfound")||errorString.includes("file not found")){return createStandardError(ERROR_CODES.FILE_NOT_FOUND,null,nativeError);}if(errorString.includes("storage")&&errorString.includes("full")){return createStandardError(ERROR_CODES.STORAGE_FULL,null,nativeError);}// Network errors
28
28
  if(errorString.includes("network")||errorString.includes("connection")||errorString.includes("internet")){return createStandardError(ERROR_CODES.NETWORK_UNAVAILABLE,null,nativeError);}// User cancellation
@@ -1,8 +1,12 @@
1
- "use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.useSafeArea=exports.useNotificationPermission=exports.useNotification=exports.useNetworkStatus=exports.useIntent=exports.useHapticFeedback=exports.useFilePicker=exports.useCameraPermission=exports.useCamera=exports.requestNotificationPermission=exports.requestHapticFeedback=exports.requestCameraPermission=void 0;var _react=require("react");var _NativeBridge=_interopRequireDefault(require("./utils/NativeBridge.js"));var _NativeInterfaces=require("./constants/NativeInterfaces.js");var _useBaseHook=require("./useBaseHook.js");var _errors=require("./errors.js");var _FileObjectConverter=require("./utils/FileObjectConverter.js");var _safeArea=require("./safeArea.js");function _interopRequireDefault(e){return e&&e.__esModule?e:{default:e};}/* eslint-disable react-compiler/react-compiler, react-hooks/exhaustive-deps */// Shared callback system for notification permission status
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.useSafeArea=exports.useNotificationPermission=exports.useNotification=exports.useNetworkStatus=exports.useIntent=exports.useHapticFeedback=exports.useGoogleSignIn=exports.useFilePicker=exports.useCameraPermission=exports.useCamera=exports.requestNotificationPermission=exports.requestHapticFeedback=exports.requestCameraPermission=void 0;var _react=require("react");var _NativeBridge=_interopRequireDefault(require("./utils/NativeBridge.js"));var _NativeInterfaces=require("./constants/NativeInterfaces.js");var _useBaseHook=require("./useBaseHook.js");var _errors=require("./errors.js");var _FileObjectConverter=require("./utils/FileObjectConverter.js");var _safeArea=require("./safeArea.js");function _interopRequireDefault(e){return e&&e.__esModule?e:{default:e};}/* eslint-disable react-compiler/react-compiler, react-hooks/exhaustive-deps */// Shared callback system for notification permission status
2
2
  // This allows multiple listeners (hook + requestNotificationPermission) to receive updates
3
3
  const permissionStatusListeners=new Set();let isPermissionCallbackRegistered=false;const registerPermissionStatusListener=callback=>{permissionStatusListeners.add(callback);// Register the shared handler with WebBridge if this is the first listener
4
4
  if(!isPermissionCallbackRegistered&&typeof window!=="undefined"&&window.WebBridge){isPermissionCallbackRegistered=true;window.WebBridge.register(_NativeInterfaces.NATIVE_CALLBACKS.NOTIFICATION_PERMISSION_STATUS,data=>{// Notify all listeners
5
- permissionStatusListeners.forEach(listener=>{try{listener(data);}catch(error){console.error("Error in permission status listener:",error);}});});}return()=>{permissionStatusListeners.delete(callback);};};const unregisterPermissionStatusListener=callback=>{permissionStatusListeners.delete(callback);};const noop=()=>{};const createSSRUnavailable=methodName=>async()=>{throw new Error(`${methodName} is not available in SSR environment`);};const SSR_FILE_PICKER_STUB={data:null,loading:false,progress:null,error:null,isWeb:true,isNative:false,execute:noop,clear:noop,clearError:noop,getFileObject:createSSRUnavailable("getFileObject"),getAllFileObjects:createSSRUnavailable("getAllFileObjects"),canCreateFileObject:false,canCreateFileObjects:[],selectedFile:null,selectedFiles:[],pickFile:noop,isLoading:false,processingState:null,clearFile:noop};const parseNativePayload=payload=>{if(payload==null)return null;if(typeof payload!=="string")return payload;return JSON.parse(payload);};const extractFiles=value=>{if(!value)return[];if(Array.isArray(value))return value.filter(Boolean);if(Array.isArray(value.files))return value.files.filter(Boolean);if(value.files)return[value.files];return[value].filter(Boolean);};const normalizeFilePickResult=payload=>{const parsed=parseNativePayload(payload);if(!parsed)return null;const files=extractFiles(parsed);if(!files.length)return null;const first=files[0];const rawOptions=!Array.isArray(parsed)&&parsed&&typeof parsed.options==="object"?parsed.options:null;let normalizedOptions=rawOptions||null;if(rawOptions){try{normalizedOptions=sanitizeFilePickerOptions(rawOptions);}catch(_error){normalizedOptions=rawOptions;}}const totalSize=parsed.totalSize??files.reduce((sum,file)=>{const size=typeof file?.size==="number"?file.size:0;return sum+size;},0);const base=Array.isArray(parsed)?{}:parsed;return{...base,files,multiple:parsed.multiple??files.length>1,count:parsed.count??files.length,totalSize,fileName:parsed.fileName??first?.fileName??first?.name??null,fileSrc:parsed.fileSrc??first?.fileSrc??first?.src??null,filePath:parsed.filePath??first?.filePath??null,size:parsed.size??first?.size??null,mimeType:parsed.mimeType??first?.mimeType??first?.type??null,transport:parsed.transport??first?.transport??null,options:normalizedOptions};};const updateProgressFromResult=(updateProgress,result)=>{if(!result)return;const first=result.files?.[0];const transport=result.transport??first?.transport??null;const bytesTotal=result.totalSize??result.size??(typeof first?.size==="number"?first.size:null);if(transport||bytesTotal!=null){updateProgress({transport:transport??null,bytesTotal});}};const mapStateToProgress=state=>{if(state==null){return{state:"starting",phase:null,message:"File picker: starting..."};}const rawState=String(state);const normalized=rawState.toLowerCase();const allowedStates=new Set(["opening","processing","routing"]);const progressState=allowedStates.has(normalized)?normalized:"starting";return{state:progressState,phase:rawState,message:`File picker: ${rawState}...`};};const sanitizeFilePickerOptions=input=>{const options={...input};const mime=typeof options.mimeType==="string"?options.mimeType.trim():"";options.mimeType=mime.length>0?mime:"*/*";const coerceInteger=(value,min)=>{if(value==null)return undefined;const numeric=typeof value==="string"?Number(value.trim()):Number(value);if(!Number.isFinite(numeric)){throw new Error("File picker numeric options must be valid numbers");}if(!Number.isInteger(numeric)){throw new Error("File picker numeric options must be integers");}if(numeric<min){throw new Error(`File picker numeric options must be ≥ ${min}`);}return numeric;};const minFiles=coerceInteger(options.minFiles,1);const maxFiles=coerceInteger(options.maxFiles,1);const minFileSize=coerceInteger(options.minFileSize,0);const maxFileSize=coerceInteger(options.maxFileSize,0);if(minFiles!==undefined)options.minFiles=minFiles;if(maxFiles!==undefined)options.maxFiles=maxFiles;if(minFileSize!==undefined)options.minFileSize=minFileSize;if(maxFileSize!==undefined)options.maxFileSize=maxFileSize;if(minFiles!==undefined&&maxFiles!==undefined&&minFiles>maxFiles){throw new Error("minFiles cannot be greater than maxFiles");}if(minFileSize!==undefined&&maxFileSize!==undefined&&minFileSize>maxFileSize){throw new Error("minFileSize cannot be greater than maxFileSize");}const multiple=typeof options.multiple==="boolean"?options.multiple:Boolean(options.multiple);options.multiple=multiple||minFiles&&minFiles>1||maxFiles&&maxFiles>1;return options;};const resolvePickPayload=input=>{if(input==null)return"*/*";if(typeof input==="string")return input.trim()||"*/*";return sanitizeFilePickerOptions(input);};const registerNativeHandlers=handlers=>{handlers.forEach(([event,handler])=>window.WebBridge.register(event,handler));return()=>{handlers.forEach(([event])=>window.WebBridge.unregister(event));};};/**
5
+ permissionStatusListeners.forEach(listener=>{try{listener(data);}catch(error){console.error("Error in permission status listener:",error);}});});}return()=>{permissionStatusListeners.delete(callback);};};const unregisterPermissionStatusListener=callback=>{permissionStatusListeners.delete(callback);};const noop=()=>{};const createSSRUnavailable=methodName=>async()=>{throw new Error(`${methodName} is not available in SSR environment`);};const SSR_FILE_PICKER_STUB={data:null,loading:false,progress:null,error:null,isWeb:true,isNative:false,execute:noop,clear:noop,clearError:noop,getFileObject:createSSRUnavailable("getFileObject"),getAllFileObjects:createSSRUnavailable("getAllFileObjects"),canCreateFileObject:false,canCreateFileObjects:[],selectedFile:null,selectedFiles:[],pickFile:noop,isLoading:false,processingState:null,clearFile:noop};const parseNativePayload=payload=>{if(payload==null)return null;if(typeof payload!=="string")return payload;return JSON.parse(payload);};const extractFiles=value=>{if(!value)return[];if(Array.isArray(value))return value.filter(Boolean);if(Array.isArray(value.files))return value.files.filter(Boolean);if(value.files)return[value.files];return[value].filter(Boolean);};const normalizeFilePickResult=payload=>{const parsed=parseNativePayload(payload);if(!parsed)return null;const files=extractFiles(parsed);if(!files.length)return null;const first=files[0];const rawOptions=!Array.isArray(parsed)&&parsed&&typeof parsed.options==="object"?parsed.options:null;let normalizedOptions=rawOptions||null;if(rawOptions){try{normalizedOptions=sanitizeFilePickerOptions(rawOptions);}catch(_error){normalizedOptions=rawOptions;}}const totalSize=parsed.totalSize??files.reduce((sum,file)=>{const size=typeof file?.size==="number"?file.size:0;return sum+size;},0);const base=Array.isArray(parsed)?{}:parsed;return{...base,files,multiple:parsed.multiple??files.length>1,count:parsed.count??files.length,totalSize,fileName:parsed.fileName??first?.fileName??first?.name??null,fileSrc:parsed.fileSrc??first?.fileSrc??first?.src??null,filePath:parsed.filePath??first?.filePath??null,size:parsed.size??first?.size??null,mimeType:parsed.mimeType??first?.mimeType??first?.type??null,transport:parsed.transport??first?.transport??null,options:normalizedOptions};};/**
6
+ * Google Sign-In hook (Android-first, Credential Manager)
7
+ * Provides a promise-like sign-in call and standardized callbacks
8
+ */const useGoogleSignIn=(defaultOptions={})=>{const base=(0,_useBaseHook.useBaseHook)("useGoogleSignIn");// Server-side rendering safety
9
+ if(typeof window==="undefined"){return{data:null,loading:false,error:null,progress:null,isWeb:true,isNative:false,signIn:()=>{},execute:()=>{},clear:()=>{},clearError:()=>{}};}if(!window.WebBridge){throw new Error("WebBridge is not initialized. Call WebBridge.init() first.");}(0,_react.useEffect)(()=>{const handleSuccess=payload=>{try{const parsed=typeof payload==="string"?JSON.parse(payload):payload;base.setDataAndComplete(parsed);}catch(err){base.handleNativeError(err);}};const handleError=nativeError=>{base.handleNativeError(nativeError);};const handleCancelled=()=>{const error=(0,_errors.createStandardError)(_errors.ERROR_CODES.OPERATION_CANCELLED,"Google sign-in cancelled",null,"User dismissed Google sign-in");base.handleNativeError(error);};window.WebBridge.register(_NativeInterfaces.NATIVE_CALLBACKS.ON_GOOGLE_SIGN_IN_SUCCESS,handleSuccess);window.WebBridge.register(_NativeInterfaces.NATIVE_CALLBACKS.ON_GOOGLE_SIGN_IN_ERROR,handleError);window.WebBridge.register(_NativeInterfaces.NATIVE_CALLBACKS.ON_GOOGLE_SIGN_IN_CANCELLED,handleCancelled);return()=>{window.WebBridge.unregister(_NativeInterfaces.NATIVE_CALLBACKS.ON_GOOGLE_SIGN_IN_SUCCESS);window.WebBridge.unregister(_NativeInterfaces.NATIVE_CALLBACKS.ON_GOOGLE_SIGN_IN_ERROR);window.WebBridge.unregister(_NativeInterfaces.NATIVE_CALLBACKS.ON_GOOGLE_SIGN_IN_CANCELLED);};},[base]);const signIn=(0,_react.useCallback)((options={})=>{base.executeOperation(()=>{const payload={...defaultOptions,...options};_NativeBridge.default.auth.googleSignIn(payload);},"google sign-in");},[base,defaultOptions]);return{data:base.data,loading:base.loading,progress:base.progress,error:base.error,isWeb:base.isWeb,isNative:base.isNative,signIn,execute:signIn,clear:base.clear,clearError:base.clearError};};exports.useGoogleSignIn=useGoogleSignIn;const updateProgressFromResult=(updateProgress,result)=>{if(!result)return;const first=result.files?.[0];const transport=result.transport??first?.transport??null;const bytesTotal=result.totalSize??result.size??(typeof first?.size==="number"?first.size:null);if(transport||bytesTotal!=null){updateProgress({transport:transport??null,bytesTotal});}};const mapStateToProgress=state=>{if(state==null){return{state:"starting",phase:null,message:"File picker: starting..."};}const rawState=String(state);const normalized=rawState.toLowerCase();const allowedStates=new Set(["opening","processing","routing"]);const progressState=allowedStates.has(normalized)?normalized:"starting";return{state:progressState,phase:rawState,message:`File picker: ${rawState}...`};};const sanitizeFilePickerOptions=input=>{const options={...input};const mime=typeof options.mimeType==="string"?options.mimeType.trim():"";options.mimeType=mime.length>0?mime:"*/*";const coerceInteger=(value,min)=>{if(value==null)return undefined;const numeric=typeof value==="string"?Number(value.trim()):Number(value);if(!Number.isFinite(numeric)){throw new Error("File picker numeric options must be valid numbers");}if(!Number.isInteger(numeric)){throw new Error("File picker numeric options must be integers");}if(numeric<min){throw new Error(`File picker numeric options must be ≥ ${min}`);}return numeric;};const minFiles=coerceInteger(options.minFiles,1);const maxFiles=coerceInteger(options.maxFiles,1);const minFileSize=coerceInteger(options.minFileSize,0);const maxFileSize=coerceInteger(options.maxFileSize,0);if(minFiles!==undefined)options.minFiles=minFiles;if(maxFiles!==undefined)options.maxFiles=maxFiles;if(minFileSize!==undefined)options.minFileSize=minFileSize;if(maxFileSize!==undefined)options.maxFileSize=maxFileSize;if(minFiles!==undefined&&maxFiles!==undefined&&minFiles>maxFiles){throw new Error("minFiles cannot be greater than maxFiles");}if(minFileSize!==undefined&&maxFileSize!==undefined&&minFileSize>maxFileSize){throw new Error("minFileSize cannot be greater than maxFileSize");}const multiple=typeof options.multiple==="boolean"?options.multiple:Boolean(options.multiple);options.multiple=multiple||minFiles&&minFiles>1||maxFiles&&maxFiles>1;return options;};const resolvePickPayload=input=>{if(input==null)return"*/*";if(typeof input==="string")return input.trim()||"*/*";return sanitizeFilePickerOptions(input);};const registerNativeHandlers=handlers=>{handlers.forEach(([event,handler])=>window.WebBridge.register(event,handler));return()=>{handlers.forEach(([event])=>window.WebBridge.unregister(event));};};/**
6
10
  * React hook for camera functionality using standardized interface
7
11
  * Handles camera permissions, photo capture, and error states
8
12
  */const useCamera=()=>{// Use standardized base hook
@@ -80,7 +80,11 @@ if(process.env.NODE_ENV==="development"){console.warn(`🌉 Development mode: Na
80
80
  * Get device information
81
81
  * @returns {Promise} - Returns device info through callback
82
82
  */getDeviceInfo:()=>this.call(_NativeInterfaces.NATIVE_COMMANDS.GET_DEVICE_INFO)};/**
83
- * Safe area methods
83
+ * Auth methods
84
+ */auth={/**
85
+ * Trigger Google Sign-In (Credential Manager)
86
+ * @param {Object} options - config (clientId, nonce, autoSelect, etc.)
87
+ */googleSignIn:(options={})=>{const payload=options&&typeof options==="object"?JSON.stringify(options):options?.toString?.();return this.call(_NativeInterfaces.NATIVE_COMMANDS.GOOGLE_SIGN_IN,payload);}};/** Safe area methods
84
88
  */safeArea={/**
85
89
  * Get current safe area insets
86
90
  * Returns insets through ON_SAFE_AREA_INSETS_UPDATED callback
@@ -1,12 +1,12 @@
1
1
  "use strict";const{exec,execSync}=require("child_process");const fs=require("fs");const path=require("path");const TerminalProgress=require("./TerminalProgress.js").default;const crypto=require("crypto");const catalystCorePath=path.dirname(require.resolve("catalyst-core/package.json"));const pwd=path.join(catalystCorePath,"dist/native");const{WEBVIEW_CONFIG,BUILD_OUTPUT_PATH}=require(`${process.env.PWD}/config/config.json`);// Configuration constants
2
- const iosConfig=WEBVIEW_CONFIG.ios;const protocol=WEBVIEW_CONFIG.useHttps?"https":"http";const ip=WEBVIEW_CONFIG.LOCAL_IP||"localhost";const port=WEBVIEW_CONFIG.port?WEBVIEW_CONFIG.useHttps?443:WEBVIEW_CONFIG.port:null;let url=port?`${protocol}://${ip}:${port}`:`${protocol}://${ip}`;const PUBLIC_PATH=`${process.env.PWD}/public`;const PROJECT_DIR=`${pwd}/iosnativeWebView`;const SCHEME_NAME="iosnativeWebView";const APP_BUNDLE_ID=iosConfig.appBundleId||"com.debug.webview";const PROJECT_NAME=path.basename(PROJECT_DIR);const IPHONE_MODEL=iosConfig.simulatorName;const GOOGLE_SERVICES_FILENAME="GoogleService-Info.plist";function generateProjectObjectId(identifier,suffix=""){return crypto.createHash("md5").update(`${identifier}:${suffix}`).digest("hex").substring(0,24).toUpperCase();}function getXcodeProjectFilePath(){return path.join(PROJECT_DIR,`${PROJECT_NAME}.xcodeproj`,"project.pbxproj");}// Define build steps for progress tracking
2
+ const iosConfig=WEBVIEW_CONFIG.ios;const isGoogleSignInEnabled=WEBVIEW_CONFIG.googleSignIn?.enabled??false;const protocol=WEBVIEW_CONFIG.useHttps?"https":"http";const ip=WEBVIEW_CONFIG.LOCAL_IP||"localhost";const port=WEBVIEW_CONFIG.port?WEBVIEW_CONFIG.useHttps?443:WEBVIEW_CONFIG.port:null;let url=port?`${protocol}://${ip}:${port}`:`${protocol}://${ip}`;const PUBLIC_PATH=`${process.env.PWD}/public`;const PROJECT_DIR=`${pwd}/iosnativeWebView`;const SCHEME_NAME="iosnativeWebView";const APP_BUNDLE_ID=iosConfig.appBundleId||"com.debug.webview";const PROJECT_NAME=path.basename(PROJECT_DIR);const IPHONE_MODEL=iosConfig.simulatorName;const GOOGLE_SERVICES_FILENAME="GoogleService-Info.plist";function generateProjectObjectId(identifier,suffix=""){return crypto.createHash("md5").update(`${identifier}:${suffix}`).digest("hex").substring(0,24).toUpperCase();}function getXcodeProjectFilePath(){return path.join(PROJECT_DIR,`${PROJECT_NAME}.xcodeproj`,"project.pbxproj");}// Define build steps for progress tracking
3
3
  const steps={config:"Generating Required Configuration for build",deviceDetection:"Detecting Physical Device",launchSimulator:"Launch iOS Simulator",clean:"Clean Build Artifacts",assets:"Process Notification Assets",build:"Build IOS Project",findApp:"Locate Built Application",install:"Install Application",launch:"Launch Application"};// Configure progress display
4
4
  const progressConfig={titlePaddingTop:2,titlePaddingBottom:1,stepPaddingLeft:4,stepSpacing:1,errorPaddingLeft:6,bottomMargin:2};const progress=new TerminalProgress(steps,"Catalyst iOS Build",progressConfig);// Utility function to run shell commands
5
5
  function runCommand(command,options={}){return new Promise((resolve,reject)=>{// eslint-disable-next-line security/detect-child-process
6
6
  exec(command,{maxBuffer:1024*1024*10,...options},(error,stdout,stderr)=>{if(error){console.error(`Command failed: ${command}`);console.error(`Error: ${error.message}`);console.error(`stderr: ${stderr}`);reject(error);return;}if(stderr){console.warn(`Warning: ${stderr}`);}resolve(stdout.trim());});});}async function getBootedSimulatorUUID(modelName){try{// First try to find a booted simulator of the specified model
7
7
  let command=`xcrun simctl list devices | grep "${modelName}" | grep "Booted" | grep -E -o -i "([0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12})" | head -n 1`;let uuid=execSync(command).toString().trim();if(uuid){console.log(`Found booted simulator of model ${modelName}`);return uuid;}// If no booted simulator of the specified model is found, check any booted simulator
8
8
  console.log(`No booted simulator of model ${modelName} found, checking for any booted simulator...`);command=`xcrun simctl list devices | grep "Booted" | grep -E -o -i "([0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12})" | head -n 1`;uuid=execSync(command).toString().trim();if(uuid){console.log("Found another booted simulator, will use it instead");return uuid;}return null;}catch(error){console.log("No booted simulators found");return null;}}async function getBootedSimulatorInfo(){try{const listCommand="xcrun simctl list devices --json";const simulatorList=JSON.parse(execSync(listCommand).toString());for(const runtime in simulatorList.devices){const devices=simulatorList.devices[runtime];for(const device of devices){if(device.state==="Booted"){return{udid:device.udid,name:device.name,runtime:runtime};}}}return null;}catch(error){console.log("Failed to get booted simulator info:",error.message);return null;}}async function generatePackageSwift(){try{const isNotificationsEnabled=WEBVIEW_CONFIG.notifications?.enabled??false;progress.log(`🔧 Generating Package.swift (notifications: ${isNotificationsEnabled})`,"info");// Create hash of current config to detect changes
9
- const configHash=crypto.createHash("md5").update(JSON.stringify({notifications:isNotificationsEnabled})).digest("hex");const hashFilePath=path.join(PROJECT_DIR,".package-config-hash");const targetPath=path.join(PROJECT_DIR,"Package.swift");let shouldUpdate=true;// Check if we need to update based on config change
9
+ const configHash=crypto.createHash("md5").update(JSON.stringify({notifications:isNotificationsEnabled,googleSignIn:isGoogleSignInEnabled})).digest("hex");const hashFilePath=path.join(PROJECT_DIR,".package-config-hash");const targetPath=path.join(PROJECT_DIR,"Package.swift");let shouldUpdate=true;// Check if we need to update based on config change
10
10
  if(fs.existsSync(hashFilePath)){const previousHash=fs.readFileSync(hashFilePath,"utf8");if(previousHash===configHash){shouldUpdate=false;progress.log("Package.swift already up to date","info");}}// Ensure Package.swift exists even if hash matches
11
11
  if(!fs.existsSync(targetPath)){shouldUpdate=true;progress.log("Package.swift missing, will generate it now","info");}if(shouldUpdate){progress.log("Generating Package.swift dynamically","info");// Build the Package.swift content dynamically
12
12
  let packageContent=`// swift-tools-version: 5.9
@@ -23,7 +23,8 @@ if(isNotificationsEnabled){packageContent+=`,
23
23
  .library(name: "CatalystNotifications", targets: ["CatalystNotifications"])`;}packageContent+=`
24
24
  ],
25
25
  dependencies: [
26
- .package(url: "https://github.com/kylef/JSONSchema.swift", from: "0.6.0")`;// Add Firebase dependency only if notifications enabled
26
+ .package(url: "https://github.com/kylef/JSONSchema.swift", from: "0.6.0"),
27
+ .package(url: "https://github.com/google/GoogleSignIn-iOS", from: "7.0.0")`;// Add Firebase dependency only if notifications enabled
27
28
  if(isNotificationsEnabled){packageContent+=`,
28
29
  .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "12.3.0")`;}packageContent+=`
29
30
  ],
@@ -33,7 +34,8 @@ if(isNotificationsEnabled){packageContent+=`,
33
34
  .target(
34
35
  name: "CatalystCore",
35
36
  dependencies: [
36
- .product(name: "JSONSchema", package: "JSONSchema.swift")
37
+ .product(name: "JSONSchema", package: "JSONSchema.swift"),
38
+ .product(name: "GoogleSignIn", package: "GoogleSignIn-iOS")
37
39
  ],
38
40
  path: "Sources/Core"
39
41
  )`;// Add CatalystNotifications target only if enabled
@@ -63,9 +65,9 @@ projectContent=projectContent.replace(/(C99974342E97D56900C25611 \/\* CatalystCo
63
65
  projectContent=projectContent.replace(/(C99974342E97D56900C25611 \/\* CatalystCore in Frameworks \*\/,)/,`$1\n\t\t\t\t${NOTIF_BUILD_FILE_ID} /* CatalystNotifications in Frameworks */,`);// 3. Add to packageProductDependencies array
64
66
  projectContent=projectContent.replace(/(packageProductDependencies = \(\s*C99974332E97D56900C25611 \/\* CatalystCore \*\/,)/,`$1\n\t\t\t\t${NOTIF_PRODUCT_ID} /* CatalystNotifications */,`);// 4. Add to XCSwiftPackageProductDependency section
65
67
  projectContent=projectContent.replace(/(\/\* End XCSwiftPackageProductDependency section \*\/)/,`\t\t${NOTIF_PRODUCT_ID} /* CatalystNotifications */ = {\n\t\t\tisa = XCSwiftPackageProductDependency;\n\t\t\tpackage = C99974322E97D56900C25611 /* XCLocalSwiftPackageReference "." */;\n\t\t\tproductName = CatalystNotifications;\n\t\t};\n$1`);fs.writeFileSync(projectFilePath,projectContent,"utf8");progress.log("✅ CatalystNotifications added to Xcode project","success");}else if(!isNotificationsEnabled&&hasNotifications){progress.log("Removing CatalystNotifications from Xcode project","info");// Remove all CatalystNotifications entries
66
- projectContent=projectContent.replace(/\t\t[A-F0-9]+ \/\* CatalystNotifications in Frameworks \*\/ = {isa = PBXBuildFile; productRef = [A-F0-9]+ \/\* CatalystNotifications \*\/; };\n/g,"");projectContent=projectContent.replace(/\t\t\t\t[A-F0-9]+ \/\* CatalystNotifications in Frameworks \*\/,\n/g,"");projectContent=projectContent.replace(/\t\t\t\t[A-F0-9]+ \/\* CatalystNotifications \*\/,\n/g,"");projectContent=projectContent.replace(/\t\t[A-F0-9]+ \/\* CatalystNotifications \*\/ = {\n\t\t\tisa = XCSwiftPackageProductDependency;\n\t\t\tpackage = [A-F0-9]+ \/\* XCLocalSwiftPackageReference "." \*\/;\n\t\t\tproductName = CatalystNotifications;\n\t\t};\n/g,"");fs.writeFileSync(projectFilePath,projectContent,"utf8");progress.log("✅ CatalystNotifications removed from Xcode project","success");}else{progress.log("Package dependencies already correct","info");}}catch(error){progress.log(`❌ Failed to update package dependencies: ${error.message}`,"error");throw error;}}async function updateInfoPlist(){try{const infoPlistPath=path.join(PROJECT_DIR,PROJECT_NAME,"Info.plist");if(fs.existsSync(infoPlistPath)){let plistContent=fs.readFileSync(infoPlistPath,"utf8");// Add CFBundleDisplayName if it doesn't exist
67
- if(!plistContent.includes("CFBundleDisplayName")){const insertPoint=plistContent.lastIndexOf("</dict>");const newEntry=`\t<key>CFBundleDisplayName</key>\n\t<string>${iosConfig.appName||"Catalyst Application"}</string>\n`;plistContent=plistContent.slice(0,insertPoint)+newEntry+plistContent.slice(insertPoint);fs.writeFileSync(infoPlistPath,plistContent,"utf8");}else{// Update existing CFBundleDisplayName with new appName
68
- const displayNameRegex=/(<key>CFBundleDisplayName<\/key>\s*<string>)([^<]*)(<\/string>)/;if(displayNameRegex.test(plistContent)){plistContent=plistContent.replace(displayNameRegex,`$1${iosConfig.appName||"Catalyst Application"}$3`);fs.writeFileSync(infoPlistPath,plistContent,"utf8");}}}}catch(err){progress.fail("config",err);process.exit(1);}}// Function to convert JSON value to Swift property
68
+ projectContent=projectContent.replace(/\t\t[A-F0-9]+ \/\* CatalystNotifications in Frameworks \*\/ = {isa = PBXBuildFile; productRef = [A-F0-9]+ \/\* CatalystNotifications \*\/; };\n/g,"");projectContent=projectContent.replace(/\t\t\t\t[A-F0-9]+ \/\* CatalystNotifications in Frameworks \*\/,\n/g,"");projectContent=projectContent.replace(/\t\t\t\t[A-F0-9]+ \/\* CatalystNotifications \*\/,\n/g,"");projectContent=projectContent.replace(/\t\t[A-F0-9]+ \/\* CatalystNotifications \*\/ = {\n\t\t\tisa = XCSwiftPackageProductDependency;\n\t\t\tpackage = [A-F0-9]+ \/\* XCLocalSwiftPackageReference "." \*\/;\n\t\t\tproductName = CatalystNotifications;\n\t\t};\n/g,"");fs.writeFileSync(projectFilePath,projectContent,"utf8");progress.log("✅ CatalystNotifications removed from Xcode project","success");}else{progress.log("Package dependencies already correct","info");}}catch(error){progress.log(`❌ Failed to update package dependencies: ${error.message}`,"error");throw error;}}async function updateInfoPlist(){try{const infoPlistPath=path.join(PROJECT_DIR,PROJECT_NAME,"Info.plist");const infoReleasePlistPath=path.join(PROJECT_DIR,PROJECT_NAME,"Info-Release.plist");const googleServicesPlistPath=path.join(PROJECT_DIR,PROJECT_NAME,GOOGLE_SERVICES_FILENAME);const googleClientId=WEBVIEW_CONFIG.googleSignIn?.clientId||WEBVIEW_CONFIG.googleSignIn?.webClientId||"";const iosClientId=WEBVIEW_CONFIG.googleSignIn?.iosClientId||"";const googleServicesContent=fs.existsSync(googleServicesPlistPath)?fs.readFileSync(googleServicesPlistPath,"utf8"):null;const reversedClientIdFromServices=googleServicesContent?(googleServicesContent.match(/<key>REVERSED_CLIENT_ID<\/key>\s*<string>([^<]+)<\/string>/)||[])[1]:null;const clientIdFromServices=googleServicesContent?(googleServicesContent.match(/<key>CLIENT_ID<\/key>\s*<string>([^<]+)<\/string>/)||[])[1]:null;const computeReversed=value=>{if(!value)return"";return value.split(".").reverse().join(".");};const resolvedClientIdForScheme=iosClientId||googleClientId||clientIdFromServices||"";const resolvedReversedClientId=reversedClientIdFromServices||computeReversed(resolvedClientIdForScheme);if(isGoogleSignInEnabled&&!resolvedReversedClientId){progress.fail("config","Google Sign-In enabled but no valid clientId found");process.exit(1);}const plistTargets=[infoPlistPath,infoReleasePlistPath];const findMatchingArrayCloseTag=(content,arrayStartIndex)=>{if(arrayStartIndex<0)return-1;const tokenRegex=/<array>|<\/array>/g;tokenRegex.lastIndex=arrayStartIndex;let depth=0;let match;while((match=tokenRegex.exec(content))!==null){if(match[0]==="<array>"){depth+=1;}else{depth-=1;if(depth===0){return match.index;}}}return-1;};const ensureGoogleUrlScheme=plistPath=>{if(!isGoogleSignInEnabled||!resolvedReversedClientId){return;}if(!fs.existsSync(plistPath)){return;}let plistContent=fs.readFileSync(plistPath,"utf8");if(plistContent.includes(resolvedReversedClientId)){return;}const urlTypeEntry=`\n\t<key>CFBundleURLTypes</key>\n\t<array>\n\t\t<dict>\n\t\t\t<key>CFBundleURLName</key>\n\t\t\t<string>googleSignIn</string>\n\t\t\t<key>CFBundleURLSchemes</key>\n\t\t\t<array>\n\t\t\t\t<string>${resolvedReversedClientId}</string>\n\t\t\t</array>\n\t\t</dict>\n\t</array>\n`;const urlTypeDictEntry=`\n\t\t<dict>\n\t\t\t<key>CFBundleURLName</key>\n\t\t\t<string>googleSignIn</string>\n\t\t\t<key>CFBundleURLSchemes</key>\n\t\t\t<array>\n\t\t\t\t<string>${resolvedReversedClientId}</string>\n\t\t\t</array>\n\t\t</dict>\n`;if(plistContent.includes("<key>CFBundleURLTypes</key>")){const urlTypesKeyIndex=plistContent.indexOf("<key>CFBundleURLTypes</key>");const urlTypesArrayStart=plistContent.indexOf("<array>",urlTypesKeyIndex);const urlTypesArrayClose=findMatchingArrayCloseTag(plistContent,urlTypesArrayStart);if(urlTypesArrayStart>=0&&urlTypesArrayClose>=0){plistContent=plistContent.slice(0,urlTypesArrayClose)+urlTypeDictEntry+plistContent.slice(urlTypesArrayClose);}else{const insertPoint=plistContent.lastIndexOf("</dict>");plistContent=plistContent.slice(0,insertPoint)+urlTypeEntry+plistContent.slice(insertPoint);progress.log(`CFBundleURLTypes block could not be parsed in ${path.basename(plistPath)}; appended block instead`,"warning");}}else{const insertPoint=plistContent.lastIndexOf("</dict>");plistContent=plistContent.slice(0,insertPoint)+urlTypeEntry+plistContent.slice(insertPoint);}fs.writeFileSync(plistPath,plistContent,"utf8");progress.log(`Added Google Sign-In URL scheme to ${path.basename(plistPath)}`,"info");};const ensureLSApplicationQueriesSchemes=plistPath=>{if(!isGoogleSignInEnabled||!resolvedReversedClientId){return;}if(!fs.existsSync(plistPath)){return;}let plistContent=fs.readFileSync(plistPath,"utf8");const matches=plistContent.match(/<key>LSApplicationQueriesSchemes<\/key>\s*<array>([\s\S]*?)<\/array>/);const existingSchemes=new Set();if(matches&&matches[1]){const schemeRegex=/<string>([^<]+)<\/string>/g;let match;while((match=schemeRegex.exec(matches[1]))!==null){existingSchemes.add(match[1]);}}const targetSchemes=["google",resolvedReversedClientId];targetSchemes.forEach(scheme=>{if(scheme&&!existingSchemes.has(scheme)){existingSchemes.add(scheme);}});const schemesArrayContent=Array.from(existingSchemes).map(s=>`\n\t\t<string>${s}</string>`).join("");const newLSBlock=`\n\t<key>LSApplicationQueriesSchemes</key>\n\t<array>${schemesArrayContent}\n\t</array>\n`;if(matches){plistContent=plistContent.replace(/<key>LSApplicationQueriesSchemes<\/key>\s*<array>[\s\S]*?<\/array>/,newLSBlock.trimEnd());}else{const insertPoint=plistContent.lastIndexOf("</dict>");plistContent=plistContent.slice(0,insertPoint)+newLSBlock+plistContent.slice(insertPoint);}fs.writeFileSync(plistPath,plistContent,"utf8");progress.log(`Ensured LSApplicationQueriesSchemes for Google in ${path.basename(plistPath)}`,"info");};const ensureDisplayName=plistPath=>{if(!fs.existsSync(plistPath))return;let plistContent=fs.readFileSync(plistPath,"utf8");// Add CFBundleDisplayName if it doesn't exist
69
+ if(!plistContent.includes("CFBundleDisplayName")){const insertPoint=plistContent.lastIndexOf("</dict>");const newEntry=`\t<key>CFBundleDisplayName</key>\n\t<string>${iosConfig.appName||"Catalyst Application"}</string>\n`;plistContent=plistContent.slice(0,insertPoint)+newEntry+plistContent.slice(insertPoint);fs.writeFileSync(plistPath,plistContent,"utf8");}else{// Update existing CFBundleDisplayName with new appName
70
+ const displayNameRegex=/(<key>CFBundleDisplayName<\/key>\s*<string>)([^<]*)(<\/string>)/;if(displayNameRegex.test(plistContent)){plistContent=plistContent.replace(displayNameRegex,`$1${iosConfig.appName||"Catalyst Application"}$3`);fs.writeFileSync(plistPath,plistContent,"utf8");}}};plistTargets.forEach(plistPath=>{ensureDisplayName(plistPath);ensureGoogleUrlScheme(plistPath);ensureLSApplicationQueriesSchemes(plistPath);});}catch(err){progress.fail("config",err);process.exit(1);}}// Function to convert JSON value to Swift property
69
71
  function generateSwiftProperty(key,value,indent=" "){if(value===null||value===undefined){return`${indent}public static let ${key}: String? = nil`;}// Special handling for cachePattern - always convert to array
70
72
  if(key==="cachePattern"){if(typeof value==="string"){// Convert single string to array
71
73
  return`${indent}public static let ${key}: [String] = ["${value}"]`;}else if(Array.isArray(value)){const arrayValues=value.map(v=>`"${v}"`).join(", ");return`${indent}public static let ${key}: [String] = [${arrayValues}]`;}}if(typeof value==="string"){return`${indent}public static let ${key} = "${value}"`;}if(typeof value==="number"){return Number.isInteger(value)?`${indent}public static let ${key} = ${value}`:`${indent}public static let ${key} = ${value}`;}if(typeof value==="boolean"){return`${indent}public static let ${key} = ${value}`;}if(Array.isArray(value)){if(value.length===0){return`${indent}public static let ${key}: [String] = []`;}// Determine array type from first element
@@ -112,7 +114,8 @@ const imageWidth=splashConfig.imageWidth||120;const imageHeight=splashConfig.ima
112
114
  public static let splashScreenImageWidth: CGFloat = 120
113
115
  public static let splashScreenImageHeight: CGFloat = 120
114
116
  public static let splashScreenCornerRadius: CGFloat = 20`;}// Ensure Notifications.enabled always exists (default to false if not configured)
115
- if(!addedKeys.has("notifications")){progress.log("Notifications not found in config, adding default (false)","info");configContent+="\n public enum Notifications {\n public static let enabled = false\n }";addedKeys.add("notifications");}else{progress.log("Notifications config was processed from WEBVIEW_CONFIG","info");}// Ensure EdgeToEdge.enabled always exists (default to false if not configured)
117
+ if(!addedKeys.has("notifications")){progress.log("Notifications not found in config, adding default (false)","info");configContent+="\n public enum Notifications {\n public static let enabled = false\n }";addedKeys.add("notifications");}else{progress.log("Notifications config was processed from WEBVIEW_CONFIG","info");}// Ensure Google Sign-In defaults exist to keep compile-time references stable
118
+ if(!addedKeys.has("googleSignIn")){progress.log("Google Sign-In not found in config, adding defaults (disabled)","info");configContent+='\n public enum GoogleSignIn {\n public static let enabled = false\n public static let clientId = ""\n public static let iosClientId = ""\n }';addedKeys.add("googleSignIn");}// Ensure EdgeToEdge.enabled always exists (default to false if not configured)
116
119
  if(!addedKeys.has("edgeToEdge")){progress.log("EdgeToEdge not found in config, adding default (false)","info");configContent+="\n public enum EdgeToEdge {\n public static let enabled = false\n }";addedKeys.add("edgeToEdge");}else{progress.log("EdgeToEdge config was processed from WEBVIEW_CONFIG","info");}// Close the enum
117
120
  configContent+=`
118
121
  }`;fs.writeFileSync(appConfigPath,configContent,"utf8");progress.log("Configuration constants generated successfully (SPM Package)","success");progress.complete("config");}catch(error){progress.fail("config",error.message);process.exit(1);}}// MARK: - Notification Asset Processing
@@ -3,6 +3,7 @@
3
3
  // This minimal version allows Xcode to open the project without resolving Firebase
4
4
  import PackageDescription
5
5
 
6
+ // TODO: haven't made this conditional yet
6
7
  let package = Package(
7
8
  name: "iosnativeWebView",
8
9
  platforms: [.iOS(.v17)],
@@ -10,7 +11,8 @@ let package = Package(
10
11
  .library(name: "CatalystCore", targets: ["CatalystCore"])
11
12
  ],
12
13
  dependencies: [
13
- .package(url: "https://github.com/kylef/JSONSchema.swift", from: "0.6.0")
14
+ .package(url: "https://github.com/kylef/JSONSchema.swift", from: "0.6.0"),
15
+ .package(url: "https://github.com/google/GoogleSignIn-iOS", from: "7.0.0")
14
16
  ],
15
17
  targets: [
16
18
  // Core functionality (WebView, bridge, utils, constants)
@@ -18,7 +20,8 @@ let package = Package(
18
20
  .target(
19
21
  name: "CatalystCore",
20
22
  dependencies: [
21
- .product(name: "JSONSchema", package: "JSONSchema.swift")
23
+ .product(name: "JSONSchema", package: "JSONSchema.swift"),
24
+ .product(name: "GoogleSignIn", package: "GoogleSignIn-iOS")
22
25
  ],
23
26
  path: "Sources/Core"
24
27
  )
@@ -296,4 +296,4 @@ class NotificationChannelManager {
296
296
  static func getChannelConfig(_ channelId: String) -> NotificationChannel {
297
297
  return NotificationChannel(rawValue: channelId) ?? .default
298
298
  }
299
- }
299
+ }
@@ -65,6 +65,7 @@ public enum CatalystConstants {
65
65
  "pickFile",
66
66
  "openFileWithIntent",
67
67
  "requestHapticFeedback",
68
+ "googleSignIn",
68
69
  "getSafeArea"
69
70
  ]
70
71
 
@@ -85,6 +86,8 @@ public enum CatalystConstants {
85
86
  print("❌ DEBUG: Notifications disabled in config - notification commands not available")
86
87
  }
87
88
 
89
+ print("🔐 DEBUG: Google Sign-In enabled in config? \(ConfigConstants.GoogleSignIn.enabled)")
90
+
88
91
  print("🔧 DEBUG: Valid commands available: \(commands.sorted().joined(separator: ", "))")
89
92
  return commands
90
93
  }
@@ -12,6 +12,13 @@ public enum ConfigConstants {
12
12
  public enum Notifications {
13
13
  public static let enabled = false
14
14
  }
15
+
16
+ // Google Sign-In configuration (defaults to disabled, populated during build)
17
+ public enum GoogleSignIn {
18
+ public static let enabled = false
19
+ public static let clientId = ""
20
+ public static let iosClientId = ""
21
+ }
15
22
 
16
23
  // Access control configuration
17
24
  public static let accessControlEnabled = false
@@ -11,6 +11,9 @@ import UIKit
11
11
  import AVFoundation
12
12
  import os
13
13
  import UserNotifications
14
+ #if canImport(GoogleSignIn)
15
+ import GoogleSignIn
16
+ #endif
14
17
 
15
18
  private let commandLogger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app", category: CatalystConstants.Logging.Categories.commandHandler)
16
19
 
@@ -33,6 +36,81 @@ class BridgeCommandHandler {
33
36
 
34
37
  // Notification handler - injected, defaults to null implementation
35
38
  private var notificationHandler: NotificationHandlerProtocol = NullNotificationHandler.shared
39
+ private var isGoogleSignInInProgress = false
40
+ private var hasAttemptedGoogleRestore = false
41
+ private var lastConfiguredGoogleClientId: String?
42
+
43
+ private struct GoogleSignInOptions {
44
+ let clientId: String?
45
+ let nonce: String?
46
+ let hint: String?
47
+ let additionalScopes: [String]?
48
+ let autoSelect: Bool
49
+ let filterByAuthorizedAccounts: Bool
50
+
51
+ static func from(params: Any?) -> GoogleSignInOptions {
52
+ if let stringParam = params as? String, !stringParam.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
53
+ if let data = stringParam.data(using: .utf8),
54
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
55
+ return GoogleSignInOptions.fromDictionary(json)
56
+ }
57
+
58
+ let trimmed = stringParam.trimmingCharacters(in: .whitespacesAndNewlines)
59
+ return GoogleSignInOptions(
60
+ clientId: trimmed,
61
+ nonce: nil,
62
+ hint: nil,
63
+ additionalScopes: nil,
64
+ autoSelect: false,
65
+ filterByAuthorizedAccounts: false
66
+ )
67
+ }
68
+
69
+ if let dict = params as? [String: Any] {
70
+ return GoogleSignInOptions.fromDictionary(dict)
71
+ }
72
+
73
+ return GoogleSignInOptions(
74
+ clientId: nil,
75
+ nonce: nil,
76
+ hint: nil,
77
+ additionalScopes: nil,
78
+ autoSelect: false,
79
+ filterByAuthorizedAccounts: false
80
+ )
81
+ }
82
+
83
+ private static func fromDictionary(_ dict: [String: Any]) -> GoogleSignInOptions {
84
+ func clean(_ value: String?) -> String? {
85
+ guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil }
86
+ return value
87
+ }
88
+
89
+ let resolvedClientId = clean(dict["clientId"] as? String) ??
90
+ clean(dict["webClientId"] as? String)
91
+
92
+ let nonce = clean(dict["nonce"] as? String)
93
+ let hint = clean(dict["hint"] as? String ?? dict["email"] as? String)
94
+ var scopes: [String]?
95
+
96
+ if let scopesArray = dict["additionalScopes"] as? [String] {
97
+ let filtered = scopesArray.compactMap { clean($0) }
98
+ scopes = filtered.isEmpty ? nil : filtered
99
+ } else if let scopesString = clean(dict["additionalScopes"] as? String) {
100
+ let splitScopes = scopesString.split(separator: ",").map { clean(String($0)) }.compactMap { $0 }
101
+ scopes = splitScopes.isEmpty ? nil : splitScopes
102
+ }
103
+
104
+ return GoogleSignInOptions(
105
+ clientId: resolvedClientId,
106
+ nonce: nonce,
107
+ hint: hint,
108
+ additionalScopes: scopes,
109
+ autoSelect: dict["autoSelect"] as? Bool ?? false,
110
+ filterByAuthorizedAccounts: dict["filterByAuthorizedAccounts"] as? Bool ?? false
111
+ )
112
+ }
113
+ }
36
114
 
37
115
  init(viewController: UIViewController, imageHandler: ImageHandler, filePickerHandler: FilePickerHandler) {
38
116
  self.viewController = viewController
@@ -170,6 +248,139 @@ class BridgeCommandHandler {
170
248
  }
171
249
  }
172
250
 
251
+ func googleSignIn(params: Any?) {
252
+ #if canImport(GoogleSignIn)
253
+ guard ConfigConstants.GoogleSignIn.enabled else {
254
+ delegate?.sendErrorCallback(
255
+ eventName: "ON_GOOGLE_SIGN_IN_ERROR",
256
+ error: "Google Sign-In is disabled in config",
257
+ code: "GOOGLE_SIGN_IN_ERROR"
258
+ )
259
+ return
260
+ }
261
+
262
+ let options = GoogleSignInOptions.from(params: params)
263
+
264
+ let resolvedClientId = (options.clientId ?? ConfigConstants.GoogleSignIn.clientId)
265
+ .trimmingCharacters(in: .whitespacesAndNewlines)
266
+
267
+ guard !resolvedClientId.isEmpty else {
268
+ delegate?.sendErrorCallback(
269
+ eventName: "ON_GOOGLE_SIGN_IN_ERROR",
270
+ error: "clientId is required for Google Sign-In",
271
+ code: "GOOGLE_SIGN_IN_ERROR"
272
+ )
273
+ return
274
+ }
275
+
276
+ guard !isGoogleSignInInProgress else {
277
+ delegate?.sendErrorCallback(
278
+ eventName: "ON_GOOGLE_SIGN_IN_ERROR",
279
+ error: "A Google Sign-In request is already in progress",
280
+ code: "GOOGLE_SIGN_IN_ERROR"
281
+ )
282
+ return
283
+ }
284
+
285
+ guard let presentingVC = resolvePresentingViewController() else {
286
+ delegate?.sendErrorCallback(
287
+ eventName: "ON_GOOGLE_SIGN_IN_ERROR",
288
+ error: "No valid view controller available for Google Sign-In",
289
+ code: "GOOGLE_SIGN_IN_ERROR"
290
+ )
291
+ return
292
+ }
293
+
294
+ isGoogleSignInInProgress = true
295
+ commandLogger.debug("Starting Google Sign-In with clientId prefix: \(resolvedClientId.prefix(12))...")
296
+
297
+ configureGoogleSignIn(clientId: resolvedClientId)
298
+
299
+ if options.autoSelect || options.filterByAuthorizedAccounts {
300
+ commandLogger.debug("Google Sign-In options autoSelect/filterByAuthorizedAccounts are not supported on iOS SDK and will be ignored")
301
+ }
302
+
303
+ DispatchQueue.main.async { [weak self] in
304
+ guard let self = self else { return }
305
+ GIDSignIn.sharedInstance.signIn(
306
+ withPresenting: presentingVC,
307
+ hint: options.hint,
308
+ additionalScopes: options.additionalScopes
309
+ ) { [weak self] result, error in
310
+ guard let self = self else { return }
311
+ self.isGoogleSignInInProgress = false
312
+
313
+ let hasValidUserData = result?.user != nil && result?.user.idToken?.tokenString != nil
314
+
315
+ if let error = error as NSError? {
316
+ if self.isCancelledGoogleError(error) {
317
+ self.delegate?.sendErrorCallback(
318
+ eventName: "ON_GOOGLE_SIGN_IN_CANCELLED",
319
+ error: "User cancelled Google sign-in",
320
+ code: "OPERATION_CANCELLED"
321
+ )
322
+ return
323
+ }
324
+
325
+ let errorDescription = error.localizedDescription.lowercased()
326
+ let isKeychainError = errorDescription.contains("keychain")
327
+
328
+ if isKeychainError && hasValidUserData {
329
+ commandLogger.warning("Google Sign-In keychain error (common on simulators), but user data is valid: \(error.localizedDescription)")
330
+ } else {
331
+ commandLogger.error("Google Sign-In error: \(error.localizedDescription)")
332
+ self.delegate?.sendErrorCallback(
333
+ eventName: "ON_GOOGLE_SIGN_IN_ERROR",
334
+ error: error.localizedDescription,
335
+ code: "GOOGLE_SIGN_IN_ERROR"
336
+ )
337
+ return
338
+ }
339
+ }
340
+
341
+ guard let user = result?.user, let idToken = user.idToken?.tokenString else {
342
+ self.delegate?.sendErrorCallback(
343
+ eventName: "ON_GOOGLE_SIGN_IN_ERROR",
344
+ error: "No ID token returned from Google sign-in",
345
+ code: "GOOGLE_SIGN_IN_ERROR"
346
+ )
347
+ return
348
+ }
349
+
350
+ var payload: [String: Any] = [
351
+ "idToken": idToken,
352
+ "provider": "google"
353
+ ]
354
+
355
+ if let email = user.profile?.email, !email.isEmpty {
356
+ payload["email"] = email
357
+ }
358
+
359
+ if let name = user.profile?.name, !name.isEmpty {
360
+ payload["name"] = name
361
+ }
362
+
363
+ if let photoUrl = user.profile?.imageURL(withDimension: 160)?.absoluteString, !photoUrl.isEmpty {
364
+ payload["photoUrl"] = photoUrl
365
+ }
366
+
367
+ if let serverAuthCode = result?.serverAuthCode, !serverAuthCode.isEmpty {
368
+ payload["serverAuthCode"] = serverAuthCode
369
+ }
370
+
371
+ commandLogger.debug("Google Sign-In success (idToken length: \(idToken.count))")
372
+ self.delegate?.sendJSONCallback(eventName: "ON_GOOGLE_SIGN_IN_SUCCESS", data: payload)
373
+ }
374
+ }
375
+ #else
376
+ delegate?.sendErrorCallback(
377
+ eventName: "ON_GOOGLE_SIGN_IN_ERROR",
378
+ error: "Google Sign-In SDK not available on this build",
379
+ code: "GOOGLE_SIGN_IN_UNAVAILABLE"
380
+ )
381
+ #endif
382
+ }
383
+
173
384
  // Get device information
174
385
  func getDeviceInfo() {
175
386
  commandLogger.debug("getDeviceInfo called")
@@ -641,4 +852,57 @@ class BridgeCommandHandler {
641
852
  }
642
853
  }
643
854
 
855
+ // MARK: - Helpers
856
+
857
+ private func resolvePresentingViewController() -> UIViewController? {
858
+ if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
859
+ if let window = scene.windows.first(where: { $0.isKeyWindow }),
860
+ let rootVC = window.rootViewController {
861
+ return rootVC.presentedViewController ?? rootVC
862
+ }
863
+ }
864
+
865
+ return viewController
866
+ }
867
+
868
+ private func configureGoogleSignIn(clientId: String) {
869
+ guard lastConfiguredGoogleClientId != clientId else { return }
870
+ lastConfiguredGoogleClientId = clientId
871
+
872
+ let iosClientId = ConfigConstants.GoogleSignIn.iosClientId
873
+ let serverClientId = clientId // This is the Web Client ID
874
+ let configClientId = !iosClientId.isEmpty ? iosClientId : serverClientId
875
+
876
+ GIDSignIn.sharedInstance.configuration = GIDConfiguration(
877
+ clientID: configClientId,
878
+ serverClientID: serverClientId
879
+ )
880
+
881
+ if !hasAttemptedGoogleRestore {
882
+ hasAttemptedGoogleRestore = true
883
+ GIDSignIn.sharedInstance.restorePreviousSignIn { user, error in
884
+ if let error = error {
885
+ commandLogger.debug("Google Sign-In restore failed: \(error.localizedDescription)")
886
+ } else if user != nil {
887
+ commandLogger.debug("Google Sign-In restored previous session")
888
+ } else {
889
+ commandLogger.debug("Google Sign-In restore found no previous user")
890
+ }
891
+ }
892
+ }
893
+ }
894
+
895
+ private func isCancelledGoogleError(_ error: NSError) -> Bool {
896
+ let description = error.localizedDescription.lowercased()
897
+ if description.contains("cancel") {
898
+ return true
899
+ }
900
+
901
+ // GIDSignIn cancellation historically uses code -5; keep the check resilient
902
+ if error.code == -5 {
903
+ return true
904
+ }
905
+
906
+ return false
907
+ }
644
908
  }
@@ -134,6 +134,23 @@ class BridgeMessageValidator {
134
134
  "properties": [:],
135
135
  "additionalProperties": false
136
136
  ],
137
+ "googleSignIn": [
138
+ "type": "object",
139
+ "properties": [
140
+ "clientId": ["type": "string"],
141
+ "webClientId": ["type": "string"],
142
+ "nonce": ["type": "string"],
143
+ "hint": ["type": "string"],
144
+ "email": ["type": "string"],
145
+ "autoSelect": ["type": "boolean"],
146
+ "filterByAuthorizedAccounts": ["type": "boolean"],
147
+ "additionalScopes": [
148
+ "type": "array",
149
+ "items": ["type": "string"]
150
+ ]
151
+ ],
152
+ "additionalProperties": false
153
+ ],
137
154
  // Notification commands
138
155
  "requestNotificationPermission": [
139
156
  "type": "object",
@@ -345,6 +362,7 @@ class BridgeMessageValidator {
345
362
  "getDeviceInfo",
346
363
  "getNetworkStatus",
347
364
  "logger",
365
+ "googleSignIn",
348
366
  "getSubscribedTopics",
349
367
  "requestNotificationPermission",
350
368
  "registerForPushNotifications",
@@ -258,6 +258,8 @@ extension NativeBridge: WKScriptMessageHandler {
258
258
  case "requestHapticFeedback":
259
259
  let feedbackType = delegateHandler.extractFeedbackType(from: params)
260
260
  commandHandler.requestHapticFeedback(feedbackType: feedbackType)
261
+ case "googleSignIn":
262
+ commandHandler.googleSignIn(params: params)
261
263
 
262
264
  // Notification commands (handled via protocol)
263
265
  case "requestNotificationPermission":
@@ -9,6 +9,9 @@ import UIKit
9
9
  import WebKit
10
10
  import os
11
11
  import CatalystCore
12
+ #if canImport(GoogleSignIn)
13
+ import GoogleSignIn
14
+ #endif
12
15
 
13
16
  private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app", category: "AppDelegate")
14
17
 
@@ -68,4 +71,14 @@ public class AppDelegate: NSObject, UIApplicationDelegate {
68
71
  userInfo: ["notification": userInfo, "completion": completionHandler]
69
72
  )
70
73
  }
74
+
75
+ public func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
76
+ #if canImport(GoogleSignIn)
77
+ if GIDSignIn.sharedInstance.handle(url) {
78
+ logWithTimestamp("🔐 Google Sign-In handled openURL callback")
79
+ return true
80
+ }
81
+ #endif
82
+ return false
83
+ }
71
84
  }
@@ -4,5 +4,9 @@
4
4
  <dict>
5
5
  <key>aps-environment</key>
6
6
  <string>development</string>
7
+ <key>keychain-access-groups</key>
8
+ <array>
9
+ <string>$(AppIdentifierPrefix)$(CFBundleIdentifier)</string>
10
+ </array>
7
11
  </dict>
8
12
  </plist>
@@ -9,6 +9,9 @@ import SwiftUI
9
9
  import UIKit
10
10
  import os
11
11
  import CatalystCore
12
+ #if canImport(GoogleSignIn)
13
+ import GoogleSignIn
14
+ #endif
12
15
 
13
16
  // IMPORTANT: This is the App target, so it CAN import CatalystNotifications safely
14
17
  // This does not create a compile-time dependency in CatalystCore
@@ -82,6 +85,13 @@ struct iosnativeWebViewApp: App {
82
85
  .onAppear {
83
86
  logWithTimestamp("🎨 WindowGroup ContentView appeared")
84
87
  }
88
+ .onOpenURL { url in
89
+ #if canImport(GoogleSignIn)
90
+ if GIDSignIn.sharedInstance.handle(url) {
91
+ logWithTimestamp("🔐 Google Sign-In handled via onOpenURL")
92
+ }
93
+ #endif
94
+ }
85
95
  }
86
96
  }
87
97
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catalyst-core",
3
- "version": "0.1.0-canary.3",
3
+ "version": "0.1.0-canary.4",
4
4
  "main": "index.js",
5
5
  "description": "Web framework that provides great performance out of the box",
6
6
  "bin": {