Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Plugin: WebSocket API #4586

Merged
merged 2 commits into from
Aug 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 8 additions & 0 deletions iina.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,8 @@
E374160C20F138A900B4F7F9 /* CollapseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E374160B20F138A900B4F7F9 /* CollapseView.swift */; };
E374160F20F3AF7E00B4F7F9 /* PrefUtilsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E374160D20F3AF7E00B4F7F9 /* PrefUtilsViewController.swift */; };
E374161120F3C0AB00B4F7F9 /* PrefUtilsViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = E374161320F3C0AB00B4F7F9 /* PrefUtilsViewController.xib */; };
E3785B072A83039600787CB7 /* JavascriptAPIWebSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3785B062A83039600787CB7 /* JavascriptAPIWebSocket.swift */; };
E3785B092A83380E00787CB7 /* WebSocketServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3785B082A83380E00787CB7 /* WebSocketServer.swift */; };
E38372922443634500F57718 /* LanguageTokenField.swift in Sources */ = {isa = PBXBuildFile; fileRef = E38372912443634500F57718 /* LanguageTokenField.swift */; };
E3839C5724FFF201007D0AB4 /* PluginStandaloneWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3839C5624FFF201007D0AB4 /* PluginStandaloneWindow.swift */; };
E3839C5924FFF817007D0AB4 /* JavascriptAPIStandaloneWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3839C5824FFF817007D0AB4 /* JavascriptAPIStandaloneWindow.swift */; };
Expand Down Expand Up @@ -1636,6 +1638,8 @@
E374160D20F3AF7E00B4F7F9 /* PrefUtilsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefUtilsViewController.swift; sourceTree = "<group>"; };
E374161220F3C0AB00B4F7F9 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/PrefUtilsViewController.xib; sourceTree = "<group>"; };
E374161520F3C0AD00B4F7F9 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/PrefUtilsViewController.strings"; sourceTree = "<group>"; };
E3785B062A83039600787CB7 /* JavascriptAPIWebSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JavascriptAPIWebSocket.swift; sourceTree = "<group>"; };
E3785B082A83380E00787CB7 /* WebSocketServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketServer.swift; sourceTree = "<group>"; };
E38372912443634500F57718 /* LanguageTokenField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageTokenField.swift; sourceTree = "<group>"; };
E3839C5624FFF201007D0AB4 /* PluginStandaloneWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginStandaloneWindow.swift; sourceTree = "<group>"; };
E3839C5824FFF817007D0AB4 /* JavascriptAPIStandaloneWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JavascriptAPIStandaloneWindow.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2502,6 +2506,7 @@
E34EAA82251A36CE00057F27 /* JavascriptAPIFile.swift */,
E38BA1BF253E54F2000B551D /* JavascriptAPIGlobal.swift */,
E3DC53972A28F0DC002A5A48 /* JavascriptAPIInput.swift */,
E3785B062A83039600787CB7 /* JavascriptAPIWebSocket.swift */,
);
name = API;
sourceTree = "<group>";
Expand Down Expand Up @@ -2530,6 +2535,7 @@
E38BA1C3254110BD000B551D /* JavascriptMessageHub.swift */,
E3FF5ABF2938582F0019CE45 /* JavascriptDevTool.swift */,
E3FEC1322A30400F00CFD845 /* PluginInputManager.swift */,
E3785B082A83380E00787CB7 /* WebSocketServer.swift */,
);
name = Javascript;
sourceTree = "<group>";
Expand Down Expand Up @@ -2891,6 +2897,7 @@
842904E21F0EC01600478376 /* AutoFileMatcher.swift in Sources */,
8466BE181D5CDD0300039D03 /* QuickSettingViewController.swift in Sources */,
84A0BA9E1D2FAD4000BC8DA1 /* MainWindowController.swift in Sources */,
E3785B092A83380E00787CB7 /* WebSocketServer.swift in Sources */,
84E48D4E1E0F1090002C7A3F /* FilterWindowController.swift in Sources */,
E3F698872120D878005792C9 /* ExtendedColors.swift in Sources */,
E3839C5924FFF817007D0AB4 /* JavascriptAPIStandaloneWindow.swift in Sources */,
Expand All @@ -2901,6 +2908,7 @@
E38B3216214FF700000F6D27 /* EventController.swift in Sources */,
E3CB75BD1FDACB82004DB10A /* SavedFilter.swift in Sources */,
E337D5E4241C12BE00B5729A /* PrefPluginPermissionListView.swift in Sources */,
E3785B072A83039600787CB7 /* JavascriptAPIWebSocket.swift in Sources */,
E342832F20B7149800139865 /* Logger.swift in Sources */,
84F725561D4783EE000DEF1B /* VolumeSliderCell.swift in Sources */,
845040401E0ACADD0079C194 /* MPVNode.swift in Sources */,
Expand Down
44 changes: 44 additions & 0 deletions iina/JavascriptAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,47 @@ class JavascriptAPI: NSObject {
return expanded.path
}
}


func createUInt8Array(fromData data: Data) -> JSValue? {
let context = JSContext.current()!
let length = data.count

// JSObjectMakeTypedArrayWithBytesNoCopy is only available on macOS 10.12.
if #available(macOS 10.12, *) {
let rawPtr = UnsafeMutableBufferPointer<UInt8>.allocate(capacity: length)
_ = data.withUnsafeBytes { (dataPtr: UnsafeRawBufferPointer) in
rawPtr.initialize(from: dataPtr)
}
let deallocator: JSTypedArrayBytesDeallocator = { ptr, _ in
ptr?.deallocate()
}
let arrayBufferRef = JSObjectMakeTypedArrayWithBytesNoCopy(context.jsGlobalContextRef,
kJSTypedArrayTypeUint8Array,
rawPtr.baseAddress,
length,
deallocator,
nil,
nil)
return JSValue(jsValueRef: arrayBufferRef, in: context)
} else {
// Inefficient fallback
let getter: @convention(block) (Int) -> UInt8 = { offset in
return data[offset]

}
context.setObject(getter, forKeyedSubscript: "__iina_data_getter" as NSString)

let array = context.evaluateScript("""
Uint8Array.from(function* () {
for (let i = 0; i < \(length); i++) {
yield __iina_data_getter(i);
}
}())
""")

context.setObject(nil, forKeyedSubscript: "__iina_data_getter" as NSString)
return array

}
}
43 changes: 0 additions & 43 deletions iina/JavascriptAPIFile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -236,47 +236,4 @@ class JavascriptFileHandle: NSObject, JavascriptFileHandleExportable {
func close() {
handle.closeFile()
}

private func createUInt8Array(fromData data: Data) -> JSValue? {
let context = JSContext.current()!
let length = data.count

// JSObjectMakeTypedArrayWithBytesNoCopy is only available on macOS 10.12.
if #available(macOS 10.12, *) {
let rawPtr = UnsafeMutableBufferPointer<UInt8>.allocate(capacity: length)
_ = data.withUnsafeBytes { (dataPtr: UnsafeRawBufferPointer) in
rawPtr.initialize(from: dataPtr)
}
let deallocator: JSTypedArrayBytesDeallocator = { ptr, _ in
ptr?.deallocate()
}
let arrayBufferRef = JSObjectMakeTypedArrayWithBytesNoCopy(context.jsGlobalContextRef,
kJSTypedArrayTypeUint8Array,
rawPtr.baseAddress,
length,
deallocator,
nil,
nil)
return JSValue(jsValueRef: arrayBufferRef, in: context)
} else {
// Inefficient fallback
let getter: @convention(block) (Int) -> UInt8 = { offset in
return data[offset]

}
context.setObject(getter, forKeyedSubscript: "__iina_data_getter" as NSString)

let array = context.evaluateScript("""
Uint8Array.from(function* () {
for (let i = 0; i < \(length); i++) {
yield __iina_data_getter(i);
}
}())
""")

context.setObject(nil, forKeyedSubscript: "__iina_data_getter" as NSString)
return array

}
}
}
220 changes: 220 additions & 0 deletions iina/JavascriptAPIWebSocket.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
//
// JavascriptAPIWebSocket.swift
// iina
//
// Created by Hechen Li on 8/8/23.
// Copyright © 2023 lhc. All rights reserved.
//

import Foundation
import JavaScriptCore
import Network

@objc protocol JavascriptAPIWebSocketControllerExportable: JSExport {
func createServer(_ options: [String: Any])
func startServer()
func onStateUpdate(_ handler: JSValue)
func onMessage(_ handler: JSValue)
func onNewConnection(_ handler: JSValue)
func onConnectionStateUpdate(_ handler: JSValue)
func sendText( _ conn: String, _ string: String) -> JSValue
}

@available(macOS 10.15, *)
class JavascriptAPIWebSocketController: JavascriptAPI, JavascriptAPIWebSocketControllerExportable {
var server: WebSocketServer?
var stateHandler: JSManagedValue?
var messageHandler: JSManagedValue?
var newConnHandler: JSManagedValue?
var connStateHandler: JSManagedValue?

func createServer(_ options: [String : Any]) {
if let previousServer = server {
previousServer.listener.cancel()
self.server = nil
}
guard let port = options["port"] as? UInt16 else {
throwError(withMessage: "ws.createServer: port not specified")
return
}
server = WebSocketServer(port: port, label: "\(pluginInstance.plugin.identifier).ws")
// The server should be created without any issue at this step,
// but errors may occur if we add TLS support in the future.
if server == nil {
throwError(withMessage: "ws.createServer: server cannot be created.")
return
}
server?.delegate = self
return
}

func startServer() {
guard let server = server else {
throwError(withMessage: "ws.startServer: server not created")
return
}
guard server.listener.state == .setup else {
throwError(withMessage: "ws.startServer: server is not in ready state")
return
}
server.start()
}

func onMessage(_ handler: JSValue) {
setHandler(handler, field: \Self.messageHandler)
}

func onStateUpdate(_ handler: JSValue) {
setHandler(handler, field: \Self.stateHandler)
}

func onNewConnection(_ handler: JSValue) {
setHandler(handler, field: \Self.newConnHandler)
}

func onConnectionStateUpdate(_ handler: JSValue) {
setHandler(handler, field: \Self.connStateHandler)
}

private func setHandler(_ handler: JSValue, field: ReferenceWritableKeyPath<JavascriptAPIWebSocketController, JSManagedValue?>) {
func removePreviousHandler() {
self[keyPath: field] = nil
JSContext.current()!.virtualMachine.removeManagedReference(self[keyPath: field], withOwner: self)
}
if handler.isNull || handler.isUndefined || self[keyPath: field] != nil {
removePreviousHandler()
return
}
guard handler.isObject else {
throwError(withMessage: "ws.on: the handler is not an object")
return
}
self[keyPath: field] = JSManagedValue(value: handler)
JSContext.current()!.virtualMachine.addManagedReference(self[keyPath: field], withOwner: self)
}

func sendText(_ conn: String, _ string: String) -> JSValue {
let data = string.data(using: .utf8)!

return createPromise { [unowned self] resolve, reject in
guard let server = self.server else {
reject.call(withArguments: ["server does not exist"])
return
}
guard let connEntry = server.connections[conn] else {
// not throwing an error hereif there's no such connection ID.
// because it's not the server's fault and we just want to "ignore the request"
resolve.call(withArguments: ["no_connection"])
return
}
do {
try server.send(data: data, to: connEntry, callback: { error in
if let error = error {
reject.call(withArguments: [error.toDict()])
} else {
resolve.call(withArguments: ["success"])
}
})
} catch (let error) {
reject.call(withArguments: [error.localizedDescription])
}
}
}
}


@available(macOS 10.15, *)
extension JavascriptAPIWebSocketController: WebSocketServerDelegate {
func stateUpdated(_ state: NWListener.State) {
guard let handler = stateHandler?.value else { return }

switch state {
case .setup:
handler.call(withArguments: ["setup"])
case .waiting(let nWError):
handler.call(withArguments: ["waiting", nWError.toDict()])
case .ready:
handler.call(withArguments: ["ready"])
case .failed(let nWError):
handler.call(withArguments: ["failed", nWError.toDict()])
case .cancelled:
handler.call(withArguments: ["cancelled"])
@unknown default:
handler.call(withArguments: ["\(state)"])
}
}

func newConnection(_ conn: NWConnection, connID: String) {
guard let handler = newConnHandler?.value else { return }
handler.call(withArguments: [
connID,
// may add more useful information in the future
[
"path": conn.currentPath?.remoteEndpoint?.debugDescription
] as [String: Any?]
])
}

func connection(_ conn: String, stateUpdated state: NWConnection.State) {
guard let handler = connStateHandler?.value else { return }

switch state {
case .setup:
handler.call(withArguments: [conn, "setup"])
case .waiting(let nWError):
handler.call(withArguments: [conn, "waiting", nWError.toDict()])
case .preparing:
handler.call(withArguments: [conn, "preparing"])
case .ready:
handler.call(withArguments: [conn, "ready"])
case .failed(let nWError):
handler.call(withArguments: [conn, "failed", nWError.toDict()])
case .cancelled:
handler.call(withArguments: [conn, "cancelled"])
@unknown default:
handler.call(withArguments: [conn, "\(state)"])
}
}

func connection(_ conn: String, receivedData data: Data, context: NWConnection.ContentContext) {
guard let handler = self.messageHandler?.value else { return }

let wsMessage = WSMessage(data: data)
handler.call(withArguments: [conn, wsMessage])
}
}


@objc fileprivate protocol WSMessageExportable: JSExport {
func text() -> String?
func data() -> JSValue?
}


/// Represents a WebSocket message, passed to JavaScript environment. Do not decode the message right away
/// since we don't know whether the JavaScript code need text or binary data, and creating UInt8Array can be expensive
@objc fileprivate class WSMessage: NSObject, WSMessageExportable {
let dataObject: Data

init(data: Data) {
self.dataObject = data
}

func text() -> String? {
return String(data: dataObject, encoding: .utf8)
}

func data() -> JSValue? {
return createUInt8Array(fromData: dataObject)
}
}

@available(macOS 10.15, *)
fileprivate extension Error where Self : CustomDebugStringConvertible {
func toDict() -> [String: Any] {
return [
"description": self.debugDescription,
"message": self.localizedDescription,
]
}
}
4 changes: 4 additions & 0 deletions iina/JavascriptPluginInstance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ class JavascriptPluginInstance {
apis["input"] = JavascriptAPIInput(context: ctx, pluginInstance: self)
}

if #available(macOS 10.15, *) {
apis["ws"] = JavascriptAPIWebSocketController(context: ctx, pluginInstance: self)
}

if player == nil {
// it's a global instance
apis["global"] = JavascriptAPIGlobalController(context: ctx, pluginInstance: self)
Expand Down