528 lines
18 KiB
Swift
528 lines
18 KiB
Swift
import Foundation
|
|
|
|
/**
|
|
flash.net.Responder for Swift
|
|
*/
|
|
open class Responder: NSObject {
|
|
|
|
public typealias Handler = (_ data:[Any?]) -> Void
|
|
|
|
private var result:Handler
|
|
private var status:Handler?
|
|
|
|
public init(result:@escaping Handler, status:Handler? = nil) {
|
|
self.result = result
|
|
self.status = status
|
|
}
|
|
|
|
final func on(result:[Any?]) {
|
|
self.result(result)
|
|
}
|
|
|
|
final func on(status:[Any?]) {
|
|
self.status?(status)
|
|
self.status = nil
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
/**
|
|
flash.net.NetConnection for Swift
|
|
*/
|
|
open class RTMPConnection: EventDispatcher {
|
|
static public let defaultWindowSizeS:Int64 = 250000
|
|
static public let supportedProtocols:[String] = ["rtmp", "rtmps", "rtmpt", "rtmpts"]
|
|
static public let defaultPort:Int = 1935
|
|
static public let defaultFlashVer:String = "FMLE/3.0 (compatible; FMSc/1.0)"
|
|
static public let defaultChunkSizeS:Int = 1024 * 8
|
|
static public let defaultCapabilities:Int = 239
|
|
static public let defaultObjectEncoding:UInt8 = 0x00
|
|
|
|
/**
|
|
NetStatusEvent#info.code for NetConnection
|
|
*/
|
|
public enum Code: String {
|
|
case callBadVersion = "NetConnection.Call.BadVersion"
|
|
case callFailed = "NetConnection.Call.Failed"
|
|
case callProhibited = "NetConnection.Call.Prohibited"
|
|
case connectAppshutdown = "NetConnection.Connect.AppShutdown"
|
|
case connectClosed = "NetConnection.Connect.Closed"
|
|
case connectFailed = "NetConnection.Connect.Failed"
|
|
case connectIdleTimeOut = "NetConnection.Connect.IdleTimeOut"
|
|
case connectInvalidApp = "NetConnection.Connect.InvalidApp"
|
|
case connectNetworkChange = "NetConnection.Connect.NetworkChange"
|
|
case connectRejected = "NetConnection.Connect.Rejected"
|
|
case connectSuccess = "NetConnection.Connect.Success"
|
|
|
|
public var level:String {
|
|
switch self {
|
|
case .callBadVersion:
|
|
return "error"
|
|
case .callFailed:
|
|
return "error"
|
|
case .callProhibited:
|
|
return "error"
|
|
case .connectAppshutdown:
|
|
return "status"
|
|
case .connectClosed:
|
|
return "status"
|
|
case .connectFailed:
|
|
return "error"
|
|
case .connectIdleTimeOut:
|
|
return "status"
|
|
case .connectInvalidApp:
|
|
return "error"
|
|
case .connectNetworkChange:
|
|
return "status"
|
|
case .connectRejected:
|
|
return "status"
|
|
case .connectSuccess:
|
|
return "status"
|
|
}
|
|
}
|
|
|
|
func data(_ description:String) -> ASObject {
|
|
return [
|
|
"code": rawValue,
|
|
"level": level,
|
|
"description": description,
|
|
]
|
|
}
|
|
}
|
|
|
|
struct Support {
|
|
enum Video: UInt16 {
|
|
case unused = 0x0001
|
|
case jpeg = 0x0002
|
|
case sorenson = 0x0004
|
|
case homebrew = 0x0008
|
|
case vp6 = 0x0010
|
|
case vp6Alpha = 0x0020
|
|
case homebrewv = 0x0040
|
|
case h264 = 0x0080
|
|
case all = 0x00FF
|
|
}
|
|
|
|
enum Sound: UInt16 {
|
|
case none = 0x0001
|
|
case adpcm = 0x0002
|
|
case mp3 = 0x0004
|
|
case intel = 0x0008
|
|
case unused = 0x0010
|
|
case nelly8 = 0x0020
|
|
case nelly = 0x0040
|
|
case g711A = 0x0080
|
|
case g711U = 0x0100
|
|
case nelly16 = 0x0200
|
|
case aac = 0x0400
|
|
case speex = 0x0800
|
|
case all = 0x0FFF
|
|
}
|
|
}
|
|
|
|
|
|
enum VideoFunction: UInt8 {
|
|
case clientSeek = 1
|
|
}
|
|
|
|
private static func createSanJoseAuthCommand(_ url:URL, description:String) -> String {
|
|
var command:String = url.absoluteString
|
|
|
|
guard let index:String.CharacterView.Index = description.characters.index(of: "?") else {
|
|
return command
|
|
}
|
|
|
|
let query:String = description.substring(from: description.characters.index(index, offsetBy: 1))
|
|
let challenge:String = String(format: "%08x", arc4random())
|
|
let dictionary:[String:String] = URL(string: "http://localhost?" + query)!.dictionaryFromQuery()
|
|
|
|
var response:String = MD5.base64("\(url.user!)\(dictionary["salt"]!)\(url.password!)")
|
|
if let opaque:String = dictionary["opaque"] {
|
|
command += "&opaque=\(opaque)"
|
|
response += opaque
|
|
} else if let challenge:String = dictionary["challenge"] {
|
|
response += challenge
|
|
}
|
|
|
|
response = MD5.base64("\(response)\(challenge)")
|
|
command += "&challenge=\(challenge)&response=\(response)"
|
|
|
|
return command
|
|
}
|
|
|
|
/// The URL of .swf.
|
|
open var swfUrl:String? = nil
|
|
/// The URL of an HTTP referer.
|
|
open var pageUrl:String? = nil
|
|
/// The time to wait for TCP/IP Handshake done.
|
|
open var timeout:Int64 {
|
|
get { return socket.timeout }
|
|
set { socket.timeout = newValue }
|
|
}
|
|
/// The name of application.
|
|
open var flashVer:String = RTMPConnection.defaultFlashVer
|
|
/// The outgoing RTMPChunkSize.
|
|
open var chunkSize:Int = RTMPConnection.defaultChunkSizeS
|
|
/// The URI passed to the RTMPConnection.connect() method.
|
|
open private(set) var uri:URL? = nil
|
|
/// This instance connected to server(true) or not(false).
|
|
open private(set) var connected:Bool = false
|
|
/// The object encoding for this RTMPConnection instance.
|
|
open var objectEncoding:UInt8 = RTMPConnection.defaultObjectEncoding
|
|
/// The statistics of total incoming bytes.
|
|
open var totalBytesIn:Int64 {
|
|
return socket.totalBytesIn
|
|
}
|
|
/// The statistics of total outgoing bytes.
|
|
open var totalBytesOut:Int64 {
|
|
return socket.totalBytesOut
|
|
}
|
|
/// The statistics of total RTMPStream counts.
|
|
open var totalStreamsCount:Int {
|
|
return streams.count
|
|
}
|
|
/// The statistics of outgoing queue bytes per second.
|
|
@objc dynamic open private(set) var previousQueueBytesOut:[Int64] = []
|
|
/// The statistics of incoming bytes per second.
|
|
@objc dynamic open private(set) var currentBytesInPerSecond:Int32 = 0
|
|
/// The statistics of outgoing bytes per second.
|
|
@objc dynamic open private(set) var currentBytesOutPerSecond:Int32 = 0
|
|
|
|
var socket:RTMPSocketCompatible!
|
|
var streams:[UInt32: RTMPStream] = [:]
|
|
var sequence:Int64 = 0
|
|
var bandWidth:UInt32 = 0
|
|
var streamsmap:[UInt16: UInt32] = [:]
|
|
var operations:[Int: Responder] = [:]
|
|
var windowSizeC:Int64 = RTMPConnection.defaultWindowSizeS {
|
|
didSet {
|
|
guard socket.connected else {
|
|
return
|
|
}
|
|
socket.doOutput(chunk: RTMPChunk(
|
|
type: .zero,
|
|
streamId: RTMPChunk.StreamID.control.rawValue,
|
|
message: RTMPWindowAcknowledgementSizeMessage(UInt32(windowSizeC))
|
|
), locked: nil)
|
|
}
|
|
}
|
|
var windowSizeS:Int64 = RTMPConnection.defaultWindowSizeS
|
|
var currentTransactionId:Int = 0
|
|
|
|
private var timer:Timer? {
|
|
didSet {
|
|
if let oldValue:Timer = oldValue {
|
|
oldValue.invalidate()
|
|
}
|
|
if let timer:Timer = timer {
|
|
RunLoop.main.add(timer, forMode: .commonModes)
|
|
}
|
|
}
|
|
}
|
|
private var messages:[UInt16:RTMPMessage] = [:]
|
|
private var arguments:[Any?] = []
|
|
private var currentChunk:RTMPChunk? = nil
|
|
private var measureInterval:Int = 3
|
|
private var fragmentedChunks:[UInt16:RTMPChunk] = [:]
|
|
private var previousTotalBytesIn:Int64 = 0
|
|
private var previousTotalBytesOut:Int64 = 0
|
|
|
|
override public init() {
|
|
super.init()
|
|
addEventListener(Event.RTMP_STATUS, selector: #selector(RTMPConnection.on(status:)))
|
|
}
|
|
|
|
deinit {
|
|
timer = nil
|
|
streams.removeAll()
|
|
removeEventListener(Event.RTMP_STATUS, selector: #selector(RTMPConnection.on(status:)))
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
open func connect(_ command:String) {
|
|
connect(command, arguments: nil)
|
|
}
|
|
|
|
open func call(_ commandName:String, responder:Responder?, arguments:Any?...) {
|
|
guard connected else {
|
|
return
|
|
}
|
|
currentTransactionId += 1
|
|
let message:RTMPCommandMessage = RTMPCommandMessage(
|
|
streamId: 0,
|
|
transactionId: currentTransactionId,
|
|
objectEncoding: objectEncoding,
|
|
commandName: commandName,
|
|
commandObject: nil,
|
|
arguments: arguments
|
|
)
|
|
if (responder != nil) {
|
|
operations[message.transactionId] = responder
|
|
}
|
|
socket.doOutput(chunk: RTMPChunk(message: message), locked: nil)
|
|
}
|
|
|
|
open func connect(_ command:String, arguments:Any?...) {
|
|
guard let uri:URL = URL(string: command), let scheme:String = uri.scheme, !connected && RTMPConnection.supportedProtocols.contains(scheme) else {
|
|
return
|
|
}
|
|
self.uri = uri
|
|
self.arguments = arguments
|
|
timer = Timer(timeInterval: 1.0, target: self, selector: #selector(RTMPConnection.on(timer:)), userInfo: nil, repeats: true)
|
|
switch scheme {
|
|
case "rtmpt", "rtmpts":
|
|
socket = socket is RTMPTSocket ? socket : RTMPTSocket()
|
|
default:
|
|
socket = socket is RTMPSocket ? socket : RTMPSocket()
|
|
}
|
|
socket.delegate = self
|
|
socket.securityLevel = uri.scheme == "rtmps" || uri.scheme == "rtmpts" ? .negotiatedSSL : .none
|
|
socket.connect(withName: uri.host!, port: uri.port ?? RTMPConnection.defaultPort)
|
|
}
|
|
|
|
open func close() {
|
|
close(isDisconnected: false)
|
|
}
|
|
|
|
func close(isDisconnected:Bool) {
|
|
guard connected || isDisconnected else {
|
|
return
|
|
}
|
|
if (!isDisconnected) {
|
|
uri = nil
|
|
}
|
|
for (id, stream) in streams {
|
|
stream.close()
|
|
streams.removeValue(forKey: id)
|
|
}
|
|
socket.close(isDisconnected: false)
|
|
timer = nil
|
|
}
|
|
|
|
func createStream(_ stream: RTMPStream) {
|
|
let responder:Responder = Responder(result: { (data) -> Void in
|
|
guard let id:Double = data[0] as? Double else {
|
|
return
|
|
}
|
|
stream.id = UInt32(id)
|
|
self.streams[stream.id] = stream
|
|
stream.readyState = .open
|
|
})
|
|
call("createStream", responder: responder)
|
|
}
|
|
|
|
@objc func on(status:Notification) {
|
|
let e:Event = Event.from(status)
|
|
|
|
guard
|
|
let data:ASObject = e.data as? ASObject,
|
|
let code:String = data["code"] as? String else {
|
|
return
|
|
}
|
|
|
|
switch code {
|
|
case Code.connectSuccess.rawValue:
|
|
connected = true
|
|
socket.chunkSizeS = chunkSize
|
|
socket.doOutput(chunk: RTMPChunk(
|
|
type: .zero,
|
|
streamId: RTMPChunk.StreamID.control.rawValue,
|
|
message: RTMPSetChunkSizeMessage(UInt32(socket.chunkSizeS))
|
|
), locked: nil)
|
|
case Code.connectRejected.rawValue:
|
|
guard
|
|
let uri:URL = uri,
|
|
let user:String = uri.user,
|
|
let password:String = uri.password,
|
|
let description:String = data["description"] as? String else {
|
|
break
|
|
}
|
|
socket.deinitConnection(isDisconnected: false)
|
|
switch true {
|
|
case description.contains("reason=nosuchuser"):
|
|
break
|
|
case description.contains("reason=authfailed"):
|
|
break
|
|
case description.contains("reason=needauth"):
|
|
let command:String = RTMPConnection.createSanJoseAuthCommand(uri, description: description)
|
|
connect(command, arguments: arguments)
|
|
case description.contains("authmod=adobe"):
|
|
if (user == "" || password == "") {
|
|
close(isDisconnected: true)
|
|
break
|
|
}
|
|
let query:String = uri.query ?? ""
|
|
let command:String = uri.absoluteString + (query == "" ? "?" : "&") + "authmod=adobe&user=\(user)"
|
|
connect(command, arguments: arguments)
|
|
default:
|
|
break
|
|
}
|
|
case Code.connectClosed.rawValue:
|
|
if let description:String = data["description"] as? String {
|
|
logger.warn(description)
|
|
}
|
|
close(isDisconnected: true)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func createConnectionChunk() -> RTMPChunk? {
|
|
guard let uri:URL = uri else {
|
|
return nil
|
|
}
|
|
|
|
var app:String = uri.path.substring(from: uri.path.characters.index(uri.path.startIndex, offsetBy: 1))
|
|
if let query:String = uri.query {
|
|
app += "?" + query
|
|
}
|
|
|
|
currentTransactionId += 1
|
|
|
|
let message:RTMPCommandMessage = RTMPCommandMessage(
|
|
streamId: 0,
|
|
transactionId: currentTransactionId,
|
|
// "connect" must be a objectEncoding = 0
|
|
objectEncoding: 0,
|
|
commandName: "connect",
|
|
commandObject: [
|
|
"app": app,
|
|
"flashVer": flashVer,
|
|
"swfUrl": swfUrl,
|
|
"tcUrl": uri.absoluteWithoutAuthenticationString,
|
|
"fpad": false,
|
|
"capabilities": RTMPConnection.defaultCapabilities,
|
|
"audioCodecs": Support.Sound.aac.rawValue,
|
|
"videoCodecs": Support.Video.h264.rawValue,
|
|
"videoFunction": VideoFunction.clientSeek.rawValue,
|
|
"pageUrl": pageUrl,
|
|
"objectEncoding": objectEncoding
|
|
],
|
|
arguments: arguments
|
|
)
|
|
|
|
return RTMPChunk(message: message)
|
|
}
|
|
|
|
@objc private func on(timer:Timer) {
|
|
let totalBytesIn:Int64 = self.totalBytesIn
|
|
let totalBytesOut:Int64 = self.totalBytesOut
|
|
currentBytesInPerSecond = Int32(totalBytesIn - previousTotalBytesIn)
|
|
currentBytesOutPerSecond = Int32(totalBytesOut - previousTotalBytesOut)
|
|
previousTotalBytesIn = totalBytesIn
|
|
previousTotalBytesOut = totalBytesOut
|
|
previousQueueBytesOut.append(socket.queueBytesOut)
|
|
for (_, stream) in streams {
|
|
stream.on(timer: timer)
|
|
}
|
|
if (measureInterval <= previousQueueBytesOut.count) {
|
|
var count:Int = 0
|
|
for i in 0..<previousQueueBytesOut.count - 1 {
|
|
if (previousQueueBytesOut[i] < previousQueueBytesOut[i + 1]) {
|
|
count += 1
|
|
}
|
|
}
|
|
if (count == measureInterval - 1) {
|
|
for (_, stream) in streams {
|
|
stream.qosDelegate?.didPublishInsufficientBW(stream, withConnection: self)
|
|
}
|
|
}
|
|
previousQueueBytesOut.removeFirst()
|
|
}
|
|
}
|
|
}
|
|
|
|
extension RTMPConnection: RTMPSocketDelegate {
|
|
// MARK: RTMPSocketDelegate
|
|
func didSetReadyState(_ readyState: RTMPSocket.ReadyState) {
|
|
switch readyState {
|
|
case .handshakeDone:
|
|
guard let chunk:RTMPChunk = createConnectionChunk() else {
|
|
close()
|
|
break
|
|
}
|
|
socket.doOutput(chunk: chunk, locked: nil)
|
|
case .closed:
|
|
connected = false
|
|
sequence = 0
|
|
currentChunk = nil
|
|
currentTransactionId = 0
|
|
previousTotalBytesIn = 0
|
|
previousTotalBytesOut = 0
|
|
messages.removeAll()
|
|
operations.removeAll()
|
|
fragmentedChunks.removeAll()
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
func didSetTotalBytesIn(_ totalBytesIn: Int64) {
|
|
guard windowSizeS * (sequence + 1) <= totalBytesIn else {
|
|
return
|
|
}
|
|
socket.doOutput(chunk: RTMPChunk(
|
|
type: sequence == 0 ? .zero : .one,
|
|
streamId: RTMPChunk.StreamID.control.rawValue,
|
|
message: RTMPAcknowledgementMessage(UInt32(totalBytesIn))
|
|
), locked: nil)
|
|
sequence += 1
|
|
}
|
|
|
|
func listen(_ data:Data) {
|
|
guard let chunk:RTMPChunk = currentChunk ?? RTMPChunk(data, size: socket.chunkSizeC) else {
|
|
socket.inputBuffer.append(data)
|
|
return
|
|
}
|
|
|
|
var position:Int = chunk.data.count
|
|
if (4 <= chunk.data.count) && (chunk.data[1] == 0xFF) && (chunk.data[2] == 0xFF) && (chunk.data[3] == 0xFF) {
|
|
position += 4
|
|
}
|
|
|
|
if (currentChunk != nil) {
|
|
position = chunk.append(data, size: socket.chunkSizeC)
|
|
}
|
|
if (chunk.type == .two) {
|
|
position = chunk.append(data, message: messages[chunk.streamId])
|
|
}
|
|
|
|
if let message:RTMPMessage = chunk.message, chunk.ready {
|
|
if (logger.isEnabledFor(level: .trace)) {
|
|
logger.trace(chunk.description)
|
|
}
|
|
switch chunk.type {
|
|
case .zero:
|
|
streamsmap[chunk.streamId] = message.streamId
|
|
case .one:
|
|
if let streamId = streamsmap[chunk.streamId] {
|
|
message.streamId = streamId
|
|
}
|
|
case .two:
|
|
break
|
|
case .three:
|
|
break
|
|
}
|
|
message.execute(self)
|
|
currentChunk = nil
|
|
messages[chunk.streamId] = message
|
|
if (0 < position && position < data.count) {
|
|
listen(data.advanced(by: position))
|
|
}
|
|
return
|
|
}
|
|
|
|
if (chunk.fragmented) {
|
|
fragmentedChunks[chunk.streamId] = chunk
|
|
currentChunk = nil
|
|
} else {
|
|
currentChunk = chunk.type == .three ? fragmentedChunks[chunk.streamId] : chunk
|
|
fragmentedChunks.removeValue(forKey: chunk.streamId)
|
|
}
|
|
|
|
if (0 < position && position < data.count) {
|
|
listen(data.advanced(by: position))
|
|
}
|
|
}
|
|
}
|