diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d26101a6..580e3ee0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4605,10 +4605,22 @@ dependencies = [ ] [[package]] -name = "tauri-plugin-ios-keyboard" -version = "0.1.1" +name = "tauri-plugin-haptics" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4efdcf9b5fa18d5683b4ba7354854c065350e3fc7de7bebee9a2752994abf561" +checksum = "01b167d598ada05c599f4460595108c8d1aa636030a97d19796c1bcda97d881d" +dependencies = [ + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", +] + +[[package]] +name = "tauri-plugin-ios-keyboard" +version = "0.2.0" dependencies = [ "serde", "tauri", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3a9c7e4e..f956d151 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -50,7 +50,7 @@ tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] } rfd = { version = "0.16", features = ["tokio"] } [target.'cfg(target_os = "ios")'.dependencies] -tauri-plugin-ios-keyboard = "0.1" +tauri-plugin-ios-keyboard = { path = "plugins/ios-keyboard" } tauri-plugin-share-sheet = { path = "plugins/share-sheet" } [target.'cfg(any(target_os = "ios", target_os = "android"))'.dependencies] diff --git a/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/.gitignore b/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/.gitignore new file mode 100644 index 00000000..5922fdaa --- /dev/null +++ b/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc +Package.resolved diff --git a/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/Package.swift b/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/Package.swift new file mode 100644 index 00000000..c7a73323 --- /dev/null +++ b/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/Package.swift @@ -0,0 +1,40 @@ +// swift-tools-version:5.3 +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import PackageDescription + +let package = Package( + name: "Tauri", + platforms: [ + .macOS(.v10_13), + .iOS(.v11), + ], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "Tauri", + type: .static, + targets: ["Tauri"]) + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + .package(name: "SwiftRs", url: "https://github.com/Brendonovich/swift-rs", from: "1.0.0") + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "Tauri", + dependencies: [ + .byName(name: "SwiftRs") + ], + path: "Sources" + ), + .testTarget( + name: "TauriTests", + dependencies: ["Tauri"] + ), + ] +) diff --git a/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/README.md b/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/README.md new file mode 100644 index 00000000..52c3f1c7 --- /dev/null +++ b/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/README.md @@ -0,0 +1,3 @@ +# Tauri + +Tauri iOS API. diff --git a/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/Sources/Tauri/Channel.swift b/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/Sources/Tauri/Channel.swift new file mode 100644 index 00000000..add065c7 --- /dev/null +++ b/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/Sources/Tauri/Channel.swift @@ -0,0 +1,65 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import Foundation + +let CHANNEL_PREFIX = "__CHANNEL__:" +let channelDataKey = CodingUserInfoKey(rawValue: "sendChannelData")! + +public class Channel: Decodable { + public let id: UInt64 + let handler: (UInt64, String) -> Void + + public required init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let channelDef = try container.decode(String.self) + + let components = channelDef.components(separatedBy: CHANNEL_PREFIX) + if components.count < 2 { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid channel definition from \(channelDef)" + ) + + } + guard let channelId = UInt64(components[1]) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid channel ID from \(channelDef)" + ) + } + + guard let handler = decoder.userInfo[channelDataKey] as? (UInt64, String) -> Void else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "missing userInfo for Channel handler. This is a Tauri issue" + ) + } + + self.id = channelId + self.handler = handler + } + + func serialize(_ data: JsonValue) -> String { + do { + return try data.jsonRepresentation() ?? "\"Failed to serialize payload\"" + } catch { + return "\"\(error)\"" + } + } + + public func send(_ data: JsonObject) { + send(.dictionary(data)) + } + + public func send(_ data: JsonValue) { + handler(id, serialize(data)) + } + + public func send(_ data: T) throws { + let json = try JSONEncoder().encode(data) + handler(id, String(decoding: json, as: UTF8.self)) + } + +} diff --git a/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/Sources/Tauri/Invoke.swift b/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/Sources/Tauri/Invoke.swift new file mode 100644 index 00000000..6f810868 --- /dev/null +++ b/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/Sources/Tauri/Invoke.swift @@ -0,0 +1,118 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import Foundation +import UIKit + +@objc public class Invoke: NSObject { + public let command: String + let callback: UInt64 + let error: UInt64 + let data: String + let sendResponse: (UInt64, String?) -> Void + let sendChannelData: (UInt64, String) -> Void + + public init( + command: String, callback: UInt64, error: UInt64, + sendResponse: @escaping (UInt64, String?) -> Void, + sendChannelData: @escaping (UInt64, String) -> Void, data: String + ) { + self.command = command + self.callback = callback + self.error = error + self.data = data + self.sendResponse = sendResponse + self.sendChannelData = sendChannelData + } + + public func getRawArgs() -> String { + return self.data + } + + public func getArgs() throws -> JSObject { + let jsonData = self.data.data(using: .utf8)! + let data = try JSONSerialization.jsonObject(with: jsonData, options: []) + return JSTypes.coerceDictionaryToJSObject( + (data as! NSDictionary), formattingDatesAsStrings: true)! + } + + public func parseArgs(_ type: T.Type) throws -> T { + let jsonData = self.data.data(using: .utf8)! + let decoder = JSONDecoder() + decoder.userInfo[channelDataKey] = sendChannelData + return try decoder.decode(type, from: jsonData) + } + + func serialize(_ data: JsonValue) -> String { + do { + return try data.jsonRepresentation() ?? "\"Failed to serialize payload\"" + } catch { + return "\"\(error)\"" + } + } + + public func resolve() { + sendResponse(callback, nil) + } + + public func resolve(_ data: JsonObject) { + resolve(.dictionary(data)) + } + + public func resolve(_ data: JsonValue) { + sendResponse(callback, serialize(data)) + } + + public func resolve(_ data: T) { + do { + let json = try JSONEncoder().encode(data) + sendResponse(callback, String(decoding: json, as: UTF8.self)) + } catch { + sendResponse(self.error, "\"\(error)\"") + } + } + + public func reject( + _ message: String, code: String? = nil, error: Error? = nil, data: JsonValue? = nil + ) { + let payload: NSMutableDictionary = [ + "message": message + ] + + if let code = code { + payload["code"] = code + } + + if let error = error { + payload["error"] = error + } + + if let data = data { + switch data { + case .dictionary(let dict): + for entry in dict { + payload[entry.key] = entry.value + } + } + } + + sendResponse(self.error, serialize(.dictionary(payload as! JsonObject))) + } + + public func unimplemented() { + unimplemented("not implemented") + } + + public func unimplemented(_ message: String) { + reject(message) + } + + public func unavailable() { + unavailable("not available") + } + + public func unavailable(_ message: String) { + reject(message) + } +} diff --git a/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/Sources/Tauri/JSTypes.swift b/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/Sources/Tauri/JSTypes.swift new file mode 100644 index 00000000..0ac9f2c7 --- /dev/null +++ b/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/Sources/Tauri/JSTypes.swift @@ -0,0 +1,123 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import Foundation + +// declare our empty protocol, and conformance, for typing +public protocol JSValue {} +extension String: JSValue {} +extension Bool: JSValue {} +extension Int: JSValue {} +extension Float: JSValue {} +extension Double: JSValue {} +extension NSNumber: JSValue {} +extension NSNull: JSValue {} +extension Array: JSValue {} +extension Date: JSValue {} +extension Dictionary: JSValue where Key == String, Value == JSValue {} + +// convenience aliases +public typealias JSObject = [String: JSValue] +public typealias JSArray = [JSValue] + +extension Dictionary where Key == String, Value == JSValue { + public func getValue(_ key: String) -> JSValue? { + return self[key] + } + + public func getString(_ key: String) -> String? { + return self[key] as? String + } + + public func getBool(_ key: String) -> Bool? { + return self[key] as? Bool + } + + public func getInt(_ key: String) -> Int? { + return self[key] as? Int + } + + public func getFloat(_ key: String) -> Float? { + if let floatValue = self[key] as? Float { + return floatValue + } else if let doubleValue = self[key] as? Double { + return Float(doubleValue) + } + return nil + } + + public func getDouble(_ key: String) -> Double? { + return self[key] as? Double + } + + public func getArray(_ key: String) -> JSArray? { + return self[key] as? JSArray + } + + public func getObject(_ key: String) -> JSObject? { + return self[key] as? JSObject + } +} + +/* + Simply casting objects from foundation class clusters (such as __NSArrayM) + doesn't work with the JSValue protocol and will always fail. So we need to + recursively and explicitly convert each value in the dictionary. + */ +public enum JSTypes {} +extension JSTypes { + public static func coerceDictionaryToJSObject( + _ dictionary: NSDictionary?, formattingDatesAsStrings: Bool = false + ) -> JSObject? { + return coerceToJSValue(dictionary, formattingDates: formattingDatesAsStrings) as? JSObject + } + + public static func coerceDictionaryToJSObject( + _ dictionary: [AnyHashable: Any]?, formattingDatesAsStrings: Bool = false + ) -> JSObject? { + return coerceToJSValue(dictionary, formattingDates: formattingDatesAsStrings) as? JSObject + } +} + +private let dateStringFormatter = ISO8601DateFormatter() + +// We need a large switch statement because we have a lot of types. +// swiftlint:disable:next cyclomatic_complexity +private func coerceToJSValue(_ value: Any?, formattingDates: Bool) -> JSValue? { + guard let value = value else { + return nil + } + switch value { + case let stringValue as String: + return stringValue + case let numberValue as NSNumber: + return numberValue + case let boolValue as Bool: + return boolValue + case let intValue as Int: + return intValue + case let floatValue as Float: + return floatValue + case let doubleValue as Double: + return doubleValue + case let dateValue as Date: + if formattingDates { + return dateStringFormatter.string(from: dateValue) + } + return dateValue + case let nullValue as NSNull: + return nullValue + case let arrayValue as NSArray: + return arrayValue.compactMap { coerceToJSValue($0, formattingDates: formattingDates) } + case let dictionaryValue as NSDictionary: + let keys = dictionaryValue.allKeys.compactMap { $0 as? String } + var result: JSObject = [:] + for key in keys { + result[key] = coerceToJSValue(dictionaryValue[key], formattingDates: formattingDates) + } + return result + default: + return nil + } +} diff --git a/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/Sources/Tauri/JsonValue.swift b/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/Sources/Tauri/JsonValue.swift new file mode 100644 index 00000000..ae6d96f7 --- /dev/null +++ b/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/Sources/Tauri/JsonValue.swift @@ -0,0 +1,58 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import Foundation + +public typealias JsonObject = [String: Any?] + +public enum JsonValue { + case dictionary(JsonObject) + + enum SerializationError: Error { + case invalidObject + } + + public func jsonRepresentation(includingFields: JsonObject? = nil) throws -> String? { + switch self { + case .dictionary(var dictionary): + if let fields = includingFields { + dictionary.merge(fields) { (current, _) in current } + } + dictionary = prepare(dictionary: dictionary) + guard JSONSerialization.isValidJSONObject(dictionary) else { + throw SerializationError.invalidObject + } + let data = try JSONSerialization.data(withJSONObject: dictionary, options: []) + return String(data: data, encoding: .utf8) + } + } + + private static let formatter = ISO8601DateFormatter() + + private func prepare(dictionary: JsonObject) -> JsonObject { + return dictionary.mapValues { (value) -> Any in + if let date = value as? Date { + return JsonValue.formatter.string(from: date) + } else if let aDictionary = value as? JsonObject { + return prepare(dictionary: aDictionary) + } else if let anArray = value as? [Any] { + return prepare(array: anArray) + } + return value + } + } + + private func prepare(array: [Any]) -> [Any] { + return array.map { (value) -> Any in + if let date = value as? Date { + return JsonValue.formatter.string(from: date) + } else if let aDictionary = value as? JsonObject { + return prepare(dictionary: aDictionary) + } else if let anArray = value as? [Any] { + return prepare(array: anArray) + } + return value + } + } +} diff --git a/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/Sources/Tauri/Logger.swift b/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/Sources/Tauri/Logger.swift new file mode 100644 index 00000000..d055090d --- /dev/null +++ b/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/Sources/Tauri/Logger.swift @@ -0,0 +1,139 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import os.log +import UIKit +import Foundation + +class StdoutRedirector { + private var originalStdout: Int32 = -1 + private var originalStderr: Int32 = -1 + private var stdoutPipe: [Int32] = [-1, -1] + private var stderrPipe: [Int32] = [-1, -1] + private var stdoutReadSource: DispatchSourceRead? + private var stderrReadSource: DispatchSourceRead? + + func start() { + originalStdout = dup(STDOUT_FILENO) + originalStderr = dup(STDERR_FILENO) + + guard Darwin.pipe(&stdoutPipe) == 0, + Darwin.pipe(&stderrPipe) == 0 else { + Logger.error("Failed to create stdout/stderr pipes") + return + } + + dup2(stdoutPipe[1], STDOUT_FILENO) + dup2(stderrPipe[1], STDERR_FILENO) + close(stdoutPipe[1]) + close(stderrPipe[1]) + + stdoutReadSource = createReader( + readPipe: stdoutPipe[0], + writeToOriginal: originalStdout, + label: "stdout" + ) + + stderrReadSource = createReader( + readPipe: stderrPipe[0], + writeToOriginal: originalStderr, + label: "stderr" + ) + } + + private func createReader( + readPipe: Int32, + writeToOriginal: Int32, + label: String + ) -> DispatchSourceRead { + let source = DispatchSource.makeReadSource( + fileDescriptor: readPipe, + queue: .global(qos: .utility) + ) + + source.setEventHandler { + let bufferSize = 4096 + var buffer = [UInt8](repeating: 0, count: bufferSize) + let bytesRead = read(readPipe, &buffer, bufferSize) + + if bytesRead > 0 { + let output = String( + bytes: buffer[0..(_ type: T.Type) throws -> T { + let jsonData = self.config.data(using: .utf8)! + let decoder = JSONDecoder() + return try decoder.decode(type, from: jsonData) + } + + @objc open func load(webview: WKWebView) {} + + @objc open func checkPermissions(_ invoke: Invoke) { + invoke.resolve() + } + + @objc open func requestPermissions(_ invoke: Invoke) { + invoke.resolve() + } + + public func trigger(_ event: String, data: JSObject) { + if let eventListeners = listeners[event] { + for channel in eventListeners { + channel.send(data) + } + } + } + + public func trigger(_ event: String, data: T) throws { + if let eventListeners = listeners[event] { + for channel in eventListeners { + try channel.send(data) + } + } + } + + @objc func registerListener(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(RegisterListenerArgs.self) + + if var eventListeners = listeners[args.event] { + eventListeners.append(args.handler) + listeners[args.event] = eventListeners + } else { + listeners[args.event] = [args.handler] + } + + invoke.resolve() + } + + @objc func removeListener(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(RemoveListenerArgs.self) + + if let eventListeners = listeners[args.event] { + + listeners[args.event] = eventListeners.filter { $0.id != args.channelId } + } + + invoke.resolve() + } +} diff --git a/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/Sources/Tauri/Tauri.swift b/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/Sources/Tauri/Tauri.swift new file mode 100644 index 00000000..82818c6e --- /dev/null +++ b/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/Sources/Tauri/Tauri.swift @@ -0,0 +1,157 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import Foundation +import SwiftRs +import UIKit +import WebKit +import os.log + +class PluginHandle { + var instance: Plugin + var loaded = false + + init(plugin: Plugin) { + instance = plugin + } +} + +public class PluginManager { + static let shared: PluginManager = PluginManager() + public var viewController: UIViewController? + var plugins: [String: PluginHandle] = [:] + var ipcDispatchQueue = DispatchQueue(label: "ipc") + public var isSimEnvironment: Bool { + #if targetEnvironment(simulator) + return true + #else + return false + #endif + } + + public func assetUrl(fromLocalURL url: URL?) -> URL? { + guard let inputURL = url else { + return nil + } + + return URL(string: "asset://localhost")!.appendingPathComponent(inputURL.path) + } + + func onWebviewCreated(_ webview: WKWebView) { + for (_, handle) in plugins { + if !handle.loaded { + handle.instance.load(webview: webview) + } + } + } + + func load(name: String, plugin: P, config: String, webview: WKWebView?) { + plugin.setConfig(config) + let handle = PluginHandle(plugin: plugin) + if let webview = webview { + handle.instance.load(webview: webview) + handle.loaded = true + } + plugins[name] = handle + } + + func invoke(name: String, invoke: Invoke) { + if let plugin = plugins[name] { + ipcDispatchQueue.async { + let selectorWithCompletionHandler = Selector(("\(invoke.command):completionHandler:")) + let selectorWithThrows = Selector(("\(invoke.command):error:")) + + if plugin.instance.responds(to: selectorWithCompletionHandler) { + let completion: @convention(block) (NSError?) -> Void = { error in + if let error = error { + invoke.reject("\(error)") + } + } + + let blockObj: AnyObject = unsafeBitCast(completion, to: AnyObject.self) + let imp = plugin.instance.method(for: selectorWithCompletionHandler) + + typealias Fn = @convention(c) (AnyObject, Selector, Invoke, AnyObject) -> Void + let fn = unsafeBitCast(imp, to: Fn.self) + fn(plugin.instance, selectorWithCompletionHandler, invoke, blockObj) + } else if plugin.instance.responds(to: selectorWithThrows) { + var error: NSError? = nil + withUnsafeMutablePointer(to: &error) { + let methodIMP: IMP! = plugin.instance.method(for: selectorWithThrows) + unsafeBitCast( + methodIMP, to: (@convention(c) (Any?, Selector, Invoke, OpaquePointer) -> Void).self)( + plugin.instance, selectorWithThrows, invoke, OpaquePointer($0)) + } + if let error = error { + invoke.reject("\(error)") + // TODO: app crashes without this leak + let _ = Unmanaged.passRetained(error) + } + } else { + let selector = Selector(("\(invoke.command):")) + if plugin.instance.responds(to: selector) { + plugin.instance.perform(selector, with: invoke) + } else { + invoke.reject("No command \(invoke.command) found for plugin \(name)") + } + } + } + } else { + invoke.reject("Plugin \(name) not initialized") + } + } +} + +extension PluginManager: NSCopying { + public func copy(with zone: NSZone? = nil) -> Any { + return self + } +} + +private var stdoutRedirector: StdoutRedirector? + +@_cdecl("log_stdout") +func logStdout() { + stdoutRedirector = StdoutRedirector() + stdoutRedirector!.start() +} + +@_cdecl("register_plugin") +func registerPlugin(name: SRString, plugin: NSObject, config: SRString, webview: WKWebView?) { + PluginManager.shared.load( + name: name.toString(), + plugin: plugin as! Plugin, + config: config.toString(), + webview: webview + ) +} + +@_cdecl("on_webview_created") +func onWebviewCreated(webview: WKWebView, viewController: UIViewController) { + PluginManager.shared.viewController = viewController + PluginManager.shared.onWebviewCreated(webview) +} + +@_cdecl("run_plugin_command") +func runCommand( + id: Int, + name: SRString, + command: SRString, + data: SRString, + callback: @escaping @convention(c) (Int, Bool, UnsafePointer) -> Void, + sendChannelData: @escaping @convention(c) (UInt64, UnsafePointer) -> Void +) { + let callbackId: UInt64 = 0 + let errorId: UInt64 = 1 + let invoke = Invoke( + command: command.toString(), callback: callbackId, error: errorId, + sendResponse: { (fn: UInt64, payload: String?) -> Void in + let success = fn == callbackId + callback(id, success, payload ?? "null") + }, + sendChannelData: { (id: UInt64, payload: String) -> Void in + sendChannelData(id, payload) + }, data: data.toString()) + PluginManager.shared.invoke(name: name.toString(), invoke: invoke) +} diff --git a/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/Sources/Tauri/UiUtils.swift b/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/Sources/Tauri/UiUtils.swift new file mode 100644 index 00000000..4097596e --- /dev/null +++ b/src-tauri/plugins/ios-keyboard/.tauri/tauri-api/Sources/Tauri/UiUtils.swift @@ -0,0 +1,15 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import UIKit + +public class UIUtils { + public static func centerPopover(rootViewController: UIViewController?, popoverController: UIViewController) { + if let viewController = rootViewController { + popoverController.popoverPresentationController?.sourceRect = CGRect(x: viewController.view.center.x, y: viewController.view.center.y, width: 0, height: 0) + popoverController.popoverPresentationController?.sourceView = viewController.view + popoverController.popoverPresentationController?.permittedArrowDirections = UIPopoverArrowDirection.up + } + } +} diff --git a/src-tauri/plugins/ios-keyboard/Cargo.toml b/src-tauri/plugins/ios-keyboard/Cargo.toml new file mode 100644 index 00000000..287a2790 --- /dev/null +++ b/src-tauri/plugins/ios-keyboard/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "tauri-plugin-ios-keyboard" +version = "0.2.0" +edition = "2021" +build = "build.rs" +links = "tauri-plugin-ios-keyboard" + +[lib] +name = "tauri_plugin_ios_keyboard" + +[dependencies] +serde = "1.0" +tauri = { version = "2" } +thiserror = "2" + +[build-dependencies] +tauri-plugin = { version = "2", features = ["build"] } diff --git a/src-tauri/plugins/ios-keyboard/build.rs b/src-tauri/plugins/ios-keyboard/build.rs new file mode 100644 index 00000000..373d3ac4 --- /dev/null +++ b/src-tauri/plugins/ios-keyboard/build.rs @@ -0,0 +1,7 @@ +const COMMANDS: &[&str] = &["ping"]; + +fn main() { + tauri_plugin::Builder::new(COMMANDS) + .ios_path("ios") + .build(); +} diff --git a/src-tauri/plugins/ios-keyboard/ios/.build/index-build/arm64-apple-macosx/debug/index/db/v13/saved/data.mdb b/src-tauri/plugins/ios-keyboard/ios/.build/index-build/arm64-apple-macosx/debug/index/db/v13/saved/data.mdb new file mode 100644 index 00000000..aa89ad86 Binary files /dev/null and b/src-tauri/plugins/ios-keyboard/ios/.build/index-build/arm64-apple-macosx/debug/index/db/v13/saved/data.mdb differ diff --git a/src-tauri/plugins/ios-keyboard/ios/.build/index-build/arm64-apple-macosx/debug/index/db/v13/saved/lock.mdb b/src-tauri/plugins/ios-keyboard/ios/.build/index-build/arm64-apple-macosx/debug/index/db/v13/saved/lock.mdb new file mode 100644 index 00000000..480f59ef Binary files /dev/null and b/src-tauri/plugins/ios-keyboard/ios/.build/index-build/arm64-apple-macosx/debug/index/db/v13/saved/lock.mdb differ diff --git a/src-tauri/plugins/ios-keyboard/ios/Package.resolved b/src-tauri/plugins/ios-keyboard/ios/Package.resolved new file mode 100644 index 00000000..4f1994b1 --- /dev/null +++ b/src-tauri/plugins/ios-keyboard/ios/Package.resolved @@ -0,0 +1,16 @@ +{ + "object": { + "pins": [ + { + "package": "SwiftRs", + "repositoryURL": "https://github.com/Brendonovich/swift-rs", + "state": { + "branch": null, + "revision": "f64a4514de07f450ec5b6aa297624cd3479d9579", + "version": "1.0.7" + } + } + ] + }, + "version": 1 +} diff --git a/src-tauri/plugins/ios-keyboard/ios/Package.swift b/src-tauri/plugins/ios-keyboard/ios/Package.swift new file mode 100644 index 00000000..45244cd2 --- /dev/null +++ b/src-tauri/plugins/ios-keyboard/ios/Package.swift @@ -0,0 +1,26 @@ +// swift-tools-version:5.5 +import PackageDescription + +let package = Package( + name: "tauri-plugin-ios-keyboard", + platforms: [ + .iOS(.v13) + ], + products: [ + .library( + name: "tauri-plugin-ios-keyboard", + type: .static, + targets: ["tauri-plugin-ios-keyboard"]) + ], + dependencies: [ + .package(name: "Tauri", path: "../.tauri/tauri-api") + ], + targets: [ + .target( + name: "tauri-plugin-ios-keyboard", + dependencies: [ + .product(name: "Tauri", package: "Tauri") + ], + path: "Sources") + ] +) diff --git a/src-tauri/plugins/ios-keyboard/ios/Sources/KeyboardPlugin.swift b/src-tauri/plugins/ios-keyboard/ios/Sources/KeyboardPlugin.swift new file mode 100644 index 00000000..e61ef62d --- /dev/null +++ b/src-tauri/plugins/ios-keyboard/ios/Sources/KeyboardPlugin.swift @@ -0,0 +1,127 @@ +import Tauri +import UIKit +import WebKit + +class KeyboardPlugin: Plugin { + private var keyboardObserver: KeyboardObserver? + + override public func load(webview: WKWebView) { + super.load(webview: webview) + + keyboardObserver = KeyboardObserver(webview: webview) { [weak self] event in + self?.sendKeyboardEvent(event) + } + + keyboardObserver?.startObserving() + } + + deinit { + keyboardObserver?.stopObserving() + } + + private func sendKeyboardEvent(_ event: KeyboardEvent) { + let data: JSObject = [ + "eventType": event.eventType, + "keyboardHeight": event.keyboardHeight, + "animationDuration": event.animationDuration + ] + self.trigger("plugin:keyboard::ios-keyboard-event", data: data) + } +} + +struct KeyboardEvent { + let eventType: String + let keyboardHeight: Double + let animationDuration: Double +} + +class KeyboardObserver { + private var onKeyboardEvent: ((KeyboardEvent) -> Void)? + private weak var webview: WKWebView? + + init(webview: WKWebView, onEvent: @escaping (KeyboardEvent) -> Void) { + self.webview = webview + self.onKeyboardEvent = onEvent + } + + func startObserving() { + let nc = NotificationCenter.default + nc.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil) + nc.addObserver(self, selector: #selector(keyboardDidShow), name: UIResponder.keyboardDidShowNotification, object: nil) + nc.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil) + nc.addObserver(self, selector: #selector(keyboardDidHide), name: UIResponder.keyboardDidHideNotification, object: nil) + } + + func stopObserving() { + NotificationCenter.default.removeObserver(self) + } + + @objc private func keyboardWillShow(notification: NSNotification) { + guard let userInfo = notification.userInfo, + let keyboardFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect, + let animationDuration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double else { + return + } + + onKeyboardEvent?(KeyboardEvent( + eventType: "will-show", + keyboardHeight: keyboardFrame.height, + animationDuration: animationDuration + )) + } + + @objc private func keyboardDidShow(notification: NSNotification) { + guard let userInfo = notification.userInfo, + let keyboardFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect, + let animationDuration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double else { + return + } + + // Lock the root scroll view AFTER WKWebView has finished its keyboard adjustment. + // This prevents the user from scrolling the whole frame while keeping the input + // correctly positioned above the keyboard. + // CSS overflow:auto containers (message list) use their own composited scroll layers + // and are unaffected by the root scrollView.isScrollEnabled. + webview?.scrollView.isScrollEnabled = false + + onKeyboardEvent?(KeyboardEvent( + eventType: "did-show", + keyboardHeight: keyboardFrame.height, + animationDuration: animationDuration + )) + } + + @objc private func keyboardWillHide(notification: NSNotification) { + guard let userInfo = notification.userInfo, + let animationDuration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double else { + return + } + + // Re-enable scrolling before WKWebView resets its viewport for keyboard hide. + webview?.scrollView.isScrollEnabled = true + + onKeyboardEvent?(KeyboardEvent( + eventType: "will-hide", + keyboardHeight: 0, + animationDuration: animationDuration + )) + } + + @objc private func keyboardDidHide(notification: NSNotification) { + guard let userInfo = notification.userInfo, + let animationDuration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double else { + return + } + + onKeyboardEvent?(KeyboardEvent( + eventType: "did-hide", + keyboardHeight: 0, + animationDuration: animationDuration + )) + } +} + +@_cdecl("init_plugin_keyboard") +func initPlugin() -> Plugin { + return KeyboardPlugin() +} diff --git a/src-tauri/plugins/ios-keyboard/permissions/autogenerated/commands/ping.toml b/src-tauri/plugins/ios-keyboard/permissions/autogenerated/commands/ping.toml new file mode 100644 index 00000000..1d135880 --- /dev/null +++ b/src-tauri/plugins/ios-keyboard/permissions/autogenerated/commands/ping.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-ping" +description = "Enables the ping command without any pre-configured scope." +commands.allow = ["ping"] + +[[permission]] +identifier = "deny-ping" +description = "Denies the ping command without any pre-configured scope." +commands.deny = ["ping"] diff --git a/src-tauri/plugins/ios-keyboard/permissions/autogenerated/reference.md b/src-tauri/plugins/ios-keyboard/permissions/autogenerated/reference.md new file mode 100644 index 00000000..103541be --- /dev/null +++ b/src-tauri/plugins/ios-keyboard/permissions/autogenerated/reference.md @@ -0,0 +1,35 @@ +## Permission Table + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`ios-keyboard:allow-ping` + + + +Enables the ping command without any pre-configured scope. + +
+ +`ios-keyboard:deny-ping` + + + +Denies the ping command without any pre-configured scope. + +
diff --git a/src-tauri/plugins/ios-keyboard/permissions/schemas/schema.json b/src-tauri/plugins/ios-keyboard/permissions/schemas/schema.json new file mode 100644 index 00000000..1e335fe2 --- /dev/null +++ b/src-tauri/plugins/ios-keyboard/permissions/schemas/schema.json @@ -0,0 +1,312 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the ping command without any pre-configured scope.", + "type": "string", + "const": "allow-ping", + "markdownDescription": "Enables the ping command without any pre-configured scope." + }, + { + "description": "Denies the ping command without any pre-configured scope.", + "type": "string", + "const": "deny-ping", + "markdownDescription": "Denies the ping command without any pre-configured scope." + } + ] + } + } +} \ No newline at end of file diff --git a/src-tauri/plugins/ios-keyboard/src/commands.rs b/src-tauri/plugins/ios-keyboard/src/commands.rs new file mode 100644 index 00000000..3dcc40cb --- /dev/null +++ b/src-tauri/plugins/ios-keyboard/src/commands.rs @@ -0,0 +1,13 @@ +use tauri::{command, AppHandle, Runtime}; + +use crate::models::*; +use crate::KeyboardExt; +use crate::Result; + +#[command] +pub(crate) async fn ping( + app: AppHandle, + payload: PingRequest, +) -> Result { + app.keyboard().ping(payload) +} diff --git a/src-tauri/plugins/ios-keyboard/src/desktop.rs b/src-tauri/plugins/ios-keyboard/src/desktop.rs new file mode 100644 index 00000000..5949e00e --- /dev/null +++ b/src-tauri/plugins/ios-keyboard/src/desktop.rs @@ -0,0 +1,21 @@ +use serde::de::DeserializeOwned; +use tauri::{plugin::PluginApi, AppHandle, Runtime}; + +use crate::models::*; + +pub fn init( + app: &AppHandle, + _api: PluginApi, +) -> crate::Result> { + Ok(Keyboard(app.clone())) +} + +pub struct Keyboard(AppHandle); + +impl Keyboard { + pub fn ping(&self, payload: PingRequest) -> crate::Result { + Ok(PingResponse { + value: payload.value, + }) + } +} diff --git a/src-tauri/plugins/ios-keyboard/src/error.rs b/src-tauri/plugins/ios-keyboard/src/error.rs new file mode 100644 index 00000000..177e8c26 --- /dev/null +++ b/src-tauri/plugins/ios-keyboard/src/error.rs @@ -0,0 +1,21 @@ +use serde::{ser::Serializer, Serialize}; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Io(#[from] std::io::Error), + #[cfg(mobile)] + #[error(transparent)] + PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} diff --git a/src-tauri/plugins/ios-keyboard/src/lib.rs b/src-tauri/plugins/ios-keyboard/src/lib.rs new file mode 100644 index 00000000..e52fa160 --- /dev/null +++ b/src-tauri/plugins/ios-keyboard/src/lib.rs @@ -0,0 +1,46 @@ +use tauri::{ + plugin::{Builder, TauriPlugin}, + Manager, Runtime, +}; + +pub use models::*; + +#[cfg(desktop)] +mod desktop; +#[cfg(mobile)] +mod mobile; + +mod commands; +mod error; +mod models; + +pub use error::{Error, Result}; + +#[cfg(desktop)] +use desktop::Keyboard; +#[cfg(mobile)] +use mobile::Keyboard; + +pub trait KeyboardExt { + fn keyboard(&self) -> &Keyboard; +} + +impl> crate::KeyboardExt for T { + fn keyboard(&self) -> &Keyboard { + self.state::>().inner() + } +} + +pub fn init() -> TauriPlugin { + Builder::new("keyboard") + .invoke_handler(tauri::generate_handler![commands::ping]) + .setup(|app, api| { + #[cfg(mobile)] + let keyboard = mobile::init(app, api)?; + #[cfg(desktop)] + let keyboard = desktop::init(app, api)?; + app.manage(keyboard); + Ok(()) + }) + .build() +} diff --git a/src-tauri/plugins/ios-keyboard/src/mobile.rs b/src-tauri/plugins/ios-keyboard/src/mobile.rs new file mode 100644 index 00000000..c507621a --- /dev/null +++ b/src-tauri/plugins/ios-keyboard/src/mobile.rs @@ -0,0 +1,31 @@ +use serde::de::DeserializeOwned; +use tauri::{ + plugin::{PluginApi, PluginHandle}, + AppHandle, Runtime, +}; + +use crate::models::*; + +#[cfg(target_os = "ios")] +tauri::ios_plugin_binding!(init_plugin_keyboard); + +pub fn init( + _app: &AppHandle, + api: PluginApi, +) -> crate::Result> { + #[cfg(target_os = "android")] + let handle = api.register_android_plugin("", "KeyboardPlugin")?; + #[cfg(target_os = "ios")] + let handle = api.register_ios_plugin(init_plugin_keyboard)?; + Ok(Keyboard(handle)) +} + +pub struct Keyboard(PluginHandle); + +impl Keyboard { + pub fn ping(&self, payload: PingRequest) -> crate::Result { + self.0 + .run_mobile_plugin("ping", payload) + .map_err(Into::into) + } +} diff --git a/src-tauri/plugins/ios-keyboard/src/models.rs b/src-tauri/plugins/ios-keyboard/src/models.rs new file mode 100644 index 00000000..2516895c --- /dev/null +++ b/src-tauri/plugins/ios-keyboard/src/models.rs @@ -0,0 +1,29 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PingRequest { + pub value: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PingResponse { + pub value: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KeyboardEvent { + pub event_type: String, + pub keyboard_height: f64, + pub animation_duration: f64, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KeyboardState { + pub is_visible: bool, + pub height: f64, + pub animation_duration: f64, +} diff --git a/src/hooks/useKeyboardResize.ts b/src/hooks/useKeyboardResize.ts index a07426d2..6987d801 100644 --- a/src/hooks/useKeyboardResize.ts +++ b/src/hooks/useKeyboardResize.ts @@ -81,11 +81,6 @@ export const useKeyboardResize = () => { "plugin:keyboard::ios-keyboard-event", ({ payload }) => { if (payload.eventType === "will-show") { - // Use position:fixed anchored to the viewport bottom instead of - // computing window.innerHeight - keyboardHeight. This bypasses - // any window.innerHeight inaccuracies in WKWebView and is immune - // to the content-scroll that WKWebView sometimes applies when an - // input is focused (scroll can't move a fixed element). root.style.position = "fixed"; root.style.top = "0"; root.style.left = "0"; @@ -157,7 +152,6 @@ export const useKeyboardResize = () => { root.style.right = "0"; root.style.bottom = "0"; root.style.overflow = "hidden"; - document.documentElement.style.overscrollBehavior = "none"; }; const applyKeyboardClosed = () => { @@ -167,7 +161,6 @@ export const useKeyboardResize = () => { root.style.right = ""; root.style.bottom = ""; root.style.overflow = ""; - document.documentElement.style.overscrollBehavior = ""; window.scrollTo(0, 0); }; diff --git a/src/index.css b/src/index.css index 57a0d49c..ff208ddf 100644 --- a/src/index.css +++ b/src/index.css @@ -109,6 +109,12 @@ html, body, #root { background-color: #202225; } +/* SPA — document never scrolls; constrains WKWebView's UIScrollView to viewport */ +html { + overflow: hidden; + overscroll-behavior: none; +} + @supports (height: 100dvh) { #root { height: 100dvh;