feat: add Chromecast playback support
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
#include "cast_media.h"
|
||||
extern "C" {
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libavcodec/bsf.h>
|
||||
#include <libavformat/avformat.h>
|
||||
#include <libavutil/opt.h>
|
||||
}
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cassert>
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <iterator>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
#include <arpa/inet.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <poll.h>
|
||||
|
||||
static void ok(int result) { assert(result >= 0); }
|
||||
static std::vector<uint8_t> readFile(const std::string &path) {
|
||||
std::ifstream file(path, std::ios::binary); assert(file.good());
|
||||
return {std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>()};
|
||||
}
|
||||
static void writeFile(const std::string &path, const std::vector<uint8_t> &bytes) {
|
||||
std::ofstream file(path, std::ios::binary); file.write(reinterpret_cast<const char *>(bytes.data()), bytes.size()); assert(file.good());
|
||||
}
|
||||
// Real source A/V built with the same bundled encoders; no PATH ffmpeg or fixtures.
|
||||
static void fixture(const std::string &path) {
|
||||
AVFormatContext *format = nullptr; ok(avformat_alloc_output_context2(&format, nullptr, "mpegts", path.c_str()));
|
||||
AVCodecContext *video = avcodec_alloc_context3(avcodec_find_encoder_by_name("h264_videotoolbox"));
|
||||
AVCodecContext *audio = avcodec_alloc_context3(avcodec_find_encoder(AV_CODEC_ID_AAC)); assert(video && audio);
|
||||
video->width = 96; video->height = 64; video->pix_fmt = AV_PIX_FMT_NV12;
|
||||
video->time_base = {1, 10}; video->framerate = {10, 1}; video->bit_rate = 300000; video->gop_size = 70; video->max_b_frames = 0;
|
||||
video->sample_aspect_ratio = {4, 3};
|
||||
av_opt_set(video->priv_data, "allow_sw", "1", 0); av_opt_set(video->priv_data, "realtime", "1", 0);
|
||||
audio->sample_rate = 48000; audio->sample_fmt = AV_SAMPLE_FMT_FLTP; audio->time_base = {1, 48000}; audio->bit_rate = 128000;
|
||||
av_channel_layout_default(&audio->ch_layout, 2);
|
||||
ok(avcodec_open2(video, video->codec, nullptr)); ok(avcodec_open2(audio, audio->codec, nullptr));
|
||||
auto *vs = avformat_new_stream(format, nullptr), *as = avformat_new_stream(format, nullptr); assert(vs && as);
|
||||
vs->time_base = video->time_base; as->time_base = audio->time_base;
|
||||
ok(avcodec_parameters_from_context(vs->codecpar, video)); ok(avcodec_parameters_from_context(as->codecpar, audio));
|
||||
AVBSFContext *aspect = nullptr;
|
||||
ok(av_bsf_alloc(av_bsf_get_by_name("h264_metadata"), &aspect));
|
||||
ok(avcodec_parameters_copy(aspect->par_in, vs->codecpar));
|
||||
aspect->time_base_in = video->time_base;
|
||||
ok(av_opt_set(aspect->priv_data, "sample_aspect_ratio", "4/3", 0));
|
||||
ok(av_bsf_init(aspect));
|
||||
ok(avio_open(&format->pb, path.c_str(), AVIO_FLAG_WRITE)); ok(avformat_write_header(format, nullptr));
|
||||
auto encode = [&](AVCodecContext *codec, AVStream *stream, AVFrame *frame) {
|
||||
ok(avcodec_send_frame(codec, frame)); auto *packet = av_packet_alloc(); assert(packet);
|
||||
for (;;) {
|
||||
int result = avcodec_receive_packet(codec, packet);
|
||||
if (result == AVERROR(EAGAIN) || result == AVERROR_EOF) break;
|
||||
ok(result);
|
||||
if (codec == video) {
|
||||
ok(av_bsf_send_packet(aspect, packet));
|
||||
while (av_bsf_receive_packet(aspect, packet) >= 0) {
|
||||
av_packet_rescale_ts(packet, codec->time_base, stream->time_base); packet->stream_index = stream->index;
|
||||
ok(av_interleaved_write_frame(format, packet)); av_packet_unref(packet);
|
||||
}
|
||||
} else {
|
||||
av_packet_rescale_ts(packet, codec->time_base, stream->time_base); packet->stream_index = stream->index;
|
||||
ok(av_interleaved_write_frame(format, packet)); av_packet_unref(packet);
|
||||
}
|
||||
}
|
||||
av_packet_free(&packet);
|
||||
};
|
||||
int64_t vi = 0, ai = 0;
|
||||
while (vi < 900 || ai < 90 * 48000) {
|
||||
bool isVideo = vi < 900 && (ai >= 90 * 48000 || vi / 10.0 <= ai / 48000.0);
|
||||
auto *f = av_frame_alloc(); assert(f);
|
||||
if (isVideo) {
|
||||
f->width = 96; f->height = 64; f->format = video->pix_fmt; f->pts = vi++;
|
||||
ok(av_frame_get_buffer(f, 32));
|
||||
for (int y = 0; y < 64; ++y) std::memset(f->data[0] + y * f->linesize[0], 40 + vi % 160, 96);
|
||||
for (int y = 0; y < 32; ++y) std::memset(f->data[1] + y * f->linesize[1], 128, 96);
|
||||
encode(video, vs, f);
|
||||
} else {
|
||||
f->sample_rate = 48000; f->format = audio->sample_fmt; f->nb_samples = audio->frame_size;
|
||||
ok(av_channel_layout_copy(&f->ch_layout, &audio->ch_layout)); f->pts = ai;
|
||||
ok(av_frame_get_buffer(f, 0));
|
||||
for (int channel = 0; channel < 2; ++channel) for (int n = 0; n < f->nb_samples; ++n)
|
||||
reinterpret_cast<float *>(f->data[channel])[n] = float(.1 * std::sin((ai + n) * 440 * 2 * 3.141592653589793 / 48000));
|
||||
ai += f->nb_samples; encode(audio, as, f);
|
||||
}
|
||||
av_frame_free(&f);
|
||||
}
|
||||
encode(video, vs, nullptr); encode(audio, as, nullptr); ok(av_write_trailer(format));
|
||||
av_bsf_free(&aspect);
|
||||
avio_closep(&format->pb); avformat_free_context(format); avcodec_free_context(&video); avcodec_free_context(&audio);
|
||||
}
|
||||
static std::vector<uint8_t> wav() {
|
||||
std::vector<uint8_t> bytes(44 + 90 * 48000 * 2);
|
||||
auto put = [&](size_t at, uint32_t value, int length) { for (int i = 0; i < length; ++i) bytes[at + i] = uint8_t(value >> (8 * i)); };
|
||||
std::memcpy(bytes.data(), "RIFF", 4); put(4, uint32_t(bytes.size() - 8), 4); std::memcpy(bytes.data() + 8, "WAVEfmt ", 8);
|
||||
put(16, 16, 4); put(20, 1, 2); put(22, 1, 2); put(24, 48000, 4); put(28, 96000, 4); put(32, 2, 2); put(34, 16, 2);
|
||||
std::memcpy(bytes.data() + 36, "data", 4); put(40, uint32_t(bytes.size() - 44), 4);
|
||||
for (size_t i = 0; i < 90 * 48000; ++i) put(44 + i * 2, uint16_t(int16_t(4000 * std::sin(i * 440 * 2 * 3.141592653589793 / 48000))), 2);
|
||||
return bytes;
|
||||
}
|
||||
struct HTTP {
|
||||
int status = 0; std::string headers; std::vector<uint8_t> bytes;
|
||||
};
|
||||
static HTTP request(const std::string &url, const std::string &method = "GET", const std::string &headers = "") {
|
||||
assert(url.starts_with("http://127.0.0.1:")); size_t slash = url.find('/', 7), colon = url.find(':', 7);
|
||||
sockaddr_in address{}; address.sin_family = AF_INET; address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); address.sin_port = htons(std::stoi(url.substr(colon + 1, slash - colon - 1)));
|
||||
int socket = ::socket(AF_INET, SOCK_STREAM, 0); assert(socket >= 0);
|
||||
timeval timeout{60, 0}; setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
|
||||
assert(connect(socket, reinterpret_cast<sockaddr *>(&address), sizeof(address)) == 0);
|
||||
std::string text = method + " " + url.substr(slash) + " HTTP/1.1\r\nHost: 127.0.0.1\r\n" + headers + "Connection: close\r\n\r\n";
|
||||
assert(send(socket, text.data(), text.size(), 0) == ssize_t(text.size()));
|
||||
std::vector<uint8_t> bytes; uint8_t buffer[65536];
|
||||
for (;;) { ssize_t n = recv(socket, buffer, sizeof(buffer), 0); assert(n >= 0); if (!n) break; bytes.insert(bytes.end(), buffer, buffer + n); }
|
||||
close(socket); std::string raw(bytes.begin(), bytes.end()); size_t end = raw.find("\r\n\r\n"); assert(end != std::string::npos);
|
||||
return {std::stoi(raw.substr(9, 3)), raw.substr(0, end + 4), {bytes.begin() + end + 4, bytes.end()}};
|
||||
}
|
||||
// A tiny real upstream tests resource-specific private headers and byte-range seek.
|
||||
struct Upstream {
|
||||
int listener; uint16_t port; std::thread threads[4]; std::atomic<bool> stopped{false};
|
||||
std::atomic<int> foreignRequests{0}, videoRequests{0}, audioRequests{0};
|
||||
std::map<std::string, std::vector<uint8_t>> resources;
|
||||
std::string foreign;
|
||||
explicit Upstream(std::vector<uint8_t> video, std::vector<uint8_t> audio) {
|
||||
resources["/source.ts"] = std::move(video); resources["/audio.wav"] = std::move(audio);
|
||||
resources["/public.ts"] = resources["/source.ts"];
|
||||
listener = socket(AF_INET, SOCK_STREAM, 0); assert(listener >= 0); int one = 1; setsockopt(listener, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one));
|
||||
sockaddr_in address{}; address.sin_family = AF_INET; address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
assert(bind(listener, reinterpret_cast<sockaddr *>(&address), sizeof(address)) == 0); assert(listen(listener, 16) == 0);
|
||||
assert(fcntl(listener, F_SETFL, O_NONBLOCK) == 0);
|
||||
socklen_t length = sizeof(address); assert(getsockname(listener, reinterpret_cast<sockaddr *>(&address), &length) == 0); port = ntohs(address.sin_port);
|
||||
}
|
||||
std::string url(std::string path) const { return "http://127.0.0.1:" + std::to_string(port) + path; }
|
||||
void start() { for (auto &thread : threads) thread = std::thread([&] {
|
||||
while (!stopped) {
|
||||
pollfd ready{listener, POLLIN, 0};
|
||||
if (poll(&ready, 1, 100) <= 0 || !(ready.revents & POLLIN)) continue;
|
||||
int client = accept(listener, nullptr, nullptr); if (client < 0) continue;
|
||||
assert(fcntl(client, F_SETFL, fcntl(client, F_GETFL) & ~O_NONBLOCK) == 0);
|
||||
int one = 1; setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one)); timeval timeout{5, 0}; setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); setsockopt(client, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
|
||||
std::string request; char buffer[2048];
|
||||
while (request.find("\r\n\r\n") == std::string::npos && request.size() < 16384) { auto n = recv(client, buffer, sizeof(buffer), 0); if (n <= 0) break; request.append(buffer, size_t(n)); }
|
||||
size_t begin = request.find(' '), end = request.find(' ', begin + 1); std::string path = request.substr(begin + 1, end - begin - 1);
|
||||
int status = 200; std::string extra; std::vector<uint8_t> data;
|
||||
if (path == "/redirect") { status = 302; extra = "Location: " + foreign + "/source.ts\r\n"; }
|
||||
else if (path == "/cross.m3u8" || path == "/index.m3u8" || path == "/public-cross.m3u8") {
|
||||
std::string playlist = "#EXTM3U\n#EXT-X-TARGETDURATION:90\n#EXTINF:90,\n" +
|
||||
(path == "/index.m3u8" ? url("") : foreign) +
|
||||
(path == "/public-cross.m3u8" ? "/public.ts" : "/source.ts") + "\n#EXT-X-ENDLIST\n";
|
||||
data.assign(playlist.begin(), playlist.end());
|
||||
} else if (auto resource = resources.find(path); resource != resources.end()) {
|
||||
bool audio = path == "/audio.wav";
|
||||
bool publicMedia = path == "/public.ts";
|
||||
bool authorized = publicMedia || request.find(audio ? "X-Audio: audio-secret" : "X-Video: video-secret") != std::string::npos;
|
||||
bool wrong = request.find(publicMedia ? "secret" : (audio ? "video-secret" : "audio-secret")) != std::string::npos;
|
||||
if (!authorized || wrong) { ++foreignRequests; status = 403; }
|
||||
else { if (audio) ++audioRequests; else ++videoRequests; data = resource->second; }
|
||||
} else status = 404;
|
||||
size_t low = 0, high = data.size();
|
||||
auto range = request.find("Range: bytes=");
|
||||
if (range != std::string::npos && !data.empty()) {
|
||||
low = std::stoull(request.substr(range + 13)); auto dash = request.find('-', range);
|
||||
if (dash != std::string::npos && std::isdigit(request[dash + 1])) high = std::min(high, size_t(std::stoull(request.substr(dash + 1))) + 1);
|
||||
if (low >= high) { status = 416; low = high = 0; }
|
||||
else { status = 206; extra += "Content-Range: bytes " + std::to_string(low) + "-" + std::to_string(high - 1) + "/" + std::to_string(data.size()) + "\r\n"; }
|
||||
}
|
||||
std::string header = "HTTP/1.1 " + std::to_string(status) + " Test\r\nConnection: close\r\nAccept-Ranges: bytes\r\n" + extra + "Content-Length: " + std::to_string(high - low) + "\r\n\r\n";
|
||||
send(client, header.data(), header.size(), 0);
|
||||
if (!request.starts_with("HEAD")) while (high > low) { auto n = send(client, data.data() + low, high - low, 0); if (n <= 0) break; low += size_t(n); }
|
||||
close(client);
|
||||
}
|
||||
}); }
|
||||
~Upstream() { stopped = true; shutdown(listener, SHUT_RDWR); for (auto &thread : threads) if (thread.joinable()) thread.join(); close(listener); }
|
||||
};
|
||||
static void playable(const HTTP &response, const std::string &path, bool video, double minimumTime) {
|
||||
assert(response.status == 200 && response.bytes.size() > 188 && response.bytes[0] == 0x47); writeFile(path, response.bytes);
|
||||
AVFormatContext *format = nullptr; ok(avformat_open_input(&format, path.c_str(), nullptr, nullptr)); ok(avformat_find_stream_info(format, nullptr));
|
||||
int audioIndex = av_find_best_stream(format, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0); assert(audioIndex >= 0);
|
||||
int videoIndex = av_find_best_stream(format, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); assert(!video || videoIndex >= 0);
|
||||
std::map<int, AVCodecContext *> codecs; std::map<int, int> frames;
|
||||
for (int index : {audioIndex, videoIndex}) if (index >= 0) {
|
||||
auto *stream = format->streams[index]; auto *codec = avcodec_alloc_context3(avcodec_find_decoder(stream->codecpar->codec_id)); assert(codec);
|
||||
ok(avcodec_parameters_to_context(codec, stream->codecpar)); ok(avcodec_open2(codec, codec->codec, nullptr)); codecs[index] = codec;
|
||||
}
|
||||
auto *packet = av_packet_alloc(); auto *frame = av_frame_alloc(); bool timestamp = false;
|
||||
while (av_read_frame(format, packet) >= 0) {
|
||||
auto found = codecs.find(packet->stream_index);
|
||||
if (found != codecs.end()) {
|
||||
if (packet->pts != AV_NOPTS_VALUE) { double time = packet->pts * av_q2d(format->streams[packet->stream_index]->time_base); assert(time >= minimumTime - .1); timestamp = true; }
|
||||
ok(avcodec_send_packet(found->second, packet));
|
||||
while (avcodec_receive_frame(found->second, frame) >= 0) {
|
||||
if (packet->stream_index == videoIndex) {
|
||||
auto sar = av_guess_sample_aspect_ratio(format, format->streams[videoIndex], frame);
|
||||
if (sar.num <= 0 || sar.den <= 0) sar = {1, 1};
|
||||
assert(sar.num > 0 && std::abs(double(frame->width) / frame->height * av_q2d(sar) - 2.0) < .01);
|
||||
}
|
||||
++frames[packet->stream_index]; av_frame_unref(frame);
|
||||
}
|
||||
}
|
||||
av_packet_unref(packet);
|
||||
}
|
||||
assert(timestamp && frames[audioIndex] > 10 && (!video || frames[videoIndex] > 10));
|
||||
for (auto &[index, codec] : codecs) avcodec_free_context(&codec);
|
||||
av_packet_free(&packet); av_frame_free(&frame); avformat_close_input(&format);
|
||||
}
|
||||
static std::pair<double, double> audioSpan(const HTTP &response, const std::string &path) {
|
||||
assert(response.status == 200);
|
||||
writeFile(path, response.bytes);
|
||||
AVFormatContext *format = nullptr;
|
||||
ok(avformat_open_input(&format, path.c_str(), nullptr, nullptr));
|
||||
ok(avformat_find_stream_info(format, nullptr));
|
||||
int index = av_find_best_stream(format, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0); assert(index >= 0);
|
||||
auto *codec = avcodec_alloc_context3(avcodec_find_decoder(format->streams[index]->codecpar->codec_id)); assert(codec);
|
||||
ok(avcodec_parameters_to_context(codec, format->streams[index]->codecpar)); ok(avcodec_open2(codec, codec->codec, nullptr));
|
||||
auto *packet = av_packet_alloc(); auto *frame = av_frame_alloc();
|
||||
double first = INFINITY, end = -INFINITY;
|
||||
while (av_read_frame(format, packet) >= 0) {
|
||||
if (packet->stream_index == index) {
|
||||
ok(avcodec_send_packet(codec, packet));
|
||||
while (avcodec_receive_frame(codec, frame) >= 0) {
|
||||
assert(frame->best_effort_timestamp != AV_NOPTS_VALUE);
|
||||
double position = frame->best_effort_timestamp * av_q2d(format->streams[index]->time_base);
|
||||
first = std::min(first, position);
|
||||
end = position + double(frame->nb_samples) / frame->sample_rate;
|
||||
av_frame_unref(frame);
|
||||
}
|
||||
}
|
||||
av_packet_unref(packet);
|
||||
}
|
||||
av_packet_free(&packet); av_frame_free(&frame); avcodec_free_context(&codec); avformat_close_input(&format);
|
||||
assert(std::isfinite(first) && end > first);
|
||||
return {first, end};
|
||||
}
|
||||
static CastMedia *start(CastMediaSource source, std::string &url, double &duration) {
|
||||
char error[512]{}, bytes[512]{}; auto *media = cast_media_create(&source, error); assert(media);
|
||||
assert(cast_media_start(media, "127.0.0.1", bytes, &duration, error)); url = bytes; return media;
|
||||
}
|
||||
int main(int argc, char **argv) {
|
||||
bool liveCheck = false;
|
||||
for (int argument = 1; argument < argc; ++argument) if (std::string(argv[argument]) == "--live") liveCheck = true;
|
||||
char temp[] = "/tmp/terebi-cast-check.XXXXXX"; assert(mkdtemp(temp)); std::filesystem::path directory(temp);
|
||||
const auto video = (directory / "source.ts").string(), audio = (directory / "source.wav").string(), segment = (directory / "segment.ts").string();
|
||||
fixture(video); writeFile(audio, wav());
|
||||
auto videoBytes = readFile(video), audioBytes = readFile(audio);
|
||||
Upstream upstream(videoBytes, audioBytes), foreign(videoBytes, audioBytes);
|
||||
upstream.foreign = foreign.url(""); upstream.start(); foreign.start();
|
||||
std::string url; double duration = 0;
|
||||
CastMediaSource local{video.c_str(), "", nullptr, nullptr, true, false, false, 0};
|
||||
auto *media = start(local, url, duration); assert(duration >= 89 && duration <= 92);
|
||||
std::string base = url.substr(0, url.rfind('/') + 1);
|
||||
auto playlist = request(url); assert(playlist.status == 200); std::string text(playlist.bytes.begin(), playlist.bytes.end()); assert(text.find("#EXT-X-ENDLIST") != std::string::npos);
|
||||
playable(request(base + "segment/10.ts"), segment, true, 60);
|
||||
playable(request(base + "segment/0.ts"), segment, true, 0);
|
||||
assert(request(base + "../source.ts").status == 404);
|
||||
assert(request(base + "%2e%2e/source.ts").status == 404);
|
||||
assert(request(url.substr(0, url.find('/', 7)) + "/index.m3u8").status == 404);
|
||||
assert(request(base + "segment/999999.ts").status == 404);
|
||||
const char vtt[] = "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nHello\n"; char subtitle[512]{};
|
||||
assert(cast_media_subtitle(media, 1, vtt, sizeof(vtt) - 1, subtitle));
|
||||
auto subtitles = request(subtitle); assert(subtitles.status == 200 && subtitles.headers.find("Access-Control-Allow-Origin: *") != std::string::npos);
|
||||
auto ranged = request(subtitle, "GET", "Range: bytes=0-5\r\n"); assert(ranged.status == 206 && ranged.bytes.size() == 6);
|
||||
assert(request(subtitle, "HEAD").bytes.empty()); assert(request(subtitle, "OPTIONS").status == 204);
|
||||
assert(request(subtitle, "GET", "Range: bytes=99999-\r\n").status == 416);
|
||||
cast_media_cancel(media); cast_media_destroy(media);
|
||||
// Previously published capabilities expire, including the bound listening socket.
|
||||
auto authority = url.substr(7, url.find('/', 7) - 7); auto colon = authority.find(':'); sockaddr_in closed{};
|
||||
closed.sin_family = AF_INET; closed.sin_addr.s_addr = htonl(INADDR_LOOPBACK); closed.sin_port = htons(std::stoi(authority.substr(colon + 1)));
|
||||
int socket = ::socket(AF_INET, SOCK_STREAM, 0); assert(connect(socket, reinterpret_cast<sockaddr *>(&closed), sizeof(closed)) != 0); close(socket);
|
||||
std::string hls = upstream.url("/index.m3u8"); CastMediaSource miruro{hls.c_str(), "X-Video: video-secret\r\n", nullptr, nullptr, false, true, false, 90};
|
||||
media = start(miruro, url, duration); base = url.substr(0, url.rfind('/') + 1);
|
||||
playable(request(base + "segment/10.ts"), segment, true, 60); cast_media_destroy(media);
|
||||
std::string networkVideo = upstream.url("/source.ts"), networkAudio = upstream.url("/audio.wav");
|
||||
CastMediaSource youtube{networkVideo.c_str(), "X-Video: video-secret\r\n", networkAudio.c_str(), "X-Audio: audio-secret\r\n", false, false, false, 90};
|
||||
media = start(youtube, url, duration); base = url.substr(0, url.rfind('/') + 1);
|
||||
playable(request(base + "segment/10.ts"), segment, true, 60);
|
||||
auto vodFirstAudio = audioSpan(request(base + "segment/0.ts"), segment);
|
||||
auto vodSecondAudio = audioSpan(request(base + "segment/1.ts"), segment);
|
||||
assert(std::abs(vodSecondAudio.first - vodFirstAudio.second) < 1.0 / 48000);
|
||||
cast_media_destroy(media);
|
||||
assert(upstream.videoRequests > 0 && upstream.audioRequests > 0 && upstream.foreignRequests == 0);
|
||||
CastMediaSource audioOnly{audio.c_str(), "", nullptr, nullptr, true, false, false, 0};
|
||||
media = start(audioOnly, url, duration); base = url.substr(0, url.rfind('/') + 1); playable(request(base + "segment/10.ts"), segment, false, 60); cast_media_destroy(media);
|
||||
for (const auto &path : {"/redirect", "/cross.m3u8"}) {
|
||||
auto resource = upstream.url(path); CastMediaSource unsafe{resource.c_str(), "X-Video: video-secret\r\n", nullptr, nullptr, false, true, false, 90};
|
||||
char error[512]{}, result[512]{}; media = cast_media_create(&unsafe, error); assert(media);
|
||||
assert(!cast_media_start(media, "127.0.0.1", result, &duration, error)); assert(std::string(error).find("secret") == std::string::npos); cast_media_destroy(media);
|
||||
}
|
||||
assert(foreign.foreignRequests == 0 && foreign.videoRequests == 0);
|
||||
auto publicManifest = upstream.url("/public-cross.m3u8");
|
||||
CastMediaSource publicHLS{publicManifest.c_str(), "X-Video: video-secret\r\n", nullptr, nullptr, false, false, false, 90};
|
||||
media = start(publicHLS, url, duration); base = url.substr(0, url.rfind('/') + 1);
|
||||
playable(request(base + "segment/0.ts"), segment, true, 0); cast_media_destroy(media);
|
||||
assert(foreign.videoRequests > 0 && foreign.foreignRequests == 0);
|
||||
if (liveCheck) {
|
||||
youtube.live = true; media = start(youtube, url, duration); base = url.substr(0, url.rfind('/') + 1); assert(duration == 0);
|
||||
auto firstAudio = audioSpan(request(base + "segment/0.ts"), segment);
|
||||
auto secondAudio = audioSpan(request(base + "segment/1.ts"), segment);
|
||||
assert(std::abs(secondAudio.first - firstAudio.second) < 1.0 / 48000);
|
||||
#ifdef CAST_MEDIA_CHECK
|
||||
assert(cast_media_check_advance(media, 54));
|
||||
#else
|
||||
assert(false && "Compile the permanent live check with CAST_MEDIA_CHECK");
|
||||
#endif
|
||||
auto response = request(url); assert(response.status == 200); std::string text(response.bytes.begin(), response.bytes.end());
|
||||
auto at = text.find("#EXT-X-MEDIA-SEQUENCE:"); assert(at != std::string::npos);
|
||||
int64_t sequence = std::stoll(text.substr(at + 22));
|
||||
size_t count = 0, cursor = 0;
|
||||
while ((cursor = text.find("#EXTINF:", cursor)) != std::string::npos) { ++count; cursor += 8; }
|
||||
assert(count == 8 && text.find("#EXT-X-ENDLIST") == std::string::npos);
|
||||
int64_t last = sequence + int64_t(count) - 1;
|
||||
assert(sequence > 0 && request(base + "segment/0.ts").status == 404);
|
||||
playable(request(base + "segment/" + std::to_string(last) + ".ts"), segment, true, 0); cast_media_destroy(media);
|
||||
}
|
||||
std::filesystem::remove_all(directory);
|
||||
std::cout << "cast-media: playable A/V, VOD random seek, separate audio, HLS origin/header policy, subtitles, ranges, capabilities, cancellation" << (liveCheck ? ", bounded live window" : " (use --live for rolling window)") << " passed\n";
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import AppKit
|
||||
|
||||
@main
|
||||
@MainActor
|
||||
struct CastPlaybackCheck {
|
||||
static func require(_ condition: @autoclosure () -> Bool, _ message: String) {
|
||||
if !condition() { fatalError("FAIL: " + message) }
|
||||
}
|
||||
|
||||
// A real I/O watchdog, not a clock used by the playback behavior under test.
|
||||
static func until(_ model: PlayerModel, _ message: String, permitError: Bool = false, _ predicate: () -> Bool) async throws {
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(45))
|
||||
while !predicate(), ContinuousClock.now < deadline {
|
||||
if !permitError, let error = model.commandError { fatalError("FAIL: " + error) }
|
||||
try await Task.sleep(for: .milliseconds(20))
|
||||
}
|
||||
require(predicate(), message)
|
||||
}
|
||||
|
||||
static func main() async throws {
|
||||
_ = NSApplication.shared
|
||||
NSApp.setActivationPolicy(.prohibited)
|
||||
let source = URL(fileURLWithPath: CommandLine.arguments[1])
|
||||
let port = UInt16(CommandLine.arguments[2])!
|
||||
let suite = "terebi.cast-playback-check." + UUID().uuidString
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let model = PlayerModel(defaults: defaults)
|
||||
defer { model.shutdown() }
|
||||
model.setVolume(0)
|
||||
model.open(source)
|
||||
try await until(model, "local source plays") { model.state.status == PLAYER_PLAYING && model.canSeek }
|
||||
require(model.setPlayback(playing: false), "local pause accepted")
|
||||
try await until(model, "local pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil }
|
||||
require(model.seekRelative(9.25 - model.state.position), "local restoration position accepted")
|
||||
require(!model.canCast, "Cast cannot transfer an unacknowledged local seek")
|
||||
try await until(model, "local position acknowledged") { !model.state.seek_pending && abs(model.state.position - 9.25) < 0.15 }
|
||||
model.cast(to: CastDevice(id: "loopback", name: "Loopback TV", host: "127.0.0.1", port: port))
|
||||
try await until(model, "receiver load acknowledges paused handoff") {
|
||||
model.castDevice != nil && !model.castConnecting && model.canSeek && model.state.status == PLAYER_PAUSED
|
||||
}
|
||||
require(abs(model.state.position - 9.25) < 0.15, "Cast starts at acknowledged local position")
|
||||
require(!model.canStepFrame && model.bufferedRange == nil, "local decoder-only controls are not exposed while casting")
|
||||
require(model.setPlayback(playing: true), "remote play accepted")
|
||||
try await until(model, "receiver play acknowledged") { model.state.status == PLAYER_PLAYING && model.playbackIntent == nil }
|
||||
require(model.seekRelative(5) && model.seekRelative(10), "rapid remote skips accumulate before acknowledgement")
|
||||
try await until(model, "receiver seek acknowledged") { !model.state.seek_pending && abs(model.state.position - 24.25) < 0.15 }
|
||||
require(model.setPlayback(playing: false), "remote pause accepted")
|
||||
try await until(model, "receiver pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil }
|
||||
model.stopCasting()
|
||||
try await until(model, "local decoder restores receiver position paused") {
|
||||
model.castDevice == nil && model.state.status == PLAYER_PAUSED && !model.state.seek_pending &&
|
||||
abs(model.state.position - 24.25) < 0.15 && !model.isBusy
|
||||
}
|
||||
require(abs((PlaybackProgress(defaults: defaults).position(for: source) ?? 0) - 24.25) < 0.15,
|
||||
"remote history is not overwritten by the abandoned local position")
|
||||
require(model.volume == 0, "local volume survives receiver volume changes")
|
||||
require(model.setPlayback(playing: true), "resume local playback before a failed Cast connection")
|
||||
try await until(model, "local resume acknowledged") { model.state.status == PLAYER_PLAYING && model.playbackIntent == nil }
|
||||
model.cast(to: CastDevice(id: "invalid", name: "Unavailable TV", host: "127.0.0.1", port: 0))
|
||||
try await until(model, "failed Cast connection restores local playback", permitError: true) {
|
||||
model.castDevice == nil && model.commandError != nil && model.state.status == PLAYER_PLAYING
|
||||
}
|
||||
require(model.canSeek && model.state.position >= 24, "connection failure preserves a usable local timeline")
|
||||
model.closeFile()
|
||||
try await until(model, "closed media releases Cast and decoder") { model.state.status == PLAYER_EMPTY }
|
||||
print("PASS: real local decode → TLS Cast LOAD/play/seek/pause → local restoration/history; failed connection recovery")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
import Foundation
|
||||
@preconcurrency import Network
|
||||
import Security
|
||||
|
||||
#if CAST_SESSION_CHECKS
|
||||
struct CastTextTrack: Sendable {
|
||||
let id: Int
|
||||
let url: URL
|
||||
let language: String
|
||||
let label: String
|
||||
}
|
||||
struct CastMediaDescription: Sendable {
|
||||
let url: URL
|
||||
let contentType: String
|
||||
let duration: Double
|
||||
let live: Bool
|
||||
let tracks: [CastTextTrack]
|
||||
}
|
||||
#endif
|
||||
|
||||
private func require(_ condition: @autoclosure () -> Bool, _ message: String) {
|
||||
if !condition() { fatalError("FAIL: " + message) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func until(_ message: String, attempts: Int = 500, _ predicate: () -> Bool) async throws {
|
||||
// An I/O watchdog, not a wall-clock date used to decide playback behavior.
|
||||
for _ in 0..<attempts {
|
||||
if predicate() { return }
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
require(predicate(), message)
|
||||
}
|
||||
@MainActor
|
||||
private final class CastCheckClock {
|
||||
private struct Waiter {
|
||||
let delay: Duration
|
||||
let deadline: Duration
|
||||
let continuation: CheckedContinuation<Void, any Error>
|
||||
}
|
||||
private var now: Duration = .zero
|
||||
private var waiters: [UUID: Waiter] = [:]
|
||||
var pendingCount: Int { waiters.count }
|
||||
func hasDelay(_ delay: Duration) -> Bool { waiters.values.contains { $0.delay == delay } }
|
||||
|
||||
func sleep(_ delay: Duration) async throws {
|
||||
try Task.checkCancellation()
|
||||
let id = UUID()
|
||||
try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
waiters[id] = Waiter(delay: delay, deadline: now + delay, continuation: continuation)
|
||||
}
|
||||
} onCancel: {
|
||||
Task { @MainActor [weak self] in
|
||||
self?.waiters.removeValue(forKey: id)?.continuation.resume(throwing: CancellationError())
|
||||
}
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
}
|
||||
|
||||
func advance(_ duration: Duration) {
|
||||
now += duration
|
||||
let due = waiters.filter { $0.value.deadline <= now }.map(\.key)
|
||||
for id in due { waiters.removeValue(forKey: id)?.continuation.resume() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
private final class Receiver {
|
||||
private let listener: NWListener
|
||||
private var socket: NWConnection?
|
||||
private var stream = CastWire.Stream()
|
||||
private var sender = ""
|
||||
private let transport = "receiver-app"
|
||||
private var media: [String: Any] = [:]
|
||||
private var session = 1
|
||||
private var position = 0.0
|
||||
private var playing = false
|
||||
private var activeTracks: [Int] = []
|
||||
var commands: [(String, [String: Any])] = []
|
||||
var rejectLoad = false
|
||||
var rejectLaunch = false
|
||||
var dropLaunch = false
|
||||
var dropLoad = false
|
||||
var dropCommands = false
|
||||
var rejectCommand = false
|
||||
var holdSeek = false
|
||||
var ignoreHeartbeat = false
|
||||
var verifyRelay = false
|
||||
var relayVerified = false
|
||||
var failure: String?
|
||||
private var heldSeek: [String: Any]?
|
||||
private var started = false
|
||||
private var volume = 1.0
|
||||
var muted = false
|
||||
var port: UInt16 { listener.port!.rawValue }
|
||||
var device: CastDevice { .init(id: "loopback", name: "Loopback TV", host: "127.0.0.1", port: port) }
|
||||
|
||||
init(identity: SecIdentity) throws {
|
||||
let tls = NWProtocolTLS.Options()
|
||||
guard let identity = sec_identity_create(identity) else { throw CastError.protocolError }
|
||||
sec_protocol_options_set_local_identity(tls.securityProtocolOptions, identity)
|
||||
sec_protocol_options_set_min_tls_protocol_version(tls.securityProtocolOptions, .TLSv12)
|
||||
let parameters = NWParameters(tls: tls, tcp: NWProtocolTCP.Options())
|
||||
parameters.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: .any)
|
||||
listener = try NWListener(using: parameters)
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
listener.stateUpdateHandler = { [weak self] state in
|
||||
Task { @MainActor in
|
||||
switch state {
|
||||
case .ready: self?.started = true
|
||||
case .failed: self?.failure = "TLS listener failed"
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
listener.newConnectionHandler = { [weak self] socket in
|
||||
Task { @MainActor in
|
||||
guard let self else { socket.cancel(); return }
|
||||
self.socket?.cancel()
|
||||
self.socket = socket
|
||||
self.stream = CastWire.Stream()
|
||||
socket.stateUpdateHandler = { [weak self] state in
|
||||
Task { @MainActor in
|
||||
if case .ready = state { self?.receive(socket) }
|
||||
}
|
||||
}
|
||||
socket.start(queue: DispatchQueue.global(qos: .userInitiated))
|
||||
}
|
||||
}
|
||||
listener.start(queue: DispatchQueue.global(qos: .userInitiated))
|
||||
try await until("TLS listener starts") { started || failure != nil }
|
||||
require(failure == nil, "TLS listener succeeds")
|
||||
}
|
||||
|
||||
func stop() { socket?.cancel(); listener.cancel() }
|
||||
|
||||
private func receive(_ socket: NWConnection) {
|
||||
socket.receive(minimumIncompleteLength: 1, maximumLength: 16384) { [weak self] data, _, ended, error in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
do {
|
||||
if let data {
|
||||
for message in try self.stream.append(data) { try await self.handle(message) }
|
||||
}
|
||||
if !ended, error == nil { self.receive(socket) }
|
||||
} catch { self.failure = "Simulated receiver protocol/relay failure: \(error.localizedDescription)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func send(_ json: [String: Any], namespace: String, source: String? = nil, fragmented: Bool = false) throws {
|
||||
let payload = try JSONSerialization.data(withJSONObject: json)
|
||||
let frame = try CastWire.frame(.init(source: source ?? (namespace == CastWire.media ? transport : "receiver-0"),
|
||||
destination: sender, namespace: namespace, payload: payload))
|
||||
if fragmented {
|
||||
// Split both the length prefix and protobuf, exercising genuine receive framing.
|
||||
for chunk in [frame.prefix(2), frame.dropFirst(2).prefix(5), frame.dropFirst(7)] {
|
||||
socket?.send(content: Data(chunk), completion: .contentProcessed { _ in })
|
||||
}
|
||||
} else { socket?.send(content: frame, completion: .contentProcessed { _ in }) }
|
||||
}
|
||||
|
||||
private func receiverStatus(_ requestID: Int) throws {
|
||||
try send(["type": "RECEIVER_STATUS", "requestId": requestID,
|
||||
"status": ["applications": [["appId": "CC1AD845", "sessionId": "app-session", "transportId": transport]],
|
||||
"volume": ["level": volume, "muted": muted]]], namespace: CastWire.receiver, fragmented: true)
|
||||
}
|
||||
|
||||
func status(requestID: Int = 0, session override: Int? = nil, content: String? = nil,
|
||||
state: String? = nil, idle: String? = nil) throws {
|
||||
var media = self.media
|
||||
if let content { media["contentId"] = content }
|
||||
try send(["type": "MEDIA_STATUS", "requestId": requestID,
|
||||
"status": [["mediaSessionId": override ?? session, "media": media,
|
||||
"playerState": state ?? (playing ? "PLAYING" : "PAUSED"), "currentTime": position,
|
||||
"idleReason": idle ?? "", "supportedMediaCommands": 4099, "activeTrackIds": activeTracks]]],
|
||||
namespace: CastWire.media, fragmented: true)
|
||||
}
|
||||
|
||||
func releaseSeek() throws {
|
||||
guard let command = heldSeek else { return }
|
||||
heldSeek = nil
|
||||
position = command["currentTime"] as? Double ?? position
|
||||
try status(requestID: command["requestId"] as? Int ?? 0)
|
||||
}
|
||||
|
||||
func invalidFrame() {
|
||||
socket?.send(content: Data([0, 1, 0, 1]), completion: .contentProcessed { _ in }) // 65537 > bound
|
||||
}
|
||||
|
||||
func takeOverApplication() throws {
|
||||
try send(["type": "RECEIVER_STATUS", "status": ["applications": []]], namespace: CastWire.receiver)
|
||||
}
|
||||
|
||||
private func handle(_ message: CastWire.Message) async throws {
|
||||
sender = message.source
|
||||
let json = try JSONSerialization.jsonObject(with: message.payload) as! [String: Any]
|
||||
let type = json["type"] as! String
|
||||
let requestID = json["requestId"] as? Int ?? 0
|
||||
commands.append((type, json))
|
||||
if message.namespace == CastWire.heartbeat {
|
||||
if type == "PING", !ignoreHeartbeat { try send(["type": "PONG"], namespace: CastWire.heartbeat) }
|
||||
return
|
||||
}
|
||||
switch type {
|
||||
case "CONNECT": break
|
||||
case "CLOSE": break
|
||||
case "LAUNCH":
|
||||
require(json["appId"] as? String == "CC1AD845", "Default Media Receiver, no registration")
|
||||
if dropLaunch { return }
|
||||
if rejectLaunch { try send(["type": "LAUNCH_ERROR", "requestId": requestID], namespace: CastWire.receiver) }
|
||||
else { try receiverStatus(requestID) }
|
||||
case "LOAD":
|
||||
if rejectLoad { try send(["type": "LOAD_FAILED", "requestId": requestID], namespace: CastWire.media); return }
|
||||
if dropLoad { return }
|
||||
media = json["media"] as! [String: Any]
|
||||
session += 1
|
||||
playing = json["autoplay"] as? Bool ?? true
|
||||
position = json["currentTime"] as? Double ?? 0
|
||||
activeTracks = json["activeTrackIds"] as? [Int] ?? []
|
||||
if verifyRelay { try await fetchRelay() }
|
||||
try status(requestID: requestID)
|
||||
case "PLAY", "PAUSE", "SEEK", "GET_STATUS", "EDIT_TRACKS_INFO":
|
||||
if dropCommands { return }
|
||||
if rejectCommand {
|
||||
try send(["type": "INVALID_PLAYER_STATE", "requestId": requestID], namespace: CastWire.media)
|
||||
return
|
||||
}
|
||||
require(json["mediaSessionId"] as? Int == session, "Controls address the acquired media session")
|
||||
if type == "PLAY" { playing = true }
|
||||
if type == "PAUSE" { playing = false }
|
||||
if type == "EDIT_TRACKS_INFO" { activeTracks = json["activeTrackIds"] as? [Int] ?? [] }
|
||||
if type == "SEEK" {
|
||||
if holdSeek { heldSeek = json; try status(); return }
|
||||
position = json["currentTime"] as? Double ?? position
|
||||
}
|
||||
try status(requestID: requestID)
|
||||
case "SET_VOLUME":
|
||||
require(message.namespace == CastWire.receiver, "Volume controls device, not stream")
|
||||
volume = (json["volume"] as? [String: Any])?["level"] as? Double ?? volume
|
||||
muted = (json["volume"] as? [String: Any])?["muted"] as? Bool ?? muted
|
||||
try receiverStatus(requestID)
|
||||
case "STOP":
|
||||
require(message.namespace == CastWire.media, "Disconnect does not stop another sender's application")
|
||||
require(json["mediaSessionId"] as? Int == session, "Disconnect stops only owned media")
|
||||
default: failure = "Unexpected command \(type)"
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchRelay() async throws {
|
||||
let url = URL(string: media["contentId"] as! String)!
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = 30
|
||||
let session = URLSession(configuration: config)
|
||||
defer { session.invalidateAndCancel() }
|
||||
func fetch(_ url: URL) async throws -> Data {
|
||||
let (data, response) = try await session.data(from: url)
|
||||
require((response as? HTTPURLResponse)?.statusCode == 200, "Receiver fetch succeeds")
|
||||
require(!data.isEmpty, "Relay returns real bytes")
|
||||
return data
|
||||
}
|
||||
var playlistURL = url
|
||||
var playlist = try await fetch(playlistURL)
|
||||
var body = String(data: playlist, encoding: .utf8) ?? ""
|
||||
require(body.hasPrefix("#EXTM3U"), "LOAD is playable HLS")
|
||||
for _ in 0..<3 {
|
||||
guard let uri = body.split(separator: "\n").first(where: { !$0.hasPrefix("#") && !$0.isEmpty }),
|
||||
let next = URL(string: String(uri).trimmingCharacters(in: .whitespacesAndNewlines), relativeTo: playlistURL)?.absoluteURL else {
|
||||
fatalError("FAIL: playlist has a media resource")
|
||||
}
|
||||
let data = try await fetch(next)
|
||||
if let text = String(data: data, encoding: .utf8), text.hasPrefix("#EXTM3U") {
|
||||
playlistURL = next; playlist = data; body = text; continue
|
||||
}
|
||||
require(data.count >= 188 && data.first == 0x47 || data.count >= 12 &&
|
||||
["ftyp", "styp", "moof"].contains(String(data: data[4..<8], encoding: .ascii) ?? ""),
|
||||
"Relay serves MPEG-TS or fragmented MP4, not placeholder content")
|
||||
for track in media["tracks"] as? [[String: Any]] ?? [] {
|
||||
if let textURL = (track["trackContentId"] as? String).flatMap(URL.init(string:)) {
|
||||
let text = try await fetch(textURL)
|
||||
require(String(data: text, encoding: .utf8)?.hasPrefix("WEBVTT") == true, "Receiver subtitles are WebVTT")
|
||||
}
|
||||
}
|
||||
relayVerified = true
|
||||
return
|
||||
}
|
||||
fatalError("FAIL: receiver playlist resolves to real media")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func testIdentity() throws -> (SecIdentity, URL) {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("terebi-cast-check-" + UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
func openssl(_ arguments: [String]) throws {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/openssl")
|
||||
process.arguments = arguments
|
||||
process.standardOutput = FileHandle.nullDevice
|
||||
process.standardError = FileHandle.nullDevice
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
require(process.terminationStatus == 0, "Creates ephemeral self-signed TLS test identity")
|
||||
}
|
||||
let key = directory.appendingPathComponent("key.pem").path
|
||||
let certificate = directory.appendingPathComponent("cert.pem").path
|
||||
let bundle = directory.appendingPathComponent("identity.p12")
|
||||
try openssl(["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", key,
|
||||
"-out", certificate, "-days", "1", "-subj", "/CN=Cast loopback"])
|
||||
try openssl(["pkcs12", "-export", "-inkey", key, "-in", certificate, "-out", bundle.path,
|
||||
"-passout", "pass:cast-check", "-keypbe", "PBE-SHA1-3DES", "-certpbe", "PBE-SHA1-3DES", "-macalg", "sha1"])
|
||||
let data = try Data(contentsOf: bundle)
|
||||
var result: CFArray?
|
||||
let status = SecPKCS12Import(data as CFData,
|
||||
[kSecImportExportPassphrase as String: "cast-check"] as CFDictionary, &result)
|
||||
require(status == errSecSuccess, "Imports ephemeral TLS identity")
|
||||
let item = (result! as! [[String: Any]])[0]
|
||||
return (item[kSecImportItemIdentity as String] as! SecIdentity, directory)
|
||||
}
|
||||
|
||||
@main
|
||||
@MainActor
|
||||
struct CastSessionCheck {
|
||||
static func codec() throws {
|
||||
let payload = Data("{\"type\":\"PING\"}".utf8)
|
||||
let message = CastWire.Message(source: "sender-0", destination: "receiver-0",
|
||||
namespace: CastWire.heartbeat, payload: payload)
|
||||
let frame = try CastWire.frame(message)
|
||||
var stream = CastWire.Stream()
|
||||
var decoded: [CastWire.Message] = []
|
||||
for byte in frame { decoded += try stream.append(Data([byte])) }
|
||||
require(decoded.count == 1 && decoded[0].payload == payload, "Byte-fragmented frame round trips")
|
||||
let pair = try stream.append(frame + frame)
|
||||
require(pair.count == 2, "Coalesced frames decode")
|
||||
for malformed in [Data([0, 0, 0, 0]), Data([0, 1, 0, 1]), Data([255, 255, 255, 255])] {
|
||||
do { var parser = CastWire.Stream(); _ = try parser.append(malformed); fatalError("FAIL: invalid length accepted") }
|
||||
catch CastError.protocolError { }
|
||||
}
|
||||
for malformed in [Data([8, 128]), Data([8, 0, 18, 127]), Data([15]), Data(repeating: 255, count: 12)] {
|
||||
do { _ = try CastWire.decode(malformed); fatalError("FAIL: malformed protobuf accepted") }
|
||||
catch CastError.protocolError { }
|
||||
}
|
||||
do {
|
||||
_ = try CastWire.frame(.init(source: "s", destination: "r", namespace: "m",
|
||||
payload: Data(repeating: 65, count: CastWire.maximumFrame)))
|
||||
fatalError("FAIL: oversized outbound frame accepted")
|
||||
} catch CastError.protocolError { }
|
||||
}
|
||||
|
||||
static func media(_ suffix: String = "first") -> CastMediaDescription {
|
||||
.init(url: URL(string: "http://127.0.0.1:9123/session-token/\(suffix).m3u8")!,
|
||||
contentType: "application/x-mpegURL", duration: 120, live: false,
|
||||
tracks: [.init(id: 17, url: URL(string: "http://127.0.0.1:9123/session-token/subtitle.vtt")!,
|
||||
language: "en", label: "English")])
|
||||
}
|
||||
|
||||
static func behavior(identity: SecIdentity) async throws {
|
||||
let peer = try Receiver(identity: identity)
|
||||
peer.muted = true
|
||||
try await peer.start()
|
||||
defer { peer.stop() }
|
||||
let clock = CastCheckClock()
|
||||
let session = CastSession(sleep: { try await clock.sleep($0) })
|
||||
var statuses: [CastStatus] = []
|
||||
var errors: [String] = []
|
||||
var disconnected = false
|
||||
session.onStatus = { statuses.append($0) }
|
||||
session.onError = { errors.append($0) }
|
||||
session.onDisconnect = { _ in disconnected = true }
|
||||
try await session.connect(to: peer.device)
|
||||
require(peer.commands.first?.0 == "CONNECT", "TLS platform channel connects before launching")
|
||||
require(!session.setPlaying(true), "No media control before load acknowledgement")
|
||||
try await session.load(media(), title: "Check", position: 9.25, playing: false, subtitle: 0)
|
||||
require(statuses.last?.position == 9.25 && statuses.last?.playing == false && statuses.last?.subtitle == 0,
|
||||
"LOAD is receiver-authoritative and subtitle IDs map to indices")
|
||||
require(statuses.last?.volume == 0, "Pre-existing device mute is reflected in controls")
|
||||
require(session.setPlaying(true) && session.setPlaying(false), "Rapid opposite commands accepted")
|
||||
try await until("PLAY and PAUSE acknowledged in order") {
|
||||
peer.commands.filter { ["PLAY", "PAUSE"].contains($0.0) }.count == 2 && statuses.last?.playing == false
|
||||
}
|
||||
require(peer.commands.filter { ["PLAY", "PAUSE"].contains($0.0) }.map(\.0) == ["PLAY", "PAUSE"], "Ordered controls")
|
||||
let beforeBurst = statuses.count
|
||||
require(session.setPlaying(true) && session.setPlaying(false) && session.setPlaying(true),
|
||||
"Three rapid intents are accepted")
|
||||
try await until("Latest playback intent is acknowledged") {
|
||||
statuses.last?.playing == true && statuses.last?.playbackPending == false
|
||||
}
|
||||
require(statuses.dropFirst(beforeBurst).contains { $0.playing && $0.playbackPending },
|
||||
"An older matching PLAY cannot settle the latest playback intent")
|
||||
require(session.setPlaying(false), "Pause before seek checks")
|
||||
try await until("Pause settles") { statuses.last?.playing == false && statuses.last?.playbackPending == false }
|
||||
peer.holdSeek = true
|
||||
let beforeSeek = statuses.count
|
||||
require(session.seek(24.25), "SEEK accepted")
|
||||
try await until("Old status arrives while SEEK pending") {
|
||||
peer.commands.contains { $0.0 == "SEEK" } && statuses.count >= beforeSeek + 2
|
||||
}
|
||||
require(statuses.last?.seekPending == true && statuses.last?.position == 9.25,
|
||||
"Old status cannot clear an unacknowledged seek or invent position")
|
||||
try await until("Heartbeat is waiting on the controlled clock") { clock.hasDelay(.seconds(5)) }
|
||||
clock.advance(.seconds(5))
|
||||
try await until("Newer status poll replies before held SEEK") {
|
||||
peer.commands.contains { $0.0 == "GET_STATUS" } && statuses.last?.seekPending == true
|
||||
}
|
||||
try peer.releaseSeek()
|
||||
try await until("SEEK request correlation") { statuses.last?.seekPending == false && statuses.last?.position == 24.25 }
|
||||
require(session.setVolume(0.3), "Device volume accepted")
|
||||
try await until("Device volume acknowledged") { statuses.last?.volume == 0.3 }
|
||||
require(session.setSubtitle(nil), "Subtitle off accepted")
|
||||
try await until("Subtitle off acknowledged") { statuses.last?.subtitle == nil }
|
||||
let oldSession = 2
|
||||
try await session.load(media("replacement"), title: "Replacement", position: 3, playing: true, subtitle: nil)
|
||||
let statusCount = statuses.count
|
||||
try peer.status(session: oldSession, content: media().url.absoluteString)
|
||||
try peer.status()
|
||||
try await until("Valid status after stale frame provides an ordered receiver barrier") {
|
||||
statuses.count >= statusCount + 1
|
||||
}
|
||||
require(statuses.count == statusCount + 1 && !disconnected, "Only the valid barrier status is published")
|
||||
try peer.status(state: "IDLE", idle: "FINISHED")
|
||||
try await until("Receiver reports natural end") { statuses.last?.ended == true }
|
||||
let beforeReplay = peer.commands.filter { $0.0 == "LOAD" }.count
|
||||
require(session.setPlaying(true) && session.setPlaying(false), "Ended replay and rapid pause accepted")
|
||||
try await until("Ended replay uses LOAD from zero then honors pause") {
|
||||
peer.commands.filter { $0.0 == "LOAD" }.count == beforeReplay + 1 &&
|
||||
statuses.last?.position == 0 && statuses.last?.playing == false && statuses.last?.ended == false
|
||||
}
|
||||
require(peer.commands.last(where: { $0.0 == "LOAD" })?.1["currentTime"] as? Double == 0,
|
||||
"Default receiver ended media reloaded, not invalid PLAY")
|
||||
require(!session.seek(.nan) && !session.setVolume(2) && !session.setSubtitle(99), "Invalid command input rejected")
|
||||
session.disconnect()
|
||||
try await until("Owned media STOP arrives") { peer.commands.contains { $0.0 == "STOP" } }
|
||||
require(disconnected && errors.isEmpty && peer.failure == nil, "Safe disconnect without spurious cancellation errors")
|
||||
}
|
||||
|
||||
static func connectionDeadline() async throws {
|
||||
let listener = try NWListener(using: .tcp, on: .any)
|
||||
@MainActor final class ListenerState {
|
||||
var accepted: [NWConnection] = []
|
||||
var listening = false
|
||||
}
|
||||
let state = ListenerState()
|
||||
listener.stateUpdateHandler = { event in
|
||||
Task { @MainActor in if case .ready = event { state.listening = true } }
|
||||
}
|
||||
listener.newConnectionHandler = { socket in
|
||||
Task { @MainActor in
|
||||
state.accepted.append(socket)
|
||||
// Accept TCP but never answer the client's TLS handshake.
|
||||
socket.start(queue: DispatchQueue.global(qos: .userInitiated))
|
||||
}
|
||||
}
|
||||
listener.start(queue: DispatchQueue.global(qos: .userInitiated))
|
||||
defer { for socket in state.accepted { socket.cancel() }; listener.cancel() }
|
||||
try await until("Incomplete TLS listener starts") { state.listening }
|
||||
let clock = CastCheckClock()
|
||||
let session = CastSession(sleep: { try await clock.sleep($0) })
|
||||
let task = Task {
|
||||
try await session.connect(to: .init(id: "stall", name: "Stalled TLS", host: "127.0.0.1",
|
||||
port: listener.port!.rawValue))
|
||||
}
|
||||
try await until("Connection handshake waits on frozen clock") {
|
||||
!state.accepted.isEmpty && clock.hasDelay(.seconds(12))
|
||||
}
|
||||
clock.advance(.seconds(12))
|
||||
do { try await task.value; fatalError("FAIL: stalled TLS handshake succeeded") }
|
||||
catch CastError.timeout { }
|
||||
try await until("Connection deadline cancels all timer handles") { clock.pendingCount == 0 }
|
||||
}
|
||||
|
||||
static func failures(identity: SecIdentity) async throws {
|
||||
for mode in ["launch", "launch-timeout", "load", "deadline", "command", "rejection", "cancel", "frame",
|
||||
"ownership", "media-error", "media-cancel", "media-interrupt", "heartbeat", "queue"] {
|
||||
let peer = try Receiver(identity: identity)
|
||||
try await peer.start()
|
||||
let clock = CastCheckClock()
|
||||
let session = CastSession(requestTimeout: .milliseconds(400), loadTimeout: .milliseconds(400),
|
||||
heartbeatInterval: mode == "heartbeat" ? .milliseconds(100) : .seconds(5),
|
||||
sleep: { try await clock.sleep($0) })
|
||||
var disconnected = false
|
||||
var errors: [String] = []
|
||||
session.onDisconnect = { _ in disconnected = true }
|
||||
session.onError = { errors.append($0) }
|
||||
if mode == "launch" { peer.rejectLaunch = true }
|
||||
if mode == "launch-timeout" { peer.dropLaunch = true }
|
||||
if mode == "heartbeat" { peer.ignoreHeartbeat = true }
|
||||
if mode == "launch" {
|
||||
do { try await session.connect(to: peer.device); fatalError("FAIL: rejected launch succeeded") }
|
||||
catch CastError.rejected { }
|
||||
} else if mode == "launch-timeout" {
|
||||
let task = Task { try await session.connect(to: peer.device) }
|
||||
try await until("Receiver launch waits on frozen clock") {
|
||||
peer.commands.contains { $0.0 == "LAUNCH" } && clock.hasDelay(.milliseconds(400))
|
||||
}
|
||||
clock.advance(.milliseconds(400))
|
||||
do { try await task.value; fatalError("FAIL: unanswered launch succeeded") }
|
||||
catch CastError.timeout { require(disconnected, "Launch deadline releases channel") }
|
||||
} else {
|
||||
try await session.connect(to: peer.device)
|
||||
try await until("Handshake deadline handles cancelled") { clock.pendingCount == 1 }
|
||||
peer.rejectLoad = mode == "load"
|
||||
peer.dropLoad = mode == "deadline" || mode == "cancel"
|
||||
if mode == "load" || mode == "deadline" {
|
||||
let task = Task { try await session.load(media(), title: "Failure", position: 0, playing: true, subtitle: nil) }
|
||||
if mode == "deadline" {
|
||||
try await until("LOAD waits on frozen clock") {
|
||||
peer.commands.contains { $0.0 == "LOAD" } && clock.hasDelay(.milliseconds(400))
|
||||
}
|
||||
clock.advance(.milliseconds(400))
|
||||
}
|
||||
do { try await task.value; fatalError("FAIL: failed load succeeded") }
|
||||
catch CastError.rejected { require(mode == "load", "LOAD rejection correlated") }
|
||||
catch CastError.timeout { require(mode == "deadline", "LOAD deadline bounded") }
|
||||
} else if mode == "cancel" {
|
||||
let task = Task { try await session.load(media(), title: "Cancel", position: 0, playing: true, subtitle: nil) }
|
||||
try await until("LOAD sent before cancellation") { peer.commands.contains { $0.0 == "LOAD" } }
|
||||
task.cancel()
|
||||
do { try await task.value; fatalError("FAIL: cancelled LOAD succeeded") }
|
||||
catch { require(disconnected, "LOAD cancellation releases channel") }
|
||||
} else {
|
||||
try await session.load(media(), title: "Check", position: 0, playing: false, subtitle: nil)
|
||||
switch mode {
|
||||
case "command":
|
||||
peer.dropCommands = true
|
||||
require(session.seek(12), "Unanswered SEEK sent")
|
||||
try await until("SEEK waits on frozen clock") {
|
||||
peer.commands.contains { $0.0 == "SEEK" } && clock.hasDelay(.milliseconds(400))
|
||||
}
|
||||
clock.advance(.milliseconds(400))
|
||||
try await until("Command deadline reports recoverable error") { !errors.isEmpty }
|
||||
require(!disconnected, "Command timeout is not channel failure")
|
||||
case "rejection":
|
||||
peer.rejectCommand = true
|
||||
require(session.setPlaying(true), "Rejected command sent")
|
||||
try await until("Command rejection reports recoverable error") { !errors.isEmpty }
|
||||
require(!disconnected, "Command rejection is not channel failure")
|
||||
case "media-error": try peer.status(state: "IDLE", idle: "ERROR")
|
||||
case "media-cancel": try peer.status(state: "IDLE", idle: "CANCELLED")
|
||||
case "media-interrupt": try peer.status(state: "IDLE", idle: "INTERRUPTED")
|
||||
case "frame": peer.invalidFrame()
|
||||
case "ownership": try peer.takeOverApplication()
|
||||
case "heartbeat":
|
||||
try await until("Heartbeat tick is scheduled on frozen clock") { clock.hasDelay(.milliseconds(100)) }
|
||||
clock.advance(.milliseconds(100))
|
||||
try await until("PING sent and next heartbeat tick scheduled") {
|
||||
peer.commands.contains { $0.0 == "PING" } && clock.hasDelay(.milliseconds(100))
|
||||
}
|
||||
clock.advance(.milliseconds(100))
|
||||
case "queue":
|
||||
peer.dropCommands = true
|
||||
var accepted = 0
|
||||
for _ in 0..<40 { if session.setPlaying(true) { accepted += 1 } }
|
||||
require(accepted == 32 && !errors.isEmpty, "Pending command resource bound enforced")
|
||||
default: break
|
||||
}
|
||||
if ["frame", "ownership", "media-error", "media-cancel", "media-interrupt", "heartbeat"].contains(mode) {
|
||||
try await until("\(mode) causes bounded channel failure") { disconnected }
|
||||
}
|
||||
}
|
||||
}
|
||||
session.disconnect(stopReceiver: false)
|
||||
try await until("Cancelled timer handles released") { clock.pendingCount == 0 }
|
||||
peer.stop()
|
||||
}
|
||||
}
|
||||
|
||||
static func playback(identity: SecIdentity, executable: String, file: String) async throws {
|
||||
let peer = try Receiver(identity: identity)
|
||||
peer.verifyRelay = true
|
||||
try await peer.start()
|
||||
defer { peer.stop() }
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: executable)
|
||||
process.arguments = [file, String(peer.port)]
|
||||
try process.run()
|
||||
defer { if process.isRunning { process.terminate() } }
|
||||
try await until("Integrated playback subprocess completes", attempts: 18000) { !process.isRunning || peer.failure != nil }
|
||||
require(peer.failure == nil, peer.failure ?? "Simulated receiver accepts integration flow")
|
||||
require(process.terminationStatus == 0, "Player model integration check succeeds")
|
||||
try await until("Disconnect STOP reaches receiver") { peer.commands.contains { $0.0 == "STOP" } }
|
||||
let load = peer.commands.first { $0.0 == "LOAD" }!.1
|
||||
require(load["currentTime"] as? Double == 9.25 && load["autoplay"] as? Bool == false, "Acknowledged paused handoff")
|
||||
require(peer.relayVerified, "Receiver fetches real relay HLS and media bytes")
|
||||
let controls = peer.commands.filter { ["PLAY", "SEEK", "PAUSE", "STOP"].contains($0.0) }
|
||||
require(controls.map(\.0) == ["PLAY", "SEEK", "SEEK", "PAUSE", "STOP"], "Integrated controls ordered")
|
||||
require(controls[1].1["currentTime"] as? Double == 14.25 &&
|
||||
controls[2].1["currentTime"] as? Double == 24.25, "Rapid skips accumulate from the pending target")
|
||||
}
|
||||
|
||||
static func realPeer(_ arguments: [String]) async throws {
|
||||
guard let port = UInt16(arguments[3]), let url = URL(string: arguments[4]) else {
|
||||
fatalError("Usage: cast-session-check --peer HOST PORT MEDIA_URL CONTENT_TYPE")
|
||||
}
|
||||
let session = CastSession()
|
||||
var status: CastStatus?
|
||||
var error: String?
|
||||
session.onStatus = { status = $0 }
|
||||
session.onDisconnect = { error = $0 }
|
||||
session.onError = { error = $0 }
|
||||
defer { session.disconnect() }
|
||||
try await session.connect(to: .init(id: "smoke", name: "Smoke receiver", host: arguments[2], port: port))
|
||||
try await session.load(.init(url: url, contentType: arguments[5], duration: 0, live: false, tracks: []),
|
||||
title: "Terebi Cast smoke", position: 0, playing: false, subtitle: nil)
|
||||
require(status != nil, "Real receiver acknowledges LOAD")
|
||||
require(session.setPlaying(true), "Real receiver PLAY accepted")
|
||||
try await until("Real receiver plays", attempts: 1500) { status?.playing == true || error != nil }
|
||||
require(error == nil && status?.playing == true, "Real receiver acknowledges PLAY")
|
||||
require(session.setPlaying(false), "Real receiver PAUSE accepted")
|
||||
try await until("Real receiver pauses", attempts: 1500) { status?.playing == false || error != nil }
|
||||
require(error == nil && status?.playing == false, "Real receiver acknowledges PAUSE")
|
||||
print("PASS: real peer TLS Default Media Receiver LOAD/PLAY/PAUSE (URLs intentionally omitted)")
|
||||
}
|
||||
|
||||
static func main() async throws {
|
||||
if CommandLine.arguments.count == 6, CommandLine.arguments[1] == "--peer" {
|
||||
try await realPeer(CommandLine.arguments)
|
||||
return
|
||||
}
|
||||
try codec()
|
||||
let (identity, directory) = try testIdentity()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--playback" {
|
||||
try await playback(identity: identity, executable: CommandLine.arguments[2], file: CommandLine.arguments[3])
|
||||
print("PASS: native TLS receiver fetches relay and acknowledges PlayerModel handoff/control/disconnect")
|
||||
} else {
|
||||
try await behavior(identity: identity)
|
||||
try await failures(identity: identity)
|
||||
try await connectionDeadline()
|
||||
print("PASS: bounded Cast framing, self-signed TLS launch/load/control, correlation, stale ownership, queue, deadlines, heartbeat, cancellation")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -813,6 +813,18 @@ static void reporting(const std::string &base, const std::string &fixtures) {
|
||||
std::abs([rows[0][@"position_seconds"] doubleValue] - 50) < .03 &&
|
||||
[rows[0][@"completion_eligible"] boolValue],
|
||||
"initial report is restored native position, not pre-resume zero");
|
||||
PlayerState receiver = player_get_state(player.get());
|
||||
receiver.position = 114;
|
||||
receiver.status = PLAYER_PAUSED;
|
||||
require(player_report_remote_state(player.get(), &receiver), "receiver-confirmed progress accepted");
|
||||
require(player_close(player.get()), "close immediately after receiver progress");
|
||||
until(player.get(), [&](auto state) {
|
||||
auto current = progressRecords(path);
|
||||
return state.status == PLAYER_EMPTY && [current.lastObject[@"event"] isEqualToString:@"closed"] &&
|
||||
[current.lastObject[@"position_seconds"] doubleValue] == 114;
|
||||
}, "closing drains final Cast position instead of abandoned decoder position");
|
||||
require([progressRecords(path).lastObject[@"completion_eligible"] boolValue],
|
||||
"receiver-confirmed video remains completion eligible");
|
||||
require(openNetwork(player.get(), base, "/failure/av.mp4", "", 4, false, true, second),
|
||||
"failed requested identity accepted asynchronously");
|
||||
until(player.get(), [](auto state) { return state.status == PLAYER_ERROR; }, "failed reported open", true);
|
||||
|
||||
Reference in New Issue
Block a user