@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.
- package/README.md +101 -2
- package/dist/blob.d.ts +60 -0
- package/dist/blob.d.ts.map +1 -0
- package/dist/blob.js +153 -0
- package/dist/blob.js.map +1 -0
- package/dist/bridge.d.ts +47 -1
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/fetch.d.ts +11 -3
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +26 -2
- package/dist/fetch.js.map +1 -1
- package/dist/hostRequests.d.ts +49 -0
- package/dist/hostRequests.d.ts.map +1 -0
- package/dist/hostRequests.js +134 -0
- package/dist/hostRequests.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/matchMedia.d.ts +43 -0
- package/dist/matchMedia.d.ts.map +1 -0
- package/dist/matchMedia.js +63 -0
- package/dist/matchMedia.js.map +1 -0
- package/dist/mediaSession.d.ts +59 -0
- package/dist/mediaSession.d.ts.map +1 -0
- package/dist/mediaSession.js +119 -0
- package/dist/mediaSession.js.map +1 -0
- package/dist/notifications.d.ts +108 -0
- package/dist/notifications.d.ts.map +1 -0
- package/dist/notifications.js +251 -0
- package/dist/notifications.js.map +1 -0
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +9 -1
- package/dist/run.js.map +1 -1
- package/dist/runtime.d.ts +3 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +72 -3
- package/dist/runtime.js.map +1 -1
- package/dist/secureStorage.d.ts +20 -0
- package/dist/secureStorage.d.ts.map +1 -0
- package/dist/secureStorage.js +28 -0
- package/dist/secureStorage.js.map +1 -0
- package/dist/subtle.d.ts +38 -0
- package/dist/subtle.d.ts.map +1 -0
- package/dist/subtle.js +72 -0
- package/dist/subtle.js.map +1 -0
- package/dist/webSocket.d.ts +73 -0
- package/dist/webSocket.d.ts.map +1 -0
- package/dist/webSocket.js +145 -0
- package/dist/webSocket.js.map +1 -0
- package/package.json +3 -3
- package/src/blob.ts +186 -0
- package/src/bridge.ts +57 -1
- package/src/fetch.ts +29 -3
- package/src/hostRequests.ts +148 -0
- package/src/index.ts +8 -0
- package/src/matchMedia.ts +79 -0
- package/src/mediaSession.ts +163 -0
- package/src/notifications.ts +317 -0
- package/src/run.ts +10 -1
- package/src/runtime.ts +73 -3
- package/src/secureStorage.ts +39 -0
- package/src/subtle.ts +87 -0
- package/src/webSocket.ts +175 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +180 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +437 -0
- package/swift/Sources/SwiftBrowserNative/Host/NotificationBridge.swift +304 -0
- package/swift/Sources/SwiftBrowserNative/Host/NowPlayingBridge.swift +190 -0
- package/swift/Sources/SwiftBrowserNative/Host/SecureStorage.swift +60 -0
- package/swift/Sources/SwiftBrowserNative/Host/ShimAliases.swift +3 -0
- package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +118 -0
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +26 -0
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeAppDelegate.swift +36 -0
- package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +2 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +22 -9
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +8 -5
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Video.swift +304 -0
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +43 -0
- package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +12 -2
- 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
|