craft-native 0.0.146 → 0.0.148

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.
@@ -2094,6 +2094,21 @@ class MainActivity : AppCompatActivity() {
2094
2094
  maxLines = if (multiline) props.optInt("numberOfLines", Int.MAX_VALUE).coerceAtLeast(1) else 1
2095
2095
  isEnabled = props.optBoolean("editable", true)
2096
2096
  configureTextStyle(this, style)
2097
+ // The style's padding is where the text starts, as on iOS and
2098
+ // the web; without one the field keeps its own.
2099
+ if (listOf("padding", "paddingHorizontal", "paddingVertical", "paddingLeft", "paddingRight", "paddingTop", "paddingBottom").any { style.has(it) }) {
2100
+ val density = resources.displayMetrics.density
2101
+ val all = style.optDouble("padding", 0.0)
2102
+ val horizontal = style.optDouble("paddingHorizontal", all)
2103
+ val vertical = style.optDouble("paddingVertical", all)
2104
+ setPadding(
2105
+ (style.optDouble("paddingLeft", horizontal) * density).toInt(),
2106
+ (style.optDouble("paddingTop", vertical) * density).toInt(),
2107
+ (style.optDouble("paddingRight", horizontal) * density).toInt(),
2108
+ (style.optDouble("paddingBottom", vertical) * density).toInt(),
2109
+ )
2110
+ }
2111
+ autofillHintFor(props)?.let { setAutofillHints(it) }
2097
2112
  val controlledValue = props.opt("value")?.takeUnless { it == JSONObject.NULL }?.toString()
2098
2113
  if (controlledValue != null && text.toString() != controlledValue) {
2099
2114
  screen.suppressedTextChanges.add(this)
@@ -2991,6 +3006,24 @@ class MainActivity : AppCompatActivity() {
2991
3006
  return (if (keyboard == 0) base else keyboard) or capitalization or correction or secure
2992
3007
  }
2993
3008
 
3009
+ /// The autofill hint for `textContentType` (iOS's names) or `autoComplete`
3010
+ /// (HTML's), so a password manager fills the field.
3011
+ private fun autofillHintFor(props: JSONObject): String? {
3012
+ val raw = props.optString("textContentType").ifBlank { props.optString("autoComplete") }
3013
+ return when (raw.replace("-", "").replace("_", "").lowercase()) {
3014
+ "username" -> View.AUTOFILL_HINT_USERNAME
3015
+ "password", "currentpassword" -> View.AUTOFILL_HINT_PASSWORD
3016
+ "newpassword" -> "newPassword"
3017
+ "onetimecode" -> "smsOTPCode"
3018
+ "emailaddress", "email" -> View.AUTOFILL_HINT_EMAIL_ADDRESS
3019
+ "telephonenumber", "tel", "phone" -> View.AUTOFILL_HINT_PHONE
3020
+ "name" -> View.AUTOFILL_HINT_NAME
3021
+ "postalcode" -> View.AUTOFILL_HINT_POSTAL_CODE
3022
+ "fullstreetaddress", "streetaddress" -> View.AUTOFILL_HINT_POSTAL_ADDRESS
3023
+ else -> null
3024
+ }
3025
+ }
3026
+
2994
3027
  private fun imeOptionsFor(props: JSONObject): Int = when (props.optString("returnKeyType")) {
2995
3028
  "done" -> EditorInfo.IME_ACTION_DONE
2996
3029
  "go" -> EditorInfo.IME_ACTION_GO
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.146";
3306
+ var version = "0.0.148";
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.146";
313
+ var version = "0.0.148";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
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.146";
61
+ var version = "0.0.148";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -789,6 +789,7 @@ async function init(options) {
789
789
  cpSync(join(TEMPLATES_DIR, "CraftMusic.swift"), join(output, "Sources", "CraftMusic.swift"));
790
790
  cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
791
791
  cpSync(join(TEMPLATES_DIR, "CraftNativeUI.swift"), join(output, "Sources", "CraftNativeUI.swift"));
792
+ cpSync(join(TEMPLATES_DIR, "CraftNativeServices.swift"), join(output, "Sources", "CraftNativeServices.swift"));
792
793
  cpSync(join(TEMPLATES_DIR, "CraftHybrid.swift"), join(output, "Sources", "CraftHybrid.swift"));
793
794
  const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
794
795
  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(/\{\{BACKGROUND_TASK_IDENTIFIERS\}\}/g, renderBackgroundTaskIdentifiers(config)).replace(/\{\{LAUNCH_SCREEN\}\}/g, renderLaunchScreen(config)).replace(/\{\{NON_EXEMPT_ENCRYPTION\}\}/g, config.usesNonExemptEncryption ? "true" : "false").replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
@@ -5174,6 +5174,8 @@ struct CraftWebView: UIViewRepresentable {
5174
5174
  // MARK: - Callback Helpers
5175
5175
  private func resolveCallback(_ callbackId: String?, result: Any) {
5176
5176
  guard let id = callbackId else { return }
5177
+ // A native screen's call (CraftWebServices): answered in Swift.
5178
+ if CraftWebServices.resolve(id, result: result) { return }
5177
5179
  let resultStr: String
5178
5180
  do {
5179
5181
  let data = try JSONSerialization.data(withJSONObject: result, options: [.fragmentsAllowed])
@@ -5196,6 +5198,7 @@ struct CraftWebView: UIViewRepresentable {
5196
5198
 
5197
5199
  private func rejectCallback(_ callbackId: String?, error: String, code: String = "CRAFT_ERROR") {
5198
5200
  guard let id = callbackId else { return }
5201
+ if CraftWebServices.reject(id, message: error, code: code) { return }
5199
5202
  // A hand-off rejection goes back through Zig's error route, so the
5200
5203
  // page's promise *rejects*. Delivering it as a result would run the
5201
5204
  // app's then-branch with an error-shaped object — fabricated
@@ -7883,6 +7886,7 @@ struct CraftWebView: UIViewRepresentable {
7883
7886
 
7884
7887
  // MARK: - Web Communication
7885
7888
  private func sendToWeb(_ event: String, data: [String: Any]) {
7889
+ CraftWebServices.forward(event, data: data)
7886
7890
  guard let webView = webView else { return }
7887
7891
  do {
7888
7892
  let jsonData = try JSONSerialization.data(withJSONObject: data)
@@ -8420,6 +8424,58 @@ final class CraftZigRuntime: NSObject {
8420
8424
  }
8421
8425
  }
8422
8426
 
8427
+ /// The page's services that live in the web view's coordinator, for native
8428
+ /// screens: GPS (one fix, a watch, and the route recording that keeps going
8429
+ /// in the background and survives a relaunch). A native screen asks with a
8430
+ /// callback id of the form "native:<uuid>", which the reply helpers answer
8431
+ /// here instead of in the page, and the coordinator's location events reach
8432
+ /// every native screen as well as the page. One recorder for both, so a run
8433
+ /// started on a page can be followed on a native screen.
8434
+ ///
8435
+ /// The coordinator exists wherever the web view does: always in a hybrid app.
8436
+ /// Calls go to the Swift dispatcher; a build that links the Zig runtime keeps
8437
+ /// the page's calls on Zig.
8438
+ enum CraftWebServices {
8439
+ private static var waiting: [String: (Result<Any, CraftNativeActionError>) -> Void] = [:]
8440
+ /// Coordinator events a native screen hears too.
8441
+ static let forwardedEvents: Set<String> = ["craftLocationUpdate", "craftLocationError", "craftMusicChange"]
8442
+ static let event = Notification.Name("craftWebServicesEvent")
8443
+
8444
+ static var isAvailable: Bool { CraftSwiftShim.coordinator != nil }
8445
+
8446
+ static func perform(action: String, body: [String: Any], completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
8447
+ DispatchQueue.main.async {
8448
+ guard let coordinator = CraftSwiftShim.coordinator else {
8449
+ completion(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "\(action) needs the app's web view, which this app does not have")))
8450
+ return
8451
+ }
8452
+ let id = "native:\(UUID().uuidString)"
8453
+ waiting[id] = completion
8454
+ coordinator.dispatch(action: action, body: body, callbackId: id)
8455
+ }
8456
+ }
8457
+
8458
+ /// True when `id` was a native screen's, which then has its answer.
8459
+ static func resolve(_ id: String, result: Any) -> Bool {
8460
+ guard id.hasPrefix("native:") else { return false }
8461
+ DispatchQueue.main.async { waiting.removeValue(forKey: id)?(.success(result)) }
8462
+ return true
8463
+ }
8464
+
8465
+ static func reject(_ id: String, message: String, code: String) -> Bool {
8466
+ guard id.hasPrefix("native:") else { return false }
8467
+ DispatchQueue.main.async { waiting.removeValue(forKey: id)?(.failure(CraftNativeActionError(code: code, message: message))) }
8468
+ return true
8469
+ }
8470
+
8471
+ static func forward(_ name: String, data: [String: Any]) {
8472
+ guard forwardedEvents.contains(name) else { return }
8473
+ DispatchQueue.main.async {
8474
+ NotificationCenter.default.post(name: event, object: nil, userInfo: ["event": name, "detail": data])
8475
+ }
8476
+ }
8477
+ }
8478
+
8423
8479
  /// The seam the Zig dispatcher hands unmigrated actions through.
8424
8480
  ///
8425
8481
  /// Discovery is symmetric and both directions are runtime-only. Zig finds this
@@ -538,8 +538,10 @@ final class CraftHybrid: NSObject {
538
538
 
539
539
  // A shared key a native screen wrote (a sign-in there): into this
540
540
  // page's storage, which reports it back as the page's own write, and
541
- // a `craftsharedstorage` event for whoever holds it in memory.
542
- function adopt(values) {
541
+ // a `craftsharedstorage` event for whoever holds it in memory. Named
542
+ // apart from the router's adopt: function declarations hoist, so a
543
+ // second `adopt` replaced the first and the router went unwrapped.
544
+ function adoptShared(values) {
543
545
  Object.keys(values || {}).forEach(function (key) {
544
546
  if (SHARE.indexOf(key) < 0) return;
545
547
  var value = values[key];
@@ -555,7 +557,7 @@ final class CraftHybrid: NSObject {
555
557
 
556
558
  window.__craftHybrid = {
557
559
  match: function (url) { var path = local(url); return path === null ? null : match(path); },
558
- adopt: adopt,
560
+ adopt: adoptShared,
559
561
  // The shell's own navigation: never handed back to it, and without
560
562
  // the page's slide, since the shell animates the push itself.
561
563
  // Answers once the page has drawn `path` (its router settles before
@@ -911,11 +913,18 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
911
913
  return nav
912
914
  }
913
915
 
916
+ /// A native screen's route params for `path`: the pattern's and the
917
+ /// query's, and the path itself.
918
+ private func params(for path: String, _ match: CraftHybridRoutes.Match) -> [String: Any] {
919
+ var params: [String: Any] = match.params
920
+ params["path"] = path
921
+ return params
922
+ }
923
+
914
924
  /// The entry for a path: its native screen, or a web entry.
915
925
  private func entry(for path: String, isTabRoot: Bool, depth: Int?, params extra: [String: Any] = [:]) -> UIViewController {
916
926
  if let match = hybrid.routes.match(path) {
917
- var params: [String: Any] = match.params
918
- params["path"] = path
927
+ var params = self.params(for: path, match)
919
928
  extra.forEach { params[$0.key] = $0.value }
920
929
  let screen = CraftNativeScreenController(config: config, routeName: match.screen, routeParams: params)
921
930
  screen.hybridEvents = self
@@ -928,6 +937,12 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
928
937
  private func show(tab id: String, rootPath: String? = nil) {
929
938
  cancelPendingTab()
930
939
  let nav = stacks[id] ?? makeStack(tab: id, rootPath: rootPath ?? (id.isEmpty ? hybrid.startPath : id))
940
+ // A stack is hidden and shown rather than moved, so UIKit tells its
941
+ // screens nothing: the shell says which went away and which is back.
942
+ if id != currentTab {
943
+ (visibleStack?.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(false)
944
+ (nav.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(true)
945
+ }
931
946
  currentTab = id
932
947
  for (key, other) in stacks { other.view.isHidden = key != id }
933
948
  view.bringSubviewToFront(nav.view)
@@ -994,6 +1009,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
994
1009
  if let nav = stacks[tab], nav.viewControllers.count > 1 {
995
1010
  nav.popToRootViewController(animated: !fromLink)
996
1011
  }
1012
+ // `/m/store?coach=5`: the tab's own screen, with the query.
1013
+ (stacks[tab]?.viewControllers.first as? CraftNativeScreenController)?.receiveRouteParams(params(for: raw, match))
997
1014
  return
998
1015
  }
999
1016
  if fromLink, let owner = CraftHybridRoutes.tab(owning: match.path, among: tabs), owner != currentTab {
@@ -1002,6 +1019,9 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1002
1019
  guard let nav = visibleStack ?? stacks.values.first else { return }
1003
1020
  if let existing = nav.viewControllers.last(where: { nativePaths[ObjectIdentifier($0)] == match.path }) {
1004
1021
  if existing !== nav.topViewController { nav.popToViewController(existing, animated: true) }
1022
+ // Opened again, perhaps with another query (`?person=8`): the
1023
+ // screen hears the params it was opened with this time.
1024
+ (existing as? CraftNativeScreenController)?.receiveRouteParams(params(for: raw, match))
1005
1025
  return
1006
1026
  }
1007
1027
  let screen = entry(for: raw, isTabRoot: false, depth: nil)
@@ -1066,6 +1086,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1066
1086
  guard top is CraftHybridWebSlot else { return }
1067
1087
  if let existing = nav.viewControllers.last(where: { nativePaths[ObjectIdentifier($0)] == match.path }) {
1068
1088
  nav.popToViewController(existing, animated: direction != "pop")
1089
+ (existing as? CraftNativeScreenController)?.receiveRouteParams(params(for: path, match))
1069
1090
  } else if !((top as? CraftHybridWebSlot)?.isTabRoot ?? false) {
1070
1091
  nav.pushViewController(entry(for: path, isTabRoot: false, depth: nil), animated: true)
1071
1092
  }
@@ -38,7 +38,9 @@ enum CraftNativeActions {
38
38
  private static let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
39
39
 
40
40
  static func capabilities(config: CraftConfig) -> [String] {
41
- var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog"]
41
+ // `speech`, `audio` (background sound) and `eventStream` (a fetch read
42
+ // as it arrives) need nothing the app has to declare.
43
+ var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking"]
42
44
  if config.enableBiometric { values.append("biometric") }
43
45
  if config.enableSecureStorage { values.append("secureStorage") }
44
46
  if config.enableLocalDatabase { values.append("database") }
@@ -48,6 +50,17 @@ enum CraftNativeActions {
48
50
  // The in-app browser and the system's web sign-in sheet (CraftBrowser).
49
51
  values.append("browser")
50
52
  if config.enableSocialAuth { values.append("appleSignIn") }
53
+ if config.enablePushNotifications { values.append("push") }
54
+ if config.enableShare { values.append("share") }
55
+ // The screen's brightness and the status bar's style need nothing
56
+ // declared; keeping the screen awake is the app's choice.
57
+ values.append(contentsOf: ["brightness", "statusBar"])
58
+ if config.enableKeepAwake { values.append("keepAwake") }
59
+ // GPS and the route recording are the web view's coordinator's
60
+ // (CraftWebServices), so a native-only app, which has none, has none.
61
+ if config.enableGeolocation && config.renderer != "native" { values.append("geolocation") }
62
+ // The Music app's song and transport are the coordinator's too.
63
+ if config.enableAppleMusic && config.renderer != "native" { values.append("music") }
51
64
  return values
52
65
  }
53
66
 
@@ -207,6 +220,149 @@ enum CraftNativeActions {
207
220
  complete(requestToken, result.mapError { CraftNativeActionError(code: $0.code, message: $0.message) }, completion)
208
221
  }
209
222
  return
223
+ case ("KeepAwake", "set"):
224
+ guard config.enableKeepAwake else {
225
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Keep awake is disabled"), completion)
226
+ return
227
+ }
228
+ let enabled = args.first as? Bool ?? true
229
+ DispatchQueue.main.async {
230
+ UIApplication.shared.isIdleTimerDisabled = enabled
231
+ complete(requestToken, .success(enabled), completion)
232
+ }
233
+ return
234
+ case ("Brightness", "get"):
235
+ DispatchQueue.main.async { complete(requestToken, .success(Double(UIScreen.main.brightness)), completion) }
236
+ return
237
+ case ("Brightness", "set"):
238
+ // The device's backlight, system-wide: a screen that dims it reads
239
+ // it first and puts it back, as @stacksjs/mobile's brightness does.
240
+ guard let level = (args.first as? NSNumber)?.doubleValue, level.isFinite else {
241
+ complete(requestToken, failure("INVALID_ARGUMENT", "Brightness.set needs a level from 0 to 1"), completion)
242
+ return
243
+ }
244
+ DispatchQueue.main.async {
245
+ UIScreen.main.brightness = CGFloat(min(max(level, 0), 1))
246
+ complete(requestToken, .success(Double(UIScreen.main.brightness)), completion)
247
+ }
248
+ return
249
+ case ("StatusBar", "setStyle"):
250
+ let style = args.first as? String ?? "default"
251
+ DispatchQueue.main.async {
252
+ complete(requestToken, .success(CraftStatusBar.setStyle(style == "light" || style == "dark" ? style : "default")), completion)
253
+ }
254
+ return
255
+ case ("Location", _):
256
+ // The page's own GPS and route recorder, in the web view's
257
+ // coordinator: one recorder, which keeps recording in the
258
+ // background and survives a relaunch, whichever side started it.
259
+ guard config.enableGeolocation else {
260
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Geolocation is disabled"), completion)
261
+ return
262
+ }
263
+ let actions = [
264
+ "getCurrentPosition": "getCurrentPosition", "watchPosition": "watchPosition", "clearWatch": "clearWatch",
265
+ "startRecording": "startLocationRecording", "pauseRecording": "pauseLocationRecording",
266
+ "resumeRecording": "resumeLocationRecording", "stopRecording": "stopLocationRecording",
267
+ "getRecordingState": "getLocationRecordingState", "readRecording": "readLocationRecording",
268
+ ]
269
+ guard let action = actions[method] else {
270
+ complete(requestToken, failure("UNKNOWN_ACTION", "Unsupported Location method"), completion)
271
+ return
272
+ }
273
+ CraftWebServices.perform(action: action, body: args.first as? [String: Any] ?? [:]) { complete(requestToken, $0, completion) }
274
+ return
275
+ case ("Music", _):
276
+ // The page's own Music app bridge (CraftMusic.swift), in the web
277
+ // view's coordinator: one observer, whose changes reach the page
278
+ // and every native screen as `craftMusicChange`.
279
+ guard config.enableAppleMusic else {
280
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Apple Music is disabled"), completion)
281
+ return
282
+ }
283
+ let actions = [
284
+ "getAuthorizationStatus": "musicGetAuthorization", "requestAuthorization": "musicRequestAuthorization",
285
+ "getNowPlaying": "musicGetNowPlaying", "control": "musicControl", "observe": "musicObserve",
286
+ ]
287
+ guard let action = actions[method] else {
288
+ complete(requestToken, failure("UNKNOWN_ACTION", "Unsupported Music method"), completion)
289
+ return
290
+ }
291
+ CraftWebServices.perform(action: action, body: args.first as? [String: Any] ?? [:]) { complete(requestToken, $0, completion) }
292
+ return
293
+ case ("Linking", "openURL"), ("Linking", "canOpenURL"):
294
+ // Another app or the system: `webcal:` subscriptions, `mailto:`,
295
+ // `tel:`, the App Store, this app's page in Settings
296
+ // (`UIApplication.openSettingsURLString`, which is `app-settings:`).
297
+ guard let address = args.first as? String, let url = URL(string: address), url.scheme != nil else {
298
+ complete(requestToken, failure("INVALID_ARGUMENT", "Linking.\(method) needs a URL it can parse"), completion)
299
+ return
300
+ }
301
+ DispatchQueue.main.async {
302
+ if method == "canOpenURL" {
303
+ complete(requestToken, .success(UIApplication.shared.canOpenURL(url)), completion)
304
+ } else {
305
+ UIApplication.shared.open(url) { complete(requestToken, .success($0), completion) }
306
+ }
307
+ }
308
+ return
309
+ case ("Share", "share"):
310
+ guard config.enableShare else {
311
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Sharing is disabled"), completion)
312
+ return
313
+ }
314
+ let options = args.first as? [String: Any] ?? [:]
315
+ DispatchQueue.main.async {
316
+ CraftNativeShare.share(options) { complete(requestToken, $0, completion) }
317
+ }
318
+ return
319
+ case ("Speech", "speak"):
320
+ // Settles when the words have been said (true) or were cut off
321
+ // (false), as the page's `craft.speech.speak` does.
322
+ guard let text = args.first as? String, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
323
+ complete(requestToken, failure("INVALID_ARGUMENT", "speech.speak needs some text to say"), completion)
324
+ return
325
+ }
326
+ let options = args.count > 1 ? args[1] as? [String: Any] ?? [:] : [:]
327
+ DispatchQueue.main.async {
328
+ CraftNativeSpeech.shared.speak(
329
+ text,
330
+ language: options["language"] as? String,
331
+ rate: (options["rate"] as? NSNumber)?.doubleValue,
332
+ interrupt: options["interrupt"] as? Bool ?? true
333
+ ) { spoken in complete(requestToken, .success(spoken), completion) }
334
+ }
335
+ return
336
+ case ("Speech", "stop"):
337
+ DispatchQueue.main.async {
338
+ CraftNativeSpeech.shared.stop()
339
+ complete(requestToken, .success(true), completion)
340
+ }
341
+ return
342
+ case ("Push", _):
343
+ guard config.enablePushNotifications else {
344
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Push notifications are disabled"), completion)
345
+ return
346
+ }
347
+ switch method {
348
+ case "getPermissionStatus":
349
+ CraftNativePush.status { complete(requestToken, .success($0), completion) }
350
+ case "requestPermission":
351
+ CraftNativePush.requestPermission { complete(requestToken, .success($0), completion) }
352
+ case "register":
353
+ CraftNativePush.register { complete(requestToken, $0, completion) }
354
+ case "openSettings":
355
+ DispatchQueue.main.async {
356
+ guard let url = URL(string: UIApplication.openSettingsURLString) else {
357
+ complete(requestToken, .success(false), completion)
358
+ return
359
+ }
360
+ UIApplication.shared.open(url) { complete(requestToken, .success($0), completion) }
361
+ }
362
+ default:
363
+ complete(requestToken, failure("UNKNOWN_ACTION", "Unsupported Push method"), completion)
364
+ }
365
+ return
210
366
  case ("Notifications", _):
211
367
  guard config.enableLocalNotifications else {
212
368
  complete(requestToken, failure("CAPABILITY_DISABLED", "Local notifications are disabled"), completion)