@swiftbrowser/native 0.18.1 → 0.18.2

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 (69) hide show
  1. package/README.md +73 -8
  2. package/dist/bridge.d.ts +64 -2
  3. package/dist/bridge.d.ts.map +1 -1
  4. package/dist/bridge.js.map +1 -1
  5. package/dist/geolocation.d.ts +73 -0
  6. package/dist/geolocation.d.ts.map +1 -0
  7. package/dist/geolocation.js +109 -0
  8. package/dist/geolocation.js.map +1 -0
  9. package/dist/hostRequests.d.ts +3 -1
  10. package/dist/hostRequests.d.ts.map +1 -1
  11. package/dist/hostRequests.js +46 -5
  12. package/dist/hostRequests.js.map +1 -1
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/nativeFunctions.d.ts +35 -0
  18. package/dist/nativeFunctions.d.ts.map +1 -0
  19. package/dist/nativeFunctions.js +90 -0
  20. package/dist/nativeFunctions.js.map +1 -0
  21. package/dist/reachability.d.ts +24 -0
  22. package/dist/reachability.d.ts.map +1 -0
  23. package/dist/reachability.js +41 -0
  24. package/dist/reachability.js.map +1 -0
  25. package/dist/run.d.ts.map +1 -1
  26. package/dist/run.js +3 -1
  27. package/dist/run.js.map +1 -1
  28. package/dist/runtime.d.ts +9 -2
  29. package/dist/runtime.d.ts.map +1 -1
  30. package/dist/runtime.js +62 -5
  31. package/dist/runtime.js.map +1 -1
  32. package/dist/speech.d.ts +148 -0
  33. package/dist/speech.d.ts.map +1 -0
  34. package/dist/speech.js +243 -0
  35. package/dist/speech.js.map +1 -0
  36. package/dist/wakeLock.d.ts +32 -0
  37. package/dist/wakeLock.d.ts.map +1 -0
  38. package/dist/wakeLock.js +60 -0
  39. package/dist/wakeLock.js.map +1 -0
  40. package/package.json +3 -3
  41. package/src/bridge.ts +70 -2
  42. package/src/geolocation.ts +160 -0
  43. package/src/hostRequests.ts +47 -7
  44. package/src/index.ts +1 -0
  45. package/src/nativeFunctions.ts +94 -0
  46. package/src/reachability.ts +49 -0
  47. package/src/run.ts +5 -3
  48. package/src/runtime.ts +58 -5
  49. package/src/speech.ts +312 -0
  50. package/src/wakeLock.ts +63 -0
  51. package/swift/Sources/SwiftBrowserNative/Host/AppleSignInBridge.swift +139 -0
  52. package/swift/Sources/SwiftBrowserNative/Host/AudioRecorderBridge.swift +5 -0
  53. package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +83 -12
  54. package/swift/Sources/SwiftBrowserNative/Host/GeolocationBridge.swift +150 -0
  55. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +131 -2
  56. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +81 -11
  57. package/swift/Sources/SwiftBrowserNative/Host/ICloudBridge.swift +112 -0
  58. package/swift/Sources/SwiftBrowserNative/Host/ReachabilityBridge.swift +47 -0
  59. package/swift/Sources/SwiftBrowserNative/Host/SpeechBridge.swift +106 -0
  60. package/swift/Sources/SwiftBrowserNative/Host/SpeechRecognitionBridge.swift +198 -0
  61. package/swift/Sources/SwiftBrowserNative/Host/WorkerHost.swift +14 -5
  62. package/swift/Sources/SwiftBrowserNative/Runtime/NativeFunctions.swift +134 -0
  63. package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +1 -0
  64. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +2 -0
  65. package/swift/Sources/SwiftBrowserNative/Views/Kinds/TextInputHints.swift +105 -0
  66. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Touches.swift +102 -0
  67. package/swift/Sources/SwiftBrowserProtocol/Props.swift +13 -0
  68. package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +9 -0
  69. package/wasm/SwiftBrowserNative.wasm +2 -2
@@ -1,16 +1,22 @@
1
1
  #if canImport(SwiftUI)
2
2
  import Foundation
3
+ #if canImport(UniformTypeIdentifiers)
4
+ import UniformTypeIdentifiers
5
+ #endif
3
6
 
4
7
  #if !canImport(FoundationBridge)
5
8
 
6
9
  /// The app's files for the page's `fileManager` (@swiftbrowser/react): the
7
10
  /// `files` host requests (packages/native/src/hostRequests.ts), answered with
8
11
  /// 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
12
+ /// the app's directories (`Documents`, `Library`, `tmp`, and the iCloud Drive
13
+ /// container once `iCloud` resolved it: ICloudBridge.swift); bytes cross the
10
14
  /// 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`.
15
+ /// bridge's `appDirectories()`. A file in the container is read and written
16
+ /// through `NSFileCoordinator` (iCloud's daemon is a second writer) and
17
+ /// downloaded first when another device wrote it (`read`, `download`).
18
+ /// Failures are `kind: detail`, as the page's own host fails: `unsupported`,
19
+ /// `notFound`, `exists`, `isDirectory`, `notDirectory`, `unavailable`.
14
20
  struct FilesBridge: Sendable {
15
21
  /// Not stored: a struct without stored properties is `Sendable`, and `handle` runs off the main actor.
16
22
  private var manager: FileManager { .default }
@@ -39,9 +45,45 @@ struct FilesBridge: Sendable {
39
45
  if let url = manager.urls(for: directory, in: .userDomainMask).first { roots.append(url.standardizedFileURL) }
40
46
  }
41
47
  roots.append(manager.temporaryDirectory.standardizedFileURL)
48
+ if let container = ICloudContainer.shared.cached { roots.append(container) }
42
49
  return roots
43
50
  }
44
51
 
52
+ /// Runs `body` on `url` under file coordination when the URL is in the iCloud container, else as it is.
53
+ private func coordinated<T>(_ url: URL, writing: NSFileCoordinator.WritingOptions?, _ body: (URL) throws -> T) throws -> T {
54
+ guard ICloudContainer.shared.contains(url) else { return try body(url) }
55
+ let coordinator = NSFileCoordinator()
56
+ var coordinationError: NSError?
57
+ var result: Result<T, any Error>?
58
+ withoutActuallyEscaping(body) { body in
59
+ let accessor: (URL) -> Void = { coordinatedURL in result = Result { try body(coordinatedURL) } }
60
+ if let writing {
61
+ coordinator.coordinate(writingItemAt: url, options: writing, error: &coordinationError, byAccessor: accessor)
62
+ } else {
63
+ coordinator.coordinate(readingItemAt: url, options: [], error: &coordinationError, byAccessor: accessor)
64
+ }
65
+ }
66
+ if let coordinationError { throw coordinationError }
67
+ guard let result else { throw HostRequestFailure("unavailable: \(url.absoluteString) could not be coordinated") }
68
+ return try result.get()
69
+ }
70
+
71
+ /// A file another device wrote is a placeholder until downloaded: starts the download and waits for it (a minute at most).
72
+ private func ensureDownloaded(_ url: URL) async throws {
73
+ guard ICloudContainer.shared.contains(url) else { return }
74
+ let keys: Set<URLResourceKey> = [.isUbiquitousItemKey, .ubiquitousItemDownloadingStatusKey]
75
+ guard let values = try? url.resourceValues(forKeys: keys), values.isUbiquitousItem == true else { return }
76
+ if values.ubiquitousItemDownloadingStatus == .current || values.ubiquitousItemDownloadingStatus == .downloaded { return }
77
+ try manager.startDownloadingUbiquitousItem(at: url)
78
+ let deadline = Date().addingTimeInterval(60)
79
+ while Date() < deadline {
80
+ try await Task.sleep(nanoseconds: 200_000_000)
81
+ let status = (try? url.resourceValues(forKeys: [.ubiquitousItemDownloadingStatusKey]))?.ubiquitousItemDownloadingStatus
82
+ if status == .current || status == .downloaded { return }
83
+ }
84
+ throw HostRequestFailure("unavailable: \(url.absoluteString) did not download from iCloud Drive")
85
+ }
86
+
45
87
  /// The file URL `params[key]` names, under today's container, inside the app's directories.
46
88
  private func url(_ params: JSONObject, _ key: String) throws -> URL {
47
89
  guard let string = params[key]?.string, let parsed = URL(string: string) else {
@@ -58,8 +100,11 @@ struct FilesBridge: Sendable {
58
100
 
59
101
  private func isDirectory(_ url: URL) -> Bool? {
60
102
  var directory: ObjCBool = false
61
- guard manager.fileExists(atPath: url.path, isDirectory: &directory) else { return nil }
62
- return directory.boolValue
103
+ if manager.fileExists(atPath: url.path, isDirectory: &directory) { return directory.boolValue }
104
+ // A file another device wrote and this one has not downloaded is a `.name.icloud` placeholder beside the path.
105
+ guard ICloudContainer.shared.contains(url) else { return nil }
106
+ let placeholder = url.deletingLastPathComponent().appendingPathComponent(".\(url.lastPathComponent).icloud")
107
+ return manager.fileExists(atPath: placeholder.path) ? false : nil
63
108
  }
64
109
 
65
110
  private func requireDirectory(_ directory: URL, for url: URL) throws {
@@ -72,6 +117,16 @@ struct FilesBridge: Sendable {
72
117
 
73
118
  func handle(_ params: JSONObject) async throws -> JSONValue {
74
119
  switch params["action"]?.string {
120
+ case "iCloud":
121
+ // The container's Documents, resolved off the main thread the first time; null without iCloud Drive.
122
+ guard let documents = await ICloudContainer.shared.resolve() else { return .null }
123
+ return .object(["url": .string(documents.absoluteString)])
124
+ case "download":
125
+ // `loadURL`: the file is on the device once this answers (a placeholder is downloaded first).
126
+ let url = try url(params, "url")
127
+ guard isDirectory(url) != nil else { throw HostRequestFailure("notFound: no file at \(url.absoluteString)") }
128
+ try await ensureDownloaded(url)
129
+ return .object(["url": .string(url.absoluteString)])
75
130
  case "read":
76
131
  let url = try url(params, "url")
77
132
  switch isDirectory(url) {
@@ -79,7 +134,8 @@ struct FilesBridge: Sendable {
79
134
  case true?: throw HostRequestFailure("isDirectory: \(url.absoluteString) is a directory")
80
135
  case false?: break
81
136
  }
82
- let data = try Data(contentsOf: url)
137
+ try await ensureDownloaded(url)
138
+ let data = try coordinated(url, writing: nil) { try Data(contentsOf: $0) }
83
139
  return .object(["data": .string(data.base64EncodedString())])
84
140
  case "write":
85
141
  let url = try url(params, "url")
@@ -88,7 +144,7 @@ struct FilesBridge: Sendable {
88
144
  }
89
145
  if isDirectory(url) == true { throw HostRequestFailure("isDirectory: \(url.absoluteString) is a directory") }
90
146
  try requireDirectory(url.deletingLastPathComponent(), for: url)
91
- try data.write(to: url, options: .atomic)
147
+ try coordinated(url, writing: .forReplacing) { try data.write(to: $0, options: .atomic) }
92
148
  return .object([:])
93
149
  case "remove":
94
150
  let url = try url(params, "url")
@@ -96,7 +152,7 @@ struct FilesBridge: Sendable {
96
152
  guard !roots.contains(where: { $0.path == url.path }) else {
97
153
  throw HostRequestFailure("unsupported: \(url.absoluteString) is one of the app's directories")
98
154
  }
99
- try manager.removeItem(at: url)
155
+ try coordinated(url, writing: .forDeleting) { try manager.removeItem(at: $0) }
100
156
  return .object([:])
101
157
  case "createDirectory":
102
158
  let url = try url(params, "url")
@@ -106,7 +162,11 @@ struct FilesBridge: Sendable {
106
162
  case "list":
107
163
  let url = try url(params, "url")
108
164
  try requireDirectory(url, for: url)
109
- let names = try manager.contentsOfDirectory(atPath: url.path).sorted { $0.localizedStandardCompare($1) == .orderedAscending }
165
+ // A placeholder `.name.icloud` lists as `name`, the file it stands for.
166
+ let listed = try manager.contentsOfDirectory(atPath: url.path)
167
+ let names = Set(listed.map { name -> String in
168
+ name.hasPrefix(".") && name.hasSuffix(".icloud") ? String(name.dropFirst().dropLast(7)) : name
169
+ }).sorted { $0.localizedStandardCompare($1) == .orderedAscending }
110
170
  return .object(["urls": .array(names.map { .string(url.appendingPathComponent($0).absoluteString) })])
111
171
  case "attributes":
112
172
  let url = try url(params, "url")
@@ -114,7 +174,13 @@ struct FilesBridge: Sendable {
114
174
  let attributes = try manager.attributesOfItem(atPath: url.path)
115
175
  let size = directory ? 0 : ((attributes[.size] as? NSNumber)?.doubleValue ?? 0)
116
176
  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)])
177
+ let created = (attributes[.creationDate] as? Date).map { JSONValue.number(($0.timeIntervalSince1970 * 1000).rounded()) } ?? .null
178
+ #if canImport(UniformTypeIdentifiers)
179
+ let type = directory ? "" : (UTType(filenameExtension: url.pathExtension)?.preferredMIMEType ?? "")
180
+ #else
181
+ let type = ""
182
+ #endif
183
+ return .object(["size": .number(size), "modified": modified, "created": created, "directory": .bool(directory), "type": .string(type)])
118
184
  case "move", "copy":
119
185
  let from = try url(params, "from")
120
186
  let to = try url(params, "to")
@@ -122,7 +188,12 @@ struct FilesBridge: Sendable {
122
188
  guard isDirectory(to) == nil else { throw HostRequestFailure("exists: something is at \(to.absoluteString) already") }
123
189
  try requireDirectory(to.deletingLastPathComponent(), for: to)
124
190
  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) }
191
+ try await ensureDownloaded(from)
192
+ if params["action"]?.string == "move" {
193
+ try coordinated(to, writing: .forReplacing) { target in try coordinated(from, writing: .forMoving) { try manager.moveItem(at: $0, to: target) } }
194
+ } else {
195
+ try coordinated(to, writing: .forReplacing) { target in try coordinated(from, writing: nil) { try manager.copyItem(at: $0, to: target) } }
196
+ }
126
197
  return .object([:])
127
198
  default:
128
199
  throw HostRequestFailure("unsupported: files: unknown action \(params["action"]?.string ?? "")")
@@ -0,0 +1,150 @@
1
+ #if canImport(SwiftUI) && canImport(CoreLocation) && !canImport(FoundationBridge)
2
+ import CoreLocation
3
+ import Foundation
4
+ import SwiftBrowserProtocol
5
+
6
+ /// `navigator.geolocation` (packages/native/src/geolocation.ts) on
7
+ /// `CLLocationManager`. `current` asks for one fix (`requestLocation`),
8
+ /// `watch` for every fix (`startUpdatingLocation`) until `clear`; the first
9
+ /// ask prompts the viewer for when-in-use authorization (the app's
10
+ /// Info.plist names `NSLocationWhenInUseUsageDescription`), and the asks
11
+ /// wait for the answer. Each fix goes to the app as `__sbGeolocation(id,
12
+ /// json)`:
13
+ ///
14
+ /// ```
15
+ /// { "position": { "latitude", "longitude", "accuracy", "altitude", "altitudeAccuracy", "heading", "speed", "timestamp" } }
16
+ /// { "error": { "code": 1 | 2, "message": string } }
17
+ /// ```
18
+ ///
19
+ /// A denial (code 1) ends a request; a failure to fix (code 2) ends a
20
+ /// `current` ask and lets a `watch` go on. `enableHighAccuracy` asks for the
21
+ /// best accuracy, else a hundred meters; `timeout` is the runtime's.
22
+ @MainActor
23
+ final class GeolocationBridge: NSObject, CLLocationManagerDelegate {
24
+ private let send: (Int, JSONValue) -> Void
25
+ private let manager = CLLocationManager()
26
+ /// The requests under way, by id: whether each is a watch.
27
+ private var requests: [Int: Bool] = [:]
28
+ private var updating = false
29
+
30
+ /// `send` hands a fix or a failure for request `id` to the app.
31
+ init(send: @escaping (Int, JSONValue) -> Void) {
32
+ self.send = send
33
+ super.init()
34
+ manager.delegate = self
35
+ }
36
+
37
+ func request(id: Int, action: String, options: JSONObject) {
38
+ switch action {
39
+ case "current", "watch":
40
+ requests[id] = action == "watch"
41
+ if options["enableHighAccuracy"]?.bool == true { manager.desiredAccuracy = kCLLocationAccuracyBest }
42
+ else if requests.count == 1 { manager.desiredAccuracy = kCLLocationAccuracyHundredMeters }
43
+ begin()
44
+ case "clear":
45
+ requests[id] = nil
46
+ settle()
47
+ default:
48
+ send(id, ["error": ["code": .number(2), "message": .string("geolocation: unknown action \(action)")]])
49
+ }
50
+ }
51
+
52
+ /// The app is going away.
53
+ func stop() {
54
+ requests.removeAll()
55
+ settle()
56
+ }
57
+
58
+ /// Asks for authorization first, else starts the fixes the requests want.
59
+ private func begin() {
60
+ switch manager.authorizationStatus {
61
+ case .notDetermined:
62
+ manager.requestWhenInUseAuthorization()
63
+ case .denied, .restricted:
64
+ fail(code: 1, message: "geolocation: location access was not allowed", endingWatches: true)
65
+ default:
66
+ fix()
67
+ }
68
+ }
69
+
70
+ private func fix() {
71
+ guard !requests.isEmpty else { return }
72
+ if requests.values.contains(true) {
73
+ if !updating {
74
+ updating = true
75
+ manager.startUpdatingLocation()
76
+ }
77
+ } else {
78
+ manager.requestLocation()
79
+ }
80
+ }
81
+
82
+ /// Stops updating when no watch is left.
83
+ private func settle() {
84
+ if updating, !requests.values.contains(true) {
85
+ updating = false
86
+ manager.stopUpdatingLocation()
87
+ }
88
+ }
89
+
90
+ private func fail(code: Int, message: String, endingWatches: Bool) {
91
+ for (id, watch) in requests {
92
+ send(id, ["error": ["code": .number(Double(code)), "message": .string(message)]])
93
+ if !watch || endingWatches { requests[id] = nil }
94
+ }
95
+ settle()
96
+ }
97
+
98
+ private func deliver(_ position: JSONValue) {
99
+ for (id, watch) in requests {
100
+ send(id, ["position": position])
101
+ if !watch { requests[id] = nil }
102
+ }
103
+ settle()
104
+ }
105
+
106
+ // MARK: CLLocationManagerDelegate (on the main thread, where the manager was made)
107
+
108
+ nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
109
+ let status = manager.authorizationStatus
110
+ MainActor.assumeIsolated {
111
+ switch status {
112
+ case .notDetermined: break
113
+ case .denied, .restricted: fail(code: 1, message: "geolocation: location access was not allowed", endingWatches: true)
114
+ default: fix()
115
+ }
116
+ }
117
+ }
118
+
119
+ nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
120
+ guard let last = locations.last else { return }
121
+ let position = Self.json(last)
122
+ MainActor.assumeIsolated { deliver(position) }
123
+ }
124
+
125
+ nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
126
+ let denied = (error as? CLError)?.code == .denied
127
+ let message = "geolocation: \(error.localizedDescription)"
128
+ MainActor.assumeIsolated {
129
+ if denied { fail(code: 1, message: message, endingWatches: true) }
130
+ else { fail(code: 2, message: message, endingWatches: false) }
131
+ }
132
+ }
133
+
134
+ private nonisolated static func json(_ location: CLLocation) -> JSONValue {
135
+ var fields: JSONObject = [
136
+ "latitude": .number(location.coordinate.latitude),
137
+ "longitude": .number(location.coordinate.longitude),
138
+ "accuracy": .number(location.horizontalAccuracy),
139
+ "timestamp": .number(location.timestamp.timeIntervalSince1970 * 1000),
140
+ ]
141
+ if location.verticalAccuracy > 0 {
142
+ fields["altitude"] = .number(location.altitude)
143
+ fields["altitudeAccuracy"] = .number(location.verticalAccuracy)
144
+ }
145
+ if location.course >= 0 { fields["heading"] = .number(location.course) }
146
+ if location.speed >= 0 { fields["speed"] = .number(location.speed) }
147
+ return .object(fields)
148
+ }
149
+ }
150
+ #endif
@@ -14,6 +14,9 @@ import FoundationBridge // the shim's Foundation stand-ins (ShimAliases.swift)
14
14
  #if canImport(CryptoKit)
15
15
  import CryptoKit
16
16
  #endif
17
+ #if os(macOS)
18
+ import IOKit.pwr_mgt
19
+ #endif
17
20
 
18
21
  /// The native primitives `@swiftbrowser/native` builds the web globals from
19
22
  /// (packages/native/src/runtime.ts), installed on `__sbNative`:
@@ -38,7 +41,13 @@ import CryptoKit
38
41
  /// | `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)` |
39
42
  /// | `digest(algorithm, base64)`, `hmac(algorithm, key, base64)` | `crypto.subtle` on CryptoKit, synchronously: the base64 digest or HMAC, or nothing when the algorithm is not one of SHA-1, SHA-256, SHA-384 and SHA-512 |
40
43
  /// | `haptic(kind)` | `navigator.vibrate`: a feedback generator's `impact`, `selection`, `success`, `warning` or `error` |
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)` |
44
+ /// | `geolocation(id, action, json)` | `navigator.geolocation` on `CLLocationManager` (GeolocationBridge.swift): `current` for one fix, `watch` for every fix until `clear`; each fix or the failure comes to `__sbGeolocation(id, json)`. Apple platforms only |
45
+ /// | `speak(id, json)`, `speechControl(action)` | `speechSynthesis` on `AVSpeechSynthesizer` (SpeechBridge.swift): an utterance's `text`, `lang`, `voice`, `rate`, `pitch` and `volume`; `cancel`, `pause` and `resume`; the events come to `__sbSpeechEvent(id, json)`. Apple platforms only |
46
+ /// | `recognize(id, action, json)` | `SpeechRecognition` on `SFSpeechRecognizer` over the microphone (SpeechRecognitionBridge.swift): `start` with `{ lang, continuous, interimResults }`, `stop`, `abort`; the results come to `__sbRecognition(id, json)`. Apple platforms only |
47
+ /// | `wakeLock(active)` | `navigator.wakeLock`: the screen stays awake while a sentinel is held (`UIApplication.isIdleTimerDisabled`; an `IOPMAssertion` on macOS) |
48
+ /// | `online()` | `navigator.onLine`: whether the network path is satisfied, from `NWPathMonitor` (ReachabilityBridge.swift), which starts on the first ask; a change comes to `__sbOnline(bool)` for the `online` and `offline` events |
49
+ /// | `hostRequest(id, kind, json)` | the web renderer's `share`, `pickFiles`, `exportFiles`, `clipboard`, `pickColor`, `webAuth`, `appleSignIn` and `files` requests, answered with the system's share sheet, pickers (the document picker exports), pasteboard, sign-in session, Sign in with Apple sheet (AppleSignInBridge.swift) and the sandbox (HostRequestBridge.swift, FilesBridge.swift), and `app:<name>`, a function the Swift app registered (NativeFunctions.swift); the answer comes to `__sbHostResponse(id, result)`, and the URLs iCloud Drive changed to `__sbFilesChanged(json)` |
50
+ /// | `nativeFunctions()`, `callNativeSync(name, json)` | the escape hatch's functions (Runtime/NativeFunctions.swift): the names the Swift app registered, and a sync one run inline, answering `{ value }` or `{ error }` as JSON; what the app sends with `NativeFunctions.send` comes to `__sbNativeEvent(name, json)` |
42
51
  /// | `secureGet(key)`, `secureSet(key, value)`, `secureRemove(key)` | `secureStorage` over the Keychain (SecureStorage.swift) |
43
52
  /// | `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")` |
44
53
  /// | `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)` |
@@ -63,6 +72,24 @@ final class HostBridge {
63
72
  #endif
64
73
  private var webSockets: WebSocketBridge?
65
74
  private var hostRequests: HostRequestBridge?
75
+ #if canImport(Network)
76
+ private var reachability: ReachabilityBridge?
77
+ #endif
78
+ #if canImport(CoreLocation) && !canImport(FoundationBridge)
79
+ private var geolocation: GeolocationBridge?
80
+ #endif
81
+ #if canImport(AVFoundation) && !canImport(FoundationBridge)
82
+ private var speech: SpeechBridge?
83
+ #endif
84
+ #if canImport(Speech) && !canImport(FoundationBridge)
85
+ private var recognition: SpeechRecognitionBridge?
86
+ #endif
87
+ #if os(macOS)
88
+ /// The display-sleep assertion held while the app holds a wake lock.
89
+ private var wakeAssertion: IOPMAssertionID?
90
+ #endif
91
+ /// Whether the app holds a wake lock (`isIdleTimerDisabled`), undone with the bridge.
92
+ private var awake = false
66
93
  #if !canImport(FoundationBridge)
67
94
  private var undo: UndoBridge?
68
95
  /// The window's undo manager, for the undo bridge when the app first sends a state.
@@ -82,6 +109,21 @@ final class HostBridge {
82
109
  for timer in timers.values { timer.invalidate() }
83
110
  timers.removeAll()
84
111
  webSockets?.invalidate()
112
+ #if canImport(Network)
113
+ reachability?.stop()
114
+ #endif
115
+ setAwake(false)
116
+ hostRequests?.stop()
117
+ NativeFunctions.sink = nil
118
+ #if canImport(CoreLocation) && !canImport(FoundationBridge)
119
+ geolocation?.stop()
120
+ #endif
121
+ #if canImport(AVFoundation) && !canImport(FoundationBridge)
122
+ speech?.stop()
123
+ #endif
124
+ #if canImport(Speech) && !canImport(FoundationBridge)
125
+ recognition?.stop()
126
+ #endif
85
127
  #if !canImport(FoundationBridge)
86
128
  undo?.manager = nil
87
129
  #endif
@@ -232,6 +274,63 @@ final class HostBridge {
232
274
  host.install("appDirectories") { _ in .string(FilesBridge.directories().json) }
233
275
  #endif
234
276
 
277
+ #if canImport(CoreLocation) && !canImport(FoundationBridge)
278
+ host.install("geolocation") { [weak self] arguments in
279
+ guard let self, let id = arguments[safe: 0]?.int, let action = arguments[safe: 1]?.string else { return nil }
280
+ if geolocation == nil {
281
+ geolocation = GeolocationBridge { [weak self] id, result in self?.callIn("__sbGeolocation", [.number(Double(id)), .string(result.json)]) }
282
+ }
283
+ let options = (arguments[safe: 2]?.string).flatMap { try? JSONValue.parse($0) }?.object ?? [:]
284
+ geolocation?.request(id: id, action: action, options: options)
285
+ return nil
286
+ }
287
+ #endif
288
+
289
+ #if canImport(AVFoundation) && !canImport(FoundationBridge)
290
+ host.install("speak") { [weak self] arguments in
291
+ guard let self, let id = arguments[safe: 0]?.int else { return nil }
292
+ if speech == nil {
293
+ speech = SpeechBridge { [weak self] id, event in self?.callIn("__sbSpeechEvent", [.number(Double(id)), .string(event.json)]) }
294
+ }
295
+ let options = (arguments[safe: 1]?.string).flatMap { try? JSONValue.parse($0) }?.object ?? [:]
296
+ speech?.speak(id: id, options: options)
297
+ return nil
298
+ }
299
+ host.install("speechControl") { [weak self] arguments in
300
+ self?.speech?.control(arguments.first?.string ?? "")
301
+ return nil
302
+ }
303
+ #endif
304
+
305
+ #if canImport(Speech) && !canImport(FoundationBridge)
306
+ host.install("recognize") { [weak self] arguments in
307
+ guard let self, let id = arguments[safe: 0]?.int, let action = arguments[safe: 1]?.string else { return nil }
308
+ if recognition == nil {
309
+ recognition = SpeechRecognitionBridge { [weak self] id, event in self?.callIn("__sbRecognition", [.number(Double(id)), .string(event.json)]) }
310
+ }
311
+ let options = (arguments[safe: 2]?.string).flatMap { try? JSONValue.parse($0) }?.object ?? [:]
312
+ recognition?.handle(id: id, action: action, options: options)
313
+ return nil
314
+ }
315
+ #endif
316
+
317
+ host.install("wakeLock") { [weak self] arguments in
318
+ self?.setAwake(arguments.first?.bool ?? false)
319
+ return nil
320
+ }
321
+
322
+ #if canImport(Network)
323
+ host.install("online") { [weak self] _ in
324
+ guard let self else { return .bool(true) }
325
+ if reachability == nil {
326
+ let bridge = ReachabilityBridge { [weak self] online in self?.callIn("__sbOnline", [.bool(online)]) }
327
+ bridge.start()
328
+ reachability = bridge
329
+ }
330
+ return .bool(reachability?.isOnline ?? true)
331
+ }
332
+ #endif
333
+
235
334
  #if canImport(MediaPlayer)
236
335
  host.install("mediaSession") { [weak self] arguments in
237
336
  guard let self, let update = arguments.first?.string else { return .string("mediaSession: expected a JSON string") }
@@ -305,10 +404,22 @@ final class HostBridge {
305
404
  return nil
306
405
  }
307
406
 
407
+ // The escape hatch's functions (NativeFunctions.swift): the async ones go
408
+ // through `hostRequest` as `app:<name>`; these list them and run a sync one.
409
+ host.install("nativeFunctions") { _ in .array(NativeFunctions.names.map(JSONValue.string)) }
410
+ host.install("callNativeSync") { arguments in
411
+ guard let name = arguments[safe: 0]?.string else { return .string(JSONValue.object(["error": "callNativeSync: expected a name"]).json) }
412
+ return .string(NativeFunctions.callSync(name, arguments[safe: 1]?.string ?? "{}").json)
413
+ }
414
+ NativeFunctions.sink = { [weak self] name, value in self?.callIn("__sbNativeEvent", [.string(name), .string(value.json)]) }
415
+
308
416
  host.install("hostRequest") { [weak self] arguments in
309
417
  guard let self, let id = arguments[safe: 0]?.int, let kind = arguments[safe: 1]?.string else { return nil }
310
418
  if hostRequests == nil {
311
- hostRequests = HostRequestBridge { [weak self] id, result in self?.callIn("__sbHostResponse", [.number(Double(id)), .string(result.json)]) }
419
+ hostRequests = HostRequestBridge(
420
+ answer: { [weak self] id, result in self?.callIn("__sbHostResponse", [.number(Double(id)), .string(result.json)]) },
421
+ filesChanged: { [weak self] urls in self?.callIn("__sbFilesChanged", [.string(JSONValue.array(urls.map(JSONValue.string)).json)]) }
422
+ )
312
423
  }
313
424
  hostRequests?.perform(id: id, kind: kind, json: arguments[safe: 2]?.string ?? "{}")
314
425
  return nil
@@ -452,6 +563,24 @@ final class HostBridge {
452
563
  #endif
453
564
  }
454
565
 
566
+ /// Keeps the screen awake (`navigator.wakeLock`) or lets it sleep again.
567
+ private func setAwake(_ active: Bool) {
568
+ guard active != awake else { return }
569
+ awake = active
570
+ #if canImport(UIKit) && !os(watchOS)
571
+ UIApplication.shared.isIdleTimerDisabled = active
572
+ #elseif os(macOS)
573
+ if active {
574
+ var id = IOPMAssertionID(0)
575
+ let made = IOPMAssertionCreateWithName(kIOPMAssertionTypePreventUserIdleDisplaySleep as CFString, IOPMAssertionLevel(kIOPMAssertionLevelOn), "SwiftBrowser wake lock" as CFString, &id)
576
+ if made == kIOReturnSuccess { wakeAssertion = id }
577
+ } else if let id = wakeAssertion {
578
+ IOPMAssertionRelease(id)
579
+ wakeAssertion = nil
580
+ }
581
+ #endif
582
+ }
583
+
455
584
  static func haptic(_ kind: String) {
456
585
  #if canImport(UIKit) && !os(tvOS)
457
586
  switch kind {
@@ -33,7 +33,9 @@ import UniformTypeIdentifiers
33
33
  /// color picker; `{ color }`), `webAuth` (`url`, `callbackScheme`,
34
34
  /// `ephemeral`: `ASWebAuthenticationSession`; `{ url }`) and, on iOS,
35
35
  /// `audioRecord` (`action`: the microphone with `AVAudioRecorder`,
36
- /// AudioRecorderBridge.swift). Against the shim
36
+ /// AudioRecorderBridge.swift). `app:<name>` is a function the Swift app
37
+ /// registered with `NativeFunctions` (Runtime/NativeFunctions.swift), the
38
+ /// escape hatch's function half, on every platform. Against the shim
37
39
  /// (the round trip) the page answers the first four itself, and the sign-in
38
40
  /// runs on the shim's session; the rest answers `unsupported`.
39
41
  @MainActor
@@ -45,14 +47,31 @@ final class HostRequestBridge {
45
47
  /// The microphone (`audioRecord`), one recording at a time.
46
48
  private lazy var recorder = AudioRecorderBridge()
47
49
  #endif
50
+ #if canImport(AuthenticationServices) && !canImport(FoundationBridge)
51
+ /// Sign in with Apple (`appleSignIn`), one sheet at a time.
52
+ private lazy var appleSignIn = AppleSignInBridge()
53
+ #endif
48
54
  #if !canImport(FoundationBridge)
49
55
  /// The app's files (`files`) for `fileManager`.
50
56
  private let files = FilesBridge()
57
+ /// iCloud Drive's changes, watched once the app asks for the container (ICloudBridge.swift).
58
+ private var iCloud: ICloudWatcher?
51
59
  #endif
60
+ /// Hands the URLs iCloud Drive changed to the app (`__sbFilesChanged`).
61
+ private let filesChanged: ([String]) -> Void
52
62
 
53
- /// `answer` hands a request's result to the app.
54
- init(answer: @escaping (Int, JSONValue) -> Void) {
63
+ /// `answer` hands a request's result to the app; `filesChanged` the URLs another device changed in iCloud Drive.
64
+ init(answer: @escaping (Int, JSONValue) -> Void, filesChanged: @escaping ([String]) -> Void = { _ in }) {
55
65
  self.answer = answer
66
+ self.filesChanged = filesChanged
67
+ }
68
+
69
+ /// The app is going away.
70
+ func stop() {
71
+ #if !canImport(FoundationBridge)
72
+ iCloud?.stop()
73
+ iCloud = nil
74
+ #endif
56
75
  }
57
76
 
58
77
  func perform(id: Int, kind: String, json: String) {
@@ -68,18 +87,29 @@ final class HostRequestBridge {
68
87
 
69
88
  private func handle(_ kind: String, _ params: JSONObject) async throws -> JSONValue {
70
89
  switch kind {
71
- case "webAuth": try await webAuth(params)
90
+ case "webAuth": return try await webAuth(params)
72
91
  #if !canImport(FoundationBridge)
73
- case "share": try await share(params)
74
- case "pickFiles": try await pickFiles(params)
75
- case "clipboard": try clipboard(params)
76
- case "pickColor": try await pickColor(params)
77
- case "files": try await files.handle(params)
92
+ case "share": return try await share(params)
93
+ case "pickFiles": return try await pickFiles(params)
94
+ case "exportFiles": return try await exportFiles(params)
95
+ case "clipboard": return try clipboard(params)
96
+ case "pickColor": return try await pickColor(params)
97
+ case "files":
98
+ let value = try await files.handle(params)
99
+ // The container resolved: from now on its changes reach the app.
100
+ if params["action"]?.string == "iCloud", value != .null, iCloud == nil { iCloud = ICloudWatcher(send: filesChanged) }
101
+ return value
102
+ #endif
103
+ #if canImport(AuthenticationServices) && !canImport(FoundationBridge)
104
+ case "appleSignIn": return try await appleSignIn.handle(params)
78
105
  #endif
79
106
  #if os(iOS) && !canImport(FoundationBridge)
80
- case "audioRecord": try await recorder.handle(params)
107
+ case "audioRecord": return try await recorder.handle(params)
81
108
  #endif
82
- default: throw HostRequestFailure("unsupported: the native app cannot answer a \(kind) request")
109
+ default:
110
+ // `app:<name>`: a function the Swift app registered (NativeFunctions.swift).
111
+ if kind.hasPrefix("app:") { return try await NativeFunctions.call(String(kind.dropFirst(4)), params) }
112
+ throw HostRequestFailure("unsupported: the native app cannot answer a \(kind) request")
83
113
  }
84
114
  }
85
115
 
@@ -237,6 +267,27 @@ final class HostRequestBridge {
237
267
  return .object(["files": .array(files)])
238
268
  }
239
269
 
270
+ /// `fileExporter`: the files are written to the temporary directory and the
271
+ /// document picker saves copies where the viewer chooses (the Files app);
272
+ /// answers `{ outcome: "saved", urls }` or `{ outcome: "cancelled" }`.
273
+ private func exportFiles(_ params: JSONObject) async throws -> JSONValue {
274
+ let urls = try writeFiles(params["files"]?.array ?? [])
275
+ guard !urls.isEmpty else { throw HostRequestFailure("exportFiles: nothing to export") }
276
+ let presenter = try presenter()
277
+ let saved: [URL] = await withCheckedContinuation { continuation in
278
+ let delegate = DocumentPickerDelegate { [weak self] delegate, urls in
279
+ self?.release(delegate)
280
+ continuation.resume(returning: urls)
281
+ }
282
+ keep(delegate)
283
+ let picker = UIDocumentPickerViewController(forExporting: urls, asCopy: true)
284
+ picker.delegate = delegate
285
+ presenter.present(picker, animated: true)
286
+ }
287
+ guard !saved.isEmpty else { return .object(["outcome": .string("cancelled")]) }
288
+ return .object(["outcome": .string("saved"), "urls": .array(saved.map { .string($0.absoluteString) })])
289
+ }
290
+
240
291
  private func clipboard(_ params: JSONObject) throws -> JSONValue {
241
292
  let pasteboard = UIPasteboard.general
242
293
  if params["action"]?.string == "readText" { return .object(["text": .string(pasteboard.string ?? "")]) }
@@ -297,6 +348,25 @@ final class HostRequestBridge {
297
348
  return .object(["files": .array(files)])
298
349
  }
299
350
 
351
+ /// `fileExporter`: a save panel per file; answers `{ outcome: "saved", urls }`, or `cancelled` at the first panel dismissed.
352
+ private func exportFiles(_ params: JSONObject) async throws -> JSONValue {
353
+ let files = params["files"]?.array ?? []
354
+ guard !files.isEmpty else { throw HostRequestFailure("exportFiles: nothing to export") }
355
+ var saved: [JSONValue] = []
356
+ for file in files {
357
+ guard let encoded = file["data"].string, let data = Data(base64Encoded: encoded) else { continue }
358
+ let panel = NSSavePanel()
359
+ let name = file["name"].string.flatMap { $0.isEmpty ? nil : $0 } ?? "file"
360
+ panel.nameFieldStringValue = name.components(separatedBy: "/").last ?? name
361
+ if let type = file["type"].string.flatMap({ UTType(mimeType: $0) }) { panel.allowedContentTypes = [type] }
362
+ let response = await panel.begin()
363
+ guard response == .OK, let url = panel.url else { return .object(["outcome": .string("cancelled")]) }
364
+ try data.write(to: url)
365
+ saved.append(.string(url.absoluteString))
366
+ }
367
+ return .object(["outcome": .string("saved"), "urls": .array(saved)])
368
+ }
369
+
300
370
  private func clipboard(_ params: JSONObject) throws -> JSONValue {
301
371
  let pasteboard = NSPasteboard.general
302
372
  if params["action"]?.string == "readText" { return .object(["text": .string(pasteboard.string(forType: .string) ?? "")]) }