@swiftbrowser/native 0.18.1 → 0.18.3
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 +73 -8
- package/dist/bridge.d.ts +64 -2
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/geolocation.d.ts +73 -0
- package/dist/geolocation.d.ts.map +1 -0
- package/dist/geolocation.js +109 -0
- package/dist/geolocation.js.map +1 -0
- package/dist/hostRequests.d.ts +3 -1
- package/dist/hostRequests.d.ts.map +1 -1
- package/dist/hostRequests.js +46 -5
- 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/nativeFunctions.d.ts +35 -0
- package/dist/nativeFunctions.d.ts.map +1 -0
- package/dist/nativeFunctions.js +90 -0
- package/dist/nativeFunctions.js.map +1 -0
- package/dist/reachability.d.ts +24 -0
- package/dist/reachability.d.ts.map +1 -0
- package/dist/reachability.js +41 -0
- package/dist/reachability.js.map +1 -0
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +3 -1
- package/dist/run.js.map +1 -1
- package/dist/runtime.d.ts +9 -2
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +62 -5
- package/dist/runtime.js.map +1 -1
- package/dist/speech.d.ts +148 -0
- package/dist/speech.d.ts.map +1 -0
- package/dist/speech.js +243 -0
- package/dist/speech.js.map +1 -0
- package/dist/wakeLock.d.ts +32 -0
- package/dist/wakeLock.d.ts.map +1 -0
- package/dist/wakeLock.js +60 -0
- package/dist/wakeLock.js.map +1 -0
- package/package.json +3 -3
- package/src/bridge.ts +70 -2
- package/src/geolocation.ts +160 -0
- package/src/hostRequests.ts +47 -7
- package/src/index.ts +1 -0
- package/src/nativeFunctions.ts +94 -0
- package/src/reachability.ts +49 -0
- package/src/run.ts +5 -3
- package/src/runtime.ts +58 -5
- package/src/speech.ts +312 -0
- package/src/wakeLock.ts +63 -0
- package/swift/Sources/SwiftBrowserNative/Host/AppleSignInBridge.swift +139 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioRecorderBridge.swift +5 -0
- package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +83 -12
- package/swift/Sources/SwiftBrowserNative/Host/GeolocationBridge.swift +150 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +131 -2
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +81 -11
- package/swift/Sources/SwiftBrowserNative/Host/ICloudBridge.swift +112 -0
- package/swift/Sources/SwiftBrowserNative/Host/ReachabilityBridge.swift +47 -0
- package/swift/Sources/SwiftBrowserNative/Host/SpeechBridge.swift +106 -0
- package/swift/Sources/SwiftBrowserNative/Host/SpeechRecognitionBridge.swift +198 -0
- package/swift/Sources/SwiftBrowserNative/Host/WorkerHost.swift +14 -5
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeFunctions.swift +134 -0
- package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +1 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +2 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/TextInputHints.swift +105 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Touches.swift +102 -0
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +13 -0
- package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +9 -0
- 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
|
|
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()`.
|
|
12
|
-
///
|
|
13
|
-
/// `
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/// | `
|
|
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
|
|
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).
|
|
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 "
|
|
76
|
-
case "
|
|
77
|
-
case "
|
|
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:
|
|
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) ?? "")]) }
|