@craft-native/ios 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.
package/README.md CHANGED
@@ -555,7 +555,7 @@ window.addEventListener('craftMusicChange', (event) => render(event.detail));
555
555
  // { available: false } without doing anything.
556
556
  // The simulator's Music app has nothing to play: launch with
557
557
  // `-CraftFakeMusicPlayer YES` (or `simctl spawn <device> defaults write <bundle id>
558
- // CraftFakeMusicPlayer -bool YES`) for three made-up songs that play and skip.
558
+ // CraftFakeMusicPlayer -bool YES`) for three made-up songs that play (counting as other audio) and skip.
559
559
  // Simulator builds only; a device always talks to the Music app.
560
560
 
561
561
  // Speech: short spoken cues that duck the person's music instead of stopping
package/dist/cli.js CHANGED
@@ -734,6 +734,7 @@ async function init(options) {
734
734
  cpSync(join(TEMPLATES_DIR, "CraftMusic.swift"), join(output, "Sources", "CraftMusic.swift"));
735
735
  cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
736
736
  cpSync(join(TEMPLATES_DIR, "CraftNativeUI.swift"), join(output, "Sources", "CraftNativeUI.swift"));
737
+ cpSync(join(TEMPLATES_DIR, "CraftNativeServices.swift"), join(output, "Sources", "CraftNativeServices.swift"));
737
738
  cpSync(join(TEMPLATES_DIR, "CraftHybrid.swift"), join(output, "Sources", "CraftHybrid.swift"));
738
739
  const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
739
740
  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>
package/dist/index.js CHANGED
@@ -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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.145",
3
+ "version": "0.0.147",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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)