Skip to content

Commit

Permalink
Merge pull request #295 from player-ui/iOS-Async-Node-Plugin
Browse files Browse the repository at this point in the history
AsyncNodePlugin- use named export, port iOS plugin
  • Loading branch information
nancywu1 authored Feb 7, 2024
2 parents 6c45e52 + 60f7f20 commit e1c6102
Show file tree
Hide file tree
Showing 13 changed files with 583 additions and 7 deletions.
9 changes: 9 additions & 0 deletions PlayerUI.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,15 @@ and display it as a SwiftUI view comprised of registered assets.
# </PACKAGES>

# <PLUGINS>

s.subspec 'AsyncNodePlugin' do |plugin|
plugin.dependency 'PlayerUI/Core'
plugin.source_files = 'ios/plugins/AsyncNodePlugin/Sources/**/*'
plugin.resource_bundles = {
'AsyncNodePlugin' => ['ios/plugins/AsyncNodePlugin/Resources/**/*.js']
}
end

s.subspec 'PrintLoggerPlugin' do |plugin|
plugin.dependency 'PlayerUI/Core'
plugin.source_files = 'ios/plugins/PrintLoggerPlugin/Sources/**/*'
Expand Down
2 changes: 1 addition & 1 deletion docs/site/pages/plugins/metrics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ import { BeaconPluginPlugin } from '@player-ui/beacon-plugin';

class MyBeaconPluginPlugin implements BeaconPluginPlugin {
apply(beaconPlugin: BeaconPlugin) {
beaconPlugin.hooks.buildBeacon.tapPromise(
beaconPlugin.hooks.buildBeacon.tap(
{ name: 'my-beacon-plugin', context: true } as Tap,
async (context, beacon) => {
const { renderTime } =
Expand Down
12 changes: 12 additions & 0 deletions generated.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ def PlayerUI(
"ENABLE_TESTING_SEARCH_PATHS": "YES",
},
srcs = glob([
"ios/plugins/AsyncNodePlugin/Sources/**/*.h",
"ios/plugins/AsyncNodePlugin/Sources/**/*.hh",
"ios/plugins/AsyncNodePlugin/Sources/**/*.m",
"ios/plugins/AsyncNodePlugin/Sources/**/*.mm",
"ios/plugins/AsyncNodePlugin/Sources/**/*.swift",
"ios/plugins/AsyncNodePlugin/Sources/**/*.c",
"ios/plugins/AsyncNodePlugin/Sources/**/*.cc",
"ios/plugins/AsyncNodePlugin/Sources/**/*.cpp",
"ios/plugins/BaseBeaconPlugin/Sources/**/*.h",
"ios/plugins/BaseBeaconPlugin/Sources/**/*.hh",
"ios/plugins/BaseBeaconPlugin/Sources/**/*.m",
Expand Down Expand Up @@ -213,6 +221,10 @@ def PlayerUI(
"ios/plugins/TypesProviderPlugin/Sources/**/*.cpp",
]),
resource_bundles = {
"AsyncNodePlugin": glob(
["ios/plugins/AsyncNodePlugin/Resources/**/*.js"],
exclude_directories = 0,
),
"BaseBeaconPlugin": glob(
["ios/plugins/BaseBeaconPlugin/Resources/**/*.js"],
exclude_directories = 0,
Expand Down
2 changes: 1 addition & 1 deletion ios/packages/core/Sources/CorePlugins/JSBasePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ open class JSBasePlugin {
*/
private func getPlugin(context: JSContext, fileName: String, pluginName: String, arguments: [Any] = []) -> JSValue {
guard
let plugin = context.getClassReference(pluginName, load: {loadJSResource(into: $0, fileName: fileName)}),
let plugin = context.getClassReference(pluginName, load: {loadJSResource(into: $0, fileName: fileName)}), !plugin.isUndefined,
let pluginValue = plugin.construct(withArguments: arguments)
else {
fatalError("Unable To Construct \(pluginName)")
Expand Down
2 changes: 1 addition & 1 deletion ios/packages/core/Sources/Player/HeadlessPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ public extension HeadlessPlayer {
let warn = JSValue(object: logger.getJSLogFor(level: .warning), in: context)
let error = JSValue(object: logger.getJSLogFor(level: .error), in: context)
for plugin in plugins {
if let plugin = plugin as? JSBasePlugin {
if let plugin = plugin as? JSBasePlugin, plugin.context != context {
plugin.context = context
}
}
Expand Down
40 changes: 40 additions & 0 deletions ios/packages/core/Sources/Types/Hooks/Hook.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,43 @@ public class Hook2<T, U>: BaseJSHook where T: CreatedFromJSValue, U: CreatedFrom
self.hook.invokeMethod("tap", withArguments: [name, JSValue(object: tapMethod, in: context) as Any])
}
}

/**
This class represents an object in the JS runtime that can be tapped into
and returns a promise that resolves when the asynchronous task is completed
*/
public class AsyncHook<T>: BaseJSHook where T: CreatedFromJSValue {
private var handler: AsyncHookHandler?

public typealias AsyncHookHandler = (T) async throws -> JSValue?

/**
Attach a closure to the hook, so when the hook is fired in the JS runtime
we receive the event in the native runtime

- parameters:
- hook: A function to run when the JS hook is fired
*/
public func tap(_ hook: @escaping AsyncHookHandler) {
let tapMethod: @convention(block) (JSValue?) -> JSValue = { value in
guard
let val = value,
let hookValue = T.createInstance(value: val) as? T
else { return JSValue() }

let promise =
JSUtilities.createPromise(context: self.context, handler: { (resolve, _) in
Task {
let result = try await hook(hookValue)
DispatchQueue.main.async {
resolve(result as Any)
}
}
})

return promise ?? JSValue()
}

self.hook.invokeMethod("tap", withArguments: [name, JSValue(object: tapMethod, in: context) as Any])
}
}
1 change: 1 addition & 0 deletions ios/packages/swiftui/Sources/SwiftUIPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ public struct SwiftUIPlayer: View, HeadlessPlayer {
}

let context: JSContext = contextBuilder()

let allPlugins = plugins + [partialMatchPlugin]
guard let playerValue = player.setupPlayer(context: context, plugins: allPlugins) else {
return logger.e("Failed to load player")
Expand Down
142 changes: 142 additions & 0 deletions ios/plugins/AsyncNodePlugin/Sources/AsyncNodePlugin.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
//
// AsyncNodePlugin.swift
// PlayerUI
//
// Created by Zhao Xia Wu on 2024-02-05.
//

import Foundation
import JavaScriptCore

public typealias AsyncHookHandler = (JSValue) async throws -> AsyncNodeHandlerType

public enum AsyncNodeHandlerType {
case multiNode([ReplacementNode])
case singleNode(ReplacementNode)
}

/**
Wraps the core AsyncNodePlugin and taps into the `onAsyncNode` hook to allow asynchronous replacement of the node object that contains `async`
*/
public class AsyncNodePlugin: JSBasePlugin, NativePlugin {
public var hooks: AsyncNodeHook?

private var asyncHookHandler: AsyncHookHandler?

/**
Constructs the AsyncNodePlugin
- Parameters:
- handler: The callback that is used to tap into the core `onAsyncNode` hook
exposed to users of the plugin allowing them to supply the replacement node used in the tap callback
*/
public convenience init(_ handler: @escaping AsyncHookHandler) {
self.init(fileName: "async-node-plugin.prod", pluginName: "AsyncNodePlugin.AsyncNodePlugin")
self.asyncHookHandler = handler
}

override public func setup(context: JSContext) {
super.setup(context: context)

if let pluginRef = self.pluginRef {
self.hooks = AsyncNodeHook(onAsyncNode: AsyncHook(baseValue: pluginRef, name: "onAsyncNode"))
}

hooks?.onAsyncNode.tap({ node in
// hook value is the original node
guard let asyncHookHandler = self.asyncHookHandler else {
return JSValue()
}

let replacementNode = try await (asyncHookHandler)(node)

switch replacementNode {
case .multiNode(let replacementNodes):
let jsValueArray = replacementNodes.compactMap({ node in
switch node {
case .concrete(let jsValue):
return jsValue
case .encodable(let encodable):
let encoder = JSONEncoder()
do {
let res = try encoder.encode(encodable)
return context.evaluateScript("(\(String(data: res, encoding: .utf8) ?? ""))") as JSValue
} catch {
return nil
}
}
})

return context.objectForKeyedSubscript("Array").objectForKeyedSubscript("from").call(withArguments: [jsValueArray])

case .singleNode(let replacementNode):
switch replacementNode {

case .encodable(let encodable):
let encoder = JSONEncoder()
do {
let res = try encoder.encode(encodable)
return context.evaluateScript("(\(String(data: res, encoding: .utf8) ?? ""))") as JSValue
} catch {
break
}
case .concrete(let jsValue):
return jsValue
}
}

return nil
})
}

override open func getUrlForFile(fileName: String) -> URL? {
ResourceUtilities.urlForFile(name: fileName, ext: "js", bundle: Bundle(for: AsyncNodePlugin.self), pathComponent: "AsyncNodePlugin.bundle")
}
}

public struct AsyncNodeHook {
public let onAsyncNode: AsyncHook<JSValue>
}

/**
Replacement node that the callback of this plugin expects, users can supply either a JSValue or an Encodable object that gets converted to a JSValue in the `setup`
*/
public enum ReplacementNode: Encodable {
case concrete(JSValue)
case encodable(Encodable)

public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()

switch self {
case .encodable(let value):
try container.encode(value)
case .concrete( _):
break
}
}
}

public struct AssetPlaceholderNode: Encodable {
public enum CodingKeys: String, CodingKey {
case asset
}

var asset: Encodable
public init(asset: Encodable) {
self.asset = asset
}

public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try? container.encode(asset, forKey: .asset)
}
}

public struct AsyncNode: Codable, Equatable {
var id: String
var async: Bool = true

public init(id: String) {
self.id = id
}
}
Loading

0 comments on commit e1c6102

Please sign in to comment.