@swiftbrowser/native 0.17.6 → 0.17.8

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.
Files changed (81) hide show
  1. package/README.md +101 -2
  2. package/dist/blob.d.ts +60 -0
  3. package/dist/blob.d.ts.map +1 -0
  4. package/dist/blob.js +153 -0
  5. package/dist/blob.js.map +1 -0
  6. package/dist/bridge.d.ts +47 -1
  7. package/dist/bridge.d.ts.map +1 -1
  8. package/dist/bridge.js.map +1 -1
  9. package/dist/fetch.d.ts +11 -3
  10. package/dist/fetch.d.ts.map +1 -1
  11. package/dist/fetch.js +26 -2
  12. package/dist/fetch.js.map +1 -1
  13. package/dist/hostRequests.d.ts +49 -0
  14. package/dist/hostRequests.d.ts.map +1 -0
  15. package/dist/hostRequests.js +134 -0
  16. package/dist/hostRequests.js.map +1 -0
  17. package/dist/index.d.ts +8 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +8 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/matchMedia.d.ts +43 -0
  22. package/dist/matchMedia.d.ts.map +1 -0
  23. package/dist/matchMedia.js +63 -0
  24. package/dist/matchMedia.js.map +1 -0
  25. package/dist/mediaSession.d.ts +59 -0
  26. package/dist/mediaSession.d.ts.map +1 -0
  27. package/dist/mediaSession.js +119 -0
  28. package/dist/mediaSession.js.map +1 -0
  29. package/dist/notifications.d.ts +108 -0
  30. package/dist/notifications.d.ts.map +1 -0
  31. package/dist/notifications.js +251 -0
  32. package/dist/notifications.js.map +1 -0
  33. package/dist/run.d.ts.map +1 -1
  34. package/dist/run.js +9 -1
  35. package/dist/run.js.map +1 -1
  36. package/dist/runtime.d.ts +3 -1
  37. package/dist/runtime.d.ts.map +1 -1
  38. package/dist/runtime.js +72 -3
  39. package/dist/runtime.js.map +1 -1
  40. package/dist/secureStorage.d.ts +20 -0
  41. package/dist/secureStorage.d.ts.map +1 -0
  42. package/dist/secureStorage.js +28 -0
  43. package/dist/secureStorage.js.map +1 -0
  44. package/dist/subtle.d.ts +38 -0
  45. package/dist/subtle.d.ts.map +1 -0
  46. package/dist/subtle.js +72 -0
  47. package/dist/subtle.js.map +1 -0
  48. package/dist/webSocket.d.ts +73 -0
  49. package/dist/webSocket.d.ts.map +1 -0
  50. package/dist/webSocket.js +145 -0
  51. package/dist/webSocket.js.map +1 -0
  52. package/package.json +3 -3
  53. package/src/blob.ts +186 -0
  54. package/src/bridge.ts +57 -1
  55. package/src/fetch.ts +29 -3
  56. package/src/hostRequests.ts +148 -0
  57. package/src/index.ts +8 -0
  58. package/src/matchMedia.ts +79 -0
  59. package/src/mediaSession.ts +163 -0
  60. package/src/notifications.ts +317 -0
  61. package/src/run.ts +10 -1
  62. package/src/runtime.ts +73 -3
  63. package/src/secureStorage.ts +39 -0
  64. package/src/subtle.ts +87 -0
  65. package/src/webSocket.ts +175 -0
  66. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +180 -0
  67. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +437 -0
  68. package/swift/Sources/SwiftBrowserNative/Host/NotificationBridge.swift +304 -0
  69. package/swift/Sources/SwiftBrowserNative/Host/NowPlayingBridge.swift +190 -0
  70. package/swift/Sources/SwiftBrowserNative/Host/SecureStorage.swift +60 -0
  71. package/swift/Sources/SwiftBrowserNative/Host/ShimAliases.swift +3 -0
  72. package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +118 -0
  73. package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +26 -0
  74. package/swift/Sources/SwiftBrowserNative/Runtime/NativeAppDelegate.swift +36 -0
  75. package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +2 -0
  76. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +22 -9
  77. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +8 -5
  78. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Video.swift +304 -0
  79. package/swift/Sources/SwiftBrowserProtocol/Props.swift +43 -0
  80. package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +12 -2
  81. package/wasm/SwiftBrowserNative.wasm +2 -2
@@ -0,0 +1,304 @@
1
+ #if canImport(SwiftUI) && canImport(UserNotifications)
2
+ import Foundation
3
+ import SwiftBrowserProtocol
4
+ import UserNotifications
5
+ #if canImport(UIKit)
6
+ import UIKit
7
+ #elseif canImport(AppKit)
8
+ import AppKit
9
+ #endif
10
+ #if canImport(FoundationBridge)
11
+ import FoundationBridge
12
+ #endif
13
+
14
+ /// The app's notifications (packages/native/src/notifications.ts) on
15
+ /// UserNotifications: the permission, local notifications (the runtime's
16
+ /// `Notification`), the badge, and remote notifications (`registerForPush`),
17
+ /// whose device token the app delegate hands to `NativeApp` (NativeAppDelegate.swift).
18
+ /// What the viewer does with a notification, and what arrives while the app
19
+ /// is open, goes to the app as `__sbNotification(json)`:
20
+ ///
21
+ /// ```
22
+ /// { "event": "opened" | "received", "remote": bool, "id": string, "title": string, "body": string, "data": {…}, "action"?: string }
23
+ /// ```
24
+ ///
25
+ /// `data` is the notification's `userInfo` (a push's payload; what
26
+ /// `new Notification(title, { data })` gave). Against the shim (the round
27
+ /// trip) the UserNotifications here is the shim's, which shows local
28
+ /// notifications with the page's Notification API and subscribes to Web Push
29
+ /// through the site's service worker (docs/ops-protocol.md, "Push
30
+ /// notifications"), so one app runs in both.
31
+ @MainActor
32
+ final class NotificationBridge {
33
+ private let send: (String, [JSONValue]) -> Void
34
+ /// `pushRegister` calls waiting for a device token, by id.
35
+ private var registering: [Int] = []
36
+ /// Whether the app's `__sbNotification` is there to call (`notificationsReady`).
37
+ private(set) var ready = false
38
+
39
+ /// `send` calls a global of the app's.
40
+ init(send: @escaping (String, [JSONValue]) -> Void) {
41
+ self.send = send
42
+ NotificationRouter.shared.attach(self)
43
+ }
44
+
45
+ /// The app listens: what came before (a tap that launched the app) is delivered.
46
+ func listen() {
47
+ ready = true
48
+ NotificationRouter.shared.flush()
49
+ }
50
+
51
+ // MARK: Permission
52
+
53
+ /// Answers `__sbNotificationPermission(id, "granted" | "denied" | "default")`,
54
+ /// after asking the viewer when `request` is set and the question is open.
55
+ func permission(id: Int, request: Bool) {
56
+ Task { @MainActor in
57
+ let center = UNUserNotificationCenter.current()
58
+ if request {
59
+ _ = try? await center.requestAuthorization(options: [.alert, .badge, .sound])
60
+ }
61
+ let settings = await center.notificationSettings()
62
+ let answer: String = switch settings.authorizationStatus {
63
+ case .authorized, .provisional, .ephemeral: "granted"
64
+ case .denied: "denied"
65
+ default: "default"
66
+ }
67
+ send("__sbNotificationPermission", [.number(Double(id)), .string(answer)])
68
+ }
69
+ }
70
+
71
+ // MARK: Local notifications
72
+
73
+ /// Shows `{ id, title, subtitle?, body?, data?, badge?, silent? }` now; the message of what was wrong, or nil.
74
+ func show(_ json: String) -> String? {
75
+ let fields: JSONObject
76
+ do {
77
+ guard let parsed = try JSONValue.parse(json).object else { return "notification: expected an object" }
78
+ fields = parsed
79
+ } catch {
80
+ return "notification: \(error)"
81
+ }
82
+ guard let id = fields["id"]?.string, let title = fields["title"]?.string else { return "notification: expected an id and a title" }
83
+ let content = UNMutableNotificationContent()
84
+ content.title = title
85
+ content.subtitle = fields["subtitle"]?.string ?? ""
86
+ content.body = fields["body"]?.string ?? ""
87
+ if let badge = fields["badge"]?.int { content.badge = NSNumber(value: badge) }
88
+ if fields["silent"]?.bool != true { content.sound = .default }
89
+ if let data = fields["data"]?.object { content.userInfo = Self.userInfo(of: data) }
90
+ UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: id, content: content, trigger: nil)) { _ in }
91
+ return nil
92
+ }
93
+
94
+ /// Closes the notifications with these identifiers (`[id, …]`).
95
+ func close(_ json: String) {
96
+ guard let ids = (try? JSONValue.parse(json))?.array?.compactMap(\.string), !ids.isEmpty else { return }
97
+ let center = UNUserNotificationCenter.current()
98
+ center.removePendingNotificationRequests(withIdentifiers: ids)
99
+ center.removeDeliveredNotifications(withIdentifiers: ids)
100
+ }
101
+
102
+ func setBadge(_ count: Int) {
103
+ UNUserNotificationCenter.current().setBadgeCount(max(0, count)) { _ in }
104
+ }
105
+
106
+ // MARK: Remote notifications
107
+
108
+ /// Registers with the system; the token or the failure answers
109
+ /// `__sbPushRegistered(id, { token } | { error })` through `NativeApp`'s
110
+ /// delegate entry points. `applicationServerKey` is what the page
111
+ /// subscribes with in the round trip (the shim's Web Push); the system
112
+ /// needs none.
113
+ func registerForPush(id: Int, applicationServerKey: String?) {
114
+ registering.append(id)
115
+ Self.systemRegistration(applicationServerKey)
116
+ }
117
+
118
+ /// Asks the system for a device token; tests substitute it.
119
+ static var systemRegistration: @MainActor (_ applicationServerKey: String?) -> Void = { applicationServerKey in
120
+ #if canImport(FoundationBridge)
121
+ if let applicationServerKey { _RemoteNotifications.applicationServerKey = applicationServerKey }
122
+ // The page has no App file with a delegate adaptor: the token needs one.
123
+ if UIApplication.shared.delegate == nil { UIApplication.shared.delegate = NativeAppDelegate() }
124
+ #endif
125
+ #if canImport(UIKit)
126
+ UIApplication.shared.registerForRemoteNotifications()
127
+ #elseif canImport(AppKit)
128
+ NSApplication.shared.registerForRemoteNotifications()
129
+ #endif
130
+ }
131
+
132
+ func registered(token: String) {
133
+ answerRegistrations(.object(["token": .string(token)]))
134
+ }
135
+
136
+ func registrationFailed(_ message: String) {
137
+ answerRegistrations(.object(["error": .string(message)]))
138
+ }
139
+
140
+ private func answerRegistrations(_ result: JSONValue) {
141
+ let ids = registering
142
+ registering = []
143
+ for id in ids { send("__sbPushRegistered", [.number(Double(id)), result]) }
144
+ }
145
+
146
+ /// A notification event for the app.
147
+ func deliver(_ event: JSONValue) {
148
+ send("__sbNotification", [.string(event.json)])
149
+ }
150
+
151
+ // MARK: Conversions
152
+
153
+ nonisolated static func userInfo(of fields: JSONObject) -> [AnyHashable: Any] {
154
+ var info: [AnyHashable: Any] = [:]
155
+ for (key, value) in fields { info[key] = foundationValue(of: value) }
156
+ return info
157
+ }
158
+
159
+ nonisolated static func foundationValue(of value: JSONValue) -> Any {
160
+ switch value {
161
+ case .null: NSNull()
162
+ case .bool(let bool): bool
163
+ case .number(let number): number == number.rounded() && abs(number) < 1e15 ? Int(number) : number
164
+ case .string(let string): string
165
+ case .array(let values): values.map(foundationValue(of:))
166
+ case .object(let fields): fields.mapValues(foundationValue(of:))
167
+ }
168
+ }
169
+
170
+ /// `userInfo` as JSON: strings, numbers, booleans, arrays and dictionaries with string keys; the rest is left out.
171
+ nonisolated static func jsonValue(of value: Any) -> JSONValue? {
172
+ switch value {
173
+ case let string as String: .string(string)
174
+ case let bool as Bool: .bool(bool)
175
+ case let int as Int: .number(Double(int))
176
+ case let double as Double: .number(double)
177
+ case let number as NSNumber: .number(number.doubleValue)
178
+ case is NSNull: .null
179
+ case let values as [Any]: .array(values.compactMap(jsonValue(of:)))
180
+ case let fields as [AnyHashable: Any]:
181
+ .object(fields.reduce(into: JSONObject()) { out, entry in
182
+ if let key = entry.key as? String, let json = jsonValue(of: entry.value) { out[key] = json }
183
+ })
184
+ default: nil
185
+ }
186
+ }
187
+
188
+ /// The event for a notification: `opened` with the action the viewer chose, or `received`.
189
+ nonisolated static func event(_ name: String, _ notification: UNNotification, action: String?) -> JSONValue {
190
+ let request = notification.request
191
+ return event(name, remote: request.trigger is UNPushNotificationTrigger, id: request.identifier,
192
+ title: request.content.title, body: request.content.body, userInfo: request.content.userInfo, action: action)
193
+ }
194
+
195
+ nonisolated static func event(_ name: String, remote: Bool, id: String, title: String, body: String, userInfo: [AnyHashable: Any], action: String?) -> JSONValue {
196
+ var fields: JSONObject = [
197
+ "event": .string(name),
198
+ "remote": .bool(remote),
199
+ "id": .string(id),
200
+ "title": .string(title),
201
+ "body": .string(body),
202
+ "data": jsonValue(of: userInfo) ?? .object([:]),
203
+ ]
204
+ if let action { fields["action"] = .string(action) }
205
+ return .object(fields)
206
+ }
207
+ }
208
+
209
+ /// The notification center's delegate, shared by every `NativeApp`, which
210
+ /// routes what the center reports to the apps' bridges: what the viewer
211
+ /// opened (`didReceive`), what arrived while the app is open (`willPresent`,
212
+ /// shown as it would be in the background), and the device token the app
213
+ /// delegate was handed. What comes before an app listens (a tap that
214
+ /// launched the app) waits for the first one.
215
+ @MainActor
216
+ final class NotificationRouter: NSObject, UNUserNotificationCenterDelegate {
217
+ static let shared = NotificationRouter()
218
+
219
+ private final class Weak {
220
+ weak var bridge: NotificationBridge?
221
+ init(_ bridge: NotificationBridge) { self.bridge = bridge }
222
+ }
223
+
224
+ private var bridges: [Weak] = []
225
+ private var pending: [JSONValue] = []
226
+ private var installed = false
227
+
228
+ /// Makes the router the center's delegate, once; before the app finishes
229
+ /// launching, for a tap that launches it.
230
+ func install() {
231
+ guard !installed else { return }
232
+ installed = true
233
+ UNUserNotificationCenter.current().delegate = self
234
+ }
235
+
236
+ /// `install` when the process is an app: a test runner has no
237
+ /// notification center (`UNUserNotificationCenter.current()` raises there).
238
+ func installIfApp() {
239
+ #if canImport(FoundationBridge)
240
+ install()
241
+ #else
242
+ if Bundle.main.bundleURL.pathExtension == "app" { install() }
243
+ #endif
244
+ }
245
+
246
+ func attach(_ bridge: NotificationBridge) {
247
+ bridges.removeAll { $0.bridge == nil }
248
+ bridges.append(Weak(bridge))
249
+ }
250
+
251
+ private var listening: [NotificationBridge] { bridges.compactMap(\.bridge).filter(\.ready) }
252
+
253
+ func flush() {
254
+ let targets = listening
255
+ guard !targets.isEmpty, !pending.isEmpty else { return }
256
+ let events = pending
257
+ pending = []
258
+ for event in events { for bridge in targets { bridge.deliver(event) } }
259
+ }
260
+
261
+ func route(_ event: JSONValue) {
262
+ let targets = listening
263
+ if targets.isEmpty {
264
+ pending.append(event)
265
+ return
266
+ }
267
+ for bridge in targets { bridge.deliver(event) }
268
+ }
269
+
270
+ func registered(token: Data) {
271
+ let hex = token.map { byte in
272
+ let digits = Array("0123456789abcdef")
273
+ return String([digits[Int(byte >> 4)], digits[Int(byte & 0x0f)]])
274
+ }.joined()
275
+ for bridge in bridges.compactMap(\.bridge) { bridge.registered(token: hex) }
276
+ }
277
+
278
+ func registrationFailed(_ message: String) {
279
+ for bridge in bridges.compactMap(\.bridge) { bridge.registrationFailed(message) }
280
+ }
281
+
282
+ /// A push the app delegate was handed (a silent one, or one with
283
+ /// `content-available`); one with an alert the center reports as it shows it.
284
+ func received(remote userInfo: [AnyHashable: Any]) {
285
+ let aps = userInfo["aps"] as? [AnyHashable: Any] ?? [:]
286
+ guard aps["alert"] == nil else { return }
287
+ route(NotificationBridge.event("received", remote: true, id: (userInfo["id"] as? String) ?? "", title: "", body: "", userInfo: userInfo, action: nil))
288
+ }
289
+
290
+ // MARK: UNUserNotificationCenterDelegate
291
+
292
+ nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
293
+ let event = NotificationBridge.event("received", notification, action: nil)
294
+ await MainActor.run { route(event) }
295
+ return [.banner, .list, .sound, .badge]
296
+ }
297
+
298
+ nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
299
+ let action = response.actionIdentifier == UNNotificationDefaultActionIdentifier ? "" : response.actionIdentifier
300
+ let event = NotificationBridge.event("opened", response.notification, action: action)
301
+ await MainActor.run { route(event) }
302
+ }
303
+ }
304
+ #endif
@@ -0,0 +1,190 @@
1
+ #if canImport(SwiftUI) && canImport(MediaPlayer)
2
+ import Foundation
3
+ import MediaPlayer
4
+ import SwiftBrowserProtocol
5
+ #if canImport(UIKit)
6
+ import UIKit
7
+ #elseif canImport(AppKit)
8
+ import AppKit
9
+ #endif
10
+ #if os(iOS) && canImport(AVFoundation)
11
+ import AVFoundation
12
+ #endif
13
+ #if canImport(FoundationBridge)
14
+ import FoundationBridge
15
+ #endif
16
+
17
+ /// The app's `navigator.mediaSession` (packages/native/src/mediaSession.ts)
18
+ /// on MediaPlayer: each `mediaSession(update)` sets `MPNowPlayingInfoCenter`
19
+ /// (the lock screen's title, artist, album and artwork, the position the
20
+ /// system extrapolates at the rate) and makes `MPRemoteCommandCenter`
21
+ /// listen for exactly the commands the app handles; a command the viewer
22
+ /// gives (a lock screen button, the media keys, the system's player
23
+ /// controls) goes back as `__sbMediaSessionCommand(json)`. The update is
24
+ /// the one the web renderer's `mediaSession` request carries
25
+ /// (docs/ops-protocol.md, "Now playing"), so an app writes the same code
26
+ /// for the page and the native app. Against the shim (the round trip) the
27
+ /// MediaPlayer here is the shim's, which hands the same to the page.
28
+ @MainActor
29
+ final class NowPlayingBridge {
30
+ private let send: (JSONValue) -> Void
31
+ /// The target added to each command listened for, by the protocol's name.
32
+ private var targets: [String: Any] = [:]
33
+ private var artworkURL: String?
34
+ private var artwork: MPMediaItemArtwork?
35
+ private var artworkLoad: Task<Void, Never>?
36
+ private var info: [String: Any]?
37
+ #if os(iOS)
38
+ private var sessionReady = false
39
+ #endif
40
+
41
+ /// `send` hands a received command to the app.
42
+ init(send: @escaping (JSONValue) -> Void) {
43
+ self.send = send
44
+ }
45
+
46
+ /// Applies one update; the message of what was wrong with it, or nil.
47
+ func update(_ json: String) -> String? {
48
+ let parsed: JSONValue
49
+ do {
50
+ parsed = try JSONValue.parse(json)
51
+ } catch {
52
+ return "mediaSession: \(error)"
53
+ }
54
+ guard let fields = parsed.object else { return "mediaSession: expected an object" }
55
+ apply(fields)
56
+ return nil
57
+ }
58
+
59
+ private func apply(_ fields: JSONObject) {
60
+ let center = MPNowPlayingInfoCenter.default()
61
+ if let metadata = fields.object("metadata") {
62
+ var next: [String: Any] = [:]
63
+ if let title = metadata.string("title") { next[MPMediaItemPropertyTitle] = title }
64
+ if let artist = metadata.string("artist") { next[MPMediaItemPropertyArtist] = artist }
65
+ if let album = metadata.string("album") { next[MPMediaItemPropertyAlbumTitle] = album }
66
+ if let position = fields.object("position") {
67
+ if let duration = position.double("duration") { next[MPMediaItemPropertyPlaybackDuration] = duration }
68
+ if let elapsed = position.double("position") { next[MPNowPlayingInfoPropertyElapsedPlaybackTime] = elapsed }
69
+ if let rate = position.double("rate") { next[MPNowPlayingInfoPropertyPlaybackRate] = rate }
70
+ }
71
+ if let state = fields.string("playbackState"), fields.object("position") == nil {
72
+ // No position: the state alone says whether it plays, through the rate.
73
+ next[MPNowPlayingInfoPropertyPlaybackRate] = state == "playing" ? 1.0 : 0.0
74
+ }
75
+ info = next
76
+ loadArtwork(metadata.string("artwork"))
77
+ publish()
78
+ } else if fields["metadata"] == .null {
79
+ info = nil
80
+ artwork = nil
81
+ artworkURL = nil
82
+ artworkLoad?.cancel()
83
+ center.nowPlayingInfo = nil
84
+ }
85
+ if let state = fields.string("playbackState") {
86
+ #if os(macOS) || canImport(FoundationBridge)
87
+ center.playbackState = state == "playing" ? .playing : state == "paused" ? .paused : .unknown
88
+ #endif
89
+ #if os(iOS)
90
+ // The lock screen shows the app that plays through an active playback session.
91
+ if state == "playing" { activateAudioSession() }
92
+ #endif
93
+ }
94
+ if let commands = fields.array("commands") {
95
+ listen(for: Set(commands.compactMap(\.string)))
96
+ }
97
+ }
98
+
99
+ /// The info with the artwork, as the center takes it whole.
100
+ private func publish() {
101
+ guard var next = info else { return }
102
+ if let artwork { next[MPMediaItemPropertyArtwork] = artwork }
103
+ MPNowPlayingInfoCenter.default().nowPlayingInfo = next
104
+ }
105
+
106
+ /// The artwork at `url` (a `data:` URL at once, a remote one through a fetch), once per URL.
107
+ private func loadArtwork(_ url: String?) {
108
+ guard url != artworkURL else { return }
109
+ artworkURL = url
110
+ artwork = nil
111
+ artworkLoad?.cancel()
112
+ guard let url else { return }
113
+ if url.hasPrefix("data:") {
114
+ if let comma = url.firstIndex(of: ","), let data = Data(base64Encoded: String(url[url.index(after: comma)...])) {
115
+ artwork = Self.artwork(from: data)
116
+ }
117
+ return
118
+ }
119
+ guard let remote = URL(string: url) else { return }
120
+ artworkLoad = Task { [weak self] in
121
+ guard let (data, _) = try? await URLSession.shared.data(from: remote), !Task.isCancelled else { return }
122
+ guard let self, self.artworkURL == url else { return }
123
+ self.artwork = Self.artwork(from: data)
124
+ self.publish()
125
+ }
126
+ }
127
+
128
+ private static func artwork(from data: Data) -> MPMediaItemArtwork? {
129
+ #if canImport(UIKit)
130
+ guard let image = UIImage(data: data) else { return nil }
131
+ #elseif canImport(AppKit)
132
+ guard let image = NSImage(data: data) else { return nil }
133
+ #endif
134
+ return MPMediaItemArtwork(boundsSize: image.size) { _ in image }
135
+ }
136
+
137
+ #if os(iOS)
138
+ private func activateAudioSession() {
139
+ guard !sessionReady else { return }
140
+ sessionReady = true
141
+ let session = AVAudioSession.sharedInstance()
142
+ try? session.setCategory(.playback, mode: .default)
143
+ try? session.setActive(true)
144
+ }
145
+ #endif
146
+
147
+ // MARK: Remote commands
148
+
149
+ /// The protocol's command names and the center's commands.
150
+ private var commands: [(name: String, command: MPRemoteCommand)] {
151
+ let center = MPRemoteCommandCenter.shared()
152
+ return [
153
+ ("play", center.playCommand),
154
+ ("pause", center.pauseCommand),
155
+ ("stop", center.stopCommand),
156
+ ("nextTrack", center.nextTrackCommand),
157
+ ("previousTrack", center.previousTrackCommand),
158
+ ("seekTo", center.changePlaybackPositionCommand),
159
+ ("skipForward", center.skipForwardCommand),
160
+ ("skipBackward", center.skipBackwardCommand),
161
+ ]
162
+ }
163
+
164
+ /// Listens for exactly `wanted`: a target for each new one, none for the
165
+ /// dropped ones, and every other command disabled (the system enables them
166
+ /// all to begin with; the protocol says the others get no button).
167
+ private func listen(for wanted: Set<String>) {
168
+ for (name, command) in commands {
169
+ let listening = targets[name] != nil
170
+ if wanted.contains(name), !listening {
171
+ if let skip = command as? MPSkipIntervalCommand, skip.preferredIntervals.isEmpty { skip.preferredIntervals = [15] }
172
+ targets[name] = command.addTarget { [weak self] event in
173
+ var fields: JSONObject = ["command": .string(name)]
174
+ if let seek = event as? MPChangePlaybackPositionCommandEvent { fields["position"] = .number(seek.positionTime) }
175
+ if let skip = event as? MPSkipIntervalCommandEvent { fields["interval"] = .number(skip.interval) }
176
+ // Called on the main thread; a hop keeps the app's handler out of the system's callback.
177
+ DispatchQueue.main.async {
178
+ MainActor.assumeIsolated { self?.send(.object(fields)) }
179
+ }
180
+ return .success
181
+ }
182
+ } else if !wanted.contains(name), listening {
183
+ command.removeTarget(targets.removeValue(forKey: name))
184
+ }
185
+ let enabled = wanted.contains(name)
186
+ if command.isEnabled != enabled { command.isEnabled = enabled }
187
+ }
188
+ }
189
+ }
190
+ #endif
@@ -0,0 +1,60 @@
1
+ #if canImport(SwiftUI)
2
+ import Foundation
3
+ #if canImport(FoundationBridge)
4
+ import FoundationBridge
5
+ #else
6
+ import Security
7
+ #endif
8
+
9
+ /// The app's `secureStorage` (packages/native/src/secureStorage.ts): generic
10
+ /// password items in the Keychain under the app's service, one per key,
11
+ /// readable after the first unlock (a background fetch can read a token).
12
+ /// Against the shim (the round trip) there is no Keychain: the values go to
13
+ /// `UserDefaults` under a prefix, which is the page's `localStorage`.
14
+ enum SecureStorage {
15
+ #if canImport(FoundationBridge)
16
+ private static let prefix = "swiftbrowser.secure."
17
+
18
+ static func get(_ key: String) -> String? {
19
+ UserDefaults.standard.string(forKey: prefix + key)
20
+ }
21
+
22
+ static func set(_ key: String, _ value: String) {
23
+ UserDefaults.standard.set(value, forKey: prefix + key)
24
+ }
25
+
26
+ static func remove(_ key: String) {
27
+ UserDefaults.standard.removeObject(forKey: prefix + key)
28
+ }
29
+ #else
30
+ private static var service: String { (Bundle.main.bundleIdentifier ?? "swiftbrowser") + ".secure" }
31
+
32
+ private static func query(_ key: String) -> [String: Any] {
33
+ [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: key]
34
+ }
35
+
36
+ static func get(_ key: String) -> String? {
37
+ var query = query(key)
38
+ query[kSecReturnData as String] = true
39
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
40
+ var item: CFTypeRef?
41
+ guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let data = item as? Data else { return nil }
42
+ return String(data: data, encoding: .utf8)
43
+ }
44
+
45
+ static func set(_ key: String, _ value: String) {
46
+ var attributes: [String: Any] = [kSecValueData as String: Data(value.utf8)]
47
+ #if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS)
48
+ attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
49
+ #endif
50
+ let status = SecItemUpdate(query(key) as CFDictionary, attributes as CFDictionary)
51
+ guard status == errSecItemNotFound else { return }
52
+ SecItemAdd(query(key).merging(attributes) { $1 } as CFDictionary, nil)
53
+ }
54
+
55
+ static func remove(_ key: String) {
56
+ SecItemDelete(query(key) as CFDictionary)
57
+ }
58
+ #endif
59
+ }
60
+ #endif
@@ -17,6 +17,7 @@ public typealias RunLoop = FoundationBridge.RunLoop
17
17
  public typealias UserDefaults = FoundationBridge.UserDefaults
18
18
  public typealias Bundle = FoundationBridge.Bundle
19
19
  public typealias URLSession = FoundationBridge.URLSession
20
+ public typealias URLSessionWebSocketTask = FoundationBridge.URLSessionWebSocketTask
20
21
  public typealias URLSessionConfiguration = FoundationBridge.URLSessionConfiguration
21
22
  public typealias URLRequest = FoundationBridge.URLRequest
22
23
  public typealias URLResponse = FoundationBridge.URLResponse
@@ -24,4 +25,6 @@ public typealias HTTPURLResponse = FoundationBridge.HTTPURLResponse
24
25
  public typealias URLCache = FoundationBridge.URLCache
25
26
  public typealias CachedURLResponse = FoundationBridge.CachedURLResponse
26
27
  public typealias DispatchQueue = FoundationBridge.DispatchQueue
28
+ // The shim's players post their notifications through the bridge's center.
29
+ public typealias NotificationCenter = FoundationBridge.NotificationCenter
27
30
  #endif