craft-native 0.0.143 → 0.0.145

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.
@@ -762,6 +762,69 @@ export declare const speech: {
762
762
  /** Stop speaking at once. Every pending `speak()` resolves `false`. */
763
763
  stop(): Promise<void>;
764
764
  };
765
+ export type MusicAuthorization = 'authorized' | 'denied' | 'restricted' | 'notDetermined' | 'unavailable';
766
+ export type MusicPlaybackState = 'stopped' | 'playing' | 'paused' | 'interrupted' | 'seekingForward' | 'seekingBackward' | 'unknown';
767
+ export interface NowPlaying {
768
+ /** False where there is no Music app to ask: a browser, Android, an app built without `enableAppleMusic`. */
769
+ available: boolean;
770
+ authorization: MusicAuthorization;
771
+ /** `appleMusic`, or `simulated` for the simulator's stand-in; null without access. */
772
+ source: 'appleMusic' | 'simulated' | null;
773
+ playbackState: MusicPlaybackState;
774
+ isPlaying: boolean;
775
+ itemId: string | null;
776
+ title: string | null;
777
+ artist: string | null;
778
+ album: string | null;
779
+ /** Seconds. */
780
+ duration: number | null;
781
+ /** Seconds into the song at `timestamp`. */
782
+ position: number | null;
783
+ /** A small square JPEG (120 px) as a data URL, when the song has artwork. */
784
+ artworkDataURL: string | null;
785
+ /** Some app is playing audio, Apple Music included: read it beside `isPlaying`. */
786
+ otherAudioPlaying: boolean;
787
+ /** When the snapshot was taken, in ms since the epoch. */
788
+ timestamp: number;
789
+ }
790
+ export interface MusicSkipOptions {
791
+ /**
792
+ * Past this many seconds into a song, go back to its start instead of the
793
+ * previous song, as the Music app's own button does. 3 by default; 0
794
+ * always goes to the previous song.
795
+ */
796
+ restartAfter?: number;
797
+ }
798
+ export type MusicMethod = 'getAuthorizationStatus' | 'requestAuthorization' | 'getNowPlaying' | 'play' | 'pause' | 'togglePlayPause' | 'skipToNext' | 'skipToPrevious' | 'observe';
799
+ /** A snapshot from native, made safe to render whatever an older or newer build sent. */
800
+ export declare function normalizeNowPlaying(value: unknown): NowPlaying;
801
+ export declare const music: {
802
+ /** Whether this app can show and steer the Music app. */
803
+ isAvailable(): boolean;
804
+ /** Whether this app build answers `method`; an older build may lack one. */
805
+ supports(method: MusicMethod): boolean;
806
+ /** Where access stands, without asking. `unavailable` off a phone. */
807
+ getAuthorizationStatus(): Promise<MusicAuthorization>;
808
+ /**
809
+ * Ask for media library access: iOS's prompt the first time, its
810
+ * remembered answer after that. Call it from the person's own tap.
811
+ */
812
+ requestAuthorization(): Promise<MusicAuthorization>;
813
+ /** The song, its artwork and the playback state, as one snapshot. */
814
+ getNowPlaying(): Promise<NowPlaying>;
815
+ /** Each resolves with the snapshot once the Music app has moved. */
816
+ play: () => Promise<NowPlaying>;
817
+ pause: () => Promise<NowPlaying>;
818
+ togglePlayPause: () => Promise<NowPlaying>;
819
+ skipToNext: () => Promise<NowPlaying>;
820
+ skipToPrevious: (options?: MusicSkipOptions) => Promise<NowPlaying>;
821
+ /**
822
+ * Hear every change of song or playback state, pushed by native rather
823
+ * than polled. The first listener starts native observation and the last
824
+ * one to leave stops it. Off a phone it never fires.
825
+ */
826
+ onChange(callback: (nowPlaying: NowPlaying) => void): () => void;
827
+ };
765
828
  export declare const deepLinks: {
766
829
  getInitialURL(): Promise<string | null>;
767
830
  /**
@@ -1244,6 +1307,7 @@ declare const mobile: {
1244
1307
  keepAwake: typeof keepAwake;
1245
1308
  brightness: typeof brightness;
1246
1309
  speech: typeof speech;
1310
+ music: typeof music;
1247
1311
  deepLinks: typeof deepLinks;
1248
1312
  network: typeof network;
1249
1313
  appReview: typeof appReview;
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.143";
3306
+ var version = "0.0.145";
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.143";
313
+ var version = "0.0.145";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
@@ -5324,6 +5324,7 @@ var notifications = {
5324
5324
  }
5325
5325
  };
5326
5326
  var speakingOnTheWeb = new Set;
5327
+ var musicWatch = { listeners: new Set, stop: null };
5327
5328
  function onCraftEvent(eventName, callback) {
5328
5329
  if (typeof globalThis === "undefined")
5329
5330
  return () => {};
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.143";
61
+ var version = "0.0.145";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -5084,6 +5084,7 @@ var notifications = {
5084
5084
  }
5085
5085
  };
5086
5086
  var speakingOnTheWeb = new Set;
5087
+ var musicWatch = { listeners: new Set, stop: null };
5087
5088
  function onCraftEvent(eventName, callback) {
5088
5089
  if (typeof globalThis === "undefined")
5089
5090
  return () => {};
@@ -60,6 +60,20 @@ export interface CraftConfig {
60
60
  enablePDFViewer?: boolean;
61
61
  enableAR?: boolean;
62
62
  enableMLKit?: boolean;
63
+ /**
64
+ * Show and control what the Music app is playing (`craft.music`, through
65
+ * MPMusicPlayerController.systemMusicPlayer). Only Apple Music: iOS gives
66
+ * no app the controls of another player such as Spotify. iOS asks the
67
+ * person for media library access the first time the page calls
68
+ * `craft.music.requestAuthorization()`, never at launch.
69
+ */
70
+ enableAppleMusic?: boolean;
71
+ /**
72
+ * Purpose strings in the app's own words, by Info.plist key
73
+ * (`{ NSAppleMusicUsageDescription: 'Shows the song playing during a workout.' }`).
74
+ * Each replaces the generic sentence Craft writes for that key.
75
+ */
76
+ usageDescriptions?: Record<string, string>;
63
77
  devServerURL?: string;
64
78
  iosVersion?: string;
65
79
  watchosVersion?: string;
@@ -169,6 +169,7 @@ var DEFAULT_CONFIG = {
169
169
  enablePDFViewer: false,
170
170
  enableAR: false,
171
171
  enableMLKit: false,
172
+ enableAppleMusic: false,
172
173
  iosVersion: "16.0",
173
174
  watchosVersion: "9.0",
174
175
  teamId: "",
@@ -212,7 +213,8 @@ var LINKED_PURPOSES = {
212
213
  NSCalendarsUsageDescription: "your calendar",
213
214
  NSBluetoothAlwaysUsageDescription: "Bluetooth",
214
215
  NSMotionUsageDescription: "motion data",
215
- NSFaceIDUsageDescription: "Face ID"
216
+ NSFaceIDUsageDescription: "Face ID",
217
+ NSAppleMusicUsageDescription: "your music"
216
218
  };
217
219
  function renderUsageDescriptions(config) {
218
220
  const entries = [
@@ -230,9 +232,14 @@ function renderUsageDescriptions(config) {
230
232
  [config.enableLocalNetwork, "NSLocalNetworkUsageDescription", `${config.appName} connects to your own devices on this network.`],
231
233
  [config.enableHealthKit, "NSHealthShareUsageDescription", `${config.appName} reads health data you choose to share.`],
232
234
  [config.enableHealthKit, "NSHealthUpdateUsageDescription", `${config.appName} writes health data only with your permission.`],
233
- [config.enableBiometric, "NSFaceIDUsageDescription", `${config.appName} uses Face ID to protect your account.`]
235
+ [config.enableBiometric, "NSFaceIDUsageDescription", `${config.appName} uses Face ID to protect your account.`],
236
+ [config.enableAppleMusic, "NSAppleMusicUsageDescription", `${config.appName} shows and controls the music playing in the Music app.`]
234
237
  ];
238
+ const own = config.usageDescriptions ?? {};
235
239
  return entries.map(([enabled, key, value]) => {
240
+ const custom = own[key]?.trim();
241
+ if (custom)
242
+ return [key, custom];
236
243
  if (enabled)
237
244
  return [key, value];
238
245
  const linked = LINKED_PURPOSES[key];
@@ -779,6 +786,7 @@ async function init(options) {
779
786
  cpSync(join(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join(output, "Sources", "CraftNativeFlatList.swift"));
780
787
  cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
781
788
  cpSync(join(TEMPLATES_DIR, "CraftHealth.swift"), join(output, "Sources", "CraftHealth.swift"));
789
+ cpSync(join(TEMPLATES_DIR, "CraftMusic.swift"), join(output, "Sources", "CraftMusic.swift"));
782
790
  cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
783
791
  cpSync(join(TEMPLATES_DIR, "CraftNativeUI.swift"), join(output, "Sources", "CraftNativeUI.swift"));
784
792
  cpSync(join(TEMPLATES_DIR, "CraftHybrid.swift"), join(output, "Sources", "CraftHybrid.swift"));
@@ -1105,6 +1105,8 @@ struct CraftConfig: Codable {
1105
1105
  var enablePDFViewer: Bool = false
1106
1106
  var enableAR: Bool = false
1107
1107
  var enableMLKit: Bool = false
1108
+ /// The Music app's song and transport through `craft.music` (CraftMusic.swift).
1109
+ var enableAppleMusic: Bool = false
1108
1110
  var devServerURL: String? = nil
1109
1111
  var trustedOrigins: [String] = []
1110
1112
  /// The longest the launch splash stays up when the page neither says it is
@@ -2162,6 +2164,43 @@ struct CraftWebView: UIViewRepresentable {
2162
2164
  } else {
2163
2165
  rejectCallback(callbackId, error: "setBrightness was called without the values it needs", code: "INVALID_ARGUMENT")
2164
2166
  }
2167
+ // MARK: - Apple Music (CraftMusic.swift)
2168
+ // Gated on enableAppleMusic. Only musicRequestAuthorization asks
2169
+ // the person anything; the rest answer from what is allowed.
2170
+ case "musicGetAuthorization":
2171
+ if config.enableAppleMusic {
2172
+ DispatchQueue.main.async { self.resolveCallback(callbackId, result: CraftMusic.shared.authorization) }
2173
+ } else {
2174
+ rejectCallback(callbackId, error: "Apple Music is disabled", code: "CAPABILITY_DISABLED")
2175
+ }
2176
+ case "musicRequestAuthorization":
2177
+ if config.enableAppleMusic {
2178
+ DispatchQueue.main.async {
2179
+ CraftMusic.shared.requestAuthorization { [weak self] status in self?.resolveCallback(callbackId, result: status) }
2180
+ }
2181
+ } else {
2182
+ rejectCallback(callbackId, error: "Apple Music is disabled", code: "CAPABILITY_DISABLED")
2183
+ }
2184
+ case "musicGetNowPlaying":
2185
+ if config.enableAppleMusic {
2186
+ DispatchQueue.main.async { self.resolveCallback(callbackId, result: CraftMusic.shared.snapshot()) }
2187
+ } else {
2188
+ rejectCallback(callbackId, error: "Apple Music is disabled", code: "CAPABILITY_DISABLED")
2189
+ }
2190
+ case "musicControl":
2191
+ if !config.enableAppleMusic {
2192
+ rejectCallback(callbackId, error: "Apple Music is disabled", code: "CAPABILITY_DISABLED")
2193
+ } else if let command = body["command"] as? String {
2194
+ musicControl(command, restartAfter: (body["restartAfter"] as? NSNumber)?.doubleValue, callbackId: callbackId)
2195
+ } else {
2196
+ rejectCallback(callbackId, error: "musicControl was called without a command", code: "INVALID_ARGUMENT")
2197
+ }
2198
+ case "musicObserve":
2199
+ if config.enableAppleMusic {
2200
+ musicObserve(body["enabled"] as? Bool ?? true, callbackId: callbackId)
2201
+ } else {
2202
+ rejectCallback(callbackId, error: "Apple Music is disabled", code: "CAPABILITY_DISABLED")
2203
+ }
2165
2204
  // MARK: - Orientation Lock
2166
2205
  case "lockOrientation":
2167
2206
  if config.enableOrientationLock {
@@ -3146,6 +3185,7 @@ struct CraftWebView: UIViewRepresentable {
3146
3185
  deepLinks: \(config.enableDeepLinks),
3147
3186
  flashlight: true,
3148
3187
  brightness: true,
3188
+ music: \(config.enableAppleMusic),
3149
3189
  speech: true,
3150
3190
  network: true,
3151
3191
  deviceInfo: true,
@@ -4684,6 +4724,7 @@ struct CraftWebView: UIViewRepresentable {
4684
4724
  // still open; these wait for the answer instead.
4685
4725
  var personFacing = {
4686
4726
  requestHealthAuthorization: true,
4727
+ musicRequestAuthorization: true,
4687
4728
  requestPermission: true,
4688
4729
  signInWithApple: true,
4689
4730
  dialogAlert: true,
@@ -4881,6 +4922,23 @@ struct CraftWebView: UIViewRepresentable {
4881
4922
  return craft._invoke('getHealthSleepIntervals', {startDate: options.startDate, endDate: options.endDate});
4882
4923
  }
4883
4924
  };
4925
+ // The Music app's song and transport. Nothing is read until
4926
+ // requestAuthorization() has been allowed; observe(true) makes
4927
+ // every change arrive as a craftMusicChange event.
4928
+ craft.music = {
4929
+ getAuthorizationStatus: function() { return craft._invoke('musicGetAuthorization'); },
4930
+ requestAuthorization: function() { return craft._invoke('musicRequestAuthorization'); },
4931
+ getNowPlaying: function() { return craft._invoke('musicGetNowPlaying'); },
4932
+ play: function() { return craft._invoke('musicControl', {command: 'play'}); },
4933
+ pause: function() { return craft._invoke('musicControl', {command: 'pause'}); },
4934
+ togglePlayPause: function() { return craft._invoke('musicControl', {command: 'togglePlayPause'}); },
4935
+ skipToNext: function() { return craft._invoke('musicControl', {command: 'skipToNext'}); },
4936
+ skipToPrevious: function(options) {
4937
+ var restartAfter = options && typeof options.restartAfter === 'number' ? options.restartAfter : null;
4938
+ return craft._invoke('musicControl', {command: 'skipToPrevious', restartAfter: restartAfter});
4939
+ },
4940
+ observe: function(enabled) { return craft._invoke('musicObserve', {enabled: enabled !== false}); }
4941
+ };
4884
4942
  craft.liveActivity = {
4885
4943
  start: function(options) { return craft._invoke('startLiveActivity', options || {}); },
4886
4944
  update: function(idOrState, state) {
@@ -6636,17 +6694,14 @@ struct CraftWebView: UIViewRepresentable {
6636
6694
 
6637
6695
  // MARK: - Social Auth (Apple Sign In)
6638
6696
  private func signInWithApple(nonce: String?, callbackId: String?) {
6697
+ // The same sheet a native screen asks for (CraftNativeUI.swift).
6639
6698
  DispatchQueue.main.async {
6640
- self.pendingCallbackId = callbackId
6641
- let provider = ASAuthorizationAppleIDProvider()
6642
- let request = provider.createRequest()
6643
- request.requestedScopes = [.fullName, .email]
6644
- if let nonce, !nonce.isEmpty { request.nonce = nonce }
6645
-
6646
- let controller = ASAuthorizationController(authorizationRequests: [request])
6647
- controller.delegate = self
6648
- controller.presentationContextProvider = self
6649
- controller.performRequests()
6699
+ CraftSignInWithApple.shared.signIn(nonce: nonce) { [weak self] result in
6700
+ switch result {
6701
+ case .success(let value): self?.resolveCallback(callbackId, result: value)
6702
+ case .failure(let failure): self?.rejectCallback(callbackId, error: failure.message, code: failure.code)
6703
+ }
6704
+ }
6650
6705
  }
6651
6706
  }
6652
6707
 
@@ -6932,6 +6987,29 @@ struct CraftWebView: UIViewRepresentable {
6932
6987
  CraftHealth.shared.getSleepIntervals(startDate: startDate, endDate: endDate) { [weak self] in self?.answer(callbackId, $0) }
6933
6988
  }
6934
6989
 
6990
+ // MARK: - Apple Music
6991
+ // The player itself is CraftMusic.swift.
6992
+ private func musicControl(_ command: String, restartAfter: Double?, callbackId: String?) {
6993
+ DispatchQueue.main.async {
6994
+ CraftMusic.shared.control(command, restartAfter: restartAfter) { [weak self] result in
6995
+ switch result {
6996
+ case .success(let snapshot): self?.resolveCallback(callbackId, result: snapshot)
6997
+ case .failure(let failure): self?.rejectCallback(callbackId, error: failure.message, code: failure.code)
6998
+ }
6999
+ }
7000
+ }
7001
+ }
7002
+
7003
+ /// Every change of song or playback state reaches the page as
7004
+ /// `craftMusicChange`, carrying the same snapshot musicGetNowPlaying
7005
+ /// answers with.
7006
+ private func musicObserve(_ enabled: Bool, callbackId: String?) {
7007
+ DispatchQueue.main.async {
7008
+ CraftMusic.shared.observe(enabled ? { [weak self] snapshot in self?.sendToWeb("craftMusicChange", data: snapshot) } : nil)
7009
+ self.resolveCallback(callbackId, result: enabled)
7010
+ }
7011
+ }
7012
+
6935
7013
  // MARK: - Live Activities
6936
7014
  private func startLiveActivity(body: [String: Any], callbackId: String?) {
6937
7015
  guard #available(iOS 16.2, *), ActivityAuthorizationInfo().areActivitiesEnabled else {
@@ -7887,56 +7965,6 @@ extension CraftWebView.Coordinator: UIDocumentPickerDelegate {
7887
7965
  }
7888
7966
  }
7889
7967
 
7890
- // MARK: - Apple Sign In Delegate
7891
- extension CraftWebView.Coordinator: ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding {
7892
- func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
7893
- if let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential {
7894
- let userId = appleIDCredential.user
7895
- let email = appleIDCredential.email
7896
- let fullName = appleIDCredential.fullName
7897
-
7898
- var name = ""
7899
- if let givenName = fullName?.givenName {
7900
- name = givenName
7901
- }
7902
- if let familyName = fullName?.familyName {
7903
- name += (name.isEmpty ? "" : " ") + familyName
7904
- }
7905
-
7906
- var result: [String: Any] = ["userId": userId]
7907
- if let email = email { result["email"] = email }
7908
- if !name.isEmpty { result["name"] = name }
7909
-
7910
- if let identityToken = appleIDCredential.identityToken,
7911
- let tokenString = String(data: identityToken, encoding: .utf8) {
7912
- result["identityToken"] = tokenString
7913
- }
7914
- // One use, five minutes: the server redeems it at Apple's token
7915
- // endpoint and so reads the identity straight from Apple.
7916
- if let authorizationCode = appleIDCredential.authorizationCode,
7917
- let codeString = String(data: authorizationCode, encoding: .utf8) {
7918
- result["authorizationCode"] = codeString
7919
- }
7920
-
7921
- resolveCallback(pendingCallbackId, result: result)
7922
- }
7923
- pendingCallbackId = nil
7924
- }
7925
-
7926
- func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
7927
- rejectCallback(pendingCallbackId, error: error.localizedDescription)
7928
- pendingCallbackId = nil
7929
- }
7930
-
7931
- func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
7932
- guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
7933
- let window = windowScene.windows.first else {
7934
- return UIWindow()
7935
- }
7936
- return window
7937
- }
7938
- }
7939
-
7940
7968
  // MARK: - Bluetooth Delegate
7941
7969
  extension CraftWebView.Coordinator: CBCentralManagerDelegate {
7942
7970
  func centralManagerDidUpdateState(_ central: CBCentralManager) {
@@ -247,8 +247,33 @@ final class CraftHybrid: NSObject {
247
247
  fileprivate(set) var pageDepth = 0
248
248
  fileprivate(set) var pageReady = false
249
249
 
250
+ /// Shared keys a native screen wrote, by the page's own key (NSNull for a
251
+ /// removal), until the page has them: it reports each back as its own
252
+ /// write, which is when it leaves this list.
253
+ private var pendingShared: [String: Any] = [:]
254
+
250
255
  private override init() {}
251
256
 
257
+ /// A native screen wrote a key the page shares (`shareStorage`, named
258
+ /// here by its native name): both Keychain copies, and the page's own
259
+ /// storage, now if it is loaded or when it next loads. Nothing for a key
260
+ /// the config does not share.
261
+ func nativeWroteShared(nativeKey: String, value: String?) {
262
+ guard let webKey = config.shareStorage?.first(where: { $0.value == nativeKey })?.key else { return }
263
+ CraftHybridKeychain.set(nativeKey, value: value)
264
+ DispatchQueue.main.async {
265
+ self.pendingShared[webKey] = value ?? NSNull()
266
+ self.flushShared()
267
+ }
268
+ }
269
+
270
+ private func flushShared() {
271
+ guard pageReady, let webView, !pendingShared.isEmpty,
272
+ let data = try? JSONSerialization.data(withJSONObject: pendingShared),
273
+ let json = String(data: data, encoding: .utf8) else { return }
274
+ webView.evaluateJavaScript("window.__craftHybrid && window.__craftHybrid.adopt && window.__craftHybrid.adopt(\(json))")
275
+ }
276
+
252
277
  static var bundleURL: URL? {
253
278
  Bundle.main.url(forResource: "native-screen", withExtension: "js", subdirectory: "dist")
254
279
  ?? Bundle.main.url(forResource: "native-screen", withExtension: "js")
@@ -511,8 +536,26 @@ final class CraftHybrid: NSObject {
511
536
  SHARE.forEach(share);
512
537
  }
513
538
 
539
+ // A shared key a native screen wrote (a sign-in there): into this
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) {
543
+ Object.keys(values || {}).forEach(function (key) {
544
+ if (SHARE.indexOf(key) < 0) return;
545
+ var value = values[key];
546
+ try {
547
+ window.sessionStorage.removeItem(key);
548
+ if (value === null) window.localStorage.removeItem(key);
549
+ else window.localStorage.setItem(key, String(value));
550
+ } catch (e) {}
551
+ share(key);
552
+ try { window.dispatchEvent(new CustomEvent('craftsharedstorage', { detail: { key: key, value: value } })); } catch (e) {}
553
+ });
554
+ }
555
+
514
556
  window.__craftHybrid = {
515
557
  match: function (url) { var path = local(url); return path === null ? null : match(path); },
558
+ adopt: adopt,
516
559
  // The shell's own navigation: never handed back to it, and without
517
560
  // the page's slide, since the shell animates the push itself.
518
561
  // Answers once the page has drawn `path` (its router settles before
@@ -593,12 +636,15 @@ final class CraftHybrid: NSObject {
593
636
  for name in names { CraftSnapshots.write(name, json: nil) }
594
637
  case .shareStorage(let key, let value):
595
638
  guard let nativeKey = config.shareStorage?[key] else { return }
639
+ // The page's own write, or its echo of one a native screen made.
640
+ pendingShared.removeValue(forKey: key)
596
641
  CraftHybridKeychain.set(nativeKey, value: value)
597
642
  case .navigated(let path, let direction, let depth, let byHost):
598
643
  pagePath = path
599
644
  pageDepth = depth
600
645
  if direction == "load" { pageReady = true }
601
646
  controller?.pageNavigated(path: path, direction: direction, depth: depth, byHost: byHost)
647
+ if direction == "load" { flushShared() }
602
648
  case .navigateNative(let path, let replace):
603
649
  controller?.showNative(path: path, replace: replace)
604
650
  case .open(let path):
@@ -0,0 +1,413 @@
1
+ import AVFoundation
2
+ import Foundation
3
+ import MediaPlayer
4
+ import UIKit
5
+
6
+ /// A music call that did not happen: the message a page sees, and the
7
+ /// bridge's error code.
8
+ struct CraftMusicError: Error {
9
+ let message: String
10
+ var code: String = "CRAFT_ERROR"
11
+ }
12
+
13
+ /// The song a player has up, whoever the player is.
14
+ struct CraftMusicItem {
15
+ let id: String
16
+ let title: String?
17
+ let artist: String?
18
+ let album: String?
19
+ let duration: TimeInterval
20
+ /// The artwork drawn at about `size`, or nil when the song has none.
21
+ let artwork: (CGSize) -> UIImage?
22
+ }
23
+
24
+ /// The slice of MPMusicPlayerController the bridge uses, so the simulator,
25
+ /// whose Music app has nothing to play, can stand a fake in for it.
26
+ protocol CraftMusicPlayer: AnyObject {
27
+ /// "appleMusic" for the Music app, "simulated" for the fake.
28
+ var source: String { get }
29
+ var item: CraftMusicItem? { get }
30
+ var playbackState: MPMusicPlaybackState { get }
31
+ var currentPlaybackTime: TimeInterval { get }
32
+ /// Called on main whenever the song or the playback state changes.
33
+ var onChange: (() -> Void)? { get set }
34
+ func play()
35
+ func pause()
36
+ func skipToNextItem()
37
+ func skipToPreviousItem()
38
+ func skipToBeginning()
39
+ func startObserving()
40
+ func stopObserving()
41
+ }
42
+
43
+ /// What the Music app is playing, and its transport: the page's
44
+ /// `craft.music` (CraftApp.swift).
45
+ ///
46
+ /// Through `MPMusicPlayerController.systemMusicPlayer`, which is the Music
47
+ /// app's own queue: what it plays, this sees and steers, and nothing else.
48
+ /// iOS gives no app another player's controls, so while Spotify or a podcast
49
+ /// app plays, `title` is null and `otherAudioPlaying` is true; a page should
50
+ /// say so rather than show controls that do nothing.
51
+ ///
52
+ /// Media library access is asked for only by `requestAuthorization`, which a
53
+ /// page calls when the person chooses to see their music; until then the
54
+ /// player is not touched at all, so nothing is read without consent.
55
+ ///
56
+ /// Everything here runs on main: MediaPlayer wants it, and every caller hops
57
+ /// there first.
58
+ final class CraftMusic {
59
+ typealias Listener = ([String: Any]) -> Void
60
+
61
+ static let shared = CraftMusic()
62
+
63
+ /// The longest side of the artwork a page is sent, in pixels. Small on
64
+ /// purpose: it is a thumbnail beside a title, and it crosses the bridge as
65
+ /// base64 on every change.
66
+ static let artworkPixels: CGFloat = 120
67
+
68
+ private lazy var player: CraftMusicPlayer = {
69
+ let player = CraftMusic.makePlayer()
70
+ player.onChange = { [weak self] in self?.playerDidChange() }
71
+ return player
72
+ }()
73
+
74
+ private var listener: Listener?
75
+ private var observing = false
76
+ private var changeScheduled = false
77
+ private var foregroundObserver: NSObjectProtocol?
78
+ private var artworkCache: (id: String, dataURL: String?)?
79
+
80
+ /// The real player, or on the simulator the fake when the
81
+ /// `CraftFakeMusicPlayer` default is set: as a launch argument
82
+ /// (`-CraftFakeMusicPlayer YES`) or with
83
+ /// `xcrun simctl spawn booted defaults write <bundle id> CraftFakeMusicPlayer -bool YES`.
84
+ private static func makePlayer() -> CraftMusicPlayer {
85
+ #if targetEnvironment(simulator)
86
+ if UserDefaults.standard.bool(forKey: "CraftFakeMusicPlayer") { return CraftFakeMusicPlayer() }
87
+ #endif
88
+ return CraftSystemMusicPlayer()
89
+ }
90
+
91
+ // MARK: Authorization
92
+
93
+ static func authorizationName(_ status: MPMediaLibraryAuthorizationStatus) -> String {
94
+ switch status {
95
+ case .authorized: return "authorized"
96
+ case .denied: return "denied"
97
+ case .restricted: return "restricted"
98
+ case .notDetermined: return "notDetermined"
99
+ @unknown default: return "denied"
100
+ }
101
+ }
102
+
103
+ var authorization: String { Self.authorizationName(MPMediaLibrary.authorizationStatus()) }
104
+ var isAuthorized: Bool { MPMediaLibrary.authorizationStatus() == .authorized }
105
+
106
+ /// Ask once; iOS answers from memory after that. Resolves with the
107
+ /// status's name.
108
+ func requestAuthorization(completion: @escaping (String) -> Void) {
109
+ guard MPMediaLibrary.authorizationStatus() == .notDetermined else {
110
+ completion(authorization)
111
+ return
112
+ }
113
+ MPMediaLibrary.requestAuthorization { status in
114
+ DispatchQueue.main.async {
115
+ completion(Self.authorizationName(status))
116
+ // A page that was already listening hears what there is now.
117
+ if status == .authorized, self.observing { self.startPlayerObservation() }
118
+ }
119
+ }
120
+ }
121
+
122
+ // MARK: Now playing
123
+
124
+ static func playbackStateName(_ state: MPMusicPlaybackState) -> String {
125
+ switch state {
126
+ case .stopped: return "stopped"
127
+ case .playing: return "playing"
128
+ case .paused: return "paused"
129
+ case .interrupted: return "interrupted"
130
+ case .seekingForward: return "seekingForward"
131
+ case .seekingBackward: return "seekingBackward"
132
+ @unknown default: return "stopped"
133
+ }
134
+ }
135
+
136
+ /// Everything a page shows about the music, in one answer. Without
137
+ /// access, only what can be said without touching the player.
138
+ func snapshot() -> [String: Any] {
139
+ var result: [String: Any] = [
140
+ "available": true,
141
+ "authorization": authorization,
142
+ "otherAudioPlaying": AVAudioSession.sharedInstance().isOtherAudioPlaying,
143
+ "timestamp": Int(Date().timeIntervalSince1970 * 1000),
144
+ "source": NSNull(),
145
+ "playbackState": "unknown",
146
+ "isPlaying": false,
147
+ "itemId": NSNull(),
148
+ "title": NSNull(),
149
+ "artist": NSNull(),
150
+ "album": NSNull(),
151
+ "duration": NSNull(),
152
+ "position": NSNull(),
153
+ "artworkDataURL": NSNull(),
154
+ ]
155
+ guard isAuthorized else { return result }
156
+
157
+ let player = self.player
158
+ let state = player.playbackState
159
+ result["source"] = player.source
160
+ result["playbackState"] = Self.playbackStateName(state)
161
+ result["isPlaying"] = state == .playing || state == .seekingForward || state == .seekingBackward
162
+ guard let item = player.item else { return result }
163
+
164
+ result["itemId"] = item.id
165
+ result["title"] = Self.orNull(item.title)
166
+ result["artist"] = Self.orNull(item.artist)
167
+ result["album"] = Self.orNull(item.album)
168
+ result["duration"] = Self.orNull(item.duration.isFinite && item.duration > 0 ? item.duration : nil)
169
+ let position = player.currentPlaybackTime
170
+ result["position"] = Self.orNull(position.isFinite && position >= 0 ? position : nil)
171
+ result["artworkDataURL"] = Self.orNull(artworkDataURL(for: item))
172
+ return result
173
+ }
174
+
175
+ private static func orNull(_ value: Any?) -> Any { value ?? NSNull() }
176
+
177
+ /// A small JPEG of the artwork as a data URL, drawn once per song.
178
+ private func artworkDataURL(for item: CraftMusicItem) -> String? {
179
+ if let cached = artworkCache, cached.id == item.id { return cached.dataURL }
180
+ let side = Self.artworkPixels
181
+ var dataURL: String?
182
+ if let image = item.artwork(CGSize(width: side, height: side)) {
183
+ let format = UIGraphicsImageRendererFormat()
184
+ format.scale = 1
185
+ format.opaque = true
186
+ let fitted = UIGraphicsImageRenderer(size: CGSize(width: side, height: side), format: format).image { _ in
187
+ // Fill the square, as a cover does, cropping the long side.
188
+ let scale = max(side / max(image.size.width, 1), side / max(image.size.height, 1))
189
+ let size = CGSize(width: image.size.width * scale, height: image.size.height * scale)
190
+ image.draw(in: CGRect(x: (side - size.width) / 2, y: (side - size.height) / 2, width: size.width, height: size.height))
191
+ }
192
+ if let jpeg = fitted.jpegData(compressionQuality: 0.75) {
193
+ dataURL = "data:image/jpeg;base64," + jpeg.base64EncodedString()
194
+ }
195
+ }
196
+ artworkCache = (item.id, dataURL)
197
+ return dataURL
198
+ }
199
+
200
+ // MARK: Transport
201
+
202
+ /// play, pause, togglePlayPause, skipToNext or skipToPrevious. Resolves
203
+ /// with the snapshot a moment later, once the Music app has moved, so a
204
+ /// page can show the outcome without waiting for the change event.
205
+ ///
206
+ /// skipToPrevious behaves like the Music app's own back button: more
207
+ /// than `restartAfter` seconds into a song (3 unless given) it goes back
208
+ /// to the start of that song; 0 always goes to the previous one.
209
+ func control(_ command: String, restartAfter: Double?, completion: @escaping (Result<[String: Any], CraftMusicError>) -> Void) {
210
+ guard isAuthorized else {
211
+ completion(.failure(CraftMusicError(message: "Music access has not been allowed", code: "NOT_AUTHORIZED")))
212
+ return
213
+ }
214
+ let player = self.player
215
+ switch command {
216
+ case "play":
217
+ player.play()
218
+ case "pause":
219
+ player.pause()
220
+ case "togglePlayPause":
221
+ if player.playbackState == .playing { player.pause() } else { player.play() }
222
+ case "skipToNext":
223
+ player.skipToNextItem()
224
+ case "skipToPrevious":
225
+ let threshold = max(restartAfter ?? 3, 0)
226
+ if threshold > 0, player.currentPlaybackTime > threshold { player.skipToBeginning() } else { player.skipToPreviousItem() }
227
+ default:
228
+ completion(.failure(CraftMusicError(message: "Unknown music command: \(command)", code: "INVALID_ARGUMENT")))
229
+ return
230
+ }
231
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
232
+ completion(.success(self.snapshot()))
233
+ }
234
+ }
235
+
236
+ // MARK: Change events
237
+
238
+ /// Hear every change of song or playback state as a snapshot, or stop
239
+ /// hearing them (`nil`). One listener: the web view's page.
240
+ func observe(_ listener: Listener?) {
241
+ self.listener = listener
242
+ if listener != nil, !observing {
243
+ observing = true
244
+ if isAuthorized { startPlayerObservation() }
245
+ // Notifications stop while the app is suspended; coming back,
246
+ // the page hears where the music is now.
247
+ foregroundObserver = NotificationCenter.default.addObserver(
248
+ forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main
249
+ ) { [weak self] _ in self?.playerDidChange() }
250
+ } else if listener == nil, observing {
251
+ observing = false
252
+ if let observer = foregroundObserver { NotificationCenter.default.removeObserver(observer) }
253
+ foregroundObserver = nil
254
+ if isAuthorized { player.stopObserving() }
255
+ }
256
+ }
257
+
258
+ private func startPlayerObservation() {
259
+ player.startObserving()
260
+ }
261
+
262
+ /// A song change arrives as two or three notifications at once; the page
263
+ /// gets one snapshot for them.
264
+ private func playerDidChange() {
265
+ guard observing, !changeScheduled else { return }
266
+ changeScheduled = true
267
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
268
+ self.changeScheduled = false
269
+ guard self.observing, let listener = self.listener else { return }
270
+ listener(self.snapshot())
271
+ }
272
+ }
273
+ }
274
+
275
+ // MARK: - The Music app
276
+
277
+ /// `MPMusicPlayerController.systemMusicPlayer`: the Music app's queue.
278
+ final class CraftSystemMusicPlayer: CraftMusicPlayer {
279
+ let source = "appleMusic"
280
+ var onChange: (() -> Void)?
281
+ private var observers: [NSObjectProtocol] = []
282
+ private var player: MPMusicPlayerController { MPMusicPlayerController.systemMusicPlayer }
283
+
284
+ var item: CraftMusicItem? {
285
+ guard let item = player.nowPlayingItem else { return nil }
286
+ // A song streamed from the catalog and not in the library has no
287
+ // persistent ID; its store ID, or what it is called, still tells one
288
+ // song from the next.
289
+ let id: String
290
+ if item.persistentID != 0 { id = String(item.persistentID) }
291
+ else if !item.playbackStoreID.isEmpty && item.playbackStoreID != "0" { id = "store:" + item.playbackStoreID }
292
+ else { id = "title:" + (item.title ?? "") + "|" + (item.artist ?? "") }
293
+ let artwork = item.artwork
294
+ return CraftMusicItem(
295
+ id: id,
296
+ title: item.title,
297
+ artist: item.artist ?? item.albumArtist,
298
+ album: item.albumTitle,
299
+ duration: item.playbackDuration,
300
+ artwork: { size in artwork?.image(at: size) }
301
+ )
302
+ }
303
+
304
+ var playbackState: MPMusicPlaybackState { player.playbackState }
305
+ var currentPlaybackTime: TimeInterval { player.currentPlaybackTime }
306
+
307
+ func play() { player.play() }
308
+ func pause() { player.pause() }
309
+ func skipToNextItem() { player.skipToNextItem() }
310
+ func skipToPreviousItem() { player.skipToPreviousItem() }
311
+ func skipToBeginning() { player.skipToBeginning() }
312
+
313
+ func startObserving() {
314
+ guard observers.isEmpty else { return }
315
+ let player = self.player
316
+ player.beginGeneratingPlaybackNotifications()
317
+ let center = NotificationCenter.default
318
+ for name in [Notification.Name.MPMusicPlayerControllerNowPlayingItemDidChange, .MPMusicPlayerControllerPlaybackStateDidChange] {
319
+ observers.append(center.addObserver(forName: name, object: player, queue: .main) { [weak self] _ in self?.onChange?() })
320
+ }
321
+ }
322
+
323
+ func stopObserving() {
324
+ guard !observers.isEmpty else { return }
325
+ observers.forEach { NotificationCenter.default.removeObserver($0) }
326
+ observers.removeAll()
327
+ player.endGeneratingPlaybackNotifications()
328
+ }
329
+ }
330
+
331
+ // MARK: - The simulator's stand-in
332
+
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.
335
+ /// Never used on a device (see `CraftMusic.makePlayer`).
336
+ final class CraftFakeMusicPlayer: CraftMusicPlayer {
337
+ let source = "simulated"
338
+ var onChange: (() -> Void)?
339
+ private var observing = false
340
+
341
+ private let songs: [(title: String, artist: String, album: String, duration: TimeInterval, hue: CGFloat)] = [
342
+ ("Intervals", "The Pacers", "Tempo Run", 214, 0.02),
343
+ ("Long Way Home", "Zone Two", "Base Miles", 251, 0.58),
344
+ ("Cooldown", "Recovery Club", "Easy Days", 187, 0.33),
345
+ ]
346
+ private var index = 0
347
+ private var state: MPMusicPlaybackState = .paused
348
+ /// Where the song was when it last started or stopped, and when that was.
349
+ private var offset: TimeInterval = 0
350
+ private var startedAt: Date?
351
+
352
+ var item: CraftMusicItem? {
353
+ let song = songs[index]
354
+ return CraftMusicItem(
355
+ id: "simulated-\(index)",
356
+ title: song.title,
357
+ artist: song.artist,
358
+ album: song.album,
359
+ duration: song.duration,
360
+ artwork: { size in
361
+ UIGraphicsImageRenderer(size: size).image { context in
362
+ UIColor(hue: song.hue, saturation: 0.7, brightness: 0.85, alpha: 1).setFill()
363
+ context.fill(CGRect(origin: .zero, size: size))
364
+ }
365
+ }
366
+ )
367
+ }
368
+
369
+ var playbackState: MPMusicPlaybackState { state }
370
+ var currentPlaybackTime: TimeInterval {
371
+ guard let startedAt = startedAt else { return offset }
372
+ return min(offset + Date().timeIntervalSince(startedAt), songs[index].duration)
373
+ }
374
+
375
+ func play() {
376
+ guard state != .playing else { return }
377
+ state = .playing
378
+ startedAt = Date()
379
+ changed()
380
+ }
381
+
382
+ func pause() {
383
+ guard state == .playing else { return }
384
+ offset = currentPlaybackTime
385
+ startedAt = nil
386
+ state = .paused
387
+ changed()
388
+ }
389
+
390
+ func skipToNextItem() { move(to: (index + 1) % songs.count) }
391
+ func skipToPreviousItem() { move(to: (index + songs.count - 1) % songs.count) }
392
+
393
+ func skipToBeginning() {
394
+ offset = 0
395
+ if state == .playing { startedAt = Date() }
396
+ changed()
397
+ }
398
+
399
+ func startObserving() { observing = true }
400
+ func stopObserving() { observing = false }
401
+
402
+ private func move(to next: Int) {
403
+ index = next
404
+ offset = 0
405
+ startedAt = state == .playing ? Date() : nil
406
+ changed()
407
+ }
408
+
409
+ private func changed() {
410
+ guard observing else { return }
411
+ DispatchQueue.main.async { self.onChange?() }
412
+ }
413
+ }
@@ -45,6 +45,9 @@ enum CraftNativeActions {
45
45
  if config.enableDeepLinks { values.append("deepLinks") }
46
46
  if config.enableLocalNotifications { values.append("notifications") }
47
47
  if config.enableHealthKit { values.append("health") }
48
+ // The in-app browser and the system's web sign-in sheet (CraftBrowser).
49
+ values.append("browser")
50
+ if config.enableSocialAuth { values.append("appleSignIn") }
48
51
  return values
49
52
  }
50
53
 
@@ -117,6 +120,11 @@ enum CraftNativeActions {
117
120
  return
118
121
  }
119
122
  sync = secureStorage(method: method, args: args)
123
+ // A key the web page shares (`shareStorage`): the page gets it too,
124
+ // so a sign-in on a native screen signs the page in.
125
+ if case .success = sync, ["set", "remove", "delete"].contains(method), let key = args.first as? String {
126
+ CraftHybrid.shared.nativeWroteShared(nativeKey: key, value: method == "set" ? args.count > 1 ? args[1] as? String : nil : nil)
127
+ }
120
128
  case ("Biometrics", "isAvailable"):
121
129
  sync = config.enableBiometric ? .success(biometricContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)) : failure("CAPABILITY_DISABLED", "Biometric authentication is disabled")
122
130
  case ("Biometrics", "getBiometricType"):
@@ -146,6 +154,32 @@ enum CraftNativeActions {
146
154
  }
147
155
  databaseQueue.async { complete(requestToken, database(method: method, args: args), completion) }
148
156
  return
157
+ case ("Browser", "open"):
158
+ // SFSafariViewController, or for `mode: 'auth'` the system's web
159
+ // sign-in sheet, the same as a page's `craft.browser.open`.
160
+ guard let address = args.first as? String, let url = URL(string: address) else {
161
+ complete(requestToken, failure("INVALID_ARGUMENT", "Browser.open needs a URL it can parse"), completion)
162
+ return
163
+ }
164
+ let options = args.count > 1 ? args[1] as? [String: Any] ?? [:] : [:]
165
+ DispatchQueue.main.async {
166
+ CraftBrowser.shared.open(url, mode: options["mode"] as? String ?? "safari", callbackScheme: options["callbackScheme"] as? String, from: nil) { answer in
167
+ complete(requestToken, .success(answer), completion)
168
+ }
169
+ }
170
+ return
171
+ case ("Auth", "signInWithApple"):
172
+ guard config.enableSocialAuth else {
173
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Social sign-in is disabled"), completion)
174
+ return
175
+ }
176
+ let options = args.first as? [String: Any] ?? [:]
177
+ DispatchQueue.main.async {
178
+ CraftSignInWithApple.shared.signIn(nonce: options["nonce"] as? String) { result in
179
+ complete(requestToken, result.mapError { CraftNativeActionError(code: $0.code, message: $0.message) }.map { $0 as Any }, completion)
180
+ }
181
+ }
182
+ return
149
183
  case ("Dialog", _):
150
184
  // The system's own alert, confirmation and action sheet, the same
151
185
  // ones a page gets (CraftNativeUI.swift), over the native screen.
@@ -1,9 +1,11 @@
1
1
  import Foundation
2
2
  import AVFoundation
3
3
  import AVKit
4
+ import CryptoKit
4
5
  import Darwin
5
6
  import JavaScriptCore
6
7
  import OSLog
8
+ import Security
7
9
  import SwiftUI
8
10
  import UIKit
9
11
 
@@ -2461,6 +2463,89 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2461
2463
  jsContext.setObject(snapshotSet, forKeyedSubscript: "craftNativeSnapshotSet" as NSString)
2462
2464
  jsContext.setObject(secureGet, forKeyedSubscript: "craftNativeSecureGet" as NSString)
2463
2465
  jsContext.setObject(setOptions, forKeyedSubscript: "craftNativeSetOptions" as NSString)
2466
+ // `crypto` for code shared with the page (a sign-in's nonce and its
2467
+ // SHA-256): JavaScriptCore has none. Random bytes from the system's
2468
+ // generator, digests from CryptoKit, both as hex.
2469
+ let randomBytes: @convention(block) (Int) -> String? = { count in
2470
+ guard count >= 0, count <= 65_536 else { return nil }
2471
+ var bytes = [UInt8](repeating: 0, count: count)
2472
+ guard count == 0 || SecRandomCopyBytes(kSecRandomDefault, count, &bytes) == errSecSuccess else { return nil }
2473
+ return bytes.map { String(format: "%02x", $0) }.joined()
2474
+ }
2475
+ let digest: @convention(block) (String, String) -> String? = { algorithm, hex in
2476
+ var data = Data(capacity: hex.count / 2)
2477
+ var index = hex.startIndex
2478
+ while index < hex.endIndex {
2479
+ let next = hex.index(index, offsetBy: 2, limitedBy: hex.endIndex) ?? hex.endIndex
2480
+ guard let byte = UInt8(hex[index..<next], radix: 16) else { return nil }
2481
+ data.append(byte)
2482
+ index = next
2483
+ }
2484
+ let bytes: [UInt8]
2485
+ switch algorithm.uppercased() {
2486
+ case "SHA-1": bytes = Array(Insecure.SHA1.hash(data: data))
2487
+ case "SHA-256": bytes = Array(SHA256.hash(data: data))
2488
+ case "SHA-384": bytes = Array(SHA384.hash(data: data))
2489
+ case "SHA-512": bytes = Array(SHA512.hash(data: data))
2490
+ default: return nil
2491
+ }
2492
+ return bytes.map { String(format: "%02x", $0) }.joined()
2493
+ }
2494
+ jsContext.setObject(randomBytes, forKeyedSubscript: "craftNativeRandomBytes" as NSString)
2495
+ jsContext.setObject(digest, forKeyedSubscript: "craftNativeDigest" as NSString)
2496
+ jsContext.evaluateScript("""
2497
+ (function() {
2498
+ var cryptoObject = globalThis.crypto || {};
2499
+ function hexToBytes(hex) {
2500
+ var bytes = new Uint8Array(hex.length / 2);
2501
+ for (var i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
2502
+ return bytes;
2503
+ }
2504
+ function toHex(bytes) {
2505
+ var out = '';
2506
+ for (var i = 0; i < bytes.length; i++) out += (bytes[i] < 16 ? '0' : '') + bytes[i].toString(16);
2507
+ return out;
2508
+ }
2509
+ function viewOf(data) {
2510
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
2511
+ if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
2512
+ throw new TypeError('crypto.subtle.digest needs an ArrayBuffer or a typed array');
2513
+ }
2514
+ if (typeof cryptoObject.getRandomValues !== 'function') {
2515
+ cryptoObject.getRandomValues = function(array) {
2516
+ if (!ArrayBuffer.isView(array)) throw new TypeError('crypto.getRandomValues needs a typed array');
2517
+ var hex = craftNativeRandomBytes(array.byteLength);
2518
+ if (hex == null) throw new Error('No random bytes from the system');
2519
+ new Uint8Array(array.buffer, array.byteOffset, array.byteLength).set(hexToBytes(hex));
2520
+ return array;
2521
+ };
2522
+ }
2523
+ if (typeof cryptoObject.randomUUID !== 'function') {
2524
+ cryptoObject.randomUUID = function() {
2525
+ var b = cryptoObject.getRandomValues(new Uint8Array(16));
2526
+ b[6] = (b[6] & 0x0f) | 0x40;
2527
+ b[8] = (b[8] & 0x3f) | 0x80;
2528
+ var h = toHex(b);
2529
+ return h.slice(0, 8) + '-' + h.slice(8, 12) + '-' + h.slice(12, 16) + '-' + h.slice(16, 20) + '-' + h.slice(20);
2530
+ };
2531
+ }
2532
+ if (!cryptoObject.subtle || typeof cryptoObject.subtle.digest !== 'function') {
2533
+ cryptoObject.subtle = Object.assign(cryptoObject.subtle || {}, {
2534
+ digest: function(algorithm, data) {
2535
+ try {
2536
+ var name = typeof algorithm === 'string' ? algorithm : algorithm && algorithm.name;
2537
+ var out = craftNativeDigest(String(name || ''), toHex(viewOf(data)));
2538
+ if (out == null) return Promise.reject(new Error('Unsupported digest: ' + name));
2539
+ return Promise.resolve(hexToBytes(out).buffer);
2540
+ } catch (error) {
2541
+ return Promise.reject(error);
2542
+ }
2543
+ }
2544
+ });
2545
+ }
2546
+ globalThis.crypto = cryptoObject;
2547
+ })();
2548
+ """)
2464
2549
  jsContext.evaluateScript("""
2465
2550
  (function() {
2466
2551
  function text(value) {
@@ -390,6 +390,74 @@ extension CraftBrowser: ASWebAuthenticationPresentationContextProviding {
390
390
  }
391
391
  }
392
392
 
393
+ // MARK: - Sign in with Apple
394
+
395
+ struct CraftSignInWithAppleError: Error {
396
+ let code: String
397
+ let message: String
398
+ }
399
+
400
+ /// `craft.signInWithApple`: the system's Sign in with Apple sheet, for a page
401
+ /// and for a native screen alike. Answers with Apple's user id, the name and
402
+ /// email when Apple shares them (the first time only), the identity token and
403
+ /// the one-use authorization code a server redeems at Apple. `nonce` is what
404
+ /// the caller wants written into the identity token (its SHA-256, by
405
+ /// convention).
406
+ final class CraftSignInWithApple: NSObject {
407
+ static let shared = CraftSignInWithApple()
408
+
409
+ private var completion: ((Result<[String: Any], CraftSignInWithAppleError>) -> Void)?
410
+ private var controller: ASAuthorizationController?
411
+
412
+ func signIn(nonce: String?, completion: @escaping (Result<[String: Any], CraftSignInWithAppleError>) -> Void) {
413
+ // One sheet at a time: a second request answers the first.
414
+ self.completion?(.failure(CraftSignInWithAppleError(code: "CANCELLED", message: "Another sign-in started")))
415
+ self.completion = completion
416
+ let request = ASAuthorizationAppleIDProvider().createRequest()
417
+ request.requestedScopes = [.fullName, .email]
418
+ if let nonce, !nonce.isEmpty { request.nonce = nonce }
419
+ let controller = ASAuthorizationController(authorizationRequests: [request])
420
+ controller.delegate = self
421
+ controller.presentationContextProvider = self
422
+ self.controller = controller
423
+ controller.performRequests()
424
+ }
425
+
426
+ private func finish(_ result: Result<[String: Any], CraftSignInWithAppleError>) {
427
+ let completion = self.completion
428
+ self.completion = nil
429
+ controller = nil
430
+ completion?(result)
431
+ }
432
+ }
433
+
434
+ extension CraftSignInWithApple: ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding {
435
+ func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
436
+ guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else {
437
+ finish(.failure(CraftSignInWithAppleError(code: "SIGN_IN_FAILED", message: "Apple answered without an Apple ID")))
438
+ return
439
+ }
440
+ var result: [String: Any] = ["userId": credential.user]
441
+ if let email = credential.email { result["email"] = email }
442
+ let name = [credential.fullName?.givenName, credential.fullName?.familyName].compactMap { $0 }.joined(separator: " ")
443
+ if !name.isEmpty { result["name"] = name }
444
+ if let token = credential.identityToken.flatMap({ String(data: $0, encoding: .utf8) }) { result["identityToken"] = token }
445
+ // One use, five minutes: the server redeems it at Apple's token
446
+ // endpoint and so reads the identity straight from Apple.
447
+ if let code = credential.authorizationCode.flatMap({ String(data: $0, encoding: .utf8) }) { result["authorizationCode"] = code }
448
+ finish(.success(result))
449
+ }
450
+
451
+ func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
452
+ let cancelled = (error as? ASAuthorizationError)?.code == .canceled
453
+ finish(.failure(CraftSignInWithAppleError(code: cancelled ? "CANCELLED" : "SIGN_IN_FAILED", message: error.localizedDescription)))
454
+ }
455
+
456
+ func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
457
+ CraftPresenter.keyWindow() ?? ASPresentationAnchor()
458
+ }
459
+ }
460
+
393
461
  // MARK: - SF Symbols
394
462
 
395
463
  /// `craft.symbols.image`: an SF Symbol drawn to a PNG data URL, so a page can
package/dist/mobile.js CHANGED
@@ -713,6 +713,116 @@ var speech = {
713
713
  getWebSpeech()?.synthesis.cancel();
714
714
  }
715
715
  };
716
+ var MUSIC_AUTHORIZATIONS = ["authorized", "denied", "restricted", "notDetermined", "unavailable"];
717
+ var MUSIC_STATES = ["stopped", "playing", "paused", "interrupted", "seekingForward", "seekingBackward", "unknown"];
718
+ function musicAuthorization(value) {
719
+ return MUSIC_AUTHORIZATIONS.includes(value) ? value : "unavailable";
720
+ }
721
+ function musicText(value) {
722
+ return typeof value === "string" && value.trim() ? value : null;
723
+ }
724
+ function musicSeconds(value) {
725
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
726
+ }
727
+ function normalizeNowPlaying(value) {
728
+ const raw = value && typeof value === "object" ? value : {};
729
+ const playbackState = MUSIC_STATES.includes(raw.playbackState) ? raw.playbackState : "unknown";
730
+ const artwork = musicText(raw.artworkDataURL);
731
+ return {
732
+ available: raw.available !== false && Object.keys(raw).length > 0,
733
+ authorization: musicAuthorization(raw.authorization),
734
+ source: raw.source === "appleMusic" || raw.source === "simulated" ? raw.source : null,
735
+ playbackState,
736
+ isPlaying: raw.isPlaying === true,
737
+ itemId: musicText(raw.itemId),
738
+ title: musicText(raw.title),
739
+ artist: musicText(raw.artist),
740
+ album: musicText(raw.album),
741
+ duration: musicSeconds(raw.duration),
742
+ position: musicSeconds(raw.position),
743
+ artworkDataURL: artwork && artwork.startsWith("data:image/") ? artwork : null,
744
+ otherAudioPlaying: raw.otherAudioPlaying === true,
745
+ timestamp: typeof raw.timestamp === "number" && Number.isFinite(raw.timestamp) ? raw.timestamp : Date.now()
746
+ };
747
+ }
748
+ function nothingPlaying() {
749
+ return normalizeNowPlaying({ available: false, authorization: "unavailable" });
750
+ }
751
+ function nativeMusic() {
752
+ const craft = getCraftRoot();
753
+ if (!craft?.music || craft.capabilities?.music === false)
754
+ return;
755
+ return craft.music;
756
+ }
757
+ var musicWatch = { listeners: new Set, stop: null };
758
+ function startMusicWatch(native) {
759
+ if (musicWatch.stop || typeof window === "undefined")
760
+ return;
761
+ const onChange = (event) => {
762
+ const nowPlaying = normalizeNowPlaying(event.detail);
763
+ for (const listener of [...musicWatch.listeners]) {
764
+ try {
765
+ listener(nowPlaying);
766
+ } catch {}
767
+ }
768
+ };
769
+ window.addEventListener("craftMusicChange", onChange);
770
+ Promise.resolve(native.observe?.(true)).catch(() => {});
771
+ musicWatch.stop = () => {
772
+ window.removeEventListener("craftMusicChange", onChange);
773
+ Promise.resolve(native.observe?.(false)).catch(() => {});
774
+ };
775
+ }
776
+ async function musicCommand(method, options) {
777
+ const native = nativeMusic();
778
+ if (typeof native?.[method] !== "function")
779
+ return nothingPlaying();
780
+ return normalizeNowPlaying(await native[method](options));
781
+ }
782
+ var music = {
783
+ isAvailable() {
784
+ return typeof nativeMusic()?.getNowPlaying === "function";
785
+ },
786
+ supports(method) {
787
+ return typeof nativeMusic()?.[method] === "function";
788
+ },
789
+ async getAuthorizationStatus() {
790
+ const native = nativeMusic();
791
+ if (typeof native?.getAuthorizationStatus !== "function")
792
+ return "unavailable";
793
+ return musicAuthorization(await native.getAuthorizationStatus());
794
+ },
795
+ async requestAuthorization() {
796
+ const native = nativeMusic();
797
+ if (typeof native?.requestAuthorization !== "function")
798
+ return "unavailable";
799
+ return musicAuthorization(await native.requestAuthorization());
800
+ },
801
+ async getNowPlaying() {
802
+ const native = nativeMusic();
803
+ if (typeof native?.getNowPlaying !== "function")
804
+ return nothingPlaying();
805
+ return normalizeNowPlaying(await native.getNowPlaying());
806
+ },
807
+ play: () => musicCommand("play"),
808
+ pause: () => musicCommand("pause"),
809
+ togglePlayPause: () => musicCommand("togglePlayPause"),
810
+ skipToNext: () => musicCommand("skipToNext"),
811
+ skipToPrevious: (options = {}) => musicCommand("skipToPrevious", options),
812
+ onChange(callback) {
813
+ const native = nativeMusic();
814
+ if (typeof native?.observe !== "function")
815
+ return () => {};
816
+ musicWatch.listeners.add(callback);
817
+ startMusicWatch(native);
818
+ return () => {
819
+ if (!musicWatch.listeners.delete(callback) || musicWatch.listeners.size > 0)
820
+ return;
821
+ musicWatch.stop?.();
822
+ musicWatch.stop = null;
823
+ };
824
+ }
825
+ };
716
826
  var deepLinks = {
717
827
  async getInitialURL() {
718
828
  return normalizeDeepLinkURL(await getCraftRoot()?.deepLinks?.getInitialURL?.());
@@ -1203,8 +1313,10 @@ export {
1203
1313
  pushNotifications,
1204
1314
  permissions,
1205
1315
  notifications,
1316
+ normalizeNowPlaying,
1206
1317
  normalizeDeepLinkURL,
1207
1318
  network,
1319
+ music,
1208
1320
  location,
1209
1321
  liveActivities,
1210
1322
  lifecycle,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.143",
3
+ "version": "0.0.145",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",