@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.
- package/README.md +79 -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 +39 -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 +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -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/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 +1 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +58 -0
- 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 +49 -1
- package/src/fetch.ts +29 -3
- package/src/hostRequests.ts +148 -0
- package/src/index.ts +7 -0
- package/src/matchMedia.ts +79 -0
- package/src/notifications.ts +317 -0
- package/src/run.ts +10 -1
- package/src/runtime.ts +60 -0
- 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 +166 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +437 -0
- package/swift/Sources/SwiftBrowserNative/Host/NotificationBridge.swift +304 -0
- package/swift/Sources/SwiftBrowserNative/Host/SecureStorage.swift +60 -0
- package/swift/Sources/SwiftBrowserNative/Host/ShimAliases.swift +1 -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/Kinds/Controls.swift +16 -5
- 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
|
-
/// `
|
|
75
|
-
///
|
|
76
|
-
///
|
|
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: {
|
|
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: {
|
|
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:
|
|
4
|
-
sha256:
|
|
3
|
+
size: 49791914 bytes
|
|
4
|
+
sha256: 32b3af1ab0d488c6d0d26f3f6e58f7c8886299aadb7f70dd25d2bf5a7e42c3f9
|