@swiftbrowser/native 0.17.7 → 0.18.0
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 +99 -11
- 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 +65 -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 +55 -0
- package/dist/hostRequests.d.ts.map +1 -0
- package/dist/hostRequests.js +196 -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/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 +23 -2
- package/dist/run.js.map +1 -1
- package/dist/runtime.d.ts +2 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +69 -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/site.d.ts +3 -3
- package/dist/site.d.ts.map +1 -1
- package/dist/site.js +1 -1
- package/dist/site.js.map +1 -1
- 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/undo.d.ts +23 -0
- package/dist/undo.d.ts.map +1 -0
- package/dist/undo.js +44 -0
- package/dist/undo.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 +74 -1
- package/src/fetch.ts +29 -3
- package/src/hostRequests.ts +216 -0
- package/src/index.ts +8 -0
- package/src/matchMedia.ts +79 -0
- package/src/notifications.ts +317 -0
- package/src/run.ts +21 -2
- package/src/runtime.ts +71 -0
- package/src/secureStorage.ts +39 -0
- package/src/site.ts +3 -3
- package/src/subtle.ts +87 -0
- package/src/undo.ts +47 -0
- package/src/webSocket.ts +175 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioRecorderBridge.swift +199 -0
- package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +154 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +211 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +451 -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/TextMeasureBridge.swift +139 -0
- package/swift/Sources/SwiftBrowserNative/Host/UndoBridge.swift +170 -0
- package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +118 -0
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +56 -0
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeAppDelegate.swift +36 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +362 -16
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/DeleteBackward.swift +147 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/KeyboardBar.swift +84 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +116 -11
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +12 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Shapes.swift +30 -9
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +163 -10
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/TextAndImages.swift +17 -4
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Video.swift +8 -3
- package/swift/Sources/SwiftBrowserNative/Views/NativeRootView.swift +8 -0
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +41 -0
- package/swift/Sources/SwiftBrowserProtocol/Op.swift +27 -4
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +110 -3
- package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +47 -0
- package/swift/Sources/SwiftBrowserProtocol/SVGPath.swift +260 -0
- package/swift/Sources/SwiftBrowserProtocol/Style.swift +58 -0
- package/swift/Sources/SwiftBrowserTree/OpTree.swift +22 -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,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,139 @@
|
|
|
1
|
+
#if canImport(SwiftUI)
|
|
2
|
+
import Foundation
|
|
3
|
+
import SwiftBrowserProtocol
|
|
4
|
+
import SwiftUI
|
|
5
|
+
#if canImport(UIKit)
|
|
6
|
+
import UIKit
|
|
7
|
+
#elseif canImport(AppKit)
|
|
8
|
+
import AppKit
|
|
9
|
+
#endif
|
|
10
|
+
|
|
11
|
+
/// `measureText(text, fontJSON, width)` for @swiftbrowser/react's
|
|
12
|
+
/// `measureText`: the size `text` takes in the font (a Font JSON object:
|
|
13
|
+
/// `textStyle` or `size`, `weight`, `design`; the body font when empty)
|
|
14
|
+
/// wrapped to `width` points (one line when null), from
|
|
15
|
+
/// `NSAttributedString`'s bounding rect with the platform font, so an app
|
|
16
|
+
/// paginating from text sizes gets what UIKit draws. Answers
|
|
17
|
+
/// `{ width, height, lines }` (`lines` is the height in the font's line
|
|
18
|
+
/// heights), or nil where there is no text system (the shim's round trip).
|
|
19
|
+
enum TextMeasureBridge {
|
|
20
|
+
static func measure(text: String, font fontJSON: JSONValue, width: Double?) -> JSONValue? {
|
|
21
|
+
#if (canImport(UIKit) || canImport(AppKit)) && !canImport(FoundationBridge) // the shim's stand-ins have no text system
|
|
22
|
+
let font = platformFont(FontSpec(json: fontJSON))
|
|
23
|
+
let bounds = CGSize(width: width.map { CGFloat(max(1, $0)) } ?? .greatestFiniteMagnitude, height: .greatestFiniteMagnitude)
|
|
24
|
+
// An empty text is one line tall, as a `Text("")` is.
|
|
25
|
+
let measured = NSAttributedString(string: text.isEmpty ? " " : text, attributes: [.font: font])
|
|
26
|
+
let rect = measured.boundingRect(with: bounds, options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil)
|
|
27
|
+
// The font's line height from the metrics UIFont and NSFont share (NSFont has no `lineHeight`).
|
|
28
|
+
let lineHeight = max(1, font.ascender - font.descender + font.leading)
|
|
29
|
+
let lines = max(1, Int((rect.height / lineHeight).rounded()))
|
|
30
|
+
return .object([
|
|
31
|
+
"width": .number(Double(text.isEmpty ? 0 : ceil(rect.width))),
|
|
32
|
+
"height": .number(Double(ceil(rect.height))),
|
|
33
|
+
"lines": .number(Double(lines)),
|
|
34
|
+
])
|
|
35
|
+
#else
|
|
36
|
+
return nil
|
|
37
|
+
#endif
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#if canImport(UIKit)
|
|
41
|
+
typealias PlatformFont = UIFont
|
|
42
|
+
#elseif canImport(AppKit)
|
|
43
|
+
typealias PlatformFont = NSFont
|
|
44
|
+
#endif
|
|
45
|
+
|
|
46
|
+
#if (canImport(UIKit) || canImport(AppKit)) && !canImport(FoundationBridge) // the shim's stand-ins have no text system
|
|
47
|
+
/// The platform font a Font JSON object names: a text style's preferred font (Dynamic Type) or a point size, with its weight and design.
|
|
48
|
+
static func platformFont(_ spec: FontSpec?) -> PlatformFont {
|
|
49
|
+
let weight = spec?.weight.map(platformWeight) ?? .regular
|
|
50
|
+
var font: PlatformFont
|
|
51
|
+
if let size = spec?.size {
|
|
52
|
+
font = PlatformFont.systemFont(ofSize: CGFloat(size), weight: weight)
|
|
53
|
+
} else {
|
|
54
|
+
#if canImport(UIKit)
|
|
55
|
+
let base = UIFont.preferredFont(forTextStyle: textStyle(spec?.textStyle ?? "body"))
|
|
56
|
+
font = spec?.weight == nil ? base : UIFont.systemFont(ofSize: base.pointSize, weight: weight)
|
|
57
|
+
#else
|
|
58
|
+
let base = NSFont.preferredFont(forTextStyle: textStyle(spec?.textStyle ?? "body"))
|
|
59
|
+
font = spec?.weight == nil ? base : NSFont.systemFont(ofSize: base.pointSize, weight: weight)
|
|
60
|
+
#endif
|
|
61
|
+
}
|
|
62
|
+
if let design = spec?.design.flatMap(platformDesign), let descriptor = font.fontDescriptor.withDesign(design) {
|
|
63
|
+
#if canImport(UIKit)
|
|
64
|
+
font = UIFont(descriptor: descriptor, size: font.pointSize)
|
|
65
|
+
#else
|
|
66
|
+
font = NSFont(descriptor: descriptor, size: font.pointSize) ?? font
|
|
67
|
+
#endif
|
|
68
|
+
}
|
|
69
|
+
return font
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private static func platformWeight(_ name: String) -> PlatformFont.Weight {
|
|
73
|
+
switch name {
|
|
74
|
+
case "ultraLight": return .ultraLight
|
|
75
|
+
case "thin": return .thin
|
|
76
|
+
case "light": return .light
|
|
77
|
+
case "medium": return .medium
|
|
78
|
+
case "semibold": return .semibold
|
|
79
|
+
case "bold": return .bold
|
|
80
|
+
case "heavy": return .heavy
|
|
81
|
+
case "black": return .black
|
|
82
|
+
default: return .regular
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#if canImport(UIKit)
|
|
87
|
+
private static func platformDesign(_ name: String) -> UIFontDescriptor.SystemDesign? {
|
|
88
|
+
switch name {
|
|
89
|
+
case "serif": return .serif
|
|
90
|
+
case "rounded": return .rounded
|
|
91
|
+
case "monospaced": return .monospaced
|
|
92
|
+
default: return nil
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private static func textStyle(_ name: String) -> UIFont.TextStyle {
|
|
97
|
+
switch name {
|
|
98
|
+
case "largeTitle": return .largeTitle
|
|
99
|
+
case "title": return .title1
|
|
100
|
+
case "title2": return .title2
|
|
101
|
+
case "title3": return .title3
|
|
102
|
+
case "headline": return .headline
|
|
103
|
+
case "subheadline": return .subheadline
|
|
104
|
+
case "callout": return .callout
|
|
105
|
+
case "footnote": return .footnote
|
|
106
|
+
case "caption": return .caption1
|
|
107
|
+
case "caption2": return .caption2
|
|
108
|
+
default: return .body
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
#else
|
|
112
|
+
private static func platformDesign(_ name: String) -> NSFontDescriptor.SystemDesign? {
|
|
113
|
+
switch name {
|
|
114
|
+
case "serif": return .serif
|
|
115
|
+
case "rounded": return .rounded
|
|
116
|
+
case "monospaced": return .monospaced
|
|
117
|
+
default: return nil
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private static func textStyle(_ name: String) -> NSFont.TextStyle {
|
|
122
|
+
switch name {
|
|
123
|
+
case "largeTitle": return .largeTitle
|
|
124
|
+
case "title": return .title1
|
|
125
|
+
case "title2": return .title2
|
|
126
|
+
case "title3": return .title3
|
|
127
|
+
case "headline": return .headline
|
|
128
|
+
case "subheadline": return .subheadline
|
|
129
|
+
case "callout": return .callout
|
|
130
|
+
case "footnote": return .footnote
|
|
131
|
+
case "caption": return .caption1
|
|
132
|
+
case "caption2": return .caption2
|
|
133
|
+
default: return .body
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
#endif
|
|
137
|
+
#endif
|
|
138
|
+
}
|
|
139
|
+
#endif
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#if canImport(SwiftUI) && !canImport(FoundationBridge)
|
|
2
|
+
import Foundation
|
|
3
|
+
import SwiftBrowserProtocol
|
|
4
|
+
|
|
5
|
+
/// The app's undo manager (`useUndoManager` in @swiftbrowser/react,
|
|
6
|
+
/// packages/native/src/undo.ts) on the window's `UndoManager`, so the
|
|
7
|
+
/// system's undo reaches it: the shake and the three-finger swipe on iOS,
|
|
8
|
+
/// ⌘Z and ⇧⌘Z from a keyboard, the Edit menu on macOS. The JavaScript stack
|
|
9
|
+
/// is the truth (its closures are JavaScript, and `canUndo` must change in
|
|
10
|
+
/// the pass that registered them); the window's manager mirrors it. Each
|
|
11
|
+
/// `undoState(json)` (`canUndo`, `canRedo`, `undoActionName`,
|
|
12
|
+
/// `redoActionName`) rebuilds the mirror: one proxy action on the undo
|
|
13
|
+
/// stack when the app can undo and one on the redo stack when it can redo,
|
|
14
|
+
/// named as the app names them, each sending `__sbUndoCommand("undo" |
|
|
15
|
+
/// "redo")` when the system runs it. The redo stack is reached the only
|
|
16
|
+
/// way `UndoManager` allows, from inside an undo: on the turn after the
|
|
17
|
+
/// undo proxy's (whose event group the run loop has closed by then), a
|
|
18
|
+
/// no-op is registered and undone at once, and the proxy it registers
|
|
19
|
+
/// while undoing lands on the redo stack. A proxy the system runs calls
|
|
20
|
+
/// into the app, whose new state arrives while the manager is still
|
|
21
|
+
/// undoing, so the mirror is rebuilt on the next turn of the run loop.
|
|
22
|
+
///
|
|
23
|
+
/// Against the shim (the round trip) the page's keyboard is the host and
|
|
24
|
+
/// this file is empty.
|
|
25
|
+
@MainActor
|
|
26
|
+
final class UndoBridge {
|
|
27
|
+
struct State: Equatable {
|
|
28
|
+
var canUndo = false
|
|
29
|
+
var canRedo = false
|
|
30
|
+
var undoActionName = ""
|
|
31
|
+
var redoActionName = ""
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private let send: (String) -> Void
|
|
35
|
+
private(set) var state = State()
|
|
36
|
+
/// What the mirror does, for the tests (nil: nothing is traced).
|
|
37
|
+
nonisolated(unsafe) static var trace: (@MainActor (String) -> Void)?
|
|
38
|
+
/// Whether a proxy is running the app's undo or redo: the state it sends back is mirrored afterwards.
|
|
39
|
+
private var applying = false
|
|
40
|
+
/// The window's manager, from the root view's environment.
|
|
41
|
+
weak var manager: UndoManager? {
|
|
42
|
+
didSet {
|
|
43
|
+
if manager !== oldValue {
|
|
44
|
+
if let oldValue {
|
|
45
|
+
oldValue.removeAllActions(withTarget: self)
|
|
46
|
+
// A proxy registered this turn sits in an event group still
|
|
47
|
+
// open, out of `removeAllActions`'s reach until the turn ends.
|
|
48
|
+
DispatchQueue.main.async { [weak self] in
|
|
49
|
+
MainActor.assumeIsolated { if let self { oldValue.removeAllActions(withTarget: self) } }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
mirror()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// `send` hands the system's `undo` or `redo` to the app.
|
|
58
|
+
init(send: @escaping (String) -> Void) {
|
|
59
|
+
self.send = send
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// Applies one state; the message of what was wrong with it, or nil.
|
|
63
|
+
func update(_ json: String) -> String? {
|
|
64
|
+
let parsed: JSONValue
|
|
65
|
+
do {
|
|
66
|
+
parsed = try JSONValue.parse(json)
|
|
67
|
+
} catch {
|
|
68
|
+
return "undoState: \(error)"
|
|
69
|
+
}
|
|
70
|
+
guard let fields = parsed.object else { return "undoState: expected an object" }
|
|
71
|
+
state = State(
|
|
72
|
+
canUndo: fields.bool("canUndo") ?? false,
|
|
73
|
+
canRedo: fields.bool("canRedo") ?? false,
|
|
74
|
+
undoActionName: fields.string("undoActionName") ?? "",
|
|
75
|
+
redoActionName: fields.string("redoActionName") ?? ""
|
|
76
|
+
)
|
|
77
|
+
if !applying { mirror() }
|
|
78
|
+
return nil
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// Rebuilds the window's manager from `state`; while another group is open on it, on the next turn of the run loop.
|
|
82
|
+
private func mirror() {
|
|
83
|
+
guard let manager else { return }
|
|
84
|
+
guard manager.groupingLevel == 0 else {
|
|
85
|
+
afterTurn { $0.mirror() }
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
Self.trace?("mirror level=\(manager.groupingLevel) before: \(describe(manager)) state: \(state)")
|
|
89
|
+
manager.removeAllActions(withTarget: self)
|
|
90
|
+
Self.trace?("mirror after removeAllActions: \(describe(manager))")
|
|
91
|
+
// The undo proxy first: a registration made while not undoing clears
|
|
92
|
+
// the redo stack, so the redo proxy must be the last thing planted,
|
|
93
|
+
// and on the next turn: this turn's event group stays open around
|
|
94
|
+
// the undo proxy until the run loop ends it, and the no-op's
|
|
95
|
+
// `undo()` would close and undo it whole.
|
|
96
|
+
if state.canUndo {
|
|
97
|
+
manager.beginUndoGrouping()
|
|
98
|
+
registerProxy("undo", named: state.undoActionName)
|
|
99
|
+
manager.endUndoGrouping()
|
|
100
|
+
}
|
|
101
|
+
if state.canRedo { later { $0.plantRedo(attempts: 3) } }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// The redo proxy, from inside the undo of a no-op: on a turn with no
|
|
105
|
+
/// group open (a later one while this one's event group is still open:
|
|
106
|
+
/// a block run in the same drain of the main queue as the registration
|
|
107
|
+
/// that opened it comes before the run loop's end of the turn).
|
|
108
|
+
private func plantRedo(attempts: Int) {
|
|
109
|
+
guard let manager else { return }
|
|
110
|
+
Self.trace?("plantRedo attempts=\(attempts) level=\(manager.groupingLevel) \(describe(manager)) state.canRedo=\(state.canRedo)")
|
|
111
|
+
guard state.canRedo, !manager.canRedo else { return }
|
|
112
|
+
guard manager.groupingLevel == 0 else {
|
|
113
|
+
if attempts > 0 { afterTurn { $0.plantRedo(attempts: attempts - 1) } }
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
manager.beginUndoGrouping()
|
|
117
|
+
manager.registerUndo(withTarget: self) { target in
|
|
118
|
+
MainActor.assumeIsolated { target.registerProxy("redo", named: target.state.redoActionName) }
|
|
119
|
+
}
|
|
120
|
+
manager.endUndoGrouping()
|
|
121
|
+
Self.trace?("plantRedo before undo(): \(describe(manager))")
|
|
122
|
+
manager.undo()
|
|
123
|
+
Self.trace?("plantRedo after undo(): \(describe(manager))")
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private func describe(_ manager: UndoManager) -> String {
|
|
127
|
+
"undo=\(manager.canUndo)/\(manager.undoActionName) redo=\(manager.canRedo)/\(manager.redoActionName) undoing=\(manager.isUndoing) redoing=\(manager.isRedoing) level=\(manager.groupingLevel)"
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// Registers the proxy that sends `command`, on whichever stack the manager is filling.
|
|
131
|
+
private func registerProxy(_ command: String, named name: String) {
|
|
132
|
+
guard let manager else { return }
|
|
133
|
+
manager.registerUndo(withTarget: self) { target in
|
|
134
|
+
MainActor.assumeIsolated { target.perform(command) }
|
|
135
|
+
}
|
|
136
|
+
manager.setActionName(name)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/// The system ran a proxy: the app undoes or redoes, and the mirror follows once the manager is done.
|
|
140
|
+
private func perform(_ command: String) {
|
|
141
|
+
guard let manager else { return } // taken away: a proxy left in a group that was open then
|
|
142
|
+
Self.trace?("perform \(command): \(describe(manager))")
|
|
143
|
+
applying = true
|
|
144
|
+
send(command)
|
|
145
|
+
applying = false
|
|
146
|
+
// What the app can now do the other way, so the manager is right at once; the rebuild confirms it.
|
|
147
|
+
if command == "undo", state.canRedo { registerProxy("redo", named: state.redoActionName) }
|
|
148
|
+
if command == "redo", state.canUndo { registerProxy("undo", named: state.undoActionName) }
|
|
149
|
+
mirrorLater()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private func mirrorLater() {
|
|
153
|
+
later { $0.mirror() }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/// `body` on the next drain of the main queue, with this bridge.
|
|
157
|
+
private func later(_ body: @escaping @MainActor (UndoBridge) -> Void) {
|
|
158
|
+
DispatchQueue.main.async { [weak self] in
|
|
159
|
+
MainActor.assumeIsolated { if let self { body(self) } }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/// `body` a moment later, on a turn of the run loop after this one has ended (and closed its event group).
|
|
164
|
+
private func afterTurn(_ body: @escaping @MainActor (UndoBridge) -> Void) {
|
|
165
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1)) { [weak self] in
|
|
166
|
+
MainActor.assumeIsolated { if let self { body(self) } }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
#endif
|