craft-native 0.0.145 → 0.0.147

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.145";
3306
+ var version = "0.0.147";
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.145";
313
+ var version = "0.0.147";
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.145";
61
+ var version = "0.0.147";
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"]
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
  }
@@ -31,6 +31,11 @@ protocol CraftMusicPlayer: AnyObject {
31
31
  var currentPlaybackTime: TimeInterval { get }
32
32
  /// Called on main whenever the song or the playback state changes.
33
33
  var onChange: (() -> Void)? { get set }
34
+ /// Sound this player makes that iOS cannot see as another app's: only
35
+ /// the simulator's stand-in, which plays nothing yet should read like
36
+ /// the Music app playing. Read before access is allowed, so the real
37
+ /// player answers false without touching the Music app.
38
+ var soundsLikeOtherAudio: Bool { get }
34
39
  func play()
35
40
  func pause()
36
41
  func skipToNextItem()
@@ -139,7 +144,7 @@ final class CraftMusic {
139
144
  var result: [String: Any] = [
140
145
  "available": true,
141
146
  "authorization": authorization,
142
- "otherAudioPlaying": AVAudioSession.sharedInstance().isOtherAudioPlaying,
147
+ "otherAudioPlaying": AVAudioSession.sharedInstance().isOtherAudioPlaying || player.soundsLikeOtherAudio,
143
148
  "timestamp": Int(Date().timeIntervalSince1970 * 1000),
144
149
  "source": NSNull(),
145
150
  "playbackState": "unknown",
@@ -277,6 +282,7 @@ final class CraftMusic {
277
282
  /// `MPMusicPlayerController.systemMusicPlayer`: the Music app's queue.
278
283
  final class CraftSystemMusicPlayer: CraftMusicPlayer {
279
284
  let source = "appleMusic"
285
+ let soundsLikeOtherAudio = false
280
286
  var onChange: (() -> Void)?
281
287
  private var observers: [NSObjectProtocol] = []
282
288
  private var player: MPMusicPlayerController { MPMusicPlayerController.systemMusicPlayer }
@@ -330,8 +336,9 @@ final class CraftSystemMusicPlayer: CraftMusicPlayer {
330
336
 
331
337
  // MARK: - The simulator's stand-in
332
338
 
333
- /// Three made-up songs that play, pause and skip like the Music app, so the
334
- /// bridge, the page's bar and its events can be tried in the simulator.
339
+ /// Three made-up songs that play (from launch), pause and skip like the Music
340
+ /// app, and count as other audio while they play, so the
341
+ /// bridge, a page's offer, its bar and its events can be tried in the simulator.
335
342
  /// Never used on a device (see `CraftMusic.makePlayer`).
336
343
  final class CraftFakeMusicPlayer: CraftMusicPlayer {
337
344
  let source = "simulated"
@@ -344,10 +351,14 @@ final class CraftFakeMusicPlayer: CraftMusicPlayer {
344
351
  ("Cooldown", "Recovery Club", "Easy Days", 187, 0.33),
345
352
  ]
346
353
  private var index = 0
347
- private var state: MPMusicPlaybackState = .paused
354
+ /// Playing from the start, as when someone opens the app with music on:
355
+ /// that is what a page offering its controls waits for.
356
+ private var state: MPMusicPlaybackState = .playing
348
357
  /// Where the song was when it last started or stopped, and when that was.
349
358
  private var offset: TimeInterval = 0
350
- private var startedAt: Date?
359
+ private var startedAt: Date? = Date()
360
+
361
+ var soundsLikeOtherAudio: Bool { state == .playing }
351
362
 
352
363
  var item: CraftMusicItem? {
353
364
  let song = songs[index]
@@ -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,15 @@ 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") }
51
62
  return values
52
63
  }
53
64
 
@@ -207,6 +218,131 @@ enum CraftNativeActions {
207
218
  complete(requestToken, result.mapError { CraftNativeActionError(code: $0.code, message: $0.message) }, completion)
208
219
  }
209
220
  return
221
+ case ("KeepAwake", "set"):
222
+ guard config.enableKeepAwake else {
223
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Keep awake is disabled"), completion)
224
+ return
225
+ }
226
+ let enabled = args.first as? Bool ?? true
227
+ DispatchQueue.main.async {
228
+ UIApplication.shared.isIdleTimerDisabled = enabled
229
+ complete(requestToken, .success(enabled), completion)
230
+ }
231
+ return
232
+ case ("Brightness", "get"):
233
+ DispatchQueue.main.async { complete(requestToken, .success(Double(UIScreen.main.brightness)), completion) }
234
+ return
235
+ case ("Brightness", "set"):
236
+ // The device's backlight, system-wide: a screen that dims it reads
237
+ // it first and puts it back, as @stacksjs/mobile's brightness does.
238
+ guard let level = (args.first as? NSNumber)?.doubleValue, level.isFinite else {
239
+ complete(requestToken, failure("INVALID_ARGUMENT", "Brightness.set needs a level from 0 to 1"), completion)
240
+ return
241
+ }
242
+ DispatchQueue.main.async {
243
+ UIScreen.main.brightness = CGFloat(min(max(level, 0), 1))
244
+ complete(requestToken, .success(Double(UIScreen.main.brightness)), completion)
245
+ }
246
+ return
247
+ case ("StatusBar", "setStyle"):
248
+ let style = args.first as? String ?? "default"
249
+ DispatchQueue.main.async {
250
+ complete(requestToken, .success(CraftStatusBar.setStyle(style == "light" || style == "dark" ? style : "default")), completion)
251
+ }
252
+ return
253
+ case ("Location", _):
254
+ // The page's own GPS and route recorder, in the web view's
255
+ // coordinator: one recorder, which keeps recording in the
256
+ // background and survives a relaunch, whichever side started it.
257
+ guard config.enableGeolocation else {
258
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Geolocation is disabled"), completion)
259
+ return
260
+ }
261
+ let actions = [
262
+ "getCurrentPosition": "getCurrentPosition", "watchPosition": "watchPosition", "clearWatch": "clearWatch",
263
+ "startRecording": "startLocationRecording", "pauseRecording": "pauseLocationRecording",
264
+ "resumeRecording": "resumeLocationRecording", "stopRecording": "stopLocationRecording",
265
+ "getRecordingState": "getLocationRecordingState", "readRecording": "readLocationRecording",
266
+ ]
267
+ guard let action = actions[method] else {
268
+ complete(requestToken, failure("UNKNOWN_ACTION", "Unsupported Location method"), completion)
269
+ return
270
+ }
271
+ CraftWebServices.perform(action: action, body: args.first as? [String: Any] ?? [:]) { complete(requestToken, $0, completion) }
272
+ return
273
+ case ("Linking", "openURL"), ("Linking", "canOpenURL"):
274
+ // Another app or the system: `webcal:` subscriptions, `mailto:`,
275
+ // `tel:`, the App Store, this app's page in Settings
276
+ // (`UIApplication.openSettingsURLString`, which is `app-settings:`).
277
+ guard let address = args.first as? String, let url = URL(string: address), url.scheme != nil else {
278
+ complete(requestToken, failure("INVALID_ARGUMENT", "Linking.\(method) needs a URL it can parse"), completion)
279
+ return
280
+ }
281
+ DispatchQueue.main.async {
282
+ if method == "canOpenURL" {
283
+ complete(requestToken, .success(UIApplication.shared.canOpenURL(url)), completion)
284
+ } else {
285
+ UIApplication.shared.open(url) { complete(requestToken, .success($0), completion) }
286
+ }
287
+ }
288
+ return
289
+ case ("Share", "share"):
290
+ guard config.enableShare else {
291
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Sharing is disabled"), completion)
292
+ return
293
+ }
294
+ let options = args.first as? [String: Any] ?? [:]
295
+ DispatchQueue.main.async {
296
+ CraftNativeShare.share(options) { complete(requestToken, $0, completion) }
297
+ }
298
+ return
299
+ case ("Speech", "speak"):
300
+ // Settles when the words have been said (true) or were cut off
301
+ // (false), as the page's `craft.speech.speak` does.
302
+ guard let text = args.first as? String, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
303
+ complete(requestToken, failure("INVALID_ARGUMENT", "speech.speak needs some text to say"), completion)
304
+ return
305
+ }
306
+ let options = args.count > 1 ? args[1] as? [String: Any] ?? [:] : [:]
307
+ DispatchQueue.main.async {
308
+ CraftNativeSpeech.shared.speak(
309
+ text,
310
+ language: options["language"] as? String,
311
+ rate: (options["rate"] as? NSNumber)?.doubleValue,
312
+ interrupt: options["interrupt"] as? Bool ?? true
313
+ ) { spoken in complete(requestToken, .success(spoken), completion) }
314
+ }
315
+ return
316
+ case ("Speech", "stop"):
317
+ DispatchQueue.main.async {
318
+ CraftNativeSpeech.shared.stop()
319
+ complete(requestToken, .success(true), completion)
320
+ }
321
+ return
322
+ case ("Push", _):
323
+ guard config.enablePushNotifications else {
324
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Push notifications are disabled"), completion)
325
+ return
326
+ }
327
+ switch method {
328
+ case "getPermissionStatus":
329
+ CraftNativePush.status { complete(requestToken, .success($0), completion) }
330
+ case "requestPermission":
331
+ CraftNativePush.requestPermission { complete(requestToken, .success($0), completion) }
332
+ case "register":
333
+ CraftNativePush.register { complete(requestToken, $0, completion) }
334
+ case "openSettings":
335
+ DispatchQueue.main.async {
336
+ guard let url = URL(string: UIApplication.openSettingsURLString) else {
337
+ complete(requestToken, .success(false), completion)
338
+ return
339
+ }
340
+ UIApplication.shared.open(url) { complete(requestToken, .success($0), completion) }
341
+ }
342
+ default:
343
+ complete(requestToken, failure("UNKNOWN_ACTION", "Unsupported Push method"), completion)
344
+ }
345
+ return
210
346
  case ("Notifications", _):
211
347
  guard config.enableLocalNotifications else {
212
348
  complete(requestToken, failure("CAPABILITY_DISABLED", "Local notifications are disabled"), completion)