@swiftbrowser/native 0.17.8 → 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 (60) hide show
  1. package/README.md +20 -9
  2. package/dist/bridge.d.ts +27 -1
  3. package/dist/bridge.d.ts.map +1 -1
  4. package/dist/bridge.js.map +1 -1
  5. package/dist/hostRequests.d.ts +10 -4
  6. package/dist/hostRequests.d.ts.map +1 -1
  7. package/dist/hostRequests.js +65 -3
  8. package/dist/hostRequests.js.map +1 -1
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/run.d.ts.map +1 -1
  14. package/dist/run.js +14 -1
  15. package/dist/run.js.map +1 -1
  16. package/dist/runtime.d.ts +1 -0
  17. package/dist/runtime.d.ts.map +1 -1
  18. package/dist/runtime.js +12 -1
  19. package/dist/runtime.js.map +1 -1
  20. package/dist/site.d.ts +3 -3
  21. package/dist/site.d.ts.map +1 -1
  22. package/dist/site.js +1 -1
  23. package/dist/site.js.map +1 -1
  24. package/dist/undo.d.ts +23 -0
  25. package/dist/undo.d.ts.map +1 -0
  26. package/dist/undo.js +44 -0
  27. package/dist/undo.js.map +1 -0
  28. package/package.json +3 -3
  29. package/src/bridge.ts +26 -1
  30. package/src/hostRequests.ts +72 -4
  31. package/src/index.ts +1 -0
  32. package/src/run.ts +11 -1
  33. package/src/runtime.ts +12 -1
  34. package/src/site.ts +3 -3
  35. package/src/undo.ts +47 -0
  36. package/swift/Sources/SwiftBrowserNative/Host/AudioRecorderBridge.swift +199 -0
  37. package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +154 -0
  38. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +45 -0
  39. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +16 -2
  40. package/swift/Sources/SwiftBrowserNative/Host/TextMeasureBridge.swift +139 -0
  41. package/swift/Sources/SwiftBrowserNative/Host/UndoBridge.swift +170 -0
  42. package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +30 -0
  43. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +347 -12
  44. package/swift/Sources/SwiftBrowserNative/Views/Kinds/DeleteBackward.swift +147 -0
  45. package/swift/Sources/SwiftBrowserNative/Views/Kinds/KeyboardBar.swift +84 -0
  46. package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +116 -11
  47. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +12 -1
  48. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Shapes.swift +30 -9
  49. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +163 -10
  50. package/swift/Sources/SwiftBrowserNative/Views/Kinds/TextAndImages.swift +17 -4
  51. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Video.swift +8 -3
  52. package/swift/Sources/SwiftBrowserNative/Views/NativeRootView.swift +8 -0
  53. package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +41 -0
  54. package/swift/Sources/SwiftBrowserProtocol/Op.swift +27 -4
  55. package/swift/Sources/SwiftBrowserProtocol/Props.swift +110 -3
  56. package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +47 -0
  57. package/swift/Sources/SwiftBrowserProtocol/SVGPath.swift +260 -0
  58. package/swift/Sources/SwiftBrowserProtocol/Style.swift +58 -0
  59. package/swift/Sources/SwiftBrowserTree/OpTree.swift +22 -2
  60. package/wasm/SwiftBrowserNative.wasm +2 -2
@@ -0,0 +1,154 @@
1
+ #if canImport(SwiftUI)
2
+ import Foundation
3
+
4
+ #if !canImport(FoundationBridge)
5
+
6
+ /// The app's files for the page's `fileManager` (@swiftbrowser/react): the
7
+ /// `files` host requests (packages/native/src/hostRequests.ts), answered with
8
+ /// Foundation's `FileManager` in the app's sandbox. A URL must be in one of
9
+ /// the app's directories (`Documents`, `Library`, `tmp`); bytes cross the
10
+ /// bridge as base64; the standard directories go to the page through the
11
+ /// bridge's `appDirectories()`. Failures are `kind: detail`, as the page's own
12
+ /// host fails: `unsupported`, `notFound`, `exists`, `isDirectory`,
13
+ /// `notDirectory`.
14
+ struct FilesBridge: Sendable {
15
+ /// Not stored: a struct without stored properties is `Sendable`, and `handle` runs off the main actor.
16
+ private var manager: FileManager { .default }
17
+
18
+ /// The standard directories, made where they are not yet (`Application Support` is not, on iOS).
19
+ static func directories() -> JSONValue {
20
+ let manager = FileManager.default
21
+ let documents = manager.urls(for: .documentDirectory, in: .userDomainMask).first!
22
+ let support = manager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
23
+ let caches = manager.urls(for: .cachesDirectory, in: .userDomainMask).first!
24
+ for directory in [documents, support, caches] {
25
+ try? manager.createDirectory(at: directory, withIntermediateDirectories: true)
26
+ }
27
+ return .object([
28
+ "documents": .string(documents.standardizedFileURL.absoluteString),
29
+ "applicationSupport": .string(support.standardizedFileURL.absoluteString),
30
+ "caches": .string(caches.standardizedFileURL.absoluteString),
31
+ "temporary": .string(manager.temporaryDirectory.standardizedFileURL.absoluteString),
32
+ ])
33
+ }
34
+
35
+ /// Where the app may read and write: its documents, its library (Application Support, Caches) and its temporary directory.
36
+ private var roots: [URL] {
37
+ var roots = [URL]()
38
+ for directory in [FileManager.SearchPathDirectory.documentDirectory, .libraryDirectory] {
39
+ if let url = manager.urls(for: directory, in: .userDomainMask).first { roots.append(url.standardizedFileURL) }
40
+ }
41
+ roots.append(manager.temporaryDirectory.standardizedFileURL)
42
+ return roots
43
+ }
44
+
45
+ /// The file URL `params[key]` names, under today's container, inside the app's directories.
46
+ private func url(_ params: JSONObject, _ key: String) throws -> URL {
47
+ guard let string = params[key]?.string, let parsed = URL(string: string) else {
48
+ throw HostRequestFailure("unsupported: files: \(key) is not a URL")
49
+ }
50
+ guard parsed.isFileURL else { throw HostRequestFailure("unsupported: \(string) is not a file URL") }
51
+ let url = AppFiles.current(parsed).standardizedFileURL
52
+ let path = url.path
53
+ guard roots.contains(where: { path == $0.path || path.hasPrefix($0.path + "/") }) else {
54
+ throw HostRequestFailure("unsupported: \(string) is not in the app's directories")
55
+ }
56
+ return url
57
+ }
58
+
59
+ private func isDirectory(_ url: URL) -> Bool? {
60
+ var directory: ObjCBool = false
61
+ guard manager.fileExists(atPath: url.path, isDirectory: &directory) else { return nil }
62
+ return directory.boolValue
63
+ }
64
+
65
+ private func requireDirectory(_ directory: URL, for url: URL) throws {
66
+ switch isDirectory(directory) {
67
+ case nil: throw HostRequestFailure("notFound: no directory at \(directory.absoluteString) (for \(url.absoluteString))")
68
+ case false?: throw HostRequestFailure("notDirectory: \(directory.absoluteString) is a file")
69
+ case true?: break
70
+ }
71
+ }
72
+
73
+ func handle(_ params: JSONObject) async throws -> JSONValue {
74
+ switch params["action"]?.string {
75
+ case "read":
76
+ let url = try url(params, "url")
77
+ switch isDirectory(url) {
78
+ case nil: throw HostRequestFailure("notFound: no file at \(url.absoluteString)")
79
+ case true?: throw HostRequestFailure("isDirectory: \(url.absoluteString) is a directory")
80
+ case false?: break
81
+ }
82
+ let data = try Data(contentsOf: url)
83
+ return .object(["data": .string(data.base64EncodedString())])
84
+ case "write":
85
+ let url = try url(params, "url")
86
+ guard let data = Data(base64Encoded: params["data"]?.string ?? "") else {
87
+ throw HostRequestFailure("unsupported: files: data is not base64")
88
+ }
89
+ if isDirectory(url) == true { throw HostRequestFailure("isDirectory: \(url.absoluteString) is a directory") }
90
+ try requireDirectory(url.deletingLastPathComponent(), for: url)
91
+ try data.write(to: url, options: .atomic)
92
+ return .object([:])
93
+ case "remove":
94
+ let url = try url(params, "url")
95
+ guard isDirectory(url) != nil else { throw HostRequestFailure("notFound: nothing at \(url.absoluteString)") }
96
+ guard !roots.contains(where: { $0.path == url.path }) else {
97
+ throw HostRequestFailure("unsupported: \(url.absoluteString) is one of the app's directories")
98
+ }
99
+ try manager.removeItem(at: url)
100
+ return .object([:])
101
+ case "createDirectory":
102
+ let url = try url(params, "url")
103
+ if isDirectory(url) == false { throw HostRequestFailure("exists: \(url.absoluteString) is a file") }
104
+ try manager.createDirectory(at: url, withIntermediateDirectories: true)
105
+ return .object([:])
106
+ case "list":
107
+ let url = try url(params, "url")
108
+ try requireDirectory(url, for: url)
109
+ let names = try manager.contentsOfDirectory(atPath: url.path).sorted { $0.localizedStandardCompare($1) == .orderedAscending }
110
+ return .object(["urls": .array(names.map { .string(url.appendingPathComponent($0).absoluteString) })])
111
+ case "attributes":
112
+ let url = try url(params, "url")
113
+ guard let directory = isDirectory(url) else { return .null }
114
+ let attributes = try manager.attributesOfItem(atPath: url.path)
115
+ let size = directory ? 0 : ((attributes[.size] as? NSNumber)?.doubleValue ?? 0)
116
+ let modified = (attributes[.modificationDate] as? Date).map { JSONValue.number(($0.timeIntervalSince1970 * 1000).rounded()) } ?? .null
117
+ return .object(["size": .number(size), "modified": modified, "directory": .bool(directory)])
118
+ case "move", "copy":
119
+ let from = try url(params, "from")
120
+ let to = try url(params, "to")
121
+ guard isDirectory(from) != nil else { throw HostRequestFailure("notFound: nothing at \(from.absoluteString)") }
122
+ guard isDirectory(to) == nil else { throw HostRequestFailure("exists: something is at \(to.absoluteString) already") }
123
+ try requireDirectory(to.deletingLastPathComponent(), for: to)
124
+ if to.path.hasPrefix(from.path + "/") { throw HostRequestFailure("unsupported: \(to.absoluteString) is inside \(from.absoluteString)") }
125
+ if params["action"]?.string == "move" { try manager.moveItem(at: from, to: to) } else { try manager.copyItem(at: from, to: to) }
126
+ return .object([:])
127
+ default:
128
+ throw HostRequestFailure("unsupported: files: unknown action \(params["action"]?.string ?? "")")
129
+ }
130
+ }
131
+ }
132
+
133
+ #endif
134
+
135
+ /// Files the app keeps (a recording's, `fileManager`'s) are named by their
136
+ /// URL, and iOS moves an app's container when it is installed again: a file
137
+ /// URL into an old `Documents` that is not there anymore is the same path
138
+ /// under today's. (Against the shim nothing moves.)
139
+ enum AppFiles {
140
+ static func current(_ url: URL) -> URL {
141
+ #if canImport(FoundationBridge)
142
+ return url
143
+ #else
144
+ let manager = FileManager.default
145
+ guard url.isFileURL, !manager.fileExists(atPath: url.path) else { return url }
146
+ guard let range = url.path.range(of: "/Documents/"),
147
+ let documents = manager.urls(for: .documentDirectory, in: .userDomainMask).first
148
+ else { return url }
149
+ let moved = documents.appendingPathComponent(String(url.path[range.upperBound...]))
150
+ return manager.fileExists(atPath: moved.path) ? moved : url
151
+ #endif
152
+ }
153
+ }
154
+ #endif
@@ -30,7 +30,9 @@ import CryptoKit
30
30
  /// | `randomBytes(count)` | base64 of random bytes (`crypto.getRandomValues`) |
31
31
  /// | `openURL(url)` | the system opener |
32
32
  /// | `locale()` | the viewer's locale identifier |
33
+ /// | `measureText(text, fontJSON, width)` | @swiftbrowser/react's `measureText`: `{ width, height, lines }` of the text in the font wrapped to the width (one line when null), from `NSAttributedString`'s bounding rect, synchronously (TextMeasureBridge.swift); nothing where there is no text system |
33
34
  /// | `launchArguments()` | the process's arguments (`-screen list` from a scheme or `simctl launch`) |
35
+ /// | `appDirectories()` | the app's standard directories as `file:` URLs, a JSON object `{ documents, applicationSupport, caches, temporary }`, for `fileManager` (FilesBridge.swift); its `files` requests go through `hostRequest`. Apple platforms only |
34
36
  /// | `mediaSession(update)` | `navigator.mediaSession`: the lock screen's now-playing info and the remote commands the app handles, on MediaPlayer (NowPlayingBridge.swift); a command the viewer gives comes to `__sbMediaSessionCommand(json)` |
35
37
  /// | `notificationsReady()`, `notificationPermission(id, request)`, `notificationShow(json)`, `notificationClose(json)`, `setBadge(count)`, `pushRegister(id, key?)` | notifications on UserNotifications (NotificationBridge.swift): the permission answers `__sbNotificationPermission(id, state)`, a registration `__sbPushRegistered(id, result)`, and what the viewer opens or what arrives comes to `__sbNotification(json)` |
36
38
  /// | `wsOpen(id, url, protocols)`, `wsSend(id, json)`, `wsClose(id, code, reason)` | `WebSocket` on `URLSessionWebSocketTask` (WebSocketBridge.swift); what a socket receives comes to `__sbWebSocketEvent(id, json)` |
@@ -38,6 +40,7 @@ import CryptoKit
38
40
  /// | `haptic(kind)` | `navigator.vibrate`: a feedback generator's `impact`, `selection`, `success`, `warning` or `error` |
39
41
  /// | `hostRequest(id, kind, json)` | the web renderer's `share`, `pickFiles`, `clipboard`, `pickColor` and `webAuth` requests, answered with the system's share sheet, pickers, pasteboard and sign-in session (HostRequestBridge.swift); the answer comes to `__sbHostResponse(id, result)` |
40
42
  /// | `secureGet(key)`, `secureSet(key, value)`, `secureRemove(key)` | `secureStorage` over the Keychain (SecureStorage.swift) |
43
+ /// | `undoState(json)` | the app's undo manager's `canUndo`, `canRedo`, `undoActionName` and `redoActionName`, mirrored on the window's `UndoManager` (UndoBridge.swift) so the shake, the three-finger swipe, ⌘Z and the Edit menu reach the app; what the system asks comes to `__sbUndoCommand("undo" \| "redo")` |
41
44
  /// | `workerStart(id, name, engine)`, `workerPost(id, text)`, `workerTerminate(id)` | workers (JavaScriptCore only): a `JSContext` on a thread (`"context"`, WorkerHost.swift) or a hidden web view with WebKit's JIT (`"webview"`, WebViewWorkerHost.swift); their messages come to `__sbWorkerMessage(id, text)`, their errors to `__sbWorkerError(id, message)` |
42
45
  ///
43
46
  /// Everything JavaScript-facing runs on the main thread; work that is not
@@ -60,6 +63,11 @@ final class HostBridge {
60
63
  #endif
61
64
  private var webSockets: WebSocketBridge?
62
65
  private var hostRequests: HostRequestBridge?
66
+ #if !canImport(FoundationBridge)
67
+ private var undo: UndoBridge?
68
+ /// The window's undo manager, for the undo bridge when the app first sends a state.
69
+ private weak var undoManager: UndoManager?
70
+ #endif
63
71
  /// Keys of `localStorage` live under this prefix in `UserDefaults`.
64
72
  static let storagePrefix = "swiftbrowser.storage."
65
73
 
@@ -74,6 +82,9 @@ final class HostBridge {
74
82
  for timer in timers.values { timer.invalidate() }
75
83
  timers.removeAll()
76
84
  webSockets?.invalidate()
85
+ #if !canImport(FoundationBridge)
86
+ undo?.manager = nil
87
+ #endif
77
88
  #if canImport(JavaScriptCore)
78
89
  for worker in workers.values { worker.terminate() }
79
90
  workers.removeAll()
@@ -209,8 +220,18 @@ final class HostBridge {
209
220
 
210
221
  host.install("locale") { _ in .string(Locale.current.identifier) }
211
222
 
223
+ host.install("measureText") { arguments in
224
+ guard let text = arguments[safe: 0]?.string else { return nil }
225
+ let font = (try? JSONValue.parse(arguments[safe: 1]?.string ?? "{}")) ?? .object([:])
226
+ return TextMeasureBridge.measure(text: text, font: font, width: arguments[safe: 2]?.double)
227
+ }
228
+
212
229
  host.install("launchArguments") { _ in .array(ProcessInfo.processInfo.arguments.map(JSONValue.string)) }
213
230
 
231
+ #if !canImport(FoundationBridge)
232
+ host.install("appDirectories") { _ in .string(FilesBridge.directories().json) }
233
+ #endif
234
+
214
235
  #if canImport(MediaPlayer)
215
236
  host.install("mediaSession") { [weak self] arguments in
216
237
  guard let self, let update = arguments.first?.string else { return .string("mediaSession: expected a JSON string") }
@@ -293,6 +314,13 @@ final class HostBridge {
293
314
  return nil
294
315
  }
295
316
 
317
+ #if !canImport(FoundationBridge)
318
+ host.install("undoState") { [weak self] arguments in
319
+ guard let self, let json = arguments.first?.string else { return .string("undoState: expected a JSON string") }
320
+ return undoBridge.update(json).map(JSONValue.string)
321
+ }
322
+ #endif
323
+
296
324
  host.install("secureGet") { arguments in
297
325
  guard let key = arguments.first?.string else { return nil }
298
326
  return SecureStorage.get(key).map(JSONValue.string) ?? .null
@@ -367,6 +395,23 @@ final class HostBridge {
367
395
  }
368
396
  #endif
369
397
 
398
+ #if !canImport(FoundationBridge)
399
+ /// The undo bridge, made when the app first sends its undo manager's state.
400
+ private var undoBridge: UndoBridge {
401
+ if let undo { return undo }
402
+ let bridge = UndoBridge { [weak self] command in self?.callIn("__sbUndoCommand", [.string(command)]) }
403
+ bridge.manager = undoManager
404
+ undo = bridge
405
+ return bridge
406
+ }
407
+
408
+ /// The window's `UndoManager` (the root view's environment), which mirrors the app's.
409
+ func setUndoManager(_ manager: UndoManager?) {
410
+ undoManager = manager
411
+ undo?.manager = manager
412
+ }
413
+ #endif
414
+
370
415
  /// The WebSocket bridge, made when the app opens its first socket.
371
416
  private var webSocketBridge: WebSocketBridge {
372
417
  if let webSockets { return webSockets }
@@ -30,8 +30,10 @@ import UniformTypeIdentifiers
30
30
  /// `multiple`: the photo picker for pictures and videos, else the document
31
31
  /// picker; `{ files: [{ name, type, data }] }`), `clipboard` (`write` with
32
32
  /// `text` or `files`, `readText`; `{ text }`), `pickColor` (`color`: the
33
- /// color picker; `{ color }`) and `webAuth` (`url`, `callbackScheme`,
34
- /// `ephemeral`: `ASWebAuthenticationSession`; `{ url }`). Against the shim
33
+ /// color picker; `{ color }`), `webAuth` (`url`, `callbackScheme`,
34
+ /// `ephemeral`: `ASWebAuthenticationSession`; `{ url }`) and, on iOS,
35
+ /// `audioRecord` (`action`: the microphone with `AVAudioRecorder`,
36
+ /// AudioRecorderBridge.swift). Against the shim
35
37
  /// (the round trip) the page answers the first four itself, and the sign-in
36
38
  /// runs on the shim's session; the rest answers `unsupported`.
37
39
  @MainActor
@@ -39,6 +41,14 @@ final class HostRequestBridge {
39
41
  private let answer: (Int, JSONValue) -> Void
40
42
  /// Delegates and sessions kept alive until they answer.
41
43
  private var active: [ObjectIdentifier: AnyObject] = [:]
44
+ #if os(iOS) && !canImport(FoundationBridge)
45
+ /// The microphone (`audioRecord`), one recording at a time.
46
+ private lazy var recorder = AudioRecorderBridge()
47
+ #endif
48
+ #if !canImport(FoundationBridge)
49
+ /// The app's files (`files`) for `fileManager`.
50
+ private let files = FilesBridge()
51
+ #endif
42
52
 
43
53
  /// `answer` hands a request's result to the app.
44
54
  init(answer: @escaping (Int, JSONValue) -> Void) {
@@ -64,6 +74,10 @@ final class HostRequestBridge {
64
74
  case "pickFiles": try await pickFiles(params)
65
75
  case "clipboard": try clipboard(params)
66
76
  case "pickColor": try await pickColor(params)
77
+ case "files": try await files.handle(params)
78
+ #endif
79
+ #if os(iOS) && !canImport(FoundationBridge)
80
+ case "audioRecord": try await recorder.handle(params)
67
81
  #endif
68
82
  default: throw HostRequestFailure("unsupported: the native app cannot answer a \(kind) request")
69
83
  }
@@ -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
@@ -40,6 +40,10 @@ public final class NativeApp {
40
40
  /// URLs the app was opened with before its script could take them (a
41
41
  /// universal link that launched it): delivered once it loads.
42
42
  private var pendingURLs: [URL] = []
43
+ #if !canImport(FoundationBridge)
44
+ /// The window's undo manager, kept across reloads.
45
+ private weak var undoManager: UndoManager?
46
+ #endif
43
47
 
44
48
  #if canImport(JavaScriptCore)
45
49
  public convenience init() {
@@ -57,6 +61,19 @@ public final class NativeApp {
57
61
  body()
58
62
  }
59
63
  }
64
+ // `withAnimation(_:completionCriteria:_:completion:)`: SwiftUI calls the
65
+ // completion when the pass's animation ends; with no animation, right
66
+ // after the pass (the tree sends the event a turn later, off the
67
+ // pass's stack, as the app answers with a pass of its own).
68
+ tree.animateWithCompletion = { spec, completion, body, done in
69
+ guard let animation = spec.map(Animation.init(spec:)) else {
70
+ body()
71
+ done()
72
+ return
73
+ }
74
+ let criteria: AnimationCompletionCriteria = completion.criteria == "removed" ? .removed : .logicallyComplete
75
+ withAnimation(animation, completionCriteria: criteria, body, completion: done)
76
+ }
60
77
  tree.onEvent = { [weak self] event in self?.dispatch(event) }
61
78
  #if canImport(JavaScriptCore)
62
79
  (host as? JavaScriptCoreHost)?.onError = { [weak self] error in self?.error = error.description }
@@ -137,8 +154,21 @@ public final class NativeApp {
137
154
  try? tree.apply(tree.root.children.map { Op.remove(id: $0.id) } + [.commit(animation: nil)])
138
155
  host.reset()
139
156
  bridge = HostBridge(host: host, tree: tree)
157
+ #if !canImport(FoundationBridge)
158
+ bridge?.setUndoManager(undoManager)
159
+ #endif
140
160
  }
141
161
 
162
+ #if !canImport(FoundationBridge)
163
+ /// The window's `UndoManager` (`NativeRootView` reads it from the
164
+ /// environment): it mirrors the app's undo manager (UndoBridge.swift),
165
+ /// so the system's undo gestures, keys and menu reach the app.
166
+ public func setUndoManager(_ manager: UndoManager?) {
167
+ undoManager = manager
168
+ bridge?.setUndoManager(manager)
169
+ }
170
+ #endif
171
+
142
172
  /// Loads a bundled resource, `app.js` by default.
143
173
  public func load(resource: String = "app", extension ext: String = "js", bundle: Bundle = .main) {
144
174
  guard let url = bundle.url(forResource: resource, withExtension: ext) else {