@swiftbrowser/native 0.17.7 → 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 (70) hide show
  1. package/README.md +79 -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 +39 -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 +7 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +7 -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/notifications.d.ts +108 -0
  26. package/dist/notifications.d.ts.map +1 -0
  27. package/dist/notifications.js +251 -0
  28. package/dist/notifications.js.map +1 -0
  29. package/dist/run.d.ts.map +1 -1
  30. package/dist/run.js +9 -1
  31. package/dist/run.js.map +1 -1
  32. package/dist/runtime.d.ts +1 -0
  33. package/dist/runtime.d.ts.map +1 -1
  34. package/dist/runtime.js +58 -0
  35. package/dist/runtime.js.map +1 -1
  36. package/dist/secureStorage.d.ts +20 -0
  37. package/dist/secureStorage.d.ts.map +1 -0
  38. package/dist/secureStorage.js +28 -0
  39. package/dist/secureStorage.js.map +1 -0
  40. package/dist/subtle.d.ts +38 -0
  41. package/dist/subtle.d.ts.map +1 -0
  42. package/dist/subtle.js +72 -0
  43. package/dist/subtle.js.map +1 -0
  44. package/dist/webSocket.d.ts +73 -0
  45. package/dist/webSocket.d.ts.map +1 -0
  46. package/dist/webSocket.js +145 -0
  47. package/dist/webSocket.js.map +1 -0
  48. package/package.json +3 -3
  49. package/src/blob.ts +186 -0
  50. package/src/bridge.ts +49 -1
  51. package/src/fetch.ts +29 -3
  52. package/src/hostRequests.ts +148 -0
  53. package/src/index.ts +7 -0
  54. package/src/matchMedia.ts +79 -0
  55. package/src/notifications.ts +317 -0
  56. package/src/run.ts +10 -1
  57. package/src/runtime.ts +60 -0
  58. package/src/secureStorage.ts +39 -0
  59. package/src/subtle.ts +87 -0
  60. package/src/webSocket.ts +175 -0
  61. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +166 -0
  62. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +437 -0
  63. package/swift/Sources/SwiftBrowserNative/Host/NotificationBridge.swift +304 -0
  64. package/swift/Sources/SwiftBrowserNative/Host/SecureStorage.swift +60 -0
  65. package/swift/Sources/SwiftBrowserNative/Host/ShimAliases.swift +1 -0
  66. package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +118 -0
  67. package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +26 -0
  68. package/swift/Sources/SwiftBrowserNative/Runtime/NativeAppDelegate.swift +36 -0
  69. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +16 -5
  70. 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,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
@@ -0,0 +1,118 @@
1
+ #if canImport(SwiftUI)
2
+ import Foundation
3
+ import SwiftBrowserProtocol
4
+ #if canImport(FoundationBridge)
5
+ import FoundationBridge
6
+ #endif
7
+
8
+ /// The app's `WebSocket`s (packages/native/src/webSocket.ts) on
9
+ /// `URLSessionWebSocketTask`: `wsOpen` starts one, which is open once its
10
+ /// first ping is answered (the handshake is done by then), `wsSend` sends
11
+ /// text or bytes, `wsClose` closes it. What a socket receives, and how it
12
+ /// ends, goes to the app as `__sbWebSocketEvent(id, json)`: `open`,
13
+ /// `message` (`text` or base64 `binary`), `error` and `close` (`code`,
14
+ /// `reason`, `wasClean`). Against the shim (the round trip) the task is the
15
+ /// shim's, over the page's WebSocket.
16
+ @MainActor
17
+ final class WebSocketBridge {
18
+ private let session: URLSession
19
+ private let send: (Int, JSONValue) -> Void
20
+ private var tasks: [Int: URLSessionWebSocketTask] = [:]
21
+
22
+ /// `send` hands an event for socket `id` to the app.
23
+ init(session: URLSession, send: @escaping (Int, JSONValue) -> Void) {
24
+ self.session = session
25
+ self.send = send
26
+ }
27
+
28
+ func open(id: Int, url string: String, protocols: [String]) {
29
+ guard let url = URL(string: string) else {
30
+ emit(id, ["type": .string("error"), "message": .string("invalid URL")])
31
+ emit(id, ["type": .string("close"), "code": .number(1006), "reason": .string(""), "wasClean": .bool(false)])
32
+ return
33
+ }
34
+ let task = protocols.isEmpty ? session.webSocketTask(with: url) : session.webSocketTask(with: url, protocols: protocols)
35
+ tasks[id] = task
36
+ task.resume()
37
+ task.sendPing { [weak self] error in
38
+ Task { @MainActor [weak self] in
39
+ guard let self, self.tasks[id] === task else { return }
40
+ if let error {
41
+ self.fail(id: id, task: task, error: error)
42
+ return
43
+ }
44
+ self.emit(id, ["type": .string("open"), "protocol": .string("")])
45
+ self.receive(id: id, task: task)
46
+ }
47
+ }
48
+ }
49
+
50
+ /// `{ text }` or `{ binary }` (base64).
51
+ func send(id: Int, json: String) {
52
+ guard let task = tasks[id], let fields = (try? JSONValue.parse(json))?.object else { return }
53
+ let message: URLSessionWebSocketTask.Message
54
+ if let text = fields["text"]?.string {
55
+ message = .string(text)
56
+ } else if let encoded = fields["binary"]?.string, let data = Data(base64Encoded: encoded) {
57
+ message = .data(data)
58
+ } else {
59
+ return
60
+ }
61
+ task.send(message) { [weak self] error in
62
+ guard let error else { return }
63
+ Task { @MainActor [weak self] in
64
+ guard let self, self.tasks[id] === task else { return }
65
+ self.fail(id: id, task: task, error: error)
66
+ }
67
+ }
68
+ }
69
+
70
+ func close(id: Int, code: Int, reason: String) {
71
+ guard let task = tasks.removeValue(forKey: id) else { return }
72
+ task.cancel(with: URLSessionWebSocketTask.CloseCode(rawValue: code) ?? .normalClosure, reason: reason.isEmpty ? nil : Data(reason.utf8))
73
+ emit(id, ["type": .string("close"), "code": .number(Double(code)), "reason": .string(reason), "wasClean": .bool(true)])
74
+ }
75
+
76
+ /// Ends every socket; the app that owned the bridge is going away.
77
+ func invalidate() {
78
+ for task in tasks.values { task.cancel(with: .goingAway, reason: nil) }
79
+ tasks.removeAll()
80
+ }
81
+
82
+ private func receive(id: Int, task: URLSessionWebSocketTask) {
83
+ task.receive { [weak self] result in
84
+ Task { @MainActor [weak self] in
85
+ guard let self, self.tasks[id] === task else { return }
86
+ switch result {
87
+ case .success(let message):
88
+ switch message {
89
+ case .string(let text): self.emit(id, ["type": .string("message"), "text": .string(text)])
90
+ case .data(let data): self.emit(id, ["type": .string("message"), "binary": .string(data.base64EncodedString())])
91
+ @unknown default: break
92
+ }
93
+ self.receive(id: id, task: task)
94
+ case .failure(let error):
95
+ self.fail(id: id, task: task, error: error)
96
+ }
97
+ }
98
+ }
99
+ }
100
+
101
+ /// The socket ended: a close the server sent (its code and reason), else an error and an unclean close.
102
+ private func fail(id: Int, task: URLSessionWebSocketTask, error: any Error) {
103
+ tasks[id] = nil
104
+ let code = task.closeCode
105
+ if code != .invalid {
106
+ let reason = task.closeReason.map { String(decoding: $0, as: UTF8.self) } ?? ""
107
+ emit(id, ["type": .string("close"), "code": .number(Double(code.rawValue)), "reason": .string(reason), "wasClean": .bool(true)])
108
+ } else {
109
+ emit(id, ["type": .string("error"), "message": .string(error.localizedDescription)])
110
+ emit(id, ["type": .string("close"), "code": .number(1006), "reason": .string(""), "wasClean": .bool(false)])
111
+ }
112
+ }
113
+
114
+ private func emit(_ id: Int, _ fields: JSONObject) {
115
+ send(id, .object(fields))
116
+ }
117
+ }
118
+ #endif
@@ -61,9 +61,35 @@ public final class NativeApp {
61
61
  #if canImport(JavaScriptCore)
62
62
  (host as? JavaScriptCoreHost)?.onError = { [weak self] error in self?.error = error.description }
63
63
  #endif
64
+ #if canImport(UserNotifications)
65
+ NotificationRouter.shared.installIfApp()
66
+ #endif
64
67
  bridge = HostBridge(host: host, tree: tree)
65
68
  }
66
69
 
70
+ #if canImport(UserNotifications)
71
+ // MARK: Remote notifications
72
+ //
73
+ // What the app delegate is handed (NativeAppDelegate.swift, or the app's
74
+ // own delegate calling these), for `registerForPush` and `onNotification`
75
+ // in `@swiftbrowser/native` (NotificationBridge.swift).
76
+
77
+ /// The device token: `registerForPush` resolves with it, as hex.
78
+ public static func didRegisterForRemoteNotifications(deviceToken: Data) {
79
+ NotificationRouter.shared.registered(token: deviceToken)
80
+ }
81
+
82
+ /// No token: `registerForPush` rejects with the error.
83
+ public static func didFailToRegisterForRemoteNotifications(error: any Error) {
84
+ NotificationRouter.shared.registrationFailed("\(error)")
85
+ }
86
+
87
+ /// A push delivered to the app delegate (a silent one): a `received` event.
88
+ public static func didReceiveRemoteNotification(userInfo: [AnyHashable: Any]) {
89
+ NotificationRouter.shared.received(remote: userInfo)
90
+ }
91
+ #endif
92
+
67
93
  /// Evaluates the app's bundle (what `@swiftbrowser/native` built: it calls
68
94
  /// `run(<App/>)` at the end, which renders the first pass into the tree
69
95
  /// before this returns). The environment, if known, is sent right after.
@@ -0,0 +1,36 @@
1
+ #if canImport(SwiftUI) && canImport(UIKit) && canImport(UserNotifications)
2
+ import Foundation
3
+ import UIKit
4
+ import UserNotifications
5
+ #if canImport(FoundationBridge)
6
+ import FoundationBridge
7
+ #endif
8
+
9
+ /// The app delegate a native app adopts for remote notifications, through
10
+ /// `@UIApplicationDelegateAdaptor(NativeAppDelegate.self)` in its App file
11
+ /// (the project `native init` writes does): it hands the device token, a
12
+ /// failure to get one and the pushes delivered to the app to `NativeApp`,
13
+ /// for `registerForPush` and `onNotification` in `@swiftbrowser/native`
14
+ /// (NotificationBridge.swift). An app with a delegate of its own calls the
15
+ /// same `NativeApp` entry points from it.
16
+ open class NativeAppDelegate: UIResponder, UIApplicationDelegate {
17
+ open func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
18
+ // Before launching finishes, so a tap that launched the app is reported.
19
+ NotificationRouter.shared.install()
20
+ return true
21
+ }
22
+
23
+ open func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
24
+ NativeApp.didRegisterForRemoteNotifications(deviceToken: deviceToken)
25
+ }
26
+
27
+ open func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) {
28
+ NativeApp.didFailToRegisterForRemoteNotifications(error: error)
29
+ }
30
+
31
+ open func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult {
32
+ NativeApp.didReceiveRemoteNotification(userInfo: userInfo)
33
+ return .newData
34
+ }
35
+ }
36
+ #endif
@@ -71,9 +71,20 @@ struct ToggleElement: View {
71
71
  }
72
72
  }
73
73
 
74
- /// `textfield`: a controlled field. Each keystroke sends `text`; the app's
75
- /// state is the truth the getter reads back, with no frame in between
76
- /// because the dispatch is synchronous.
74
+ /// The `text` event for a write to a field's binding, or nil when the
75
+ /// field's text already is `value`. SwiftUI writes a text binding on a focus
76
+ /// and a commit with the text it has, not only on a keystroke; the page's
77
+ /// renderer sends `text` only for input, and an app that takes every `text`
78
+ /// as an edit (Amindia's Discard button, appearing on a tap into a field)
79
+ /// must hear only changes natively too.
80
+ func textChange(of node: ElementNode, to value: String) -> RendererEvent? {
81
+ (node.props.string("text") ?? "") == value ? nil : .text(node.id, value)
82
+ }
83
+
84
+ /// `textfield`: a controlled field. Each keystroke sends `text` (a write of
85
+ /// the text the field has sends nothing, `textChange`); the app's state is
86
+ /// the truth the getter reads back, with no frame in between because the
87
+ /// dispatch is synchronous.
77
88
  struct TextFieldElement: View {
78
89
  let node: ElementNode
79
90
  @Environment(\.nativeApp) private var app
@@ -83,7 +94,7 @@ struct TextFieldElement: View {
83
94
  let props = TextFieldProps(props: node.props)
84
95
  let text = Binding(
85
96
  get: { TextFieldProps(props: node.props).text },
86
- set: { app?.dispatch(.text(node.id, $0)) }
97
+ set: { if let event = textChange(of: node, to: $0) { app?.dispatch(event) } }
87
98
  )
88
99
  let field: AnyView = {
89
100
  if props.secure { return AnyView(SecureField(props.placeholder, text: text)) }
@@ -133,7 +144,7 @@ struct TextEditorElement: View {
133
144
  var body: some View {
134
145
  TextEditor(text: Binding(
135
146
  get: { node.props.string("text") ?? "" },
136
- set: { app?.dispatch(.text(node.id, $0)) }
147
+ set: { if let event = textChange(of: node, to: $0) { app?.dispatch(event) } }
137
148
  ))
138
149
  }
139
150
  }
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/wasm/SwiftBrowserNative.wasm
3
- size: 49658341 bytes
4
- sha256: 9aa09e4a1125c88786c07b9293dd9eec6264583717cd1f8227808596da2d911a
3
+ size: 49791914 bytes
4
+ sha256: 32b3af1ab0d488c6d0d26f3f6e58f7c8886299aadb7f70dd25d2bf5a7e42c3f9