From 033cb51f4da6e35e2d50bd129e6ef3f95c377f15 Mon Sep 17 00:00:00 2001 From: Kaito Udagawa Date: Thu, 25 Jul 2024 03:14:30 +0900 Subject: [PATCH] Fix preview (#127) * Fix preview * Format * Fix * Fix * Fix * Fix * Delete swiftlint.yaml --- .github/scripts/check-swift-format.sh | 1 + .github/workflows/swiftlint.yaml | 24 - .../MasterPlaylistWriter.swift | 2 +- Packages/HLSServer/.gitignore | 8 + Packages/HLSServer/Package.swift | 32 + .../Sources/HLSServer/HLSServer.swift | 66 +++ .../Sources/HLSServer/HTTPHandler.swift | 548 ++++++++++++++++++ .../Tests/HLSServerTests/HLSServerTests.swift | 37 ++ .../xcshareddata/swiftpm/Package.resolved | 47 +- Recoreon/Models/ScreenRecordEntry.swift | 1 + .../Recoreon20240724T063654-app.m3u8 | 4 +- .../Recoreon20240724T063654.m3u8 | 2 +- .../Recoreon20240724T063710.m3u8 | 2 +- .../ScreenRecordPreviewView.swift | 80 ++- RecoreonCommon/RecoreonPathService.swift | 4 + project.yml | 9 + 16 files changed, 809 insertions(+), 58 deletions(-) delete mode 100644 .github/workflows/swiftlint.yaml create mode 100644 Packages/HLSServer/.gitignore create mode 100644 Packages/HLSServer/Package.swift create mode 100644 Packages/HLSServer/Sources/HLSServer/HLSServer.swift create mode 100644 Packages/HLSServer/Sources/HLSServer/HTTPHandler.swift create mode 100644 Packages/HLSServer/Tests/HLSServerTests/HLSServerTests.swift diff --git a/.github/scripts/check-swift-format.sh b/.github/scripts/check-swift-format.sh index 76bb4ae..a3a09b5 100755 --- a/.github/scripts/check-swift-format.sh +++ b/.github/scripts/check-swift-format.sh @@ -1,5 +1,6 @@ #!/bin/bash dirs=( + Packages FragmentedRecordWriter FragmentedRecordWriterTests Recoreon diff --git a/.github/workflows/swiftlint.yaml b/.github/workflows/swiftlint.yaml deleted file mode 100644 index 666c0aa..0000000 --- a/.github/workflows/swiftlint.yaml +++ /dev/null @@ -1,24 +0,0 @@ ---- -name: "swiftlint" - -"on": - push: - branches: - - "main" - pull_request: - branches: - - "main" - -jobs: - SwiftLint: - runs-on: "macos-latest" - - steps: - - name: "Checkout" - uses: "actions/checkout@v4" - - - name: "Install SwiftLint" - run: "brew install swiftlint" - - - name: "Run SwiftLint" - run: "swiftlint" diff --git a/FragmentedRecordWriter/MasterPlaylistWriter.swift b/FragmentedRecordWriter/MasterPlaylistWriter.swift index cc56d6a..61265c9 100644 --- a/FragmentedRecordWriter/MasterPlaylistWriter.swift +++ b/FragmentedRecordWriter/MasterPlaylistWriter.swift @@ -26,7 +26,7 @@ public struct MasterPlaylistWriter { \(videoIndexURL.lastPathComponent) #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="App",DEFAULT=YES,URI="\(appAudioIndexURL.lastPathComponent)" - #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="Mic",DEFAULT=NO,URI="\(micAudioIndexURL.lastPathComponent)" + #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="Mic",DEFAULT=NO,URI="\(micAudioIndexURL.lastPathComponent)"\n """ try masterPlaylistContent.write(to: masterPlaylistURL, atomically: true, encoding: .utf8) diff --git a/Packages/HLSServer/.gitignore b/Packages/HLSServer/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/Packages/HLSServer/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Packages/HLSServer/Package.swift b/Packages/HLSServer/Package.swift new file mode 100644 index 0000000..4cb5655 --- /dev/null +++ b/Packages/HLSServer/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 5.10 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "HLSServer", + platforms: [.iOS(.v17)], + products: [ + .library( + name: "HLSServer", + targets: ["HLSServer"] + ) + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-nio.git", exact: "2.68.0") + ], + targets: [ + .target( + name: "HLSServer", + dependencies: [ + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOHTTP1", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + ] + ), + .testTarget( + name: "HLSServerTests", + dependencies: ["HLSServer"] + ), + ] +) diff --git a/Packages/HLSServer/Sources/HLSServer/HLSServer.swift b/Packages/HLSServer/Sources/HLSServer/HLSServer.swift new file mode 100644 index 0000000..e45f16c --- /dev/null +++ b/Packages/HLSServer/Sources/HLSServer/HLSServer.swift @@ -0,0 +1,66 @@ +// Derived from https://github.com/apple/swift-nio/blob/main/Sources/NIOHTTP1Server/main.swift + +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2017-2021 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation +import NIOCore +import NIOHTTP1 +import NIOPosix + +enum HLSServerError: Error { + case localAddressPortNotAvailable +} + +public struct HLSServer { + public let port: Int + + private let channel: Channel + + public init(htdocs: String, host: String, port: Int = 0) throws { + let fileIO = NonBlockingFileIO(threadPool: .singleton) + + func childChannelInitializer(channel: Channel) -> EventLoopFuture { + channel.pipeline.configureHTTPServerPipeline(withErrorHandling: true).flatMap { + channel.pipeline.addHandler(HTTPHandler(fileIO: fileIO, htdocsPath: htdocs)) + } + } + + let socketBootstrap = ServerBootstrap(group: MultiThreadedEventLoopGroup.singleton) + // Specify backlog and enable SO_REUSEADDR for the server itself + .serverChannelOption(ChannelOptions.backlog, value: 256) + .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + + // Set the handlers that are applied to the accepted Channels + .childChannelInitializer(childChannelInitializer(channel:)) + + // Enable SO_REUSEADDR for the accepted Channels + .childChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .childChannelOption(ChannelOptions.maxMessagesPerRead, value: 1) + .childChannelOption(ChannelOptions.allowRemoteHalfClosure, value: true) + + channel = try socketBootstrap.bind(host: host, port: port).wait() + + guard let port = channel.localAddress?.port else { + throw HLSServerError.localAddressPortNotAvailable + } + + self.port = port + } + + public func close() async throws { + try await channel.eventLoop.shutdownGracefully() + } + +} diff --git a/Packages/HLSServer/Sources/HLSServer/HTTPHandler.swift b/Packages/HLSServer/Sources/HLSServer/HTTPHandler.swift new file mode 100644 index 0000000..23803ef --- /dev/null +++ b/Packages/HLSServer/Sources/HLSServer/HTTPHandler.swift @@ -0,0 +1,548 @@ +// Derived from https://github.com/apple/swift-nio/blob/main/Sources/NIOHTTP1Server/main.swift + +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2017-2021 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import NIOCore +import NIOHTTP1 +import NIOPosix + +extension String { + func chopPrefix(_ prefix: String) -> String? { + if self.unicodeScalars.starts(with: prefix.unicodeScalars) { + return String(self[self.index(self.startIndex, offsetBy: prefix.count)...]) + } else { + return nil + } + } + + func containsDotDot() -> Bool { + for idx in self.indices { + if self[idx] == "." && idx < self.index(before: self.endIndex) + && self[self.index(after: idx)] == "." + { + return true + } + } + return false + } +} + +private func httpResponseHead( + request: HTTPRequestHead, + status: HTTPResponseStatus, + headers: HTTPHeaders = HTTPHeaders() +) -> HTTPResponseHead { + var head = HTTPResponseHead(version: request.version, status: status, headers: headers) + let connectionHeaders: [String] = head.headers[canonicalForm: "connection"].map { + $0.lowercased() + } + + if !connectionHeaders.contains("keep-alive") && !connectionHeaders.contains("close") { + // the user hasn't pre-set either 'keep-alive' or 'close', so we might need to add headers + + switch (request.isKeepAlive, request.version.major, request.version.minor) { + case (true, 1, 0): + // HTTP/1.0 and the request has 'Connection: keep-alive', we should mirror that + head.headers.add(name: "Connection", value: "keep-alive") + case (false, 1, let n) where n >= 1: + // HTTP/1.1 (or treated as such) and the request has 'Connection: close', we should mirror that + head.headers.add(name: "Connection", value: "close") + default: + // we should match the default or are dealing with some HTTP that we don't support, let's leave as is + () + } + } + return head +} + +public final class HTTPHandler: ChannelInboundHandler { + private enum FileIOMethod { + case sendfile + case nonblockingFileIO + } + public typealias InboundIn = HTTPServerRequestPart + public typealias OutboundOut = HTTPServerResponsePart + + private enum State { + case idle + case waitingForRequestBody + case sendingResponse + + mutating func requestReceived() { + precondition(self == .idle, "Invalid state for request received: \(self)") + self = .waitingForRequestBody + } + + mutating func requestComplete() { + precondition(self == .waitingForRequestBody, "Invalid state for request complete: \(self)") + self = .sendingResponse + } + + mutating func responseComplete() { + precondition(self == .sendingResponse, "Invalid state for response complete: \(self)") + self = .idle + } + } + + private var buffer: ByteBuffer! = nil + private var keepAlive = false + private var state = State.idle + private let htdocsPath: String + + private var infoSavedRequestHead: HTTPRequestHead? + private var infoSavedBodyBytes: Int = 0 + + private var continuousCount: Int = 0 + + private var handler: ((ChannelHandlerContext, HTTPServerRequestPart) -> Void)? + private var handlerFuture: EventLoopFuture? + private let fileIO: NonBlockingFileIO + private let defaultResponse = "Hello World\r\n" + + public init(fileIO: NonBlockingFileIO, htdocsPath: String) { + self.htdocsPath = htdocsPath + self.fileIO = fileIO + } + + func handleInfo(context: ChannelHandlerContext, request: HTTPServerRequestPart) { + switch request { + case .head(let request): + self.infoSavedRequestHead = request + self.infoSavedBodyBytes = 0 + self.keepAlive = request.isKeepAlive + self.state.requestReceived() + case .body(buffer: let buf): + self.infoSavedBodyBytes += buf.readableBytes + case .end: + self.state.requestComplete() + let response = """ + HTTP method: \(self.infoSavedRequestHead!.method)\r + URL: \(self.infoSavedRequestHead!.uri)\r + body length: \(self.infoSavedBodyBytes)\r + headers: \(self.infoSavedRequestHead!.headers)\r + client: \(context.remoteAddress?.description ?? "zombie")\r + IO: SwiftNIO Electric Boogaloo™️\r\n + """ + self.buffer.clear() + self.buffer.writeString(response) + var headers = HTTPHeaders() + headers.add(name: "Content-Length", value: "\(response.utf8.count)") + context.write( + self.wrapOutboundOut( + .head( + httpResponseHead(request: self.infoSavedRequestHead!, status: .ok, headers: headers)) + ), + promise: nil + ) + context.write(self.wrapOutboundOut(.body(.byteBuffer(self.buffer))), promise: nil) + self.completeResponse(context, trailers: nil, promise: nil) + } + } + + func handleEcho(context: ChannelHandlerContext, request: HTTPServerRequestPart) { + self.handleEcho(context: context, request: request, balloonInMemory: false) + } + + func handleEcho( + context: ChannelHandlerContext, request: HTTPServerRequestPart, balloonInMemory: Bool = false + ) { + switch request { + case .head(let request): + self.keepAlive = request.isKeepAlive + self.infoSavedRequestHead = request + self.state.requestReceived() + if balloonInMemory { + self.buffer.clear() + } else { + context.writeAndFlush( + self.wrapOutboundOut(.head(httpResponseHead(request: request, status: .ok))), + promise: nil + ) + } + case .body(buffer: var buf): + if balloonInMemory { + self.buffer.writeBuffer(&buf) + } else { + context.writeAndFlush(self.wrapOutboundOut(.body(.byteBuffer(buf))), promise: nil) + } + case .end: + self.state.requestComplete() + if balloonInMemory { + var headers = HTTPHeaders() + headers.add(name: "Content-Length", value: "\(self.buffer.readableBytes)") + context.write( + self.wrapOutboundOut( + .head( + httpResponseHead(request: self.infoSavedRequestHead!, status: .ok, headers: headers)) + ), + promise: nil + ) + context.write(self.wrapOutboundOut(.body(.byteBuffer(self.buffer))), promise: nil) + self.completeResponse(context, trailers: nil, promise: nil) + } else { + self.completeResponse(context, trailers: nil, promise: nil) + } + } + } + + func handleJustWrite( + context: ChannelHandlerContext, + request: HTTPServerRequestPart, + statusCode: HTTPResponseStatus = .ok, + string: String, + trailer: (String, String)? = nil, + delay: TimeAmount = .nanoseconds(0) + ) { + switch request { + case .head(let request): + self.keepAlive = request.isKeepAlive + self.state.requestReceived() + context.writeAndFlush( + self.wrapOutboundOut(.head(httpResponseHead(request: request, status: statusCode))), + promise: nil + ) + case .body(buffer: _): + () + case .end: + self.state.requestComplete() + context.eventLoop.scheduleTask(in: delay) { () -> Void in + var buf = context.channel.allocator.buffer(capacity: string.utf8.count) + buf.writeString(string) + context.writeAndFlush(self.wrapOutboundOut(.body(.byteBuffer(buf))), promise: nil) + var trailers: HTTPHeaders? = nil + if let trailer = trailer { + trailers = HTTPHeaders() + trailers?.add(name: trailer.0, value: trailer.1) + } + + self.completeResponse(context, trailers: trailers, promise: nil) + } + } + } + + func handleContinuousWrites(context: ChannelHandlerContext, request: HTTPServerRequestPart) { + switch request { + case .head(let request): + self.keepAlive = request.isKeepAlive + self.continuousCount = 0 + self.state.requestReceived() + func doNext() { + self.buffer.clear() + self.continuousCount += 1 + self.buffer.writeString("line \(self.continuousCount)\n") + context.writeAndFlush(self.wrapOutboundOut(.body(.byteBuffer(self.buffer)))).map { + context.eventLoop.scheduleTask(in: .milliseconds(400), doNext) + }.whenFailure { (_: Error) in + self.completeResponse(context, trailers: nil, promise: nil) + } + } + context.writeAndFlush( + self.wrapOutboundOut(.head(httpResponseHead(request: request, status: .ok))), + promise: nil + ) + doNext() + case .end: + self.state.requestComplete() + default: + break + } + } + + func handleMultipleWrites( + context: ChannelHandlerContext, + request: HTTPServerRequestPart, + strings: [String], + delay: TimeAmount + ) { + switch request { + case .head(let request): + self.keepAlive = request.isKeepAlive + self.continuousCount = 0 + self.state.requestReceived() + func doNext() { + self.buffer.clear() + self.buffer.writeString(strings[self.continuousCount]) + self.continuousCount += 1 + context.writeAndFlush(self.wrapOutboundOut(.body(.byteBuffer(self.buffer)))).whenSuccess { + if self.continuousCount < strings.count { + context.eventLoop.scheduleTask(in: delay, doNext) + } else { + self.completeResponse(context, trailers: nil, promise: nil) + } + } + } + context.writeAndFlush( + self.wrapOutboundOut(.head(httpResponseHead(request: request, status: .ok))), + promise: nil + ) + doNext() + case .end: + self.state.requestComplete() + default: + break + } + } + + func dynamicHandler(request reqHead: HTTPRequestHead) -> ( + (ChannelHandlerContext, HTTPServerRequestPart) -> Void + )? { + if let howLong = reqHead.uri.chopPrefix("/dynamic/write-delay/") { + return { context, req in + self.handleJustWrite( + context: context, + request: req, + string: self.defaultResponse, + delay: Int64(howLong).map { .milliseconds($0) } ?? .seconds(0) + ) + } + } + + switch reqHead.uri { + case "/dynamic/echo": + return self.handleEcho + case "/dynamic/echo_balloon": + return { self.handleEcho(context: $0, request: $1, balloonInMemory: true) } + case "/dynamic/pid": + return { context, req in + self.handleJustWrite(context: context, request: req, string: "\(getpid())") + } + case "/dynamic/write-delay": + return { context, req in + self.handleJustWrite( + context: context, + request: req, + string: self.defaultResponse, + delay: .milliseconds(100) + ) + } + case "/dynamic/info": + return self.handleInfo + case "/dynamic/trailers": + return { context, req in + self.handleJustWrite( + context: context, + request: req, + string: "\(getpid())\r\n", + trailer: ("Trailer-Key", "Trailer-Value") + ) + } + case "/dynamic/continuous": + return self.handleContinuousWrites + case "/dynamic/count-to-ten": + return { + self.handleMultipleWrites( + context: $0, + request: $1, + strings: (1...10).map { "\($0)" }, + delay: .milliseconds(100) + ) + } + case "/dynamic/client-ip": + return { context, req in + self.handleJustWrite( + context: context, + request: req, + string: "\(context.remoteAddress.debugDescription)" + ) + } + default: + return { context, req in + self.handleJustWrite( + context: context, request: req, statusCode: .notFound, string: "not found") + } + } + } + + private func handleFile( + context: ChannelHandlerContext, + request: HTTPServerRequestPart, + ioMethod: FileIOMethod, + path: String + ) { + self.buffer.clear() + + func sendErrorResponse(request: HTTPRequestHead, _ error: Error) { + var body = context.channel.allocator.buffer(capacity: 128) + let response = { () -> HTTPResponseHead in + switch error { + case let e as IOError where e.errnoCode == ENOENT: + body.writeStaticString("IOError (not found)\r\n") + return httpResponseHead(request: request, status: .notFound) + case let e as IOError: + body.writeStaticString("IOError (other)\r\n") + body.writeString(e.description) + body.writeStaticString("\r\n") + return httpResponseHead(request: request, status: .notFound) + default: + body.writeString("\(type(of: error)) error\r\n") + return httpResponseHead(request: request, status: .internalServerError) + } + }() + body.writeString("\(error)") + body.writeStaticString("\r\n") + context.write(self.wrapOutboundOut(.head(response)), promise: nil) + context.write(self.wrapOutboundOut(.body(.byteBuffer(body))), promise: nil) + context.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil) + context.channel.close(promise: nil) + } + + func responseHead(request: HTTPRequestHead, fileRegion region: FileRegion) -> HTTPResponseHead { + var response = httpResponseHead(request: request, status: .ok) + response.headers.add(name: "Content-Length", value: "\(region.endIndex)") + response.headers.add(name: "Content-Type", value: "text/plain; charset=utf-8") + return response + } + + switch request { + case .head(let request): + self.keepAlive = request.isKeepAlive + self.state.requestReceived() + guard !request.uri.containsDotDot() else { + let response = httpResponseHead(request: request, status: .forbidden) + context.write(self.wrapOutboundOut(.head(response)), promise: nil) + self.completeResponse(context, trailers: nil, promise: nil) + return + } + let path = self.htdocsPath + "/" + path + let fileHandleAndRegion = self.fileIO.openFile(path: path, eventLoop: context.eventLoop) + fileHandleAndRegion.whenFailure { + sendErrorResponse(request: request, $0) + } + fileHandleAndRegion.whenSuccess { (file, region) in + switch ioMethod { + case .nonblockingFileIO: + var responseStarted = false + let response = responseHead(request: request, fileRegion: region) + if region.readableBytes == 0 { + responseStarted = true + context.write(self.wrapOutboundOut(.head(response)), promise: nil) + } + return self.fileIO.readChunked( + fileRegion: region, + chunkSize: 32 * 1024, + allocator: context.channel.allocator, + eventLoop: context.eventLoop + ) { buffer in + if !responseStarted { + responseStarted = true + context.write(self.wrapOutboundOut(.head(response)), promise: nil) + } + return context.writeAndFlush(self.wrapOutboundOut(.body(.byteBuffer(buffer)))) + }.flatMap { () -> EventLoopFuture in + let p = context.eventLoop.makePromise(of: Void.self) + self.completeResponse(context, trailers: nil, promise: p) + return p.futureResult + }.flatMapError { error in + if !responseStarted { + let response = httpResponseHead(request: request, status: .ok) + context.write(self.wrapOutboundOut(.head(response)), promise: nil) + var buffer = context.channel.allocator.buffer(capacity: 100) + buffer.writeString("fail: \(error)") + context.write(self.wrapOutboundOut(.body(.byteBuffer(buffer))), promise: nil) + self.state.responseComplete() + return context.writeAndFlush(self.wrapOutboundOut(.end(nil))) + } else { + return context.close() + } + }.whenComplete { (_: Result) in + _ = try? file.close() + } + case .sendfile: + let response = responseHead(request: request, fileRegion: region) + context.write(self.wrapOutboundOut(.head(response)), promise: nil) + context.writeAndFlush(self.wrapOutboundOut(.body(.fileRegion(region)))).flatMap { + let p = context.eventLoop.makePromise(of: Void.self) + self.completeResponse(context, trailers: nil, promise: p) + return p.futureResult + }.flatMapError { (_: Error) in + context.close() + }.whenComplete { (_: Result) in + _ = try? file.close() + } + } + } + case .end: + self.state.requestComplete() + default: + fatalError("oh noes: \(request)") + } + } + + private func completeResponse( + _ context: ChannelHandlerContext, + trailers: HTTPHeaders?, + promise: EventLoopPromise? + ) { + self.state.responseComplete() + + let promise = self.keepAlive ? promise : (promise ?? context.eventLoop.makePromise()) + if !self.keepAlive { + promise!.futureResult.whenComplete { (_: Result) in context.close(promise: nil) } + } + self.handler = nil + + context.writeAndFlush(self.wrapOutboundOut(.end(trailers)), promise: promise) + } + + public func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let reqPart = self.unwrapInboundIn(data) + if let handler = self.handler { + handler(context, reqPart) + return + } + + switch reqPart { + case .head(let request): + print(request.uri) + self.handler = { + self.handleFile(context: $0, request: $1, ioMethod: .nonblockingFileIO, path: request.uri) + } + self.handler!(context, reqPart) + return + case .body: + break + case .end: + self.state.requestComplete() + let content = HTTPServerResponsePart.body(.byteBuffer(buffer!.slice())) + context.write(self.wrapOutboundOut(content), promise: nil) + self.completeResponse(context, trailers: nil, promise: nil) + } + } + + public func channelReadComplete(context: ChannelHandlerContext) { + context.flush() + } + + public func handlerAdded(context: ChannelHandlerContext) { + self.buffer = context.channel.allocator.buffer(capacity: 0) + } + + public func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { + switch event { + case let evt as ChannelEvent where evt == ChannelEvent.inputClosed: + // The remote peer half-closed the channel. At this time, any + // outstanding response will now get the channel closed, and + // if we are idle or waiting for a request body to finish we + // will close the channel immediately. + switch self.state { + case .idle, .waitingForRequestBody: + context.close(promise: nil) + case .sendingResponse: + self.keepAlive = false + } + default: + context.fireUserInboundEventTriggered(event) + } + } +} diff --git a/Packages/HLSServer/Tests/HLSServerTests/HLSServerTests.swift b/Packages/HLSServer/Tests/HLSServerTests/HLSServerTests.swift new file mode 100644 index 0000000..dede7bc --- /dev/null +++ b/Packages/HLSServer/Tests/HLSServerTests/HLSServerTests.swift @@ -0,0 +1,37 @@ +import NIOCore +import NIOHTTP1 +import NIOPosix +import XCTest + +@testable import HLSServer + +final class HLSServerTests: XCTestCase { + func testExample() async throws { + let fileManager = FileManager.default + + let htdocsDirectory = fileManager.temporaryDirectory.appending( + path: "htdocs", + directoryHint: .isDirectory + ) + try? fileManager.removeItem(at: htdocsDirectory) + try fileManager.createDirectory(at: htdocsDirectory, withIntermediateDirectories: true) + + let testFileName = "text.txt" + let testFileContent = "test" + + try testFileContent.write( + to: htdocsDirectory.appending(path: testFileName, directoryHint: .notDirectory), + atomically: true, + encoding: .utf8 + ) + + let host = "::1" + let server = try HLSServer(htdocs: htdocsDirectory.path(percentEncoded: false), host: host) + let url = URL(string: "http://[\(host)]:\(server.port)/\(testFileName)")! + let (data, response) = try await URLSession.shared.data(from: url) + + XCTAssert(String(decoding: data, as: UTF8.self) == testFileContent) + + try await server.close() + } +} diff --git a/Recoreon.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Recoreon.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8c9bd83..88f40cc 100644 --- a/Recoreon.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Recoreon.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "8741862eef32bb20765cb7c49226ada77f94692e208bd29289c5ab93debd5f59", + "originHash" : "1832b704d68ab3a2748ad199b65e9d5966024f829da4780bca78d426aa23e8d1", "pins" : [ { "identity" : "ffmpeg-kit-spm", @@ -9,6 +9,51 @@ "revision" : "3f9c7b0968299cb49fca2d86e66a9e1fcc2f19f0", "version" : "6.0.3" } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "cd142fd2f64be2100422d658e7411e39489da985", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", + "version" : "1.1.2" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio", + "state" : { + "revision" : "fc79798d5a150d61361a27ce0c51169b889e23de", + "version" : "2.68.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "d2ba781702a1d8285419c15ee62fd734a9437ff5", + "version" : "1.3.2" + } + }, + { + "identity" : "swifter", + "kind" : "remoteSourceControl", + "location" : "https://github.com/httpswift/swifter.git", + "state" : { + "revision" : "9483a5d459b45c3ffd059f7b55f9638e268632fd", + "version" : "1.5.0" + } } ], "version" : 3 diff --git a/Recoreon/Models/ScreenRecordEntry.swift b/Recoreon/Models/ScreenRecordEntry.swift index 82517a6..22cf6be 100644 --- a/Recoreon/Models/ScreenRecordEntry.swift +++ b/Recoreon/Models/ScreenRecordEntry.swift @@ -7,4 +7,5 @@ struct ScreenRecordEntry: Identifiable, Hashable { let summaryBody: String var id: URL { url } + var recordID: String { url.lastPathComponent } } diff --git a/Recoreon/Preview Content/Recoreon20240724T063654/Recoreon20240724T063654-app.m3u8 b/Recoreon/Preview Content/Recoreon20240724T063654/Recoreon20240724T063654-app.m3u8 index 1a8f608..d7e9aa9 100644 --- a/Recoreon/Preview Content/Recoreon20240724T063654/Recoreon20240724T063654-app.m3u8 +++ b/Recoreon/Preview Content/Recoreon20240724T063654/Recoreon20240724T063654-app.m3u8 @@ -4,9 +4,9 @@ #EXT-X-MEDIA-SEQUENCE:1 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-INDEPENDENT-SEGMENTS -#EXT-X-MAP:URI="Record01-app-init.m4s" +#EXT-X-MAP:URI="Recoreon20240724T063654-app-init.m4s" #EXTINF:3.48299, -Record01-app-000000.m4s +Recoreon20240724T063654-app-000000.m4s #EXTINF:5.96753, Recoreon20240724T063654-app-000001.m4s #EXTINF:0.60372, diff --git a/Recoreon/Preview Content/Recoreon20240724T063654/Recoreon20240724T063654.m3u8 b/Recoreon/Preview Content/Recoreon20240724T063654/Recoreon20240724T063654.m3u8 index ab9a645..539d7e0 100644 --- a/Recoreon/Preview Content/Recoreon20240724T063654/Recoreon20240724T063654.m3u8 +++ b/Recoreon/Preview Content/Recoreon20240724T063654/Recoreon20240724T063654.m3u8 @@ -3,4 +3,4 @@ Recoreon20240724T063654-video.m3u8 #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="App",DEFAULT=YES,URI="Recoreon20240724T063654-app.m3u8" -#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="Mic",DEFAULT=NO,URI="Recoreon20240724T063654-mic.m3u8" \ No newline at end of file +#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="Mic",DEFAULT=NO,URI="Recoreon20240724T063654-mic.m3u8" diff --git a/Recoreon/Preview Content/Recoreon20240724T063710/Recoreon20240724T063710.m3u8 b/Recoreon/Preview Content/Recoreon20240724T063710/Recoreon20240724T063710.m3u8 index 9f874fc..0b94f3f 100644 --- a/Recoreon/Preview Content/Recoreon20240724T063710/Recoreon20240724T063710.m3u8 +++ b/Recoreon/Preview Content/Recoreon20240724T063710/Recoreon20240724T063710.m3u8 @@ -3,4 +3,4 @@ Recoreon20240724T063710-video.m3u8 #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="App",DEFAULT=YES,URI="Recoreon20240724T063710-app.m3u8" -#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="Mic",DEFAULT=NO,URI="Recoreon20240724T063710-mic.m3u8" \ No newline at end of file +#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="Mic",DEFAULT=NO,URI="Recoreon20240724T063710-mic.m3u8" diff --git a/Recoreon/UI/ScreenRecordView/ScreenRecordPreviewView.swift b/Recoreon/UI/ScreenRecordView/ScreenRecordPreviewView.swift index 4f85d8c..d482e1d 100644 --- a/Recoreon/UI/ScreenRecordView/ScreenRecordPreviewView.swift +++ b/Recoreon/UI/ScreenRecordView/ScreenRecordPreviewView.swift @@ -1,4 +1,5 @@ import AVKit +import HLSServer import SwiftUI struct ScreenRecordPreviewViewRoute: Hashable { @@ -9,40 +10,62 @@ struct ScreenRecordPreviewView: View { let recoreonServices: RecoreonServices let screenRecordEntry: ScreenRecordEntry - @State var player = AVPlayer() + func openHLSServer(fragmentedRecordURL: URL) -> HLSServer? { + return try? HLSServer( + htdocs: screenRecordEntry.url.path(percentEncoded: false), + host: "::1" + ) + } + + func getMasterPlaylistRemoteURL( + server: HLSServer?, + fragmentedRecordURL: URL + ) -> URL? { + guard let port = server?.port else { return nil } - @State var isRemuxing: Bool = false + let recoreonPathService = recoreonServices.recoreonPathService - @State var isShowingRemoveConfirmation = false + let masterPlaylistName = recoreonPathService.getMasterPlaylistURL( + fragmentedRecordURL: screenRecordEntry.url + ).lastPathComponent + + let masterPlaylistRemoteURL = URL( + string: "http://[::1]:\(port)/\(masterPlaylistName)" + ) + + return masterPlaylistRemoteURL + } + + func createPreviewPlayer(masterPlaylistRemoteURL: URL?) -> AVPlayer { + if let masterPlaylistRemoteURL = masterPlaylistRemoteURL { + return AVPlayer(url: masterPlaylistRemoteURL) + } else { + return AVPlayer() + } + } var body: some View { - ZStack { - VideoPlayer(player: player) - .accessibilityIdentifier("PreviewVideoPlayer") - .onAppear { - Task { - isRemuxing = true - guard - let previewURL = await recoreonServices.screenRecordService.remuxPreviewVideo( - screenRecordEntry: screenRecordEntry) - else { - isRemuxing = false - return - } - player.replaceCurrentItem(with: AVPlayerItem(url: previewURL)) - isRemuxing = false - player.play() - } - } - .onDisappear { - player.pause() + let server = openHLSServer( + fragmentedRecordURL: screenRecordEntry.url + ) + + let masterPlaylistRemoteURL = getMasterPlaylistRemoteURL( + server: server, + fragmentedRecordURL: screenRecordEntry.url + ) + + let player: AVPlayer = createPreviewPlayer( + masterPlaylistRemoteURL: masterPlaylistRemoteURL + ) + + VideoPlayer(player: player) + .accessibilityIdentifier("PreviewVideoPlayer") + .onDisappear { + player.pause() + Task { + try await server?.close() } - if isRemuxing { - ProgressView() - .tint(.white) - .scaleEffect(CGSize(width: 10, height: 10)) } - } } } @@ -65,6 +88,7 @@ struct ScreenRecordPreviewViewContainer: View { #Preview { let recoreonServices = PreviewRecoreonServices() + recoreonServices.recoreonPathService.wipe() recoreonServices.deployAllAssets() let screenRecordService = recoreonServices.screenRecordService let screenRecordEntries = screenRecordService.listScreenRecordEntries() diff --git a/RecoreonCommon/RecoreonPathService.swift b/RecoreonCommon/RecoreonPathService.swift index 4e2e649..579a55c 100644 --- a/RecoreonCommon/RecoreonPathService.swift +++ b/RecoreonCommon/RecoreonPathService.swift @@ -145,6 +145,10 @@ public struct RecoreonPathService { }) } + public func getMasterPlaylistURL(fragmentedRecordURL: URL) -> URL { + let recordID = fragmentedRecordURL.lastPathComponent + return fragmentedRecordURL.appending(path: "\(recordID).m3u8", directoryHint: .notDirectory) + } // RecordNote diff --git a/project.yml b/project.yml index b91918e..e77e3d7 100644 --- a/project.yml +++ b/project.yml @@ -33,6 +33,13 @@ packages: url: "https://github.com/kaito-tokyo/ffmpeg-kit-spm.git" from: "6.0.3" + Swifter: + url: "https://github.com/httpswift/swifter.git" + from: "1.5.0" + + HLSServer: + path: "Packages/HLSServer" + targets: FragmentedRecordWriter: type: "library.static" @@ -111,6 +118,8 @@ targets: - target: "RecoreonCommon" - target: "RecoreonBroadcastUploadExtension" - package: "FFmpegKit" + - package: "Swifter" + - package: "HLSServer" settings: DEVELOPMENT_ASSET_PATHS: '"Recoreon/Preview Content"' ENABLE_PREVIEWS: true