feat: initial work for iOS plugins (#6205)

This commit is contained in:
Lucas Fernandes Nogueira 2023-02-11 05:30:44 -08:00 committed by GitHub
parent f379e2f3da
commit 05dad08768
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 1074 additions and 71 deletions

View File

@ -0,0 +1,6 @@
---
"tauri-runtime": minor
"tauri-runtime-wry": minor
---
Add `find_class`, `run_on_android_context` on `RuntimeHandle`.

View File

@ -0,0 +1,6 @@
---
"cli.rs": minor
"cli.js": minor
---
Add commands to add native Android and iOS functionality to plugins.

View File

@ -0,0 +1,7 @@
---
"tauri-macros": major
"tauri-codegen": major
"tauri": major
---
Return `bool` in the invoke handler.

View File

@ -0,0 +1,5 @@
---
"tauri": minor
---
Run Android and iOS native plugins on the invoke handler if a Rust plugin command is not found.

View File

@ -0,0 +1,5 @@
---
"tauri": minor
---
Added `initialize_android_plugin` and `initialize_ios_plugin` APIs on `AppHandle`.

View File

@ -0,0 +1,5 @@
---
"tauri-build": minor
---
Add `mobile::PluginBuilder` for running build tasks related to Tauri plugins.

7
.changes/with-webview.md Normal file
View File

@ -0,0 +1,7 @@
---
"tauri": minor
"tauri-runtime": minor
"tauri-runtime-wry": minor
---
Implemented `with_webview` on Android and iOS.

4
.gitignore vendored
View File

@ -87,3 +87,7 @@ test_video.mp4
# old cli directories
/tooling/cli.js
/tooling/cli.rs
# Swift
Package.resolved
.build

28
Package.swift Normal file
View File

@ -0,0 +1,28 @@
// swift-tools-version:5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "TauriWorkspace",
products: [
.library(name: "Tauri", targets: ["Tauri"]),
],
dependencies: [
.package(url: "https://github.com/Brendonovich/swift-rs", revision: "eb6de914ad57501da5019154d476d45660559999"),
],
targets: [
.target(
name: "Tauri",
dependencies: [
.product(name: "SwiftRs", package: "swift-rs"),
],
path: "core/tauri/ios/Sources/Tauri"
),
.testTarget(
name: "TauriTests",
dependencies: ["Tauri"],
path: "core/tauri/ios/Tests/TauriTests"
),
]
)

View File

@ -27,6 +27,9 @@ heck = "0.4"
json-patch = "0.2"
walkdir = "2"
[target."cfg(target_os = \"macos\")".dependencies]
swift-rs = { git = "https://github.com/Brendonovich/swift-rs", rev = "eb6de914ad57501da5019154d476d45660559999", features = ["build"] }
[target."cfg(windows)".dependencies]
winres = "0.1"
semver = "1"

View File

@ -9,6 +9,7 @@ use anyhow::Result;
#[derive(Default)]
pub struct PluginBuilder {
android_path: Option<PathBuf>,
ios_path: Option<PathBuf>,
}
impl PluginBuilder {
@ -23,53 +24,79 @@ impl PluginBuilder {
self
}
/// Sets the iOS project path.
pub fn ios_path<P: Into<PathBuf>>(mut self, ios_path: P) -> Self {
self.ios_path.replace(ios_path.into());
self
}
/// Injects the mobile templates in the given path relative to the manifest root.
pub fn run(self) -> Result<()> {
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
if target_os == "android" {
if let Some(path) = self.android_path {
let manifest_dir = var("CARGO_MANIFEST_DIR").map(PathBuf::from).unwrap();
if let Ok(project_dir) = var("TAURI_ANDROID_PROJECT_PATH") {
let source = manifest_dir.join(path);
let pkg_name = var("CARGO_PKG_NAME").unwrap();
match target_os.as_str() {
"android" => {
if let Some(path) = self.android_path {
let manifest_dir = var("CARGO_MANIFEST_DIR").map(PathBuf::from).unwrap();
if let Ok(project_dir) = var("TAURI_ANDROID_PROJECT_PATH") {
let source = manifest_dir.join(path);
let pkg_name = var("CARGO_PKG_NAME").unwrap();
println!("cargo:rerun-if-env-changed=TAURI_ANDROID_PROJECT_PATH");
println!("cargo:rerun-if-env-changed=TAURI_ANDROID_PROJECT_PATH");
let project_dir = PathBuf::from(project_dir);
let project_dir = PathBuf::from(project_dir);
inject_android_project(source, project_dir.join("tauri-plugins").join(&pkg_name))?;
inject_android_project(source, project_dir.join("tauri-plugins").join(&pkg_name))?;
let gradle_settings_path = project_dir.join("tauri.settings.gradle");
let gradle_settings = fs::read_to_string(&gradle_settings_path)?;
let include = format!(
"include ':{pkg_name}'
let gradle_settings_path = project_dir.join("tauri.settings.gradle");
let gradle_settings = fs::read_to_string(&gradle_settings_path)?;
let include = format!(
"include ':{pkg_name}'
project(':{pkg_name}').projectDir = new File('./tauri-plugins/{pkg_name}')"
);
if !gradle_settings.contains(&include) {
fs::write(
&gradle_settings_path,
format!("{gradle_settings}\n{include}"),
)?;
}
);
if !gradle_settings.contains(&include) {
fs::write(
&gradle_settings_path,
format!("{gradle_settings}\n{include}"),
)?;
}
let app_build_gradle_path = project_dir.join("app").join("tauri.build.gradle.kts");
let app_build_gradle = fs::read_to_string(&app_build_gradle_path)?;
let implementation = format!(r#"implementation(project(":{pkg_name}"))"#);
let target = "dependencies {";
if !app_build_gradle.contains(&implementation) {
fs::write(
&app_build_gradle_path,
app_build_gradle.replace(target, &format!("{target}\n {implementation}")),
)?
let app_build_gradle_path = project_dir.join("app").join("tauri.build.gradle.kts");
let app_build_gradle = fs::read_to_string(&app_build_gradle_path)?;
let implementation = format!(r#"implementation(project(":{pkg_name}"))"#);
let target = "dependencies {";
if !app_build_gradle.contains(&implementation) {
fs::write(
&app_build_gradle_path,
app_build_gradle.replace(target, &format!("{target}\n {implementation}")),
)?
}
}
}
}
#[cfg(target_os = "macos")]
"ios" => {
if let Some(path) = self.ios_path {
link_swift_library(&std::env::var("CARGO_PKG_NAME").unwrap(), path);
}
}
_ => (),
}
Ok(())
}
}
#[cfg(target_os = "macos")]
#[doc(hidden)]
pub fn link_swift_library(name: &str, source: impl AsRef<Path>) {
let source = source.as_ref();
println!("cargo:rerun-if-changed={}", source.display());
swift_rs::build::SwiftLinker::new("10.13")
.with_ios("11")
.with_package(name, source)
.link();
}
#[doc(hidden)]
pub fn inject_android_project(source: impl AsRef<Path>, target: impl AsRef<Path>) -> Result<()> {
let source = source.as_ref();

View File

@ -105,9 +105,7 @@ type FileDropHandler = dyn Fn(&Window, WryFileDropEvent) -> bool + 'static;
#[cfg(all(desktop, feature = "system-tray"))]
pub use tauri_runtime::TrayId;
#[cfg(any(desktop, target_os = "android"))]
mod webview;
#[cfg(any(desktop, target_os = "android"))]
pub use webview::Webview;
#[cfg(all(desktop, feature = "system-tray"))]
@ -1028,7 +1026,6 @@ pub enum ApplicationMessage {
}
pub enum WindowMessage {
#[cfg(any(desktop, target_os = "android"))]
WithWebview(Box<dyn FnOnce(Webview) + Send>),
AddEventListener(Uuid, Box<dyn Fn(&WindowEvent) + Send>),
AddMenuEventListener(Uuid, Box<dyn Fn(&MenuEvent) + Send>),
@ -1205,7 +1202,6 @@ impl<T: UserEvent> Dispatch<T> for WryDispatcher<T> {
id
}
#[cfg(any(desktop, target_os = "android"))]
fn with_webview<F: FnOnce(Box<dyn std::any::Any>) + Send + 'static>(&self, f: F) -> Result<()> {
send_user_message(
&self.context,
@ -2305,7 +2301,6 @@ fn handle_user_message<T: UserEvent>(
});
if let Some((Some(window), window_event_listeners, menu_event_listeners)) = w {
match window_message {
#[cfg(any(target_os = "android", desktop))]
WindowMessage::WithWebview(f) => {
if let WindowHandle::Webview { inner: w, .. } = &window {
#[cfg(any(
@ -2328,6 +2323,14 @@ fn handle_user_message<T: UserEvent>(
ns_window: w.ns_window(),
});
}
#[cfg(target_os = "ios")]
{
use wry::webview::WebviewExtIOS;
f(Webview {
webview: w.webview(),
manager: w.manager(),
});
}
#[cfg(windows)]
{
f(Webview {

View File

@ -26,6 +26,16 @@ mod imp {
}
}
#[cfg(target_os = "ios")]
mod imp {
use cocoa::base::id;
pub struct Webview {
pub webview: id,
pub manager: id,
}
}
#[cfg(windows)]
mod imp {
use webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2Controller;

View File

@ -505,7 +505,7 @@ pub trait Dispatch<T: UserEvent>: Debug + Clone + Send + Sync + Sized + 'static
/// Registers a window event handler.
fn on_menu_event<F: Fn(&window::MenuEvent) + Send + 'static>(&self, f: F) -> Uuid;
#[cfg(any(desktop, target_os = "android"))]
/// Runs a closure with the platform webview object as argument.
fn with_webview<F: FnOnce(Box<dyn std::any::Any>) + Send + 'static>(&self, f: F) -> Result<()>;
/// Open the web inspector which is usually called devtools.

View File

@ -115,6 +115,9 @@ jni = "0.20"
[target."cfg(target_os = \"ios\")".dependencies]
log = "0.4"
libc = "0.2"
objc = "0.2"
cocoa = "0.24"
swift-rs = { git = "https://github.com/Brendonovich/swift-rs", rev = "eb6de914ad57501da5019154d476d45660559999" }
[build-dependencies]
heck = "0.4"

View File

@ -154,6 +154,13 @@ fn main() {
.expect("failed to copy tauri-api Android project");
}
}
#[cfg(target_os = "macos")]
{
if target_os == "ios" {
tauri_build::mobile::link_swift_library("Tauri", "./mobile/ios-api");
}
}
}
// create aliases for the given module with its apis.

9
core/tauri/mobile/ios-api/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@ -0,0 +1,34 @@
// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "Tauri",
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(url: "https://github.com/Brendonovich/swift-rs", revision: "eb6de914ad57501da5019154d476d45660559999"),
],
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: [
.product(name: "SwiftRs", package: "swift-rs"),
],
path: "Sources"
),
.testTarget(
name: "TauriTests",
dependencies: ["Tauri"]
),
]
)

View File

@ -0,0 +1,3 @@
# Tauri
Tauri iOS API.

View File

@ -0,0 +1,57 @@
import Foundation
import MetalKit
@objc public class Invoke: NSObject, JSValueContainer, BridgedJSValueContainer {
public var jsObjectRepresentation: JSObject {
return data as? JSObject ?? [:]
}
public var dictionaryRepresentation: NSDictionary {
return data as NSDictionary
}
public static var jsDateFormatter: ISO8601DateFormatter = {
return ISO8601DateFormatter()
}()
var sendResponse: (JsonValue?, JsonValue?) -> Void
var data: NSDictionary
public init(sendResponse: @escaping (JsonValue?, JsonValue?) -> Void, data: NSDictionary) {
self.sendResponse = sendResponse
self.data = data
}
public func resolve(_ data: JsonValue? = nil) {
sendResponse(data, nil)
}
public func reject(_ message: String, _ code: String? = nil, _ error: Error? = nil, _ data: JsonValue? = nil) {
let payload: NSMutableDictionary = ["message": message, "code": code ?? "", "error": error ?? ""]
if let data = data {
switch data {
case .dictionary(let dict):
for entry in dict {
payload[entry.key] = entry.value
}
}
}
sendResponse(nil, .dictionary(payload as! JsonObject))
}
public func unimplemented() {
unimplemented("not implemented")
}
public func unimplemented(_ message: String) {
sendResponse(nil, .dictionary(["message": message]))
}
public func unavailable() {
unavailable("not available")
}
public func unavailable(_ message: String) {
sendResponse(nil, .dictionary(["message": message]))
}
}

View File

@ -0,0 +1,238 @@
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]
// string types
public protocol JSStringContainer {
func getString(_ key: String, _ defaultValue: String) -> String
func getString(_ key: String) -> String?
}
extension JSStringContainer {
public func getString(_ key: String, _ defaultValue: String) -> String {
return getString(key) ?? defaultValue
}
}
// boolean types
public protocol JSBoolContainer {
func getBool(_ key: String, _ defaultValue: Bool) -> Bool
func getBool(_ key: String) -> Bool?
}
extension JSBoolContainer {
public func getBool(_ key: String, _ defaultValue: Bool) -> Bool {
return getBool(key) ?? defaultValue
}
}
// integer types
public protocol JSIntContainer {
func getInt(_ key: String, _ defaultValue: Int) -> Int
func getInt(_ key: String) -> Int?
}
extension JSIntContainer {
public func getInt(_ key: String, _ defaultValue: Int) -> Int {
return getInt(key) ?? defaultValue
}
}
// float types
public protocol JSFloatContainer {
func getFloat(_ key: String, _ defaultValue: Float) -> Float
func getFloat(_ key: String) -> Float?
}
extension JSFloatContainer {
public func getFloat(_ key: String, _ defaultValue: Float) -> Float {
return getFloat(key) ?? defaultValue
}
}
// double types
public protocol JSDoubleContainer {
func getDouble(_ key: String, _ defaultValue: Double) -> Double
func getDouble(_ key: String) -> Double?
}
extension JSDoubleContainer {
public func getDouble(_ key: String, _ defaultValue: Double) -> Double {
return getDouble(key) ?? defaultValue
}
}
// date types
public protocol JSDateContainer {
func getDate(_ key: String, _ defaultValue: Date) -> Date
func getDate(_ key: String) -> Date?
}
extension JSDateContainer {
public func getDate(_ key: String, _ defaultValue: Date) -> Date {
return getDate(key) ?? defaultValue
}
}
// array types
public protocol JSArrayContainer {
func getArray(_ key: String, _ defaultValue: JSArray) -> JSArray
func getArray<T>(_ key: String, _ ofType: T.Type) -> [T]?
func getArray(_ key: String) -> JSArray?
}
extension JSArrayContainer {
public func getArray(_ key: String, _ defaultValue: JSArray) -> JSArray {
return getArray(key) ?? defaultValue
}
public func getArray<T>(_ key: String, _ ofType: T.Type) -> [T]? {
return getArray(key) as? [T]
}
}
// dictionary types
public protocol JSObjectContainer {
func getObject(_ key: String, _ defaultValue: JSObject) -> JSObject
func getObject(_ key: String) -> JSObject?
}
extension JSObjectContainer {
public func getObject(_ key: String, _ defaultValue: JSObject) -> JSObject {
return getObject(key) ?? defaultValue
}
}
public protocol JSValueContainer: JSStringContainer, JSBoolContainer, JSIntContainer, JSFloatContainer,
JSDoubleContainer, JSDateContainer, JSArrayContainer, JSObjectContainer {
static var jsDateFormatter: ISO8601DateFormatter { get }
var jsObjectRepresentation: JSObject { get }
}
extension JSValueContainer {
public func getValue(_ key: String) -> JSValue? {
return jsObjectRepresentation[key]
}
public func getString(_ key: String) -> String? {
return jsObjectRepresentation[key] as? String
}
public func getBool(_ key: String) -> Bool? {
return jsObjectRepresentation[key] as? Bool
}
public func getInt(_ key: String) -> Int? {
return jsObjectRepresentation[key] as? Int
}
public func getFloat(_ key: String) -> Float? {
if let floatValue = jsObjectRepresentation[key] as? Float {
return floatValue
} else if let doubleValue = jsObjectRepresentation[key] as? Double {
return Float(doubleValue)
}
return nil
}
public func getDouble(_ key: String) -> Double? {
return jsObjectRepresentation[key] as? Double
}
public func getDate(_ key: String) -> Date? {
if let isoString = jsObjectRepresentation[key] as? String {
return Self.jsDateFormatter.date(from: isoString)
}
return jsObjectRepresentation[key] as? Date
}
public func getArray(_ key: String) -> JSArray? {
return jsObjectRepresentation[key] as? JSArray
}
public func getObject(_ key: String) -> JSObject? {
return jsObjectRepresentation[key] as? JSObject
}
}
@objc protocol BridgedJSValueContainer: NSObjectProtocol {
static var jsDateFormatter: ISO8601DateFormatter { get }
var dictionaryRepresentation: NSDictionary { get }
}
/*
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
}
public static func coerceArrayToJSArray(_ array: [Any]?, formattingDatesAsStrings: Bool = false) -> JSArray? {
return array?.compactMap { coerceToJSValue($0, formattingDates: formattingDatesAsStrings) }
}
}
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
}
}

View File

@ -0,0 +1,54 @@
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
}
}
}

View File

@ -0,0 +1,6 @@
import WebKit
import os.log
@objc public protocol Plugin {
@objc func load(webview: WKWebView)
}

View File

@ -0,0 +1,95 @@
import SwiftRs
import MetalKit
import WebKit
import os.log
class PluginHandle {
var instance: NSObject
var loaded = false
init(plugin: NSObject) {
instance = plugin
}
}
class PluginManager {
static var shared: PluginManager = PluginManager()
var plugins: [String:PluginHandle] = [:]
func onWebviewCreated(_ webview: WKWebView) {
for (_, handle) in plugins {
if (!handle.loaded) {
handle.instance.perform(#selector(Plugin.load), with: webview)
}
}
}
func load<P: Plugin & NSObject>(webview: WKWebView?, name: String, plugin: P) {
let handle = PluginHandle(plugin: plugin)
if let webview = webview {
handle.instance.perform(#selector(Plugin.load), with: webview)
handle.loaded = true
}
plugins[name] = handle
}
func invoke(name: String, methodName: String, invoke: Invoke) {
if let plugin = plugins[name] {
let selectorWithThrows = Selector(("\(methodName):error:"))
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, selectorWithThrows, invoke, OpaquePointer($0))
}
if let error = error {
invoke.reject("\(error)")
toRust(error) // TODO app is crashing without this memory leak (when an error is thrown)
}
} else {
let selector = Selector(("\(methodName):"))
if plugin.instance.responds(to: selector) {
plugin.instance.perform(selector, with: invoke)
} else {
invoke.reject("No method \(methodName) found for plugin \(name)")
}
}
} else {
invoke.reject("Plugin \(name) not initialized")
}
}
}
extension PluginManager: NSCopying {
func copy(with zone: NSZone? = nil) -> Any {
return self
}
}
public func registerPlugin<P: Plugin & NSObject>(webview: WKWebView?, name: String, plugin: P) {
PluginManager.shared.load(
webview: webview,
name: name,
plugin: plugin
)
}
@_cdecl("on_webview_created")
func onWebviewCreated(webview: WKWebView) {
PluginManager.shared.onWebviewCreated(webview)
}
@_cdecl("invoke_plugin")
func invokePlugin(webview: WKWebView, name: UnsafePointer<SRString>, methodName: UnsafePointer<SRString>, data: NSDictionary, callback: UInt, error: UInt) {
let invoke = Invoke(sendResponse: { (successResult: JsonValue?, errorResult: JsonValue?) -> Void in
let (fn, payload) = errorResult == nil ? (callback, successResult) : (error, errorResult)
var payloadJson: String
do {
try payloadJson = payload == nil ? "null" : payload!.jsonRepresentation() ?? "`Failed to serialize payload`"
} catch {
payloadJson = "`\(error)`"
}
webview.evaluateJavaScript("window['_\(fn)'](\(payloadJson))")
}, data: data)
PluginManager.shared.invoke(name: name.pointee.to_string(), methodName: methodName.pointee.to_string(), invoke: invoke)
}

View File

@ -0,0 +1,11 @@
import XCTest
@testable import Tauri
final class TauriTests: XCTestCase {
func testExample() throws {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct
// results.
XCTAssertEqual(Tauri().text, "Hello, World!")
}
}

View File

@ -384,6 +384,22 @@ impl<R: Runtime> AppHandle<R> {
self.runtime_handle.create_proxy()
}
/// Initializes an iOS plugin.
#[cfg(target_os = "ios")]
pub fn initialize_ios_plugin(
&self,
init_fn: unsafe extern "C" fn(cocoa::base::id),
) -> crate::Result<()> {
if let Some(window) = self.windows().values().next() {
window.with_webview(move |w| {
unsafe { init_fn(w.inner()) };
})?;
} else {
unsafe { init_fn(cocoa::base::nil) };
}
Ok(())
}
/// Initializes an Android plugin.
#[cfg(target_os = "android")]
pub fn initialize_android_plugin(

15
core/tauri/src/ios.rs Normal file
View File

@ -0,0 +1,15 @@
use cocoa::base::id;
use swift_rs::SRString;
extern "C" {
pub fn invoke_plugin(
webview: id,
name: &SRString,
method: &SRString,
data: id,
callback: usize,
error: usize,
);
pub fn on_webview_created(webview: id);
}

View File

@ -162,6 +162,9 @@
#![warn(missing_docs, rust_2018_idioms)]
#![cfg_attr(doc_cfg, feature(doc_cfg))]
#[cfg(any(target_os = "macos", target_os = "ios"))]
#[doc(hidden)]
pub use cocoa;
#[cfg(target_os = "macos")]
#[doc(hidden)]
pub use embed_plist;
@ -188,6 +191,8 @@ mod pattern;
pub mod plugin;
pub mod window;
use tauri_runtime as runtime;
#[cfg(target_os = "ios")]
mod ios;
#[cfg(target_os = "android")]
mod jni_helpers;
/// The allowlist scopes.

View File

@ -1400,6 +1400,15 @@ impl<R: Runtime> WindowManager<R> {
.created(window_);
});
#[cfg(target_os = "ios")]
{
window
.with_webview(|w| {
unsafe { crate::ios::on_webview_created(w.inner()) };
})
.expect("failed to run on_webview_created hook");
}
window
}

View File

@ -342,7 +342,6 @@ impl<T: UserEvent> Dispatch<T> for MockDispatcher {
Uuid::new_v4()
}
#[cfg(any(desktop, target_os = "android"))]
fn with_webview<F: FnOnce(Box<dyn std::any::Any>) + Send + 'static>(&self, f: F) -> Result<()> {
Ok(())
}

View File

@ -673,14 +673,11 @@ impl<'de, R: Runtime> CommandArg<'de, R> for Window<R> {
}
/// The platform webview handle. Accessed with [`Window#method.with_webview`];
#[cfg(all(any(desktop, target_os = "android"), feature = "wry"))]
#[cfg_attr(
doc_cfg,
doc(cfg(all(any(desktop, target_os = "android"), feature = "wry")))
)]
#[cfg(feature = "wry")]
#[cfg_attr(doc_cfg, doc(cfg(feature = "wry")))]
pub struct PlatformWebview(tauri_runtime_wry::Webview);
#[cfg(all(any(desktop, target_os = "android"), feature = "wry"))]
#[cfg(feature = "wry")]
impl PlatformWebview {
/// Returns [`webkit2gtk::WebView`] handle.
#[cfg(any(
@ -716,8 +713,8 @@ impl PlatformWebview {
/// Returns the [WKWebView] handle.
///
/// [WKWebView]: https://developer.apple.com/documentation/webkit/wkwebview
#[cfg(target_os = "macos")]
#[cfg_attr(doc_cfg, doc(cfg(target_os = "macos")))]
#[cfg(any(target_os = "macos", target_os = "ios"))]
#[cfg_attr(doc_cfg, doc(cfg(any(target_os = "macos", target_os = "ios"))))]
pub fn inner(&self) -> cocoa::base::id {
self.0.webview
}
@ -725,8 +722,8 @@ impl PlatformWebview {
/// Returns WKWebView [controller] handle.
///
/// [controller]: https://developer.apple.com/documentation/webkit/wkusercontentcontroller
#[cfg(target_os = "macos")]
#[cfg_attr(doc_cfg, doc(cfg(target_os = "macos")))]
#[cfg(any(target_os = "macos", target_os = "ios"))]
#[cfg_attr(doc_cfg, doc(cfg(any(target_os = "macos", target_os = "ios"))))]
pub fn controller(&self) -> cocoa::base::id {
self.0.manager
}
@ -869,11 +866,8 @@ impl<R: Runtime> Window<R> {
/// });
/// }
/// ```
#[cfg(all(feature = "wry", any(desktop, target_os = "android")))]
#[cfg_attr(
doc_cfg,
doc(all(feature = "wry", any(desktop, target_os = "android")))
)]
#[cfg(all(feature = "wry"))]
#[cfg_attr(doc_cfg, doc(all(feature = "wry")))]
pub fn with_webview<F: FnOnce(PlatformWebview) + Send + 'static>(
&self,
f: F,
@ -1352,12 +1346,161 @@ impl<R: Runtime> Window<R> {
.map(|c| c.to_string())
.unwrap_or_else(String::new);
#[cfg(target_os = "android")]
#[cfg(mobile)]
let (message, resolver) = (invoke.message.clone(), invoke.resolver.clone());
#[allow(unused_variables)]
let handled = manager.extend_api(plugin, invoke);
#[cfg(target_os = "ios")]
{
if !handled {
let plugin = plugin.to_string();
self.with_webview(move |webview| {
use cocoa::base::{id, nil, NO, YES};
use objc::*;
use serde_json::Value as JsonValue;
const UTF8_ENCODING: usize = 4;
struct NSString(id);
impl NSString {
fn new(s: &str) -> Self {
// Safety: objc runtime calls are unsafe
NSString(unsafe {
let ns_string: id = msg_send![class!(NSString), alloc];
let ns_string: id = msg_send![ns_string,
initWithBytes:s.as_ptr()
length:s.len()
encoding:UTF8_ENCODING];
// The thing is allocated in rust, the thing must be set to autorelease in rust to relinquish control
// or it can not be released correctly in OC runtime
let _: () = msg_send![ns_string, autorelease];
ns_string
})
}
}
unsafe fn add_json_value_to_array(array: id, value: JsonValue) {
match value {
JsonValue::Null => {
let null: id = msg_send![class!(NSNull), null];
let () = msg_send![array, addObject: null];
}
JsonValue::Bool(val) => {
let value = if val { YES } else { NO };
let v: id = msg_send![class!(NSNumber), numberWithBool: value];
let () = msg_send![array, addObject: v];
}
JsonValue::Number(val) => {
let number: id = if let Some(v) = val.as_i64() {
msg_send![class!(NSNumber), numberWithInteger: v]
} else if let Some(v) = val.as_u64() {
msg_send![class!(NSNumber), numberWithUnsignedLongLong: v]
} else if let Some(v) = val.as_f64() {
msg_send![class!(NSNumber), numberWithDouble: v]
} else {
unreachable!()
};
let () = msg_send![array, addObject: number];
}
JsonValue::String(val) => {
let () = msg_send![array, addObject: NSString::new(&val)];
}
JsonValue::Array(val) => {
let nsarray: id = msg_send![class!(NSMutableArray), alloc];
let inner_array: id = msg_send![nsarray, init];
for value in val {
add_json_value_to_array(inner_array, value);
}
let () = msg_send![array, addObject: inner_array];
}
JsonValue::Object(val) => {
let dictionary: id = msg_send![class!(NSMutableDictionary), alloc];
let data: id = msg_send![dictionary, init];
for (key, value) in val {
add_json_entry_to_dictionary(data, key, value);
}
let () = msg_send![array, addObject: data];
}
}
}
unsafe fn add_json_entry_to_dictionary(data: id, key: String, value: JsonValue) {
let key = NSString::new(&key);
match value {
JsonValue::Null => {
let null: id = msg_send![class!(NSNull), null];
let () = msg_send![data, setObject:null forKey: key];
}
JsonValue::Bool(val) => {
let value = if val { YES } else { NO };
let () = msg_send![data, setObject:value forKey: key];
}
JsonValue::Number(val) => {
let number: id = if let Some(v) = val.as_i64() {
msg_send![class!(NSNumber), numberWithInteger: v]
} else if let Some(v) = val.as_u64() {
msg_send![class!(NSNumber), numberWithUnsignedLongLong: v]
} else if let Some(v) = val.as_f64() {
msg_send![class!(NSNumber), numberWithDouble: v]
} else {
unreachable!()
};
let () = msg_send![data, setObject:number forKey: key];
}
JsonValue::String(val) => {
let () = msg_send![data, setObject:NSString::new(&val) forKey: key];
}
JsonValue::Array(val) => {
let nsarray: id = msg_send![class!(NSMutableArray), alloc];
let array: id = msg_send![nsarray, init];
for value in val {
add_json_value_to_array(array, value);
}
let () = msg_send![data, setObject:array forKey: key];
}
JsonValue::Object(val) => {
let dictionary: id = msg_send![class!(NSMutableDictionary), alloc];
let inner_data: id = msg_send![dictionary, init];
for (key, value) in val {
add_json_entry_to_dictionary(inner_data, key, value);
}
let () = msg_send![data, setObject:inner_data forKey: key];
}
}
}
let data = if let JsonValue::Object(map) = message.payload {
unsafe {
let dictionary: id = msg_send![class!(NSMutableDictionary), alloc];
let data: id = msg_send![dictionary, init];
for (key, value) in map {
add_json_entry_to_dictionary(data, key, value);
}
data
}
} else {
nil
};
unsafe {
crate::ios::invoke_plugin(
webview.inner(),
&plugin.as_str().into(),
&message.command.as_str().into(),
data,
resolver.callback.0,
resolver.error.0,
)
};
})?;
}
}
#[cfg(target_os = "android")]
{
if !handled {

View File

@ -2896,6 +2896,16 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601"
[[package]]
name = "swift-rs"
version = "0.3.0"
source = "git+https://github.com/Brendonovich/swift-rs?rev=eb6de914ad57501da5019154d476d45660559999#eb6de914ad57501da5019154d476d45660559999"
dependencies = [
"base64 0.13.1",
"serde",
"serde_json",
]
[[package]]
name = "syn"
version = "1.0.107"
@ -3036,6 +3046,7 @@ dependencies = [
"serialize-to-javascript",
"shared_child",
"state",
"swift-rs",
"tar",
"tauri-build",
"tauri-macros",
@ -3066,6 +3077,7 @@ dependencies = [
"quote",
"semver 1.0.16",
"serde_json",
"swift-rs",
"tauri-codegen",
"tauri-utils",
"walkdir",
@ -3130,6 +3142,7 @@ dependencies = [
name = "tauri-plugin-sample"
version = "0.1.0"
dependencies = [
"log",
"tauri",
"tauri-build",
]

View File

@ -8,5 +8,5 @@ fn main() {
codegen = codegen.dev();
}
codegen.build();
tauri_build::build()
tauri_build::build();
}

View File

@ -5,6 +5,7 @@ edition = "2021"
[dependencies]
tauri = { path = "../../../../core/tauri" }
log = "0.4"
[build-dependencies]
tauri-build = { path = "../../../../core/tauri-build/" }

View File

@ -3,6 +3,7 @@ use std::process::exit;
fn main() {
if let Err(error) = tauri_build::mobile::PluginBuilder::new()
.android_path("android")
.ios_path("ios")
.run()
{
println!("{error:#}");

View File

@ -0,0 +1,10 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
Package.resolved

View File

@ -0,0 +1,34 @@
// swift-tools-version:5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "tauri-plugin-sample",
platforms: [
.iOS(.v11),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "tauri-plugin-sample",
type: .static,
targets: ["tauri-plugin-sample"]),
],
dependencies: [
// Dependencies declare other packages that this package depends on.
//.package(url: "https://github.com/tauri-apps/tauri", branch: "next"),
.package(name: "Tauri", path: "../../../../../core/tauri/mobile/ios-api")
],
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-plugin-sample",
dependencies: [
//.product(name: "Tauri", package: "tauri"),
.byName(name: "Tauri")
],
path: "Sources")
]
)

View File

@ -0,0 +1,3 @@
# Tauri Plugin sample
A description of this package.

View File

@ -0,0 +1,17 @@
import MetalKit
import WebKit
import Tauri
class ExamplePlugin: NSObject, Plugin {
@objc func load(webview: WKWebView) {}
@objc public func ping(_ invoke: Invoke) throws {
let value = invoke.getString("value")
invoke.resolve(.dictionary(["value": value as Any]))
}
}
@_cdecl("init_plugin_sample")
func initPlugin(webview: WKWebView?) {
Tauri.registerPlugin(webview: webview, name: "sample", plugin: ExamplePlugin())
}

View File

@ -0,0 +1,8 @@
import XCTest
@testable import ExamplePlugin
final class ExamplePluginTests: XCTestCase {
func testExample() throws {
let plugin = ExamplePlugin()
}
}

View File

@ -7,15 +7,19 @@ const PLUGIN_NAME: &str = "sample";
#[cfg(target_os = "android")]
const PLUGIN_IDENTIFIER: &str = "com.plugin.test";
pub fn init<R: Runtime>() -> TauriPlugin<R> {
#[allow(unused_mut)]
let mut builder = Builder::new(PLUGIN_NAME);
#[cfg(target_os = "android")]
{
builder = builder.setup(|app| {
app.initialize_android_plugin(PLUGIN_NAME, PLUGIN_IDENTIFIER, "ExamplePlugin")?;
Ok(())
});
}
builder.build()
#[cfg(target_os = "ios")]
extern "C" {
fn init_plugin_sample(webview: tauri::cocoa::base::id);
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new(PLUGIN_NAME)
.setup(|app| {
#[cfg(target_os = "android")]
app.initialize_android_plugin(PLUGIN_NAME, PLUGIN_IDENTIFIER, "ExamplePlugin")?;
#[cfg(target_os = "ios")]
app.initialize_ios_plugin(init_plugin_sample)?;
Ok(())
})
.build()
}

View File

@ -82,7 +82,7 @@ pub fn command(options: Options, noise_level: NoiseLevel) -> Result<()> {
configure_cargo(app, Some((&mut env, config)))?;
// run an initial build to initialize plugins
Target::all().first_key_value().unwrap().1.build(
Target::all().values().next().unwrap().build(
config,
metadata,
&env,

View File

@ -117,7 +117,7 @@ fn run_dev(
let target_triple = device
.as_ref()
.map(|d| d.target().triple.to_string())
.unwrap_or_else(|| Target::all().first_key_value().unwrap().1.triple.into());
.unwrap_or_else(|| Target::all().values().next().unwrap().triple.into());
dev_options.target = Some(target_triple.clone());
let mut interface = crate::dev::setup(&mut dev_options, true)?;
@ -138,7 +138,7 @@ fn run_dev(
let target = Target::all()
.values()
.find(|t| t.triple == target_triple)
.unwrap_or(Target::all().first_key_value().unwrap().1);
.unwrap_or(Target::all().values().next().unwrap());
target.build(config, metadata, &env, noise_level, true, Profile::Debug)?;
let open = options.open;

View File

@ -22,6 +22,7 @@ use std::{
const BACKEND_PLUGIN_DIR: Dir<'_> = include_dir!("templates/plugin/backend");
const API_PLUGIN_DIR: Dir<'_> = include_dir!("templates/plugin/with-api");
const ANDROID_PLUGIN_DIR: Dir<'_> = include_dir!("templates/plugin/android");
const IOS_PLUGIN_DIR: Dir<'_> = include_dir!("templates/plugin/ios");
#[derive(Debug, Parser)]
#[clap(about = "Initializes a Tauri plugin project")]
@ -48,6 +49,9 @@ pub struct Options {
/// Adds native Android support.
#[clap(long)]
android: bool,
/// Adds native iOS support.
#[clap(long)]
ios: bool,
}
impl Options {
@ -163,7 +167,17 @@ pub fn command(mut options: Options) -> Result<()> {
)
},
)
.with_context(|| "failed to render Tauri template")?;
.with_context(|| "failed to render plugin Android template")?;
}
if options.ios {
template::render(
&handlebars,
&data,
&IOS_PLUGIN_DIR,
template_target_path.join("ios"),
)
.with_context(|| "failed to render plugin iOS template")?;
}
}
Ok(())

View File

@ -0,0 +1,8 @@
//
// Tauri.swift
// api_iOS
//
// Created by Lucas Nogueira on 29/01/23.
//
import Foundation

View File

@ -72,9 +72,9 @@ targets:
ENABLE_BITCODE: false
ARCHS: [{{join ios-valid-archs}}]
VALID_ARCHS: {{~#each ios-valid-archs}} {{this}} {{/each}}
LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/x86_64-apple-ios/$(CONFIGURATION)
LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/aarch64-apple-ios/$(CONFIGURATION)
LIBRARY_SEARCH_PATHS[arch=arm64-sim]: $(inherited) $(PROJECT_DIR)/Externals/aarch64-apple-ios-sim/$(CONFIGURATION)
LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/x86_64-apple-ios/$(CONFIGURATION) $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)
LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/aarch64-apple-ios/$(CONFIGURATION) $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)
LIBRARY_SEARCH_PATHS[arch=arm64-sim]: $(inherited) $(PROJECT_DIR)/Externals/aarch64-apple-ios-sim/$(CONFIGURATION) $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: true
groups: [app]
dependencies:

View File

@ -0,0 +1,10 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
Package.resolved

View File

@ -0,0 +1,32 @@
// swift-tools-version:5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "tauri-plugin-{{ plugin_name }}",
platforms: [
.iOS(.v11),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "tauri-plugin-{{ plugin_name }}",
type: .static,
targets: ["tauri-plugin-{{ plugin_name }}"]),
],
dependencies: [
// Dependencies declare other packages that this package depends on.
.package(url: "https://github.com/tauri-apps/tauri", branch: "next"),
],
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-plugin-{{ plugin_name }}",
dependencies: [
.product(name: "Tauri", package: "tauri")
],
path: "Sources")
]
)

View File

@ -0,0 +1,3 @@
# Tauri Plugin {{ plugin_name_original }}
A description of this package.

View File

@ -0,0 +1,17 @@
import MetalKit
import WebKit
import Tauri
class ExamplePlugin: NSObject, Plugin {
@objc func load(webview: WKWebView) {}
@objc public func ping(_ invoke: Invoke) throws {
let value = invoke.getString("value")
invoke.resolve(.dictionary(["value": value as Any]))
}
}
@_cdecl("init_plugin_{{ plugin_name_snake_case }}")
func initPlugin(webview: WKWebView?) {
Tauri.registerPlugin(webview: webview, name: "{{plugin_name}}", plugin: ExamplePlugin())
}

View File

@ -0,0 +1,8 @@
import XCTest
@testable import ExamplePlugin
final class ExamplePluginTests: XCTestCase {
func testExample() throws {
let plugin = ExamplePlugin()
}
}