craft-native 0.0.118 → 0.0.119

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.
@@ -3,13 +3,17 @@ package {{PACKAGE_NAME}}
3
3
  import android.content.ClipData
4
4
  import android.content.ClipboardManager
5
5
  import android.content.Context
6
+ import android.content.Intent
6
7
  import android.graphics.BitmapFactory
7
8
  import android.graphics.Color
8
9
  import android.graphics.Paint
9
10
  import android.graphics.Typeface
10
11
  import android.graphics.drawable.GradientDrawable
12
+ import android.net.Uri
11
13
  import android.os.Build
12
14
  import android.os.Bundle
15
+ import android.os.Handler
16
+ import android.os.Looper
13
17
  import android.os.VibrationEffect
14
18
  import android.os.Vibrator
15
19
  import android.text.Editable
@@ -39,6 +43,7 @@ import androidx.javascriptengine.JavaScriptSandbox
39
43
  import androidx.javascriptengine.Message
40
44
  import androidx.javascriptengine.MessagePort
41
45
  import androidx.javascriptengine.MessagePortClient
46
+ import com.google.common.util.concurrent.ListenableFuture
42
47
  import org.json.JSONArray
43
48
  import org.json.JSONObject
44
49
  import java.net.URL
@@ -46,6 +51,8 @@ import java.util.concurrent.Executors
46
51
  import java.util.concurrent.Future
47
52
  import java.util.UUID
48
53
 
54
+ private const val CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS = 30_000L
55
+
49
56
  private class CraftNativeScrollView(context: Context) : FrameLayout(context) {
50
57
  val content = LinearLayout(context)
51
58
  private val vertical = ScrollView(context)
@@ -87,6 +94,9 @@ class MainActivity : AppCompatActivity() {
87
94
  val flatListOwners = mutableMapOf<String, String>()
88
95
  val flatListOwnerByView = mutableMapOf<CraftNativeFlatList, String>()
89
96
  val mutations = CraftNativeMutationDocument()
97
+ val capabilityScope = UUID.randomUUID().toString()
98
+ val pendingCapabilities = mutableSetOf<String>()
99
+ val capabilityTimeouts = mutableMapOf<String, Runnable>()
90
100
  var active = true
91
101
 
92
102
  fun close() {
@@ -100,7 +110,16 @@ class MainActivity : AppCompatActivity() {
100
110
  private var sandbox: JavaScriptSandbox? = null
101
111
  private val screens = mutableListOf<Screen>()
102
112
  private var bundle = ""
113
+ private lateinit var config: JSONObject
114
+ private lateinit var capabilities: CraftNativeCapabilities
115
+ private val capabilityHandler = Handler(Looper.getMainLooper())
103
116
  private var hapticsEnabled = false
117
+ private var clipboardEnabled = false
118
+ internal var nativeAppState = "inactive"
119
+ private set
120
+ private var deliveredInitialDeepLink = false
121
+ private var initialDeepLink: Uri? = null
122
+ private var initialDeepLinkClaimed = false
104
123
  private var defaultTextColor = Color.WHITE
105
124
  private var useMessagePorts = false
106
125
  private val imageExecutor = Executors.newFixedThreadPool(2)
@@ -110,14 +129,19 @@ class MainActivity : AppCompatActivity() {
110
129
 
111
130
  override fun onCreate(savedInstanceState: Bundle?) {
112
131
  super.onCreate(savedInstanceState)
132
+ initialDeepLink = savedInstanceState?.getString("craft.initialDeepLink")?.let(Uri::parse) ?: intent?.data
133
+ initialDeepLinkClaimed = savedInstanceState?.getBoolean("craft.initialDeepLinkClaimed", false) ?: false
134
+ deliveredInitialDeepLink = savedInstanceState?.getBoolean("craft.deliveredInitialDeepLink", false) ?: false
113
135
  setContentView(R.layout.activity_main)
114
136
  root = findViewById(R.id.native_root)
115
- val config = runCatching {
137
+ config = runCatching {
116
138
  assets.open("craft.config.json").bufferedReader().use {
117
139
  JSONObject(it.readText())
118
140
  }
119
141
  }.getOrDefault(JSONObject())
120
142
  hapticsEnabled = config.optBoolean("enableHaptics", false)
143
+ clipboardEnabled = config.optBoolean("enableClipboard", false)
144
+ capabilities = CraftNativeCapabilities(this, config)
121
145
  val background = color(config.optString("backgroundColor", "#1a1a2e"), Color.BLACK)
122
146
  root.setBackgroundColor(background)
123
147
  defaultTextColor = if (Color.luminance(background) < 0.5) Color.WHITE else Color.BLACK
@@ -131,29 +155,45 @@ class MainActivity : AppCompatActivity() {
131
155
  showError("Native STX requires Android JavaScriptSandbox, which is unavailable on this device.")
132
156
  return
133
157
  }
134
- val connection = JavaScriptSandbox.createConnectedInstanceAsync(this)
135
- connection.addListener({
136
- try {
137
- val engine = connection.get()
138
- if (isDestroyed) {
139
- engine.close()
140
- return@addListener
141
- }
142
- useMessagePorts = engine.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_MESSAGE_PORTS)
143
- && engine.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_PROMISE_RETURN)
144
- if (!useMessagePorts && !engine.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_CONSOLE_MESSAGING)) {
145
- engine.close()
146
- showError("Native STX requires JavaScript message ports or console messaging, unavailable on this device.")
147
- return@addListener
148
- }
149
- Log.i(TAG, "Native STX bridge: ${if (useMessagePorts) "message ports" else "console callback"}")
150
- sandbox = engine
151
- openScreen(null)
152
- } catch (error: Exception) {
153
- Log.e(TAG, "JavaScriptSandbox connection failed", error)
154
- showError("Native STX JavaScript engine could not start: ${error.message}")
158
+ val connection = synchronized(SANDBOX_LOCK) {
159
+ sharedSandboxConnection ?: sharedSandbox?.let { existing ->
160
+ sandbox = existing
161
+ null
162
+ } ?: JavaScriptSandbox.createConnectedInstanceAsync(applicationContext).also {
163
+ sharedSandboxConnection = it
155
164
  }
156
- }, ContextCompat.getMainExecutor(this))
165
+ }
166
+ if (connection == null) {
167
+ sandbox?.let(::attachSandbox)
168
+ } else {
169
+ connection.addListener({
170
+ try {
171
+ val engine = connection.get()
172
+ synchronized(SANDBOX_LOCK) {
173
+ sharedSandbox = engine
174
+ }
175
+ if (!isDestroyed) {
176
+ sandbox = engine
177
+ attachSandbox(engine)
178
+ }
179
+ } catch (error: Exception) {
180
+ synchronized(SANDBOX_LOCK) { sharedSandboxConnection = null }
181
+ Log.e(TAG, "JavaScriptSandbox connection failed", error)
182
+ if (!isDestroyed) showError("Native STX JavaScript engine could not start: ${error.message}")
183
+ }
184
+ }, ContextCompat.getMainExecutor(this))
185
+ }
186
+ }
187
+
188
+ private fun attachSandbox(engine: JavaScriptSandbox) {
189
+ useMessagePorts = engine.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_MESSAGE_PORTS)
190
+ && engine.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_PROMISE_RETURN)
191
+ if (!useMessagePorts && !engine.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_CONSOLE_MESSAGING)) {
192
+ showError("Native STX requires JavaScript message ports or console messaging, unavailable on this device.")
193
+ return
194
+ }
195
+ Log.i(TAG, "Native STX bridge: ${if (useMessagePorts) "message ports" else "console callback"}")
196
+ openScreen(null)
157
197
  }
158
198
 
159
199
  private fun openScreen(route: String?, params: JSONObject = JSONObject()) {
@@ -170,13 +210,13 @@ class MainActivity : AppCompatActivity() {
170
210
  val port = if (useMessagePorts) {
171
211
  isolate.createMessageChannel("craft", executor, MessagePortClient { message ->
172
212
  val current = screen ?: return@MessagePortClient
173
- if (current.active && message.type == Message.TYPE_STRING) receive(current, message.string)
213
+ if (current.active && message.type == Message.TYPE_STRING) receiveSafely(current, message.string)
174
214
  })
175
215
  } else {
176
216
  isolate.setConsoleCallback(executor, JavaScriptConsoleCallback { entry ->
177
217
  val current = screen ?: return@JavaScriptConsoleCallback
178
218
  if (current.active && entry.message.startsWith(prefix)) {
179
- receive(current, entry.message.removePrefix(prefix))
219
+ receiveSafely(current, entry.message.removePrefix(prefix))
180
220
  }
181
221
  })
182
222
  null
@@ -194,7 +234,12 @@ class MainActivity : AppCompatActivity() {
194
234
  const port = await android.getNamedPort('craft');
195
235
  $routePrelude
196
236
  globalThis.__stxNativeBridge = {
237
+ platform: 'android',
197
238
  mutationProtocolVersion: 1,
239
+ capabilityProtocolVersion: $CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION,
240
+ capabilityTimeoutMs: $CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS,
241
+ capabilities: ${capabilities.names()},
242
+ initialAppState: ${JSONObject.quote(nativeAppState)},
198
243
  postMessage: function(value) { port.postMessage(value); },
199
244
  onMessage: function(callback) { port.onmessage = function(event) { callback(event.data); }; }
200
245
  };
@@ -202,7 +247,12 @@ class MainActivity : AppCompatActivity() {
202
247
  """.trimIndent() else """
203
248
  $routePrelude
204
249
  globalThis.__stxNativeBridge = {
250
+ platform: 'android',
205
251
  mutationProtocolVersion: 1,
252
+ capabilityProtocolVersion: $CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION,
253
+ capabilityTimeoutMs: $CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS,
254
+ capabilities: ${capabilities.names()},
255
+ initialAppState: ${JSONObject.quote(nativeAppState)},
206
256
  postMessage: function(value) { console.log(${JSONObject.quote(prefix)} + value); },
207
257
  onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
208
258
  };
@@ -214,7 +264,18 @@ class MainActivity : AppCompatActivity() {
214
264
  setup.get()
215
265
  val load = isolate.evaluateJavaScriptAsync(bundle)
216
266
  load.addListener({
217
- try { load.get() } catch (error: Exception) {
267
+ try {
268
+ load.get()
269
+ val launchLink = initialDeepLink
270
+ if (!deliveredInitialDeepLink
271
+ && config.optBoolean("enableDeepLinks")
272
+ && launchLink != null
273
+ && screens.lastOrNull() === activeScreen
274
+ ) {
275
+ deliveredInitialDeepLink = true
276
+ send(activeScreen, "DEEP_LINK", deepLinkData(launchLink).put("initial", true))
277
+ }
278
+ } catch (error: Exception) {
218
279
  Log.e(TAG, "Native STX bundle failed", error)
219
280
  if (activeScreen.active) showError("Native STX bundle failed: ${error.message}")
220
281
  }
@@ -232,9 +293,17 @@ class MainActivity : AppCompatActivity() {
232
293
  root.addView(screen.view)
233
294
  }
234
295
 
296
+ override fun onSaveInstanceState(outState: Bundle) {
297
+ outState.putString("craft.initialDeepLink", initialDeepLink?.toString())
298
+ outState.putBoolean("craft.initialDeepLinkClaimed", initialDeepLinkClaimed)
299
+ outState.putBoolean("craft.deliveredInitialDeepLink", deliveredInitialDeepLink)
300
+ super.onSaveInstanceState(outState)
301
+ }
302
+
235
303
  private fun receive(screen: Screen, raw: String) {
236
304
  val message = runCatching { JSONObject(raw) }.getOrNull() ?: return
237
305
  val payload = message.optJSONObject("payload") ?: JSONObject()
306
+ Log.d(TAG, "Native STX ${message.optString("type")} (${raw.length} chars) for ${screen.route ?: "home"}")
238
307
  when (message.optString("type")) {
239
308
  "RENDER" -> payload.optJSONObject("document")?.let { render(screen, it) }
240
309
  "MUTATE" -> try {
@@ -270,6 +339,26 @@ class MainActivity : AppCompatActivity() {
270
339
  }
271
340
  "NAVIGATE_BACK" -> if (screens.lastOrNull() === screen) back()
272
341
  "API_REQUEST" -> answerAPI(screen, message, payload)
342
+ "API_CANCEL" -> {
343
+ val id = payload.optString("requestId")
344
+ if (id.isNotBlank()) {
345
+ val token = "${screen.capabilityScope}/$id"
346
+ screen.pendingCapabilities.remove(token)
347
+ screen.capabilityTimeouts.remove(token)?.let(capabilityHandler::removeCallbacks)
348
+ capabilities.cancel(token)
349
+ }
350
+ }
351
+ }
352
+ }
353
+
354
+ private fun receiveSafely(screen: Screen, raw: String) {
355
+ try {
356
+ receive(screen, raw)
357
+ }
358
+ catch (error: Exception) {
359
+ val route = screen.route ?: "home"
360
+ Log.e(TAG, "Native STX message failed for $route", error)
361
+ if (screen.active) showError("Native STX message failed for $route: ${error.message}")
273
362
  }
274
363
  }
275
364
 
@@ -728,34 +817,118 @@ class MainActivity : AppCompatActivity() {
728
817
  val id = message.optString("id")
729
818
  if (id.isBlank()) return
730
819
  val args = payload.optJSONArray("args") ?: JSONArray()
731
- val response = JSONObject().put("requestId", id)
732
- val result = when (payload.optString("module") to payload.optString("method")) {
733
- "Device" to "getInfo" -> response.put("data", JSONObject()
820
+ val token = "${screen.capabilityScope}/$id"
821
+ screen.pendingCapabilities.add(token)
822
+ val timeout = Runnable {
823
+ if (!screen.active || !screen.pendingCapabilities.remove(token)) return@Runnable
824
+ screen.capabilityTimeouts.remove(token)
825
+ capabilities.cancel(token)
826
+ send(screen, "API_ERROR", JSONObject()
827
+ .put("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION)
828
+ .put("requestId", id)
829
+ .put("code", "TIMEOUT")
830
+ .put("message", "Native API request timed out"), id)
831
+ }
832
+ screen.capabilityTimeouts[token] = timeout
833
+ capabilityHandler.postDelayed(timeout, CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS)
834
+ capabilities.perform(
835
+ token,
836
+ payload.optInt("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION),
837
+ payload.optString("module"),
838
+ payload.optString("method"),
839
+ args,
840
+ ) { result ->
841
+ if (!screen.active || !screen.pendingCapabilities.remove(token)) return@perform
842
+ screen.capabilityTimeouts.remove(token)?.let(capabilityHandler::removeCallbacks)
843
+ val response = JSONObject()
844
+ .put("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION)
845
+ .put("requestId", id)
846
+ if (result.code == null) response.put("data", result.data ?: JSONObject.NULL)
847
+ else response.put("code", result.code).put("message", result.message ?: "Native API failed")
848
+ send(screen, if (result.code == null) "API_RESPONSE" else "API_ERROR", response, id)
849
+ }
850
+ }
851
+
852
+ internal fun performImmediateCapability(module: String, method: String, args: JSONArray): CraftNativeCapabilityResult =
853
+ when (module to method) {
854
+ "Device" to "getInfo" -> CraftNativeCapabilityResult(data = JSONObject()
734
855
  .put("platform", "android")
735
856
  .put("osVersion", Build.VERSION.RELEASE)
736
857
  .put("model", Build.MODEL)
737
- .put("manufacturer", Build.MANUFACTURER))
738
- "Clipboard" to "write" -> {
858
+ .put("manufacturer", Build.MANUFACTURER)
859
+ .put("deviceId", android.provider.Settings.Secure.getString(contentResolver, android.provider.Settings.Secure.ANDROID_ID))
860
+ .put("isTablet", resources.configuration.smallestScreenWidthDp >= 600)
861
+ .put("screen", JSONObject()
862
+ .put("width", resources.displayMetrics.widthPixels / resources.displayMetrics.density)
863
+ .put("height", resources.displayMetrics.heightPixels / resources.displayMetrics.density)
864
+ .put("scale", resources.displayMetrics.density)))
865
+ "Clipboard" to "write" -> if (!clipboardEnabled) {
866
+ CraftNativeCapabilityResult(code = "CAPABILITY_DISABLED", message = "Clipboard is disabled")
867
+ } else if (args.opt(0) !is String) {
868
+ CraftNativeCapabilityResult(code = "INVALID_ARGUMENT", message = "Clipboard.write needs text")
869
+ } else {
739
870
  val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
740
- clipboard.setPrimaryClip(ClipData.newPlainText("", args.optString(0)))
741
- response.put("data", JSONObject().put("ok", true))
871
+ clipboard.setPrimaryClip(ClipData.newPlainText("", args.getString(0)))
872
+ CraftNativeCapabilityResult(data = true)
742
873
  }
743
- "Clipboard" to "read" -> {
874
+ "Clipboard" to "read" -> if (!clipboardEnabled) {
875
+ CraftNativeCapabilityResult(code = "CAPABILITY_DISABLED", message = "Clipboard is disabled")
876
+ } else {
744
877
  val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
745
- response.put("data", clipboard.primaryClip?.getItemAt(0)?.coerceToText(this)?.toString() ?: "")
878
+ CraftNativeCapabilityResult(data = clipboard.primaryClip?.getItemAt(0)?.coerceToText(this)?.toString() ?: "")
746
879
  }
747
- "Haptics" to "impact" -> {
748
- if (!hapticsEnabled) response.put("code", "CAPABILITY_DISABLED")
749
- .put("message", "Haptics are disabled")
750
- else {
751
- val vibrator = getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
752
- vibrator.vibrate(VibrationEffect.createOneShot(30, VibrationEffect.DEFAULT_AMPLITUDE))
753
- response.put("data", JSONObject().put("ok", true))
754
- }
880
+ "Haptics" to "impact" -> if (!hapticsEnabled) {
881
+ CraftNativeCapabilityResult(code = "CAPABILITY_DISABLED", message = "Haptics are disabled")
882
+ } else {
883
+ val vibrator = getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
884
+ vibrator.vibrate(VibrationEffect.createOneShot(30, VibrationEffect.DEFAULT_AMPLITUDE))
885
+ CraftNativeCapabilityResult(data = true)
755
886
  }
756
- else -> response.put("code", "UNKNOWN_ACTION").put("message", "Unsupported native API")
887
+ else -> CraftNativeCapabilityResult(code = "UNKNOWN_ACTION", message = "Unsupported native API")
757
888
  }
758
- send(screen, if (result.has("code")) "API_ERROR" else "API_RESPONSE", result, id)
889
+
890
+ internal fun deepLinkData(uri: android.net.Uri): JSONObject = JSONObject()
891
+ .put("url", uri.toString())
892
+ .put("scheme", uri.scheme ?: "")
893
+ .put("host", uri.host ?: "")
894
+ .put("path", uri.path ?: "")
895
+ .put("query", uri.query ?: "")
896
+ .put("queryParams", JSONObject().apply {
897
+ uri.queryParameterNames.forEach { name -> put(name, uri.getQueryParameter(name) ?: "") }
898
+ })
899
+
900
+ private fun publish(type: String, payload: JSONObject) {
901
+ screens.lastOrNull()?.takeIf { it.active }?.let { send(it, type, payload) }
902
+ }
903
+
904
+ override fun onStart() {
905
+ super.onStart()
906
+ nativeAppState = "active"
907
+ publish("APP_STATE", JSONObject().put("state", nativeAppState))
908
+ }
909
+
910
+ override fun onPause() {
911
+ nativeAppState = "inactive"
912
+ publish("APP_STATE", JSONObject().put("state", nativeAppState))
913
+ super.onPause()
914
+ }
915
+
916
+ override fun onStop() {
917
+ nativeAppState = "background"
918
+ publish("APP_STATE", JSONObject().put("state", nativeAppState))
919
+ super.onStop()
920
+ }
921
+
922
+ override fun onNewIntent(intent: Intent) {
923
+ super.onNewIntent(intent)
924
+ setIntent(intent)
925
+ intent.data?.let { publish("DEEP_LINK", deepLinkData(it).put("initial", false)) }
926
+ }
927
+
928
+ internal fun consumeInitialDeepLink(): Uri? {
929
+ if (initialDeepLinkClaimed) return null
930
+ initialDeepLinkClaimed = true
931
+ return initialDeepLink
759
932
  }
760
933
 
761
934
  private fun send(screen: Screen, type: String, payload: JSONObject, correlationId: String? = null) {
@@ -804,6 +977,10 @@ class MainActivity : AppCompatActivity() {
804
977
  }
805
978
 
806
979
  private fun closeScreen(screen: Screen) {
980
+ screen.capabilityTimeouts.values.forEach(capabilityHandler::removeCallbacks)
981
+ screen.capabilityTimeouts.clear()
982
+ screen.pendingCapabilities.forEach(capabilities::cancel)
983
+ screen.pendingCapabilities.clear()
807
984
  releaseViewState(screen, retainedViews(screen).toList())
808
985
  screen.controls.clear()
809
986
  screen.handlers.clear()
@@ -833,9 +1010,14 @@ class MainActivity : AppCompatActivity() {
833
1010
  imageJobs.values.forEach { it.cancel(true) }
834
1011
  imageJobs.clear()
835
1012
  imageExecutor.shutdownNow()
836
- sandbox?.close()
1013
+ capabilities.close()
837
1014
  super.onDestroy()
838
1015
  }
839
1016
 
840
- companion object { private const val TAG = "CraftNativeAndroid" }
1017
+ companion object {
1018
+ private const val TAG = "CraftNativeAndroid"
1019
+ private val SANDBOX_LOCK = Any()
1020
+ private var sharedSandbox: JavaScriptSandbox? = null
1021
+ private var sharedSandboxConnection: ListenableFuture<JavaScriptSandbox>? = null
1022
+ }
841
1023
  }
@@ -352,7 +352,7 @@ export declare const camera: {
352
352
  /**
353
353
  * Biometric type.
354
354
  */
355
- export type BiometricType = 'faceId' | 'touchId' | 'fingerprint' | 'face' | 'iris';
355
+ export type BiometricType = 'faceId' | 'touchId' | 'fingerprint' | 'face' | 'iris' | 'none';
356
356
  /**
357
357
  * Biometric authentication API.
358
358
  *
@@ -433,6 +433,15 @@ export declare const secureStorage: {
433
433
  * @param key - Storage key
434
434
  */
435
435
  delete(key: string): Promise<void>;
436
+ /**
437
+ * Remove a securely stored value using the legacy native contract.
438
+ *
439
+ * New code may use {@link delete}; this alias keeps older native screens
440
+ * and the public `craft.d.ts` contract working across bridge versions.
441
+ *
442
+ * @param key - Storage key
443
+ */
444
+ remove(key: string): Promise<void>;
436
445
  /**
437
446
  * Clear all securely stored values.
438
447
  */
@@ -621,6 +630,12 @@ export interface NotificationOptions {
621
630
  export type BridgeNotification = Omit<NotificationOptions, 'scheduleAt'> & {
622
631
  delay?: number;
623
632
  };
633
+ /** A local notification waiting for delivery. */
634
+ export interface PendingLocalNotification {
635
+ id: string;
636
+ title: string;
637
+ body: string;
638
+ }
624
639
  /**
625
640
  * The bridges' spelling of a notification: `scheduleAt` becomes `delay`.
626
641
  *
@@ -668,6 +683,14 @@ export declare const notifications: {
668
683
  * @param options - Notification options with scheduleAt
669
684
  */
670
685
  schedule(options: NotificationOptions): Promise<void>;
686
+ /**
687
+ * Cancel one scheduled notification.
688
+ */
689
+ cancel(id: string): Promise<void>;
690
+ /**
691
+ * Read notifications that have not fired yet.
692
+ */
693
+ pending(): Promise<PendingLocalNotification[]>;
671
694
  /**
672
695
  * Cancel all scheduled notifications.
673
696
  */
package/dist/cli.js CHANGED
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3303
3303
  `);
3304
3304
  }
3305
3305
  // package.json
3306
- var version = "0.0.118";
3306
+ var version = "0.0.119";
3307
3307
 
3308
3308
  // bin/cli.ts
3309
3309
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.118";
313
+ var version = "0.0.119";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
@@ -5072,6 +5072,17 @@ var secureStorage = {
5072
5072
  throw new Error("Craft secure storage bridge is unavailable");
5073
5073
  localStorage.removeItem(`secure_${key}`);
5074
5074
  },
5075
+ async remove(key) {
5076
+ const craft = getCraftMobile();
5077
+ if (typeof window !== "undefined" && craft?.secureStorage) {
5078
+ if (typeof craft.secureStorage.remove === "function")
5079
+ return craft.secureStorage.remove(key);
5080
+ return craft.secureStorage.delete(key);
5081
+ }
5082
+ if (typeof window !== "undefined" && window.craft)
5083
+ throw new Error("Craft secure storage bridge is unavailable");
5084
+ localStorage.removeItem(`secure_${key}`);
5085
+ },
5075
5086
  async clear() {
5076
5087
  const craft = getCraftMobile();
5077
5088
  if (typeof window !== "undefined" && craft?.secureStorage) {
@@ -5271,6 +5282,20 @@ var notifications = {
5271
5282
  }
5272
5283
  throw new Error("Scheduled notifications not available in web");
5273
5284
  },
5285
+ async cancel(id) {
5286
+ const craft = getCraftMobile();
5287
+ if (typeof window !== "undefined" && craft?.notifications) {
5288
+ return craft.notifications.cancel(id);
5289
+ }
5290
+ throw new Error("Scheduled notifications not available in web");
5291
+ },
5292
+ async pending() {
5293
+ const craft = getCraftMobile();
5294
+ if (typeof window !== "undefined" && craft?.notifications) {
5295
+ return craft.notifications.pending();
5296
+ }
5297
+ return [];
5298
+ },
5274
5299
  async cancelAll() {
5275
5300
  const craft = getCraftMobile();
5276
5301
  if (typeof window !== "undefined" && craft?.notifications) {
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
58
58
  `);
59
59
  }
60
60
  // package.json
61
- var version = "0.0.118";
61
+ var version = "0.0.119";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -4832,6 +4832,17 @@ var secureStorage = {
4832
4832
  throw new Error("Craft secure storage bridge is unavailable");
4833
4833
  localStorage.removeItem(`secure_${key}`);
4834
4834
  },
4835
+ async remove(key) {
4836
+ const craft = getCraftMobile();
4837
+ if (typeof window !== "undefined" && craft?.secureStorage) {
4838
+ if (typeof craft.secureStorage.remove === "function")
4839
+ return craft.secureStorage.remove(key);
4840
+ return craft.secureStorage.delete(key);
4841
+ }
4842
+ if (typeof window !== "undefined" && window.craft)
4843
+ throw new Error("Craft secure storage bridge is unavailable");
4844
+ localStorage.removeItem(`secure_${key}`);
4845
+ },
4835
4846
  async clear() {
4836
4847
  const craft = getCraftMobile();
4837
4848
  if (typeof window !== "undefined" && craft?.secureStorage) {
@@ -5031,6 +5042,20 @@ var notifications = {
5031
5042
  }
5032
5043
  throw new Error("Scheduled notifications not available in web");
5033
5044
  },
5045
+ async cancel(id) {
5046
+ const craft = getCraftMobile();
5047
+ if (typeof window !== "undefined" && craft?.notifications) {
5048
+ return craft.notifications.cancel(id);
5049
+ }
5050
+ throw new Error("Scheduled notifications not available in web");
5051
+ },
5052
+ async pending() {
5053
+ const craft = getCraftMobile();
5054
+ if (typeof window !== "undefined" && craft?.notifications) {
5055
+ return craft.notifications.pending();
5056
+ }
5057
+ return [];
5058
+ },
5034
5059
  async cancelAll() {
5035
5060
  const craft = getCraftMobile();
5036
5061
  if (typeof window !== "undefined" && craft?.notifications) {
@@ -66,6 +66,22 @@ export interface CraftConfig {
66
66
  teamId?: string;
67
67
  urlSchemes?: string[];
68
68
  trustedOrigins?: string[];
69
+ /**
70
+ * The domains the app's web view treats as its own (WKAppBoundDomains, at
71
+ * most 10). Service workers only run in an iOS web view on these, so a site
72
+ * that works offline in Safari works offline in the app. Unset, they are the
73
+ * hosts of `trustedOrigins` and the dev server: the origins the bridge is
74
+ * already injected into, and once any are declared iOS keeps script
75
+ * injection to them. `[]` declares none.
76
+ */
77
+ appBoundDomains?: string[];
78
+ /**
79
+ * Also refuse to load anything but the app-bound domains in the web view
80
+ * (WKWebViewConfiguration.limitsNavigationsToAppBoundDomains). Off by
81
+ * default: links elsewhere already open in Safari, and embedded players and
82
+ * maps in iframes keep loading.
83
+ */
84
+ limitNavigationsToAppBoundDomains?: boolean;
69
85
  associatedDomains?: string[];
70
86
  appGroups?: string[];
71
87
  appIconPath?: string;
@@ -148,6 +164,8 @@ export declare function renderAppearance(config: Pick<CraftConfig, 'appearance'
148
164
  };
149
165
  export declare function renderUsageDescriptions(config: CraftConfig): string;
150
166
  export declare function renderOrientations(config: CraftConfig): string;
167
+ /** WKAppBoundDomains: the configured domains plus the dev server's host, at most 10. */
168
+ export declare function renderAppBoundDomains(config: CraftConfig): string;
151
169
  export declare function renderUrlTypes(config: CraftConfig): string;
152
170
  export declare function renderBackgroundModes(config: CraftConfig): string;
153
171
  export declare function renderEntitlements(config: CraftConfig): string;
@@ -111,6 +111,27 @@ function renderOrientations(config) {
111
111
  return values.map((value) => ` <string>${names[value]}</string>`).join(`
112
112
  `);
113
113
  }
114
+ function renderAppBoundDomains(config) {
115
+ const hostOf = (value) => {
116
+ try {
117
+ const url = new URL(value);
118
+ return url.protocol === "https:" || url.protocol === "http:" ? url.hostname.toLowerCase() : null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ };
123
+ const domains = config.appBoundDomains !== undefined ? config.appBoundDomains.map((value) => value.trim().toLowerCase()).filter(Boolean) : (config.trustedOrigins ?? []).map(hostOf).filter((host) => !!host);
124
+ if (!domains.length)
125
+ return "";
126
+ const devHost = config.devServerURL ? hostOf(config.devServerURL) : null;
127
+ if (devHost)
128
+ domains.push(devHost);
129
+ const unique = [...new Set(domains)].slice(0, 10);
130
+ return ` <key>WKAppBoundDomains</key>
131
+ <array>
132
+ ${plistArray(unique)}
133
+ </array>`;
134
+ }
114
135
  function renderUrlTypes(config) {
115
136
  const schemes = [...new Set(config.urlSchemes?.map((value) => value.trim()).filter(Boolean) ?? [])];
116
137
  if (!schemes.length)
@@ -168,6 +189,12 @@ ${plistArray(config.appGroups)}
168
189
  entries.push(` <key>aps-environment</key>
169
190
  <string>$(CRAFT_APNS_ENVIRONMENT)</string>`);
170
191
  }
192
+ if (config.enableSecureStorage) {
193
+ entries.push(` <key>keychain-access-groups</key>
194
+ <array>
195
+ <string>$(AppIdentifierPrefix)$(CFBundleIdentifier)</string>
196
+ </array>`);
197
+ }
171
198
  return `<?xml version="1.0" encoding="UTF-8"?>
172
199
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
173
200
  <plist version="1.0">
@@ -410,7 +437,7 @@ async function init(options) {
410
437
  cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
411
438
  cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
412
439
  const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
413
- const infoPlist = infoPlistTemplate.replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{UI_STYLE\}\}/g, renderAppearance(config).interfaceStyle).replace(/\{\{STATUS_BAR_STYLE\}\}/g, renderAppearance(config).statusBarStyle).replace(/\{\{ORIENTATIONS\}\}/g, renderOrientations(config)).replace(/\{\{USAGE_DESCRIPTIONS\}\}/g, renderUsageDescriptions(config)).replace(/\{\{URL_TYPES\}\}/g, renderUrlTypes(config)).replace(/\{\{BACKGROUND_MODES\}\}/g, renderBackgroundModes(config)).replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
440
+ const infoPlist = infoPlistTemplate.replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{UI_STYLE\}\}/g, renderAppearance(config).interfaceStyle).replace(/\{\{STATUS_BAR_STYLE\}\}/g, renderAppearance(config).statusBarStyle).replace(/\{\{ORIENTATIONS\}\}/g, renderOrientations(config)).replace(/\{\{USAGE_DESCRIPTIONS\}\}/g, renderUsageDescriptions(config)).replace(/\{\{URL_TYPES\}\}/g, renderUrlTypes(config)).replace(/\{\{APP_BOUND_DOMAINS\}\}/g, renderAppBoundDomains(config)).replace(/\{\{BACKGROUND_MODES\}\}/g, renderBackgroundModes(config)).replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
414
441
  <true/>
415
442
  <key>NSSupportsLiveActivitiesFrequentUpdates</key>
416
443
  <true/>` : "");
@@ -729,6 +756,7 @@ export {
729
756
  renderDeviceFamilies,
730
757
  renderBackgroundModes,
731
758
  renderAppearance,
759
+ renderAppBoundDomains,
732
760
  productName,
733
761
  pickSimulator,
734
762
  orderSimulators,