@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.
Files changed (100) hide show
  1. package/README.md +99 -11
  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 +65 -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 +55 -0
  14. package/dist/hostRequests.d.ts.map +1 -0
  15. package/dist/hostRequests.js +196 -0
  16. package/dist/hostRequests.js.map +1 -0
  17. package/dist/index.d.ts +8 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +8 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/matchMedia.d.ts +43 -0
  22. package/dist/matchMedia.d.ts.map +1 -0
  23. package/dist/matchMedia.js +63 -0
  24. package/dist/matchMedia.js.map +1 -0
  25. package/dist/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 +23 -2
  31. package/dist/run.js.map +1 -1
  32. package/dist/runtime.d.ts +2 -0
  33. package/dist/runtime.d.ts.map +1 -1
  34. package/dist/runtime.js +69 -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/site.d.ts +3 -3
  41. package/dist/site.d.ts.map +1 -1
  42. package/dist/site.js +1 -1
  43. package/dist/site.js.map +1 -1
  44. package/dist/subtle.d.ts +38 -0
  45. package/dist/subtle.d.ts.map +1 -0
  46. package/dist/subtle.js +72 -0
  47. package/dist/subtle.js.map +1 -0
  48. package/dist/undo.d.ts +23 -0
  49. package/dist/undo.d.ts.map +1 -0
  50. package/dist/undo.js +44 -0
  51. package/dist/undo.js.map +1 -0
  52. package/dist/webSocket.d.ts +73 -0
  53. package/dist/webSocket.d.ts.map +1 -0
  54. package/dist/webSocket.js +145 -0
  55. package/dist/webSocket.js.map +1 -0
  56. package/package.json +3 -3
  57. package/src/blob.ts +186 -0
  58. package/src/bridge.ts +74 -1
  59. package/src/fetch.ts +29 -3
  60. package/src/hostRequests.ts +216 -0
  61. package/src/index.ts +8 -0
  62. package/src/matchMedia.ts +79 -0
  63. package/src/notifications.ts +317 -0
  64. package/src/run.ts +21 -2
  65. package/src/runtime.ts +71 -0
  66. package/src/secureStorage.ts +39 -0
  67. package/src/site.ts +3 -3
  68. package/src/subtle.ts +87 -0
  69. package/src/undo.ts +47 -0
  70. package/src/webSocket.ts +175 -0
  71. package/swift/Sources/SwiftBrowserNative/Host/AudioRecorderBridge.swift +199 -0
  72. package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +154 -0
  73. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +211 -0
  74. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +451 -0
  75. package/swift/Sources/SwiftBrowserNative/Host/NotificationBridge.swift +304 -0
  76. package/swift/Sources/SwiftBrowserNative/Host/SecureStorage.swift +60 -0
  77. package/swift/Sources/SwiftBrowserNative/Host/ShimAliases.swift +1 -0
  78. package/swift/Sources/SwiftBrowserNative/Host/TextMeasureBridge.swift +139 -0
  79. package/swift/Sources/SwiftBrowserNative/Host/UndoBridge.swift +170 -0
  80. package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +118 -0
  81. package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +56 -0
  82. package/swift/Sources/SwiftBrowserNative/Runtime/NativeAppDelegate.swift +36 -0
  83. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +362 -16
  84. package/swift/Sources/SwiftBrowserNative/Views/Kinds/DeleteBackward.swift +147 -0
  85. package/swift/Sources/SwiftBrowserNative/Views/Kinds/KeyboardBar.swift +84 -0
  86. package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +116 -11
  87. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +12 -1
  88. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Shapes.swift +30 -9
  89. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +163 -10
  90. package/swift/Sources/SwiftBrowserNative/Views/Kinds/TextAndImages.swift +17 -4
  91. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Video.swift +8 -3
  92. package/swift/Sources/SwiftBrowserNative/Views/NativeRootView.swift +8 -0
  93. package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +41 -0
  94. package/swift/Sources/SwiftBrowserProtocol/Op.swift +27 -4
  95. package/swift/Sources/SwiftBrowserProtocol/Props.swift +110 -3
  96. package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +47 -0
  97. package/swift/Sources/SwiftBrowserProtocol/SVGPath.swift +260 -0
  98. package/swift/Sources/SwiftBrowserProtocol/Style.swift +58 -0
  99. package/swift/Sources/SwiftBrowserTree/OpTree.swift +22 -2
  100. 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