@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.
- package/README.md +20 -9
- package/dist/bridge.d.ts +27 -1
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/hostRequests.d.ts +10 -4
- package/dist/hostRequests.d.ts.map +1 -1
- package/dist/hostRequests.js +65 -3
- package/dist/hostRequests.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +14 -1
- package/dist/run.js.map +1 -1
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +12 -1
- package/dist/runtime.js.map +1 -1
- 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/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/package.json +3 -3
- package/src/bridge.ts +26 -1
- package/src/hostRequests.ts +72 -4
- package/src/index.ts +1 -0
- package/src/run.ts +11 -1
- package/src/runtime.ts +12 -1
- package/src/site.ts +3 -3
- package/src/undo.ts +47 -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 +45 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +16 -2
- package/swift/Sources/SwiftBrowserNative/Host/TextMeasureBridge.swift +139 -0
- package/swift/Sources/SwiftBrowserNative/Host/UndoBridge.swift +170 -0
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +30 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +347 -12
- 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,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 }`)
|
|
34
|
-
/// `ephemeral`: `ASWebAuthenticationSession`; `{ url }`)
|
|
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 {
|