diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7136926..d363252 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Install host test dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends binutils libcurl4-openssl-dev libexpat1-dev libsdl2-dev pkg-config - run: tools/bootstrap.sh - run: tools/zig.sh build fmt-check - run: tools/zig.sh build test diff --git a/README.md b/README.md index 2188625..7d6dcf2 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,16 @@ GreenOvercast game library -GreenOvercast is a native Xbox Cloud Gaming client for small ARM64 Linux -handhelds. It requests a stream matching the device display and uses hardware -video decoding on supported H700 and Rockchip firmware. +GreenOvercast is a native Xbox Cloud Gaming and GeForce NOW client for small +ARM64 Linux handhelds, with hardware video decoding on supported H700 and +Rockchip firmware. -This independent project is not affiliated with or endorsed by Microsoft. +This independent project is not affiliated with Microsoft or NVIDIA. -Requires an [Xbox Game Pass plan](https://www.xbox.com/cloud-gaming) that includes cloud gaming and internet access. +You need internet access and an account for your chosen service: + +- Xbox: an [Xbox Game Pass plan](https://www.xbox.com/cloud-gaming) that includes cloud gaming. +- [GeForce NOW](https://www.nvidia.com/en-us/geforce-now/): a membership and access to the games you want to play. ## Install @@ -19,9 +22,9 @@ the [PortMaster FAQ](https://portmaster.games/faq.html#do-i-have-to-use-portmast When the installation finishes, return to the device frontend and launch GreenOvercast from Ports. -The first launch shows a Microsoft device code. Open -[microsoft.com/link](https://www.microsoft.com/link) on another device and enter -the code to sign in. +Choose Xbox or GeForce NOW, then open the sign-in link shown on screen on +another device and enter the code. Your login is saved. You can switch services +from Settings without closing the app. GreenOvercast is experimental. @@ -44,28 +47,46 @@ GreenOvercast is experimental. Settings include Xbox/Nintendo face-button layouts, game artwork, and Sign out. Games that return a 16:9 stream remain letterboxed on 4:3 displays. +GeForce NOW uses the same gamepad controls. For store dialogs and game +launchers, press Select + Y to toggle mouse mode: D-pad or left stick moves the +pointer, A clicks, B right-clicks, and L1 / R1 scroll. Store sign-in may still be +required inside the stream. + +GeForce NOW Free sessions may show ads before the game. Start opens ad information +when an icon is shown; B returns to the ad or cancels playback. + ## Supported devices -| Device | OS | Status | -| ---------- | ------------------------------- | ------ | -| RG35XX-H | muOS 2508.4 | Tested | -| RG40XX-H | Knulli (Batocera 42) | Tested | -| RG40XX-H | ROCKNIX 20260801 | Tested | -| Miyoo Flip | SpruceOS 4.2.0 | Tested | -| R36S | AmberELEC prerelease-20250515 | Tested | - -Hardware decoding is verified on the H700 systems above. muOS and Knulli use -CedarX; ROCKNIX 20260801 uses the bundled Cedrus modules. Tested Rockchip builds -use Rockchip MPP on RK3566 with SpruceOS and RK3326 with AmberELEC. Other +| Device | SoC | OS | Status | +| ---------- | --------------- | ----------------------------- | ------ | +| RG35XX-H | Allwinner H700 | muOS 2508.4 | Tested | +| RG40XX-H | Allwinner H700 | Knulli (Batocera 42) | Tested | +| RG40XX-H | Allwinner H700 | ROCKNIX 20260801 | Tested | +| Miyoo Flip | Rockchip RK3566 | SpruceOS 4.3.3 | Tested | +| R36S | Rockchip RK3326 | AmberELEC prerelease-20250515 | Tested | + +Hardware decoding is verified on the configurations above. H700 uses CedarX on +muOS and Knulli, and Cedrus on ROCKNIX. RK3566 and RK3326 use Rockchip MPP. Other devices fall back to software decoding, which is too slow for normal gameplay. -The release requires glibc 2.38 or newer. ArkOS ships glibc 2.30 and is not -supported. +GeForce NOW playback has been tested on muOS, Knulli, SpruceOS, and the R36S +running dArkOS 02012026. R36S frame pacing still needs work. The ROCKNIX and +AmberELEC rows above currently cover Xbox playback. + +The release requires glibc 2.38 or newer. ArkOS (glibc 2.30) is not supported; +the tested dArkOS fork uses glibc 2.41. ## Build -You need a Linux or macOS host with `cmake`, `curl`, `git`, `make`, `patch`, -`perl`, and `python3`. +You need a Linux or macOS host with CMake 3.24+, `curl`, `git`, `make`, `patch`, +`perl`, `pkg-config`, `python3`, and `tar`. + +Build validation also needs `llvm-readelf` or GNU `readelf` (`binutils` on +Debian/Ubuntu, or `llvm` with its `bin` directory on `PATH` on macOS). + +Host tests also need SDL2, curl and Expat development files: install +`libsdl2-dev libcurl4-openssl-dev libexpat1-dev pkg-config` on Debian/Ubuntu or +`sdl2 curl expat pkg-config` with Homebrew. ```sh tools/bootstrap.sh diff --git a/THIRDPARTY.md b/THIRDPARTY.md index e4ba393..c0b2a63 100644 --- a/THIRDPARTY.md +++ b/THIRDPARTY.md @@ -15,9 +15,14 @@ release. Full license texts for the bundled libraries ship alongside the port in | [CedarX](https://github.com/allwinner-zh/media-codec) (Allwinner H.264 decoder) | LGPLv2.1-or-later | Hardware H.264 decode (shared) | | [Linux Cedrus](https://www.kernel.org/) (7.0.11) | GPL-2.0-only | ROCKNIX H700 V4L2 Request decoder modules | | [Rockchip MPP](https://github.com/rockchip-linux/mpp) (v1.1.0) | Apache-2.0/MIT | Optional Rockchip H.264 decode (shared) | -| [FFmpeg](https://ffmpeg.org/) (libavcodec, libavutil, libswscale) | LGPLv2.1-or-later | H.264 decode, JPEG artwork, and color conversion | +| [FFmpeg](https://ffmpeg.org/) | LGPLv2.1-or-later | Video/audio decode, MP4 ads, artwork, and color conversion | +| [Expat](https://libexpat.github.io/) | MIT | VAST XML parsing (static) | +| [zlib](https://zlib.net/) | Zlib | PNG decompression (static into FFmpeg) | | [libudev-zero](https://github.com/illiliti/libudev-zero) | ISC | V4L2 media-device discovery (static) | | [SDL2](https://www.libsdl.org/) | Zlib | Window, renderer, audio, and controller input (device) | | [OpenSSL](https://www.openssl.org/) | Apache-2.0 | TLS, WebRTC DTLS, and credential encryption (static) | -| [libcurl](https://curl.se/) | curl (MIT-style) | HTTP for Xbox services (static) | +| [libcurl](https://curl.se/) | curl (MIT-style) | HTTP and WebSocket transport (static) | | [Opus](https://opus-codec.org/) | BSD-3-Clause | Audio decode (static) | + +Xbox and NVIDIA icons are from [Bootstrap Icons](https://github.com/twbs/icons) +(MIT). diff --git a/build.zig b/build.zig index d930e64..0953c82 100644 --- a/build.zig +++ b/build.zig @@ -3,15 +3,17 @@ const std = @import("std"); // glibc 2.38 is the oldest userspace supported by the packaged release. const aarch64_linux_query: std.Target.Query = .{ .cpu_arch = .aarch64, + // Keep baseline ARM64 atomics independent of firmware runtime helpers. + .cpu_features_sub = std.Target.aarch64.featureSet(&.{.outline_atomics}), .os_tag = .linux, .abi = .gnu, .glibc_version = .{ .major = 2, .minor = 38, .patch = 0 }, }; const c_test_flags = &.{ "-std=c11", "-Wall", "-Wextra", "-Werror" }; +const aarch64_c_test_flags = &.{ "-std=c11", "-Wall", "-Wextra", "-Werror", "-mno-outline-atomics" }; const project_include_paths = [_][]const u8{ - ".tools/deps/aarch64-linux-gnu/include", "vendor/libdatachannel/include", "src/media/audio", "src/media/video", @@ -51,6 +53,7 @@ const release_zig_roots = [_]struct { .imports = &.{ .{ .name = "json_reader", .path = "src/net/json_reader.zig" }, .{ .name = "json_writer", .path = "src/net/json_writer.zig" }, + .{ .name = "uuid", .path = "src/util/uuid.zig" }, }, }, .{ .name = "greenovercast-controller", .path = "src/input/controller.zig" }, @@ -81,6 +84,7 @@ const release_c_sources = [_][]const u8{ "src/media/video/video_decoder_v4l2_request.c", "src/auth/token_store_adapter.c", "src/net/http_client.c", + "src/net/websocket_client.c", "src/ui/artwork_decoder.c", }; @@ -122,6 +126,7 @@ fn addReleaseZigObject( .link_libc = true, }); addProjectIncludes(b, module); + module.addIncludePath(b.path(".tools/deps/aarch64-linux-gnu/include")); for (imports) |item| addZigImport(b, module, target, optimize, item.name, item.path); return b.addObject(.{ .name = name, .root_module = module }); @@ -135,17 +140,52 @@ fn addZigImport( name: []const u8, source: []const u8, ) void { - module.addImport(name, b.createModule(.{ + const imported = b.createModule(.{ .root_source_file = b.path(source), .target = target, .optimize = optimize, - })); + }); + if (std.mem.eql(u8, name, "xml_document") or std.mem.eql(u8, name, "clip_player")) + imported.addIncludePath(b.path(".tools/deps/aarch64-linux-gnu/include")); + module.addImport(name, imported); } fn addStaticArchive(module: *std.Build.Module, b: *std.Build, path: []const u8) void { module.addObjectFile(b.path(path)); } +fn addHostUnitTest( + b: *std.Build, + test_step: *std.Build.Step, + source: []const u8, + project_includes: bool, + sdl: bool, + imports: []const ZigImport, +) void { + const module = b.createModule(.{ + .root_source_file = b.path(source), + .target = b.graph.host, + .optimize = .Debug, + .link_libc = project_includes, + }); + if (project_includes) addProjectIncludes(b, module); + if (sdl) module.linkSystemLibrary("SDL2", .{ .use_pkg_config = .force }); + if (std.mem.endsWith(u8, source, "/vast.zig") or std.mem.endsWith(u8, source, "/ad_policy.zig") or + std.mem.endsWith(u8, source, "/ad_network.zig")) + addZigImport(b, module, b.graph.host, .Debug, "xml_document", "src/net/xml_document.zig"); + if (std.mem.endsWith(u8, source, "/vast.zig") or std.mem.endsWith(u8, source, "/xml_document.zig") or + std.mem.endsWith(u8, source, "/ad_policy.zig") or std.mem.endsWith(u8, source, "/ad_network.zig")) + module.linkSystemLibrary("expat", .{ .use_pkg_config = .force }); + if (std.mem.endsWith(u8, source, "/ad_network.zig")) { + module.addCSourceFile(.{ .file = b.path("src/net/http_client.c"), .flags = &.{ "-std=gnu11", "-D_GNU_SOURCE", "-Wall", "-Wextra", "-Werror" } }); + module.linkSystemLibrary("curl", .{ .use_pkg_config = .force }); + } + for (imports) |item| + addZigImport(b, module, b.graph.host, .Debug, item.name, item.path); + const unit_tests = b.addTest(.{ .root_module = module }); + test_step.dependOn(&b.addRunArtifact(unit_tests).step); +} + fn addReleaseArtifacts( b: *std.Build, target: std.Build.ResolvedTarget, @@ -166,11 +206,38 @@ fn addReleaseArtifacts( .strip = true, }); addProjectIncludes(b, main_module); + main_module.addIncludePath(b.path(".tools/deps/aarch64-linux-gnu/include")); + addZigImport(b, main_module, target, optimize, "xml_document", "src/net/xml_document.zig"); + addZigImport(b, main_module, target, optimize, "clip_player", "src/media/clip/player.zig"); + addZigImport( + b, + main_module, + target, + optimize, + "form_writer", + "src/net/form_writer.zig", + ); + addZigImport( + b, + main_module, + target, + optimize, + "catalog_parser", + "src/catalog/catalog_parser.zig", + ); + addZigImport( + b, + main_module, + target, + optimize, + "uuid", + "src/util/uuid.zig", + ); main_module.addLibraryPath(b.path(".tools/deps/aarch64-linux-gnu/lib")); main_module.addRPathSpecial("$ORIGIN"); main_module.addCSourceFiles(.{ .files = &release_c_sources, - .flags = &.{ "-std=gnu11", "-D_GNU_SOURCE", "-Wall", "-Wextra", "-Werror" }, + .flags = &.{ "-std=gnu11", "-D_GNU_SOURCE", "-Wall", "-Wextra", "-Werror", "-mno-outline-atomics" }, }); for (release_zig_roots) |root| { @@ -188,7 +255,8 @@ fn addReleaseArtifacts( addStaticArchive(main_module, b, ".tools/deps/aarch64-linux-gnu/lib/libssl.a"); addStaticArchive(main_module, b, ".tools/deps/aarch64-linux-gnu/lib/libcrypto.a"); addStaticArchive(main_module, b, ".tools/deps/aarch64-linux-gnu/lib/libopus.a"); - for ([_][]const u8{ "pthread", "dl", "SDL2", "avcodec", "avutil", "swscale" }) |library| { + addStaticArchive(main_module, b, ".tools/deps/aarch64-linux-gnu/lib/libexpat.a"); + for ([_][]const u8{ "pthread", "dl", "SDL2", "avcodec", "avutil", "swscale", "avformat", "swresample" }) |library| { main_module.linkSystemLibrary(library, .{ .use_pkg_config = .no }); } @@ -217,6 +285,7 @@ fn addReleaseArtifacts( .files = &cedar_sources, .flags = &.{ "-std=gnu11", + "-mno-outline-atomics", "-D_GNU_SOURCE", "-includestdint.h", "-fvisibility=hidden", @@ -244,7 +313,7 @@ fn addReleaseArtifacts( cedar_test_module.addIncludePath(b.path("src/media/video")); cedar_test_module.addCSourceFile(.{ .file = b.path("tests/device/cedar_bridge_test.c"), - .flags = c_test_flags, + .flags = aarch64_c_test_flags, }); cedar_test_module.linkLibrary(cedar); cedar_test_module.addRPathSpecial("$ORIGIN"); @@ -267,7 +336,7 @@ fn addReleaseArtifacts( mpp_module.addRPathSpecial("$ORIGIN"); mpp_module.addCSourceFile(.{ .file = b.path("src/media/video/mpp_bridge.c"), - .flags = c_test_flags, + .flags = aarch64_c_test_flags, }); mpp_module.linkSystemLibrary("rockchip_mpp", .{ .use_pkg_config = .no, @@ -316,7 +385,7 @@ fn addReleaseArtifacts( }); module.addIncludePath(b.path("src/media/video")); module.addRPathSpecial("$ORIGIN"); - module.addCSourceFile(.{ .file = b.path(item.source), .flags = c_test_flags }); + module.addCSourceFile(.{ .file = b.path(item.source), .flags = aarch64_c_test_flags }); module.addObject(mpp_loader_device_object); module.linkSystemLibrary("dl", .{ .use_pkg_config = .no }); const tool = b.addExecutable(.{ .name = item.name, .root_module = module }); @@ -330,6 +399,8 @@ fn addReleaseArtifacts( .{ .source = ".tools/deps/aarch64-linux-gnu/lib/libavcodec.so.63", .name = "libavcodec.so.63" }, .{ .source = ".tools/deps/aarch64-linux-gnu/lib/libavutil.so.61", .name = "libavutil.so.61" }, .{ .source = ".tools/deps/aarch64-linux-gnu/lib/libswscale.so.10", .name = "libswscale.so.10" }, + .{ .source = ".tools/deps/aarch64-linux-gnu/lib/libavformat.so.63", .name = "libavformat.so.63" }, + .{ .source = ".tools/deps/aarch64-linux-gnu/lib/libswresample.so.7", .name = "libswresample.so.7" }, }; var private_library_installs: [private_libraries.len]*std.Build.Step.InstallFile = undefined; for (private_libraries, 0..) |library, index| { @@ -341,14 +412,33 @@ fn addReleaseArtifacts( private_library_installs[index] = b.addInstallBinFile(copy.getOutput(), library.name); } + const runtime_check = b.addSystemCommand(&.{ + "sh", + b.pathFromRoot("tools/check-aarch64-runtime.sh"), + b.getInstallPath(.bin, "webrtc_stream"), + b.getInstallPath(.lib, "libgreenovercast-cedar.so"), + b.getInstallPath(.bin, "cedar_bridge_test"), + b.getInstallPath(.prefix, "rockchip/libgreenovercast-mpp.so"), + b.getInstallPath(.prefix, "rockchip/librockchip_mpp.so.1"), + b.getInstallPath(.prefix, "rockchip/greenovercast-mpp-probe.aarch64"), + b.getInstallPath(.prefix, "rockchip/greenovercast-mpp-bridge-test.aarch64"), + b.getInstallPath(.bin, "libavcodec.so.63"), + b.getInstallPath(.bin, "libavutil.so.61"), + b.getInstallPath(.bin, "libswscale.so.10"), + b.getInstallPath(.bin, "libavformat.so.63"), + b.getInstallPath(.bin, "libswresample.so.7"), + }); + runtime_check.setCwd(b.path(".")); + runtime_check.step.dependOn(&install_executable.step); + runtime_check.step.dependOn(&install_cedar.step); + runtime_check.step.dependOn(&install_cedar_test.step); + runtime_check.step.dependOn(&install_mpp.step); + runtime_check.step.dependOn(&install_mpp_runtime.step); + for (mpp_device_installs) |install| runtime_check.step.dependOn(&install.step); + for (private_library_installs) |install| runtime_check.step.dependOn(&install.step); + const release = b.step("release", "Build the aarch64 application and decoder plugins"); - release.dependOn(&install_executable.step); - release.dependOn(&install_cedar.step); - release.dependOn(&install_cedar_test.step); - release.dependOn(&install_mpp.step); - release.dependOn(&install_mpp_runtime.step); - for (mpp_device_installs) |install| release.dependOn(&install.step); - for (private_library_installs) |install| release.dependOn(&install.step); + release.dependOn(&runtime_check.step); return release; } @@ -407,6 +497,39 @@ pub fn build(b: *std.Build) void { const release = addReleaseArtifacts(b, aarch64_linux, .ReleaseSafe); b.default_step.dependOn(release); + const ad_test_module = b.createModule(.{ + .root_source_file = b.path("tests/device/ad_playback_test.zig"), + .target = aarch64_linux, + .optimize = .ReleaseSafe, + .link_libc = true, + }); + addProjectIncludes(b, ad_test_module); + ad_test_module.addIncludePath(b.path(".tools/deps/aarch64-linux-gnu/include")); + ad_test_module.addLibraryPath(b.path(".tools/deps/aarch64-linux-gnu/lib")); + const ads_module = b.createModule(.{ + .root_source_file = b.path("src/provider/geforce_now/ad_playback.zig"), + .target = aarch64_linux, + .optimize = .ReleaseSafe, + }); + addProjectIncludes(b, ads_module); + ads_module.addIncludePath(b.path(".tools/deps/aarch64-linux-gnu/include")); + for ([_]ZigImport{ + .{ .name = "xml_document", .path = "src/net/xml_document.zig" }, + .{ .name = "clip_player", .path = "src/media/clip/player.zig" }, + .{ .name = "uuid", .path = "src/util/uuid.zig" }, + }) |item| addZigImport(b, ads_module, aarch64_linux, .ReleaseSafe, item.name, item.path); + ad_test_module.addImport("gfn_ads", ads_module); + ad_test_module.addCSourceFiles(.{ .files = &.{ "src/ui/artwork_decoder.c", "src/net/http_client.c" }, .flags = aarch64_c_test_flags }); + for ([_][]const u8{ "avcodec", "avformat", "avutil", "swresample", "swscale", "SDL2", "pthread" }) |library| + ad_test_module.linkSystemLibrary(library, .{ .use_pkg_config = .no }); + for ([_][]const u8{ "expat", "curl", "ssl", "crypto" }) |library| + addStaticArchive(ad_test_module, b, b.fmt(".tools/deps/aarch64-linux-gnu/lib/lib{s}.a", .{library})); + ad_test_module.addRPathSpecial("$ORIGIN"); + const ad_test = b.addExecutable(.{ .name = "greenovercast-ad-test.aarch64", .root_module = ad_test_module }); + ad_test.step.dependOn(release); + const ad_test_step = b.step("ad-player-test", "Build the aarch64 ad playback device test"); + ad_test_step.dependOn(&b.addInstallArtifact(ad_test, .{}).step); + const product_check = b.step("product-check", "Build the complete aarch64 product"); product_check.dependOn(release); @@ -435,7 +558,7 @@ pub fn build(b: *std.Build) void { smoke.root_module.addImport("stat_compat", stat_compat); smoke.addCSourceFile(.{ .file = b.path("src/smoke/cpp_probe.cpp"), - .flags = &.{"-std=c++17"}, + .flags = &.{ "-std=c++17", "-mno-outline-atomics" }, }); smoke.linkLibCpp(); @@ -456,32 +579,121 @@ pub fn build(b: *std.Build) void { }); rocknix_build_regression_test.setCwd(b.path(".")); test_step.dependOn(&rocknix_build_regression_test.step); - - const test_roots = [_][]const u8{ - "src/app/state.zig", - "src/catalog/catalog_parser.zig", - "src/catalog/catalog_search.zig", - "src/input/wire_encoder.zig", - "src/input/guide_chord.zig", - "src/session/message_protocol.zig", - "src/ui/keyboard.zig", - "src/ui/control_icons.zig", - "src/ui/navigation_repeat.zig", - "src/ui/persistent_settings.zig", - "src/ui/stream_dimensions.zig", - "src/media/rtp/h264_depacketizer.zig", - "src/net/json_reader.zig", - "src/net/json_writer.zig", - "src/net/form_writer.zig", + const dependency_toolchain_test = b.addSystemCommand(&.{ + "sh", + b.pathFromRoot("tests/dependency_toolchain_test.sh"), + }); + dependency_toolchain_test.setCwd(b.path(".")); + test_step.dependOn(&dependency_toolchain_test.step); + const aarch64_runtime_test = b.addSystemCommand(&.{ + "sh", + b.pathFromRoot("tests/aarch64_runtime_test.sh"), + }); + aarch64_runtime_test.setCwd(b.path(".")); + test_step.dependOn(&aarch64_runtime_test.step); + + const tests = [_]struct { + source: []const u8, + project_includes: bool = false, + sdl: bool = false, + imports: []const ZigImport = &.{}, + }{ + .{ .source = "src/app/state.zig" }, + .{ .source = "src/catalog/catalog_parser.zig" }, + .{ .source = "src/catalog/catalog_search.zig" }, + .{ .source = "src/input/wire_encoder.zig" }, + .{ .source = "src/input/guide_chord.zig" }, + .{ .source = "src/session/message_protocol.zig" }, + .{ .source = "src/ui/keyboard.zig" }, + .{ .source = "src/ui/control_icons.zig" }, + .{ .source = "src/ui/navigation_repeat.zig" }, + .{ .source = "src/ui/persistent_settings.zig" }, + .{ .source = "src/ui/stream_dimensions.zig" }, + .{ .source = "src/media/rtp/h264_depacketizer.zig" }, + .{ .source = "src/net/json_reader.zig" }, + .{ .source = "src/net/json_writer.zig" }, + .{ .source = "src/net/form_writer.zig" }, + .{ .source = "src/util/uuid.zig" }, + .{ .source = "src/provider/geforce_now/input_protocol.zig" }, + .{ .source = "src/provider/geforce_now/pointer_input.zig" }, + .{ .source = "src/provider/geforce_now/endpoint.zig" }, + .{ .source = "src/provider/geforce_now/cloudmatch_protocol.zig" }, + .{ .source = "src/provider/geforce_now/ad_protocol.zig" }, + .{ .source = "src/provider/geforce_now/ad_policy.zig", .project_includes = true }, + .{ .source = "src/provider/geforce_now/ad_network.zig", .project_includes = true }, + .{ .source = "src/net/xml_document.zig", .project_includes = true }, + .{ .source = "src/provider/geforce_now/vast.zig", .project_includes = true }, + .{ .source = "src/provider/geforce_now/subscription_protocol.zig" }, + .{ .source = "src/provider/geforce_now/provider_protocol.zig" }, + .{ .source = "src/provider/geforce_now/signaling_protocol.zig" }, + .{ .source = "src/provider/geforce_now/sdp_protocol.zig" }, + .{ + .source = "src/provider/geforce_now/auth_protocol.zig", + .imports = &.{.{ .name = "form_writer", .path = "src/net/form_writer.zig" }}, + }, + .{ + .source = "src/provider/geforce_now/auth_client.zig", + .project_includes = true, + .sdl = true, + .imports = &.{ + .{ .name = "gfn_http_fake", .path = "tests/gfn_http_fake.zig" }, + .{ .name = "form_writer", .path = "src/net/form_writer.zig" }, + .{ .name = "uuid", .path = "src/util/uuid.zig" }, + }, + }, + .{ + .source = "src/provider/geforce_now/catalog_protocol.zig", + .imports = &.{.{ .name = "catalog_parser", .path = "src/catalog/catalog_parser.zig" }}, + }, + .{ + .source = "src/provider/geforce_now/catalog_service.zig", + .project_includes = true, + .sdl = true, + .imports = &.{ + .{ .name = "gfn_http_fake", .path = "tests/gfn_http_fake.zig" }, + .{ .name = "form_writer", .path = "src/net/form_writer.zig" }, + .{ .name = "catalog_parser", .path = "src/catalog/catalog_parser.zig" }, + .{ .name = "uuid", .path = "src/util/uuid.zig" }, + }, + }, + .{ + .source = "src/provider/geforce_now/session_client.zig", + .project_includes = true, + .sdl = true, + .imports = &.{ + .{ .name = "gfn_http_fake", .path = "tests/gfn_http_fake.zig" }, + .{ .name = "form_writer", .path = "src/net/form_writer.zig" }, + .{ .name = "uuid", .path = "src/util/uuid.zig" }, + }, + }, + .{ + .source = "src/provider/geforce_now/webrtc_session.zig", + .project_includes = true, + .sdl = true, + }, }; - for (test_roots) |root| { - const unit_tests = b.addTest(.{ - .root_source_file = b.path(root), - .target = b.graph.host, - .optimize = .Debug, - }); - test_step.dependOn(&b.addRunArtifact(unit_tests).step); - } + for (tests) |test_config| + addHostUnitTest( + b, + test_step, + test_config.source, + test_config.project_includes, + test_config.sdl, + test_config.imports, + ); + + const http_test = b.addTest(.{ + .root_source_file = b.path("tests/http_client_test.zig"), + .target = b.graph.host, + .optimize = .Debug, + }); + http_test.addIncludePath(b.path("src/net")); + http_test.addCSourceFile(.{ .file = b.path("src/net/http_client.c"), .flags = c_test_flags }); + http_test.linkLibC(); + http_test.linkSystemLibrary("curl"); + const run_http_test = b.addRunArtifact(http_test); + run_http_test.setEnvironmentVariable("NO_PROXY", "127.0.0.1,localhost"); + test_step.dependOn(&run_http_test.step); const video_decoder_object = addHostZigObject(b, "video-decoder", "src/media/video/video_decoder.zig"); @@ -536,6 +748,20 @@ pub fn build(b: *std.Build) void { const fake_cedar_valid = addHostCFakeLibrary(b, "fake-cedar-valid", "tests/cedar_fake_valid.c"); + const cedar_list_test = b.addExecutable(.{ + .name = "cedar-list-test", + .root_source_file = null, + .target = b.graph.host, + .optimize = .ReleaseSafe, + }); + cedar_list_test.addIncludePath(b.path("vendor/cedarx/base/include")); + cedar_list_test.addCSourceFile(.{ + .file = b.path("tests/cedar_list_test.c"), + .flags = &.{ "-std=gnu11", "-Wall", "-Wextra", "-Werror" }, + }); + cedar_list_test.linkLibC(); + test_step.dependOn(&b.addRunArtifact(cedar_list_test).step); + const cedar_decoder_test = addHostCExecutable( b, "video-decoder-cedar-test", diff --git a/packaging/portmaster/greenovercast/GreenOvercast.sh b/packaging/portmaster/greenovercast/GreenOvercast.sh index f88823d..8decca6 100644 --- a/packaging/portmaster/greenovercast/GreenOvercast.sh +++ b/packaging/portmaster/greenovercast/GreenOvercast.sh @@ -57,6 +57,9 @@ chmod 700 "$credential_dir" || fail "Unable to protect GreenOvercast's private s credential_file="$credential_dir/tokens.bin" credential_key_file="$credential_dir/tokens.key" +gfn_credential_file="$credential_dir/geforce-now-tokens.bin" +gfn_credential_key_file="$credential_dir/geforce-now-tokens.key" +gfn_device_id_file="$credential_dir/geforce-now-device-id" video_bootstrap_file="$credential_dir/h264-parameter-sets.bin" catalog_file="$credential_dir/catalog.tsv" settings_file="$config_dir/settings.tsv" @@ -66,7 +69,9 @@ log_file="$credential_dir/greenovercast.log" chmod 600 "$log_file" exec > >(tee "$log_file") 2>&1 -for private_file in "$credential_file" "$credential_key_file" "$catalog_file"; do +for private_file in "$credential_file" "$credential_key_file" \ + "$gfn_credential_file" "$gfn_credential_key_file" "$gfn_device_id_file" \ + "$catalog_file"; do [ ! -e "$private_file" ] || chmod 600 "$private_file" done @@ -78,6 +83,13 @@ fi export GREENOVERCAST_TOKEN_FILE="$credential_file" export GREENOVERCAST_TOKEN_KEY_FILE="$credential_key_file" +export GREENOVERCAST_GFN_TOKEN_FILE="$gfn_credential_file" +export GREENOVERCAST_GFN_TOKEN_KEY_FILE="$gfn_credential_key_file" +export GREENOVERCAST_GFN_DEVICE_ID_FILE="$gfn_device_id_file" +[ ! -f "$config_dir/geforce-now-oauth-client-id" ] || + export GREENOVERCAST_GFN_CLIENT_ID_FILE="$config_dir/geforce-now-oauth-client-id" +[ ! -f "$config_dir/geforce-now-protocol-client-id" ] || + export GREENOVERCAST_GFN_PROTOCOL_CLIENT_ID_FILE="$config_dir/geforce-now-protocol-client-id" export GREENOVERCAST_H264_BOOTSTRAP_FILE="$video_bootstrap_file" export GREENOVERCAST_CATALOG_FILE="$catalog_file" export GREENOVERCAST_SETTINGS_FILE="$settings_file" diff --git a/packaging/portmaster/greenovercast/README.md b/packaging/portmaster/greenovercast/README.md index f5aaba4..d7cdac4 100644 --- a/packaging/portmaster/greenovercast/README.md +++ b/packaging/portmaster/greenovercast/README.md @@ -1,15 +1,17 @@ ## Notes -GreenOvercast is an unofficial Xbox Cloud Gaming client for Linux handhelds. -It requires an Xbox Game Pass plan that includes cloud gaming and internet -access. +GreenOvercast is an unofficial Xbox Cloud Gaming and GeForce NOW client for +Linux handhelds. You need internet access and an account for your chosen +service. Xbox requires a Game Pass plan that includes cloud gaming; GeForce +NOW requires a membership and access to the games you want to play. -The first launch shows a Microsoft device code. Open -`https://www.microsoft.com/link` on another device and enter the code to sign -in. +Choose a service, then open the sign-in link shown on screen on another device +and enter the code. Your login is saved. You can switch services from Settings. Tested on the RG35XX-H with muOS, the RG40XX-H with Knulli and ROCKNIX 20260801, the Miyoo Flip with SpruceOS, and the R36S with AmberELEC. +GeForce NOW has been tested on muOS, Knulli, SpruceOS, and dArkOS on the R36S. +R36S frame pacing still needs work; ROCKNIX and AmberELEC testing covers Xbox. The release requires glibc 2.38 or newer. ArkOS is not supported. ## Controls @@ -30,3 +32,8 @@ The release requires glibc 2.38 or newer. ArkOS is not supported. Settings include Xbox/Nintendo face-button layouts, game artwork, and Sign out. Games that return a 16:9 stream remain letterboxed on 4:3 displays. + +GeForce NOW uses the same gamepad controls. For store dialogs and game +launchers, press Select + Y to toggle mouse mode: D-pad or left stick moves the +pointer, A clicks, B right-clicks, and L1 / R1 scroll. Store sign-in may still be +required inside the stream. diff --git a/packaging/portmaster/greenovercast/gameinfo.xml b/packaging/portmaster/greenovercast/gameinfo.xml index b1ef1ab..4a95e47 100644 --- a/packaging/portmaster/greenovercast/gameinfo.xml +++ b/packaging/portmaster/greenovercast/gameinfo.xml @@ -3,7 +3,7 @@ ./GreenOvercast.sh GreenOvercast - A native Xbox Cloud Gaming client with a searchable, controller-first library for Linux handhelds. + An unofficial Xbox Cloud Gaming and GeForce NOW client with a searchable, controller-first library for Linux handhelds. 20260726T000000 GreenOvercast GreenOvercast diff --git a/packaging/portmaster/greenovercast/greenovercast/CEDAR-SOURCE.md b/packaging/portmaster/greenovercast/greenovercast/CEDAR-SOURCE.md index 05a069a..bc3c13b 100644 --- a/packaging/portmaster/greenovercast/greenovercast/CEDAR-SOURCE.md +++ b/packaging/portmaster/greenovercast/greenovercast/CEDAR-SOURCE.md @@ -11,6 +11,7 @@ recipe is in `build.zig`. The maintained changes are limited to the aarch64/H616 port: - pointer-width-safe register addresses; +- standard member-offset calculation for the linked-list helpers; - preservation of 32-bit H616 IOMMU addresses; - the H616 VE-version query; - corrected initialization, teardown, and codec-registration error paths; diff --git a/packaging/portmaster/greenovercast/greenovercast/FFMPEG-SOURCE.md b/packaging/portmaster/greenovercast/greenovercast/FFMPEG-SOURCE.md index cc98442..dd8b52a 100644 --- a/packaging/portmaster/greenovercast/greenovercast/FFMPEG-SOURCE.md +++ b/packaging/portmaster/greenovercast/greenovercast/FFMPEG-SOURCE.md @@ -1,7 +1,7 @@ # FFmpeg -GreenOvercast ships `libavcodec`, `libavutil`, and `libswscale` from FFmpeg -9.0 under LGPLv2.1-or-later. +GreenOvercast ships `libavcodec`, `libavformat`, `libavutil`, `libswresample`, +and `libswscale` from FFmpeg 9.0 under LGPLv2.1-or-later. Source: diff --git a/packaging/portmaster/greenovercast/greenovercast/licenses/LICENSE.Bootstrap-Icons.txt b/packaging/portmaster/greenovercast/greenovercast/licenses/LICENSE.Bootstrap-Icons.txt new file mode 100644 index 0000000..f952439 --- /dev/null +++ b/packaging/portmaster/greenovercast/greenovercast/licenses/LICENSE.Bootstrap-Icons.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2019-2024 The Bootstrap Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packaging/portmaster/greenovercast/greenovercast/licenses/LICENSE.Expat.txt b/packaging/portmaster/greenovercast/greenovercast/licenses/LICENSE.Expat.txt new file mode 100644 index 0000000..c6d184a --- /dev/null +++ b/packaging/portmaster/greenovercast/greenovercast/licenses/LICENSE.Expat.txt @@ -0,0 +1,21 @@ +Copyright (c) 1998-2000 Thai Open Source Software Center Ltd and Clark Cooper +Copyright (c) 2001-2025 Expat maintainers + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packaging/portmaster/greenovercast/greenovercast/licenses/LICENSE.zlib.txt b/packaging/portmaster/greenovercast/greenovercast/licenses/LICENSE.zlib.txt new file mode 100644 index 0000000..b7a69d0 --- /dev/null +++ b/packaging/portmaster/greenovercast/greenovercast/licenses/LICENSE.zlib.txt @@ -0,0 +1,22 @@ +Copyright notice: + + (C) 1995-2026 Jean-loup Gailly and Mark Adler + + This software is provided 'as-is', without any express or implied + warranty. In no event will the authors be held liable for any damages + arising from the use of this software. + + Permission is granted to anyone to use this software for any purpose, + including commercial applications, and to alter it and redistribute it + freely, subject to the following restrictions: + + 1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + 2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + 3. This notice may not be removed or altered from any source distribution. + + Jean-loup Gailly Mark Adler + jloup@gzip.org madler@alumni.caltech.edu diff --git a/packaging/portmaster/greenovercast/port.json b/packaging/portmaster/greenovercast/port.json index 8aa7986..85eb048 100644 --- a/packaging/portmaster/greenovercast/port.json +++ b/packaging/portmaster/greenovercast/port.json @@ -11,10 +11,10 @@ "porter": [ "Producdevity" ], - "desc": "An unofficial native Xbox Cloud Gaming client with a searchable, controller-first library for Linux handhelds.", + "desc": "An unofficial Xbox Cloud Gaming and GeForce NOW client with a searchable, controller-first library for Linux handhelds.", "desc_md": null, - "inst": "Requires an Xbox Game Pass plan that includes cloud gaming and internet access. First launch uses Microsoft's device-code sign-in.", - "inst_md": "Requires an Xbox Game Pass plan that includes cloud gaming and internet access. First launch uses Microsoft's device-code sign-in.", + "inst": "Requires internet access and either a Game Pass plan with cloud gaming, or a GeForce NOW membership and access to the games you want to play. Choose a service and follow the on-screen sign-in instructions.", + "inst_md": "Requires internet access and either a Game Pass plan with cloud gaming, or a GeForce NOW membership and access to the games you want to play. Choose a service and follow the on-screen sign-in instructions.", "genres": [ "other" ], diff --git a/src/app/release.zig b/src/app/release.zig index 0a18887..369187c 100644 --- a/src/app/release.zig +++ b/src/app/release.zig @@ -1,5 +1,10 @@ const std = @import("std"); const catalog_service = @import("../catalog/service.zig"); +const geforce_auth = @import("../provider/geforce_now/auth_client.zig"); +const geforce_catalog = @import("../provider/geforce_now/catalog_service.zig"); +const geforce_session = @import("../provider/geforce_now/session_client.zig"); +const geforce_webrtc = @import("../provider/geforce_now/webrtc_session.zig"); +const geforce_ads = @import("../provider/geforce_now/ad_playback.zig"); const c = @cImport({ @cInclude("SDL2/SDL.h"); @@ -21,11 +26,25 @@ pub const Result = enum { missing_credentials, reauth_required, signed_out, + change_provider, failed, }; +const Provider = enum { + xbox, + geforce_now, +}; + var stop_requested: c_int = 0; +fn parseProvider(value: []const u8) ?Provider { + if (std.ascii.eqlIgnoreCase(value, "xbox") or + std.ascii.eqlIgnoreCase(value, "xbox-cloud-gaming")) return .xbox; + if (std.ascii.eqlIgnoreCase(value, "geforce-now") or + std.ascii.eqlIgnoreCase(value, "gfn")) return .geforce_now; + return null; +} + fn handleStopSignal(_: c_int) callconv(.c) void { const requested: *volatile c_int = &stop_requested; requested.* = 1; @@ -61,9 +80,15 @@ pub const Release = struct { cloud: ?*c.GoCloudSession = null, webrtc: ?*c.GoWebrtcSession = null, catalog: ?*catalog_service.Service = null, + geforce_auth: ?*geforce_auth.Client = null, + geforce_cloud: ?*geforce_session.Client = null, + geforce_webrtc: ?*geforce_webrtc.Session = null, + geforce_catalog: ?*geforce_catalog.Service = null, + provider: ?Provider = null, curl_initialized: bool = false, requested_title: [128]u8 = [_]u8{0} ** 128, title_id: [128]u8 = [_]u8{0} ** 128, + title_name: [192]u8 = [_]u8{0} ** 192, fn initializeMedia(self: *Release) bool { const bootstrap_path = std.posix.getenv("GREENOVERCAST_H264_BOOTSTRAP_FILE"); @@ -80,7 +105,7 @@ pub const Release = struct { .renderer = c.go_sdl_platform_renderer(self.platform), .bootstrap_path = if (bootstrap_path) |path| path.ptr else null, .max_width = 1280, - .max_height = 720, + .max_height = 768, .decoder_preference = decoder_preference, }; self.video = c.go_video_pipeline_create(&config); @@ -147,20 +172,102 @@ pub const Release = struct { } release.curl_initialized = true; - release.auth = c.go_xbox_auth_create(); - if (release.auth == null) { - std.debug.print("Xbox authentication configuration is incomplete\n", .{}); - release.close(); - return null; + return release; + } + + pub fn selectProvider(self: *Release) Result { + if (self.provider != null) return .ok; + if (std.posix.getenv("GREENOVERCAST_SERVICE")) |value| { + const selected = parseProvider(value) orelse { + std.debug.print("Invalid GREENOVERCAST_SERVICE value\n", .{}); + return .failed; + }; + return self.initializeProvider(selected); } + return self.pickProvider(); + } - release.cloud = c.go_cloud_session_create(release.auth, release.ui()); - if (release.cloud == null) { - std.debug.print("Cloud session service could not be initialized\n", .{}); - release.close(); - return null; + fn pickProvider(self: *Release) Result { + while (!self.quitRequested()) { + const selected: Provider = switch (c.go_handheld_ui_pick_provider(self.ui())) { + c.GO_HANDHELD_UI_PROVIDER_XBOX => .xbox, + c.GO_HANDHELD_UI_PROVIDER_GEFORCE_NOW => .geforce_now, + else => return .cancelled, + }; + if (self.initializeProvider(selected) == .ok) return .ok; } - return release; + return .cancelled; + } + + fn initializeProvider(self: *Release, selected: Provider) Result { + switch (selected) { + .xbox => { + self.auth = c.go_xbox_auth_create(); + if (self.auth == null) { + std.debug.print("Xbox authentication configuration is incomplete\n", .{}); + c.go_handheld_ui_show_error(self.ui(), "XBOX UNAVAILABLE", "SERVICE CONFIGURATION IS MISSING OR INVALID"); + return .failed; + } + self.cloud = c.go_cloud_session_create(self.auth, self.ui()); + if (self.cloud == null) { + c.go_xbox_auth_destroy(self.auth); + self.auth = null; + std.debug.print("Xbox cloud session service could not be initialized\n", .{}); + c.go_handheld_ui_show_error(self.ui(), "XBOX UNAVAILABLE", "COULD NOT INITIALIZE THE SERVICE"); + return .failed; + } + }, + .geforce_now => { + self.geforce_auth = geforce_auth.Client.create( + std.heap.c_allocator, + self.ui() orelse return .failed, + ) catch |err| { + std.debug.print("GeForce NOW configuration: {s}\n", .{@errorName(err)}); + const detail: [*:0]const u8 = switch (err) { + error.MissingConfig, error.FileNotFound => "SERVICE CONFIGURATION IS MISSING", + error.InvalidConfig, error.InvalidClientId => "SERVICE CONFIGURATION IS INVALID", + else => "COULD NOT INITIALIZE THE SERVICE", + }; + c.go_handheld_ui_show_error(self.ui(), "GEFORCE NOW", detail); + return .failed; + }; + self.geforce_cloud = geforce_session.Client.create( + std.heap.c_allocator, + self.geforce_auth.?, + self.ui() orelse return .failed, + ) catch |err| { + self.geforce_auth.?.destroy(); + self.geforce_auth = null; + std.debug.print("GeForce NOW session service: {s}\n", .{@errorName(err)}); + c.go_handheld_ui_show_error(self.ui(), "GEFORCE NOW", "COULD NOT INITIALIZE THE SERVICE"); + return .failed; + }; + }, + } + self.provider = selected; + c.go_handheld_ui_set_provider( + self.ui(), + if (selected == .xbox) + c.GO_HANDHELD_UI_PROVIDER_XBOX + else + c.GO_HANDHELD_UI_PROVIDER_GEFORCE_NOW, + ); + return .ok; + } + + pub fn switchProvider(self: *Release) Result { + const next: Provider = switch (self.provider orelse return .failed) { + .xbox => .geforce_now, + .geforce_now => .xbox, + }; + self.destroyProvider(); + if (self.initializeProvider(next) == .ok) return .ok; + return self.pickProvider(); + } + + pub fn returnToProviderPicker(self: *Release) Result { + self.destroyProvider(); + return self.pickProvider(); } fn ui(self: *const Release) ?*c.GoHandheldUi { @@ -171,11 +278,22 @@ pub const Release = struct { return c.go_sdl_platform_controller(self.platform); } + pub fn quitRequested(self: *const Release) bool { + return stopRequested() or c.go_handheld_ui_quit_requested(self.ui()) != 0; + } + fn drawLoading(self: *Release, heading: [*c]const u8, detail: [*c]const u8, action: c.GoHandheldUiAction) void { c.go_handheld_ui_draw_loading(self.ui(), heading, detail, action); } pub fn loadCredentials(self: *Release) Result { + if (self.provider == .geforce_now) { + const loaded = self.geforce_auth.?.loadCredentials() catch { + std.debug.print("GeForce NOW credential store could not be read\n", .{}); + return .failed; + }; + return if (loaded) .ok else .missing_credentials; + } const result = c.go_xbox_auth_load_credentials(self.auth); if (result < 0) { std.debug.print("Credential store could not be read\n", .{}); @@ -187,6 +305,35 @@ pub const Release = struct { } pub fn deviceSignIn(self: *Release) Result { + if (self.provider == .geforce_now) { + while (true) { + const signed_in = self.geforce_auth.?.signIn() catch |err| { + std.debug.print("GeForce NOW sign-in failed: {s}\n", .{@errorName(err)}); + if (err == error.CredentialSaveFailed) return .failed; + const retry = if (err == error.CodeExpired) + c.go_handheld_ui_wait_for_retry( + self.ui(), + "CODE EXPIRED", + "REQUEST A NEW CODE", + ) + else if (err == error.AccessDenied) + c.go_handheld_ui_wait_for_retry( + self.ui(), + "SIGN IN DENIED", + "REQUEST A NEW CODE", + ) + else + c.go_handheld_ui_wait_for_retry( + self.ui(), + "SIGN IN UNAVAILABLE", + "CHECK YOUR CONNECTION", + ); + if (retry == 0) return .cancelled; + continue; + }; + return if (signed_in) .ok else .cancelled; + } + } const result = c.go_xbox_auth_device_sign_in(self.auth, self.ui()); if (result == 0) return .cancelled; if (result < 0) { @@ -198,6 +345,14 @@ pub const Release = struct { } pub fn refreshAuth(self: *Release) Result { + if (self.provider == .geforce_now) { + self.drawLoading("SIGNING IN", "REFRESHING GEFORCE NOW SESSION", c.GO_HANDHELD_UI_ACTION_NONE); + return switch (self.geforce_auth.?.refresh()) { + .ok => .ok, + .reauth_required => .reauth_required, + .failed => .failed, + }; + } self.drawLoading("SIGNING IN", "REFRESHING XBOX SESSION", c.GO_HANDHELD_UI_ACTION_NONE); debug("Refreshing auth\n", .{}); return switch (c.go_xbox_auth_refresh(self.auth)) { @@ -215,6 +370,38 @@ pub const Release = struct { } pub fn loadCatalog(self: *Release) Result { + while (!self.quitRequested()) { + const result = self.loadCatalogOnce(); + if (result != .failed) return result; + if (self.catalog) |catalog| catalog.destroy(); + self.catalog = null; + if (self.geforce_catalog) |catalog| catalog.destroy(); + self.geforce_catalog = null; + if (c.go_handheld_ui_wait_for_retry( + self.ui(), + "COULD NOT LOAD GAMES", + "RETRY OR CHOOSE ANOTHER SERVICE", + ) == 0) return if (self.quitRequested()) .cancelled else .change_provider; + } + return .cancelled; + } + + fn loadCatalogOnce(self: *Release) Result { + if (self.provider == .geforce_now) { + self.geforce_catalog = geforce_catalog.Service.create( + std.heap.c_allocator, + self.geforce_auth.?, + self.ui() orelse return .failed, + ) catch return .failed; + self.drawLoading("LOADING LIBRARY", "CHECKING GEFORCE NOW GAMES", c.GO_HANDHELD_UI_ACTION_BACK); + self.geforce_catalog.?.load() catch |err| { + if (err == error.Cancelled) return .cancelled; + std.debug.print("GeForce NOW catalog failed: {s}\n", .{@errorName(err)}); + return .failed; + }; + debug("Playable titles: {d}\n", .{self.geforce_catalog.?.titleCount()}); + return .ok; + } const cloud = self.cloud orelse return .failed; const ui_handle = self.ui() orelse return .failed; const base_url_pointer = c.go_cloud_session_base_url(cloud); @@ -245,6 +432,25 @@ pub const Release = struct { } pub fn pickTitle(self: *Release) Result { + if (self.provider == .geforce_now) { + const catalog = self.geforce_catalog orelse return .failed; + const requested = std.mem.sliceTo(&self.requested_title, 0); + const selection = catalog.pick(requested) catch return .failed; + @memset(&self.requested_title, 0); + const selected = switch (selection) { + .title => |value| value, + .cancelled => return .cancelled, + .change_provider => return .change_provider, + .sign_out => return .signed_out, + }; + if (selected.id.len >= self.title_id.len or selected.name.len >= self.title_name.len) + return .failed; + @memset(&self.title_id, 0); + @memcpy(self.title_id[0..selected.id.len], selected.id); + @memset(&self.title_name, 0); + @memcpy(self.title_name[0..selected.name.len], selected.name); + return .ok; + } if (self.catalog == null) return .failed; const requested = std.mem.sliceTo(&self.requested_title, 0); const selection = self.catalog.?.pick(requested) catch return .failed; @@ -252,6 +458,7 @@ pub const Release = struct { const selected = switch (selection) { .title_id => |value| value, .cancelled => return .cancelled, + .change_provider => return .change_provider, .sign_out => return .signed_out, }; if (selected.len >= self.title_id.len) return .failed; @@ -261,6 +468,15 @@ pub const Release = struct { } pub fn signOut(self: *Release) Result { + if (self.provider == .geforce_now) { + self.drawLoading("SIGNING OUT", "REMOVING GEFORCE NOW CREDENTIALS", c.GO_HANDHELD_UI_ACTION_NONE); + self.geforce_auth.?.signOut() catch return .failed; + if (self.geforce_catalog) |catalog| catalog.destroy(); + self.geforce_catalog = null; + @memset(&self.title_id, 0); + @memset(&self.title_name, 0); + return .ok; + } self.drawLoading("SIGNING OUT", "REMOVING XBOX CREDENTIALS", c.GO_HANDHELD_UI_ACTION_NONE); if (c.go_xbox_auth_sign_out(self.auth) != 0) { std.debug.print("Xbox credentials could not be removed\n", .{}); @@ -276,6 +492,27 @@ pub const Release = struct { if (self.title_id[0] == 0) return .failed; self.drawLoading("STARTING GAME", "ALLOCATING CLOUD SESSION", c.GO_HANDHELD_UI_ACTION_CANCEL); debug("Creating session ({s})\n", .{std.mem.sliceTo(&self.title_id, 0)}); + if (self.provider == .geforce_now) { + const requested_width: u16 = @intCast(@min( + c.go_handheld_ui_stream_width(self.ui()), + std.math.maxInt(u16), + )); + const requested_height: u16 = @intCast(@min( + c.go_handheld_ui_stream_height(self.ui()), + std.math.maxInt(u16), + )); + self.geforce_cloud.?.start( + std.mem.sliceTo(&self.title_id, 0), + std.mem.sliceTo(&self.title_name, 0), + requested_width, + requested_height, + ) catch |err| { + if (err == error.Cancelled) return .cancelled; + std.debug.print("GeForce NOW session creation failed: {s}\n", .{@errorName(err)}); + return .failed; + }; + return .ok; + } if (c.go_cloud_session_start_game(self.cloud, @ptrCast(&self.title_id)) < 0) { std.debug.print("Session creation failed\n", .{}); return .failed; @@ -284,6 +521,18 @@ pub const Release = struct { } pub fn waitReady(self: *Release) Result { + if (self.provider == .geforce_now) { + var playback = geforce_ads.Playback{ .renderer = @ptrCast(c.go_sdl_platform_renderer(self.platform)), .device = c.go_sdl_platform_audio_device(self.platform), .ui = @ptrCast(self.ui()) }; + self.geforce_cloud.?.ad_handler = .{ .context = &playback, .run = geforce_ads.Playback.handle }; + defer self.geforce_cloud.?.ad_handler = null; + self.geforce_cloud.?.waitUntilReady() catch |err| { + if (err == error.Cancelled) return .cancelled; + if (err == error.SessionEnded) return .session_ended; + std.debug.print("GeForce NOW provisioning failed: {s}\n", .{@errorName(err)}); + return .failed; + }; + return .ok; + } debug("Waiting for ReadyToConnect\n", .{}); if (c.go_cloud_session_wait_for_state(self.cloud, "ReadyToConnect", 100) < 0) { if (c.go_handheld_ui_cancelled(self.ui()) != 0) return .cancelled; @@ -294,6 +543,7 @@ pub const Release = struct { } pub fn connect(self: *Release) Result { + if (self.provider == .geforce_now) return .ok; debug("Connecting\n", .{}); if (c.go_cloud_session_connect(self.cloud) < 0) { std.debug.print("Connect failed\n", .{}); @@ -303,6 +553,7 @@ pub const Release = struct { } pub fn waitProvisioned(self: *Release) Result { + if (self.provider == .geforce_now) return .ok; debug("Waiting for Provisioned\n", .{}); if (c.go_cloud_session_wait_for_state(self.cloud, "Provisioned", 100) < 0) { if (c.go_handheld_ui_cancelled(self.ui()) != 0) return .cancelled; @@ -313,6 +564,27 @@ pub const Release = struct { } pub fn setupWebrtc(self: *Release) Result { + if (self.provider == .geforce_now) { + if (self.geforce_webrtc != null) return .failed; + const cloud = self.geforce_cloud.?.sessionInfo() orelse return .failed; + self.geforce_webrtc = geforce_webrtc.Session.create( + std.heap.c_allocator, + self.video orelse return .failed, + self.audio orelse return .failed, + self.controller() orelse return .failed, + self.ui() orelse return .failed, + cloud, + self.geforce_cloud.?.streamWidth(), + self.geforce_cloud.?.streamHeight(), + self.geforce_cloud.?.streamFramesPerSecond(), + ) catch return .failed; + self.geforce_webrtc.?.setup() catch |err| { + if (err == error.Cancelled) return .cancelled; + std.debug.print("GeForce NOW WebRTC setup failed: {s}\n", .{@errorName(err)}); + return .failed; + }; + return .ok; + } if (self.webrtc != null) return .failed; debug("Setting up WebRTC\n", .{}); const stream_width = c.go_handheld_ui_stream_width(self.ui()); @@ -336,6 +608,23 @@ pub const Release = struct { } pub fn waitConnected(self: *Release) Result { + if (self.provider == .geforce_now) { + const session = self.geforce_webrtc orelse return .failed; + const deadline = c.SDL_GetTicks() +% 60_000; + while (!session.isConnected() and !session.hasFailed() and !session.isClosed()) { + session.pump() catch |err| { + std.debug.print("GeForce NOW signaling failed: {s}\n", .{@errorName(err)}); + return .failed; + }; + const reached: i32 = @bitCast(c.SDL_GetTicks() -% deadline); + if (reached >= 0) break; + if (c.go_handheld_ui_wait(self.ui(), 16) != 0) return .cancelled; + } + if (session.isClosed()) return .session_ended; + if (!session.isConnected() or session.hasFailed()) return .failed; + session.requestKeyframe(); + return .ok; + } if (self.webrtc == null) return .failed; debug("Waiting for WebRTC connection\n", .{}); var seconds: usize = 0; @@ -369,7 +658,8 @@ pub const Release = struct { } pub fn stream(self: *Release) Result { - if (self.webrtc == null) return .failed; + if (self.provider == .xbox and self.webrtc == null) return .failed; + if (self.provider == .geforce_now and self.geforce_webrtc == null) return .failed; debug("Streaming (hold Select + Start for 1s to exit)\n", .{}); const stream_started = c.SDL_GetTicks(); var last_stats = stream_started; @@ -381,7 +671,7 @@ pub const Release = struct { std.debug.print("Audio worker failed to start\n", .{}); return .failed; } - if (c.go_cloud_session_start_keepalive(self.cloud) < 0) { + if (self.provider == .xbox and c.go_cloud_session_start_keepalive(self.cloud) < 0) { std.debug.print("Session keepalive worker failed to start\n", .{}); return .failed; } @@ -397,25 +687,51 @@ pub const Release = struct { } if (c.go_controller_input_exit_held(controller_input, 1000) != 0 or stopRequested()) return .cancelled; - if (c.go_webrtc_session_failed(self.webrtc) != 0) { - std.debug.print("WebRTC connection failed\n", .{}); - return .failed; - } - if (c.go_webrtc_session_closed(self.webrtc) != 0) { - std.debug.print("Cloud game ended\n", .{}); - return .session_ended; + if (self.provider == .xbox) { + if (c.go_webrtc_session_failed(self.webrtc) != 0) { + std.debug.print("WebRTC connection failed\n", .{}); + return .failed; + } + if (c.go_webrtc_session_closed(self.webrtc) != 0) { + std.debug.print("Cloud game ended\n", .{}); + return .session_ended; + } + } else { + const session = self.geforce_webrtc.?; + session.pump() catch |err| { + std.debug.print("GeForce NOW stream failed: {s}\n", .{@errorName(err)}); + return .failed; + }; + if (session.hasFailed()) return .failed; + if (session.isClosed()) return .session_ended; } if (c.go_video_pipeline_failed(self.video) != 0) return .failed; - c.go_webrtc_session_send_gamepad(self.webrtc); - c.go_video_pipeline_render(self.video); - c.go_webrtc_session_request_video_bitrate(self.webrtc, 2_000_000); + if (self.provider == .xbox) + c.go_webrtc_session_send_gamepad(self.webrtc) + else + self.geforce_webrtc.?.sendInput() catch return .failed; + if (c.go_video_pipeline_render(self.video) != 0) { + if (self.provider == .geforce_now) { + const pointer = self.geforce_webrtc.?.pointer; + const video = c.go_video_pipeline_stats(self.video); + c.go_handheld_ui_draw_stream_controls(self.ui(), @intFromBool(pointer.enabled), pointer.x, pointer.y, video.source_width, video.source_height, @intFromBool(c.SDL_GetTicks() -% stream_started < 8000)); + } + c.SDL_RenderPresent(c.go_sdl_platform_renderer(self.platform)); + } + if (self.provider == .xbox) + c.go_webrtc_session_request_video_bitrate(self.webrtc, 2_000_000) + else + self.geforce_webrtc.?.requestBitrate(); const now = c.SDL_GetTicks(); if (c.go_video_pipeline_needs_keyframe(self.video) != 0 and now -% last_keyframe_request >= 500) { - c.go_webrtc_session_request_keyframe(self.webrtc); + if (self.provider == .xbox) + c.go_webrtc_session_request_keyframe(self.webrtc) + else + self.geforce_webrtc.?.requestKeyframe(); last_keyframe_request = now; } if (now -% last_stats >= 1000) { @@ -441,7 +757,10 @@ pub const Release = struct { if (!debugEnabled()) return; const video = c.go_video_pipeline_stats(self.video); const audio = c.go_audio_pipeline_stats(self.audio); - const cloud = c.go_cloud_session_stats(self.cloud); + const cloud = if (self.provider == .xbox) + c.go_cloud_session_stats(self.cloud) + else + std.mem.zeroes(c.GoCloudSessionStats); std.debug.print( "[{d}s] video_rtp={d} payload={d} rejected={d}/pt{d} aus={d} frames={d}/{d} source={d}x{d} " ++ "nals={d}/{d}/{d}/{d} ts={d} synced={d} gaps={d} missing={d} late_rtp={d} " ++ @@ -500,18 +819,30 @@ pub const Release = struct { } fn closeSession(self: *Release) void { - c.go_cloud_session_stop_keepalive(self.cloud); - c.go_webrtc_session_destroy(self.webrtc); - self.webrtc = null; + if (self.provider == .xbox) { + c.go_cloud_session_stop_keepalive(self.cloud); + c.go_webrtc_session_destroy(self.webrtc); + self.webrtc = null; + } else if (self.provider == .geforce_now) { + if (self.geforce_webrtc) |session| session.destroy(); + self.geforce_webrtc = null; + } c.go_video_pipeline_stop(self.video); c.go_audio_pipeline_stop(self.audio); - c.go_cloud_session_end(self.cloud); + if (self.provider == .xbox) { + c.go_cloud_session_end(self.cloud); + } else if (self.provider == .geforce_now) { + self.geforce_cloud.?.stop() catch |err| + std.debug.print("GeForce NOW session cleanup failed: {s}\n", .{@errorName(err)}); + } } pub fn resetSession(self: *Release) Result { self.closeSession(); self.destroyMedia(); + self.requested_title = self.title_id; @memset(&self.title_id, 0); + @memset(&self.title_name, 0); if (stopRequested()) return .cancelled; if (!self.initializeMedia()) return .failed; return .ok; @@ -520,13 +851,30 @@ pub const Release = struct { pub fn close(self: *Release) void { debug("Cleanup\n", .{}); self.closeSession(); - if (self.catalog) |catalog| catalog.destroy(); - c.go_cloud_session_destroy(self.cloud); - c.go_xbox_auth_destroy(self.auth); + self.destroyProvider(); self.destroyMedia(); c.go_sdl_platform_destroy(self.platform); if (self.curl_initialized) c.curl_global_cleanup(); std.crypto.secureZero(u8, std.mem.asBytes(self)); std.heap.c_allocator.destroy(self); } + + fn destroyProvider(self: *Release) void { + if (self.catalog) |catalog| catalog.destroy(); + self.catalog = null; + if (self.geforce_catalog) |catalog| catalog.destroy(); + self.geforce_catalog = null; + if (self.geforce_cloud) |cloud| cloud.destroy(); + self.geforce_cloud = null; + if (self.geforce_auth) |auth| auth.destroy(); + self.geforce_auth = null; + c.go_cloud_session_destroy(self.cloud); + self.cloud = null; + c.go_xbox_auth_destroy(self.auth); + self.auth = null; + self.provider = null; + @memset(&self.requested_title, 0); + @memset(&self.title_id, 0); + @memset(&self.title_name, 0); + } }; diff --git a/src/app/state.zig b/src/app/state.zig index 96ea55f..e702bad 100644 --- a/src/app/state.zig +++ b/src/app/state.zig @@ -32,9 +32,14 @@ pub const Event = enum { rtc_connected, session_ended, session_failed, + provider_changed, user_quit, }; +pub fn sessionCancellation(current: State, quit_requested: bool) Event { + return if (current == .streaming or quit_requested) .user_quit else .session_ended; +} + pub fn transition(current: State, event: Event) State { if (event == .user_quit) return .shutdown; @@ -60,11 +65,13 @@ pub fn transition(current: State, event: Event) State { }, .loading_catalog => switch (event) { .catalog_loaded => .catalog, + .provider_changed => .cold_start, else => .loading_catalog, }, .catalog => switch (event) { .user_select_title => .provisioning, .auth_sign_out => .signed_out, + .provider_changed => .cold_start, else => .catalog, }, .provisioning => switch (event) { @@ -124,6 +131,20 @@ test "sign out returns the catalog to authentication" { try std.testing.expectEqual(State.device_code_pending, state); } +test "changing provider starts a fresh authentication flow" { + try std.testing.expectEqual(State.cold_start, transition(.catalog, .provider_changed)); +} + +test "catalog failure can return to service selection and reuse credentials" { + var state = transition(.loading_catalog, .provider_changed); + try std.testing.expectEqual(State.cold_start, state); + state = transition(state, .auth_tokens_found); + try std.testing.expectEqual(State.authenticating, state); + state = transition(state, .auth_success); + state = transition(state, .catalog_loaded); + try std.testing.expectEqual(State.catalog, state); +} + test "catalog selection reaches streaming" { var state = transition(.catalog, .user_select_title); try std.testing.expectEqual(State.provisioning, state); @@ -143,6 +164,14 @@ test "ended and failed sessions return to the catalog" { try std.testing.expectEqual(State.catalog, transition(.provisioning, .session_failed)); } +test "cancelling game startup returns to the catalog while quit remains explicit" { + for ([_]State{ .provisioning, .signaling, .connecting }) |state| { + try std.testing.expectEqual(State.catalog, transition(state, sessionCancellation(state, false))); + try std.testing.expectEqual(State.shutdown, transition(state, sessionCancellation(state, true))); + } + try std.testing.expectEqual(Event.user_quit, sessionCancellation(.streaming, false)); +} + test "shutdown is terminal and reachable from every active state" { const states = [_]State{ .cold_start, diff --git a/src/auth/xbox_auth.zig b/src/auth/xbox_auth.zig index ffa1619..24982c6 100644 --- a/src/auth/xbox_auth.zig +++ b/src/auth/xbox_auth.zig @@ -20,11 +20,6 @@ const auth_failed: c_int = -1; const auth_ok: c_int = 0; const auth_reauth_required: c_int = 1; -const FormField = struct { - key: []const u8, - value: []const u8, -}; - const Auth = struct { client_id: [128]u8 = [_]u8{0} ** 128, gssv_token: [8192]u8 = [_]u8{0} ** 8192, @@ -81,23 +76,6 @@ fn loadClientId(auth: *Auth) !void { try copyString(&auth.client_id, xbox_web_client_id); } -fn buildForm(output: []u8, fields: []const FormField) ![:0]u8 { - if (output.len == 0) return error.NoSpaceLeft; - var used: usize = 0; - for (fields, 0..) |field, index| { - const prefix = try std.fmt.bufPrint( - output[used..], - "{s}{s}=", - .{ if (index == 0) "" else "&", field.key }, - ); - used += prefix.len; - used += form.encode(field.value, output[used..]) catch return error.NoSpaceLeft; - } - if (used >= output.len) return error.NoSpaceLeft; - output[used] = 0; - return output[0..used :0]; -} - fn responseData(response: [*c]c.GoHttpResponse) ?[]const u8 { if (response == null or response.*.data == null) return null; return response.*.data[0..response.*.len]; @@ -164,10 +142,10 @@ fn deviceSignIn(auth: *Auth, ui: *c.GoHandheldUi) !bool { var headers = [_][*c]const u8{"Content-Type: application/x-www-form-urlencoded"}; while (true) { var body_buffer: [4096]u8 = undefined; - const body = try buildForm(&body_buffer, &.{ - .{ .key = "client_id", .value = cString(&auth.client_id) }, - .{ .key = "scope", .value = oauth_scope }, - }); + const body = try form.build(&.{ + .{ .name = "client_id", .value = cString(&auth.client_id) }, + .{ .name = "scope", .value = oauth_scope }, + }, &body_buffer); c.go_handheld_ui_draw_loading(ui, "XBOX SIGN IN", "REQUESTING A DEVICE CODE", c.GO_HANDHELD_UI_ACTION_BACK); var response = c.go_http_request( "POST", @@ -189,7 +167,7 @@ fn deviceSignIn(auth: *Auth, ui: *c.GoHandheldUi) !bool { const user_code = jsonString(data, "user_code", &user_code_buffer) catch null; const device_code = jsonString(data, "device_code", &device_code_buffer) catch null; var interval = jsonUnsigned(data, "interval", 5); - var expires_in = jsonUnsigned(data, "expires_in", 900); + const expires_in = jsonUnsigned(data, "expires_in", 900); c.go_http_response_destroy(response); if (user_code == null or device_code == null) { std.crypto.secureZero(u8, &device_code_buffer); @@ -198,15 +176,15 @@ fn deviceSignIn(auth: *Auth, ui: *c.GoHandheldUi) !bool { continue; } defer std.crypto.secureZero(u8, &device_code_buffer); - interval = std.math.clamp(interval, 1, 30); - if (expires_in < interval) expires_in = interval; + interval = @max(interval, 1); const started = c.SDL_GetTicks(); - var next_poll = started +% interval * 1000; + var last_poll = started; var restart = false; - while ((c.SDL_GetTicks() -% started) / 1000 < expires_in) { + while (true) { const now = c.SDL_GetTicks(); const elapsed = (now -% started) / 1000; + if (elapsed >= expires_in) break; c.go_handheld_ui_draw_device_code( ui, @ptrCast(&user_code_buffer), @@ -214,17 +192,16 @@ fn deviceSignIn(auth: *Auth, ui: *c.GoHandheldUi) !bool { expires_in - elapsed, ); if (c.go_handheld_ui_sign_in_action(ui) < 0) return false; - const until_poll: i32 = @bitCast(now -% next_poll); - if (until_poll < 0) { + if ((now -% last_poll) / 1000 < interval) { c.SDL_Delay(16); continue; } - const token_body = try buildForm(&body_buffer, &.{ - .{ .key = "grant_type", .value = "urn:ietf:params:oauth:grant-type:device_code" }, - .{ .key = "client_id", .value = cString(&auth.client_id) }, - .{ .key = "device_code", .value = device_code.? }, - }); + const token_body = try form.build(&.{ + .{ .name = "grant_type", .value = "urn:ietf:params:oauth:grant-type:device_code" }, + .{ .name = "client_id", .value = cString(&auth.client_id) }, + .{ .name = "device_code", .value = device_code.? }, + }, &body_buffer); response = c.go_http_request( "POST", oauth_token_url, @@ -232,7 +209,7 @@ fn deviceSignIn(auth: *Auth, ui: *c.GoHandheldUi) !bool { @ptrCast(&headers), headers.len, ); - next_poll = c.SDL_GetTicks() +% interval * 1000; + last_poll = c.SDL_GetTicks(); const token_data = responseData(response) orelse { c.go_http_response_destroy(response); continue; @@ -259,7 +236,7 @@ fn deviceSignIn(auth: *Auth, ui: *c.GoHandheldUi) !bool { const message = jsonString(token_data, "error", &error_buffer) catch null; if (message) |value| { if (std.mem.eql(u8, value, "slow_down")) - interval = @min(interval + 5, 30) + interval +|= 5 else if (!std.mem.eql(u8, value, "authorization_pending")) restart = true; } @@ -290,12 +267,12 @@ fn refresh(auth: *Auth) !c_int { "x-gssv-client: XboxComBrowser", }; - const refresh_body = try buildForm(&body_buffer, &.{ - .{ .key = "client_id", .value = cString(&auth.client_id) }, - .{ .key = "grant_type", .value = "refresh_token" }, - .{ .key = "refresh_token", .value = cString(&auth.refresh_token) }, - .{ .key = "scope", .value = oauth_scope }, - }); + const refresh_body = try form.build(&.{ + .{ .name = "client_id", .value = cString(&auth.client_id) }, + .{ .name = "grant_type", .value = "refresh_token" }, + .{ .name = "refresh_token", .value = cString(&auth.refresh_token) }, + .{ .name = "scope", .value = oauth_scope }, + }, &body_buffer); var response = c.go_http_request( "POST", oauth_token_url, @@ -399,12 +376,12 @@ fn refresh(auth: *Auth) !c_int { c.go_http_response_destroy(response); debug("gsToken obtained\n", .{}); - const passport_body = try buildForm(&body_buffer, &.{ - .{ .key = "client_id", .value = cString(&auth.client_id) }, - .{ .key = "grant_type", .value = "refresh_token" }, - .{ .key = "refresh_token", .value = cString(&auth.refresh_token) }, - .{ .key = "scope", .value = passport_scope }, - }); + const passport_body = try form.build(&.{ + .{ .name = "client_id", .value = cString(&auth.client_id) }, + .{ .name = "grant_type", .value = "refresh_token" }, + .{ .name = "refresh_token", .value = cString(&auth.refresh_token) }, + .{ .name = "scope", .value = passport_scope }, + }, &body_buffer); response = c.go_http_request( "POST", "https://login.live.com/oauth20_token.srf", diff --git a/src/catalog/catalog_parser.zig b/src/catalog/catalog_parser.zig index 5f94994..1d4216e 100644 --- a/src/catalog/catalog_parser.zig +++ b/src/catalog/catalog_parser.zig @@ -112,7 +112,7 @@ pub fn writeCString(destination: []u8, text: []const u8) bool { return true; } -fn writeDisplayCString(destination: []u8, text: []const u8) bool { +pub fn writeDisplayCString(destination: []u8, text: []const u8) bool { @memset(destination, 0); var source_index: usize = 0; var destination_index: usize = 0; diff --git a/src/catalog/service.zig b/src/catalog/service.zig index 184b6a9..b6fb890 100644 --- a/src/catalog/service.zig +++ b/src/catalog/service.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const parser = @import("catalog_parser.zig"); +const parser = @import("catalog_parser"); const c = @cImport({ @cInclude("cloud_session.h"); @@ -17,6 +17,7 @@ pub const LoadResult = enum { pub const PickResult = union(enum) { title_id: []const u8, cancelled, + change_provider, sign_out, }; @@ -115,6 +116,7 @@ pub const Service = struct { @intCast(self.count), @ptrCast(&requested_buffer), ); + if (selected == c.GO_HANDHELD_UI_PICK_CHANGE_PROVIDER) return .change_provider; if (selected == c.GO_HANDHELD_UI_PICK_SIGN_OUT) return .sign_out; if (selected == c.GO_HANDHELD_UI_PICK_CANCELLED) return .cancelled; if (selected < 0) return error.InvalidSelection; diff --git a/src/input/controller.h b/src/input/controller.h index a43b2d4..0efd535 100644 --- a/src/input/controller.h +++ b/src/input/controller.h @@ -16,17 +16,45 @@ typedef enum { GO_FACE_BUTTON_MODE_SWAPPED = 1, } GoFaceButtonMode; +enum { + GO_CONTROLLER_BUTTON_A = 1u << 0, + GO_CONTROLLER_BUTTON_B = 1u << 1, + GO_CONTROLLER_BUTTON_X = 1u << 2, + GO_CONTROLLER_BUTTON_Y = 1u << 3, + GO_CONTROLLER_BUTTON_LEFT_SHOULDER = 1u << 4, + GO_CONTROLLER_BUTTON_RIGHT_SHOULDER = 1u << 5, + GO_CONTROLLER_BUTTON_BACK = 1u << 6, + GO_CONTROLLER_BUTTON_START = 1u << 7, + GO_CONTROLLER_BUTTON_DPAD_UP = 1u << 8, + GO_CONTROLLER_BUTTON_DPAD_DOWN = 1u << 9, + GO_CONTROLLER_BUTTON_DPAD_LEFT = 1u << 10, + GO_CONTROLLER_BUTTON_DPAD_RIGHT = 1u << 11, + GO_CONTROLLER_BUTTON_LEFT_STICK = 1u << 12, + GO_CONTROLLER_BUTTON_RIGHT_STICK = 1u << 13, + GO_CONTROLLER_BUTTON_GUIDE = 1u << 14, +}; + +typedef struct { + uint32_t buttons; + int16_t left_x; + int16_t left_y; + int16_t right_x; + int16_t right_y; + uint16_t left_trigger; + uint16_t right_trigger; +} GoControllerState; + GoControllerInput* go_controller_input_create(void); void go_controller_input_destroy(GoControllerInput* input); void go_controller_input_handle_event(GoControllerInput* input, const SDL_Event* event); -int go_controller_input_event_is_active(const GoControllerInput* input, - const SDL_Event* event); +int go_controller_input_event_is_active(const GoControllerInput* input, const SDL_Event* event); void go_controller_input_set_face_button_mode(GoControllerInput* input, GoFaceButtonMode mode); SDL_GameControllerButton go_controller_input_map_button(const GoControllerInput* input, Uint8 physical_button); int go_controller_input_button_pressed(const GoControllerInput* input, SDL_GameControllerButton semantic_button); Sint16 go_controller_input_axis(const GoControllerInput* input, SDL_GameControllerAxis axis); +int go_controller_input_sample(GoControllerInput* input, GoControllerState* state); size_t go_controller_input_encode_metadata(GoControllerInput* input, uint8_t* output, size_t capacity); size_t go_controller_input_encode(GoControllerInput* input, uint8_t* output, size_t capacity); diff --git a/src/input/controller.zig b/src/input/controller.zig index b75896f..1e8cd4c 100644 --- a/src/input/controller.zig +++ b/src/input/controller.zig @@ -223,6 +223,51 @@ pub export fn go_controller_input_axis( return axis(input orelse return 0, controller_axis); } +pub export fn go_controller_input_sample(input: ?*Input, state: ?*c.GoControllerState) c_int { + const handle = input orelse return 0; + const output = state orelse return 0; + if (handle.controller == null) return 0; + + var buttons: u32 = 0; + if (semanticButtonPressed(handle, c.SDL_CONTROLLER_BUTTON_A)) buttons |= c.GO_CONTROLLER_BUTTON_A; + if (semanticButtonPressed(handle, c.SDL_CONTROLLER_BUTTON_B)) buttons |= c.GO_CONTROLLER_BUTTON_B; + if (semanticButtonPressed(handle, c.SDL_CONTROLLER_BUTTON_X)) buttons |= c.GO_CONTROLLER_BUTTON_X; + if (semanticButtonPressed(handle, c.SDL_CONTROLLER_BUTTON_Y)) buttons |= c.GO_CONTROLLER_BUTTON_Y; + if (button(handle, c.SDL_CONTROLLER_BUTTON_LEFTSHOULDER)) buttons |= c.GO_CONTROLLER_BUTTON_LEFT_SHOULDER; + if (button(handle, c.SDL_CONTROLLER_BUTTON_RIGHTSHOULDER)) buttons |= c.GO_CONTROLLER_BUTTON_RIGHT_SHOULDER; + if (button(handle, c.SDL_CONTROLLER_BUTTON_BACK)) buttons |= c.GO_CONTROLLER_BUTTON_BACK; + if (button(handle, c.SDL_CONTROLLER_BUTTON_START)) buttons |= c.GO_CONTROLLER_BUTTON_START; + if (button(handle, c.SDL_CONTROLLER_BUTTON_DPAD_UP)) buttons |= c.GO_CONTROLLER_BUTTON_DPAD_UP; + if (button(handle, c.SDL_CONTROLLER_BUTTON_DPAD_DOWN)) buttons |= c.GO_CONTROLLER_BUTTON_DPAD_DOWN; + if (button(handle, c.SDL_CONTROLLER_BUTTON_DPAD_LEFT)) buttons |= c.GO_CONTROLLER_BUTTON_DPAD_LEFT; + if (button(handle, c.SDL_CONTROLLER_BUTTON_DPAD_RIGHT)) buttons |= c.GO_CONTROLLER_BUTTON_DPAD_RIGHT; + + const stick_buttons = handle.guide_chord.update( + button(handle, c.SDL_CONTROLLER_BUTTON_LEFTSTICK), + button(handle, c.SDL_CONTROLLER_BUTTON_RIGHTSTICK), + ); + const guide_chord_active = stick_buttons.left and stick_buttons.right; + if (button(handle, c.SDL_CONTROLLER_BUTTON_GUIDE) or guide_chord_active) + buttons |= c.GO_CONTROLLER_BUTTON_GUIDE; + if (!guide_chord_active) { + if (stick_buttons.left) buttons |= c.GO_CONTROLLER_BUTTON_LEFT_STICK; + if (stick_buttons.right) buttons |= c.GO_CONTROLLER_BUTTON_RIGHT_STICK; + } + + const raw_left_y = axis(handle, c.SDL_CONTROLLER_AXIS_LEFTY); + const raw_right_y = axis(handle, c.SDL_CONTROLLER_AXIS_RIGHTY); + output.* = .{ + .buttons = buttons, + .left_x = axis(handle, c.SDL_CONTROLLER_AXIS_LEFTX), + .left_y = if (raw_left_y == std.math.minInt(i16)) std.math.maxInt(i16) else -raw_left_y, + .right_x = axis(handle, c.SDL_CONTROLLER_AXIS_RIGHTX), + .right_y = if (raw_right_y == std.math.minInt(i16)) std.math.maxInt(i16) else -raw_right_y, + .left_trigger = trigger(handle, c.SDL_CONTROLLER_AXIS_TRIGGERLEFT), + .right_trigger = trigger(handle, c.SDL_CONTROLLER_AXIS_TRIGGERRIGHT), + }; + return 1; +} + pub export fn go_controller_input_encode_metadata( input: ?*Input, output: ?[*]u8, @@ -245,44 +290,21 @@ pub export fn go_controller_input_encode( capacity: usize, ) usize { const handle = input orelse return 0; - if (handle.controller == null) return 0; const bytes = output orelse return 0; if (capacity < 38) return 0; - - var source_buttons: u32 = 0; - if (semanticButtonPressed(handle, c.SDL_CONTROLLER_BUTTON_A)) source_buttons |= wire.SourceButton.a; - if (semanticButtonPressed(handle, c.SDL_CONTROLLER_BUTTON_B)) source_buttons |= wire.SourceButton.b; - if (semanticButtonPressed(handle, c.SDL_CONTROLLER_BUTTON_X)) source_buttons |= wire.SourceButton.x; - if (semanticButtonPressed(handle, c.SDL_CONTROLLER_BUTTON_Y)) source_buttons |= wire.SourceButton.y; - if (button(handle, c.SDL_CONTROLLER_BUTTON_LEFTSHOULDER)) source_buttons |= wire.SourceButton.left_shoulder; - if (button(handle, c.SDL_CONTROLLER_BUTTON_RIGHTSHOULDER)) source_buttons |= wire.SourceButton.right_shoulder; - if (button(handle, c.SDL_CONTROLLER_BUTTON_BACK)) source_buttons |= wire.SourceButton.back; - if (button(handle, c.SDL_CONTROLLER_BUTTON_START)) source_buttons |= wire.SourceButton.start; - if (button(handle, c.SDL_CONTROLLER_BUTTON_DPAD_UP)) source_buttons |= wire.SourceButton.dpad_up; - if (button(handle, c.SDL_CONTROLLER_BUTTON_DPAD_DOWN)) source_buttons |= wire.SourceButton.dpad_down; - if (button(handle, c.SDL_CONTROLLER_BUTTON_DPAD_LEFT)) source_buttons |= wire.SourceButton.dpad_left; - if (button(handle, c.SDL_CONTROLLER_BUTTON_DPAD_RIGHT)) source_buttons |= wire.SourceButton.dpad_right; - const left_stick = button(handle, c.SDL_CONTROLLER_BUTTON_LEFTSTICK); - const right_stick = button(handle, c.SDL_CONTROLLER_BUTTON_RIGHTSTICK); - const stick_buttons = handle.guide_chord.update(left_stick, right_stick); - if (stick_buttons.left) source_buttons |= wire.SourceButton.left_stick; - if (stick_buttons.right) source_buttons |= wire.SourceButton.right_stick; - - const raw_left_y = axis(handle, c.SDL_CONTROLLER_AXIS_LEFTY); - const raw_right_y = axis(handle, c.SDL_CONTROLLER_AXIS_RIGHTY); - const left_y = if (raw_left_y == std.math.minInt(i16)) std.math.maxInt(i16) else -raw_left_y; - const right_y = if (raw_right_y == std.math.minInt(i16)) std.math.maxInt(i16) else -raw_right_y; + var state: c.GoControllerState = undefined; + if (go_controller_input_sample(handle, &state) == 0) return 0; wire.encodeGamepadRaw( bytes[0..wire.PACKET_SIZE], handle.sequence, 0.0, - wire.buttonMask(source_buttons), - axis(handle, c.SDL_CONTROLLER_AXIS_LEFTX), - left_y, - axis(handle, c.SDL_CONTROLLER_AXIS_RIGHTX), - right_y, - trigger(handle, c.SDL_CONTROLLER_AXIS_TRIGGERLEFT), - trigger(handle, c.SDL_CONTROLLER_AXIS_TRIGGERRIGHT), + wire.buttonMask(state.buttons), + state.left_x, + state.left_y, + state.right_x, + state.right_y, + state.left_trigger, + state.right_trigger, ); handle.sequence +%= 1; return wire.PACKET_SIZE; diff --git a/src/input/wire_encoder.zig b/src/input/wire_encoder.zig index cd6de6e..73dee9c 100644 --- a/src/input/wire_encoder.zig +++ b/src/input/wire_encoder.zig @@ -33,6 +33,7 @@ pub const SourceButton = struct { pub const dpad_right: u32 = 1 << 11; pub const left_stick: u32 = 1 << 12; pub const right_stick: u32 = 1 << 13; + pub const guide: u32 = 1 << 14; }; pub const GamepadState = struct { @@ -64,18 +65,14 @@ pub fn buttonMask(source: u32) u16 { .{ SourceButton.dpad_down, Button.dpad_down }, .{ SourceButton.dpad_left, Button.dpad_left }, .{ SourceButton.dpad_right, Button.dpad_right }, + .{ SourceButton.guide, Button.nexus }, + .{ SourceButton.left_stick, Button.left_stick }, + .{ SourceButton.right_stick, Button.right_stick }, }; for (mappings) |mapping| { if (source & mapping[0] != 0) mask |= mapping[1]; } - const stick_chord = SourceButton.left_stick | SourceButton.right_stick; - if (source & stick_chord == stick_chord) { - mask |= Button.nexus; - } else { - if (source & SourceButton.left_stick != 0) mask |= Button.left_stick; - if (source & SourceButton.right_stick != 0) mask |= Button.right_stick; - } return mask; } @@ -153,10 +150,20 @@ test "every physical control maps to the xCloud mask" { for (cases) |case| try std.testing.expectEqual(case[1], buttonMask(case[0])); } -test "L3 and R3 together produce only Nexus" { - const chord = SourceButton.left_stick | SourceButton.right_stick; - try std.testing.expectEqual(Button.nexus, buttonMask(chord)); - try std.testing.expectEqual(Button.a | Button.nexus, buttonMask(SourceButton.a | chord)); +test "semantic guide produces Nexus" { + try std.testing.expectEqual(Button.nexus, buttonMask(SourceButton.guide)); + try std.testing.expectEqual(Button.a | Button.nexus, buttonMask(SourceButton.a | SourceButton.guide)); +} + +test "guide preserves independent stick clicks" { + const cases = [_]struct { u32, u16 }{ + .{ SourceButton.left_stick, Button.left_stick }, + .{ SourceButton.right_stick, Button.right_stick }, + .{ SourceButton.left_stick | SourceButton.right_stick, Button.left_stick | Button.right_stick }, + }; + for (cases) |pair| { + try std.testing.expectEqual(Button.nexus | pair[1], buttonMask(SourceButton.guide | pair[0])); + } } test "button A encodes correctly" { diff --git a/src/main.zig b/src/main.zig index 12fa0da..6c5dfd8 100644 --- a/src/main.zig +++ b/src/main.zig @@ -38,7 +38,7 @@ fn stage(result: release_mod.Result) enum { ok, cancelled, failed } { }; } -const CatalogFlow = enum { ok, cancelled, failed }; +const CatalogFlow = enum { ok, cancelled, change_provider, failed }; fn openCatalog( release: *release_mod.Release, @@ -72,10 +72,11 @@ fn openCatalog( } break; } - switch (stage(release.loadCatalog())) { + switch (release.loadCatalog()) { .ok => if (!move(state, .catalog_loaded, .catalog)) return .failed, .cancelled => return .cancelled, - .failed => return .failed, + .change_provider => return .change_provider, + else => return .failed, } return .ok; } @@ -111,52 +112,84 @@ pub fn main() u8 { const release = release_mod.Release.open(requested_title) orelse return 1; defer release.close(); - var state = state_mod.State.cold_start; - var needs_sign_in = false; - const credentials = release.loadCredentials(); - if (credentials == .missing_credentials) { - if (!move(&state, .auth_no_tokens, .signed_out)) return 1; - needs_sign_in = true; - } else if (credentials == .ok) { - if (!move(&state, .auth_tokens_found, .authenticating)) return 1; - } else { - return finish(&state, 1); - } - - switch (openCatalog(release, &state, needs_sign_in)) { + switch (stage(release.selectProvider())) { .ok => {}, - .cancelled => return finish(&state, 0), - .failed => return finish(&state, 1), + .cancelled => return 0, + .failed => return 1, } - while (true) { - const selection = release.pickTitle(); - if (selection == .signed_out) { - if (release.signOut() != .ok or !move(&state, .auth_sign_out, .signed_out)) - return finish(&state, 1); - switch (openCatalog(release, &state, true)) { - .ok => continue, - .cancelled => return finish(&state, 0), - .failed => return finish(&state, 1), - } - } - switch (stage(selection)) { - .ok => if (!move(&state, .user_select_title, .provisioning)) return 1, - .cancelled => return finish(&state, 0), - .failed => return finish(&state, 1), - } - const outcome = runSelectedSession(release, &state); - if (outcome == .cancelled) return finish(&state, 0); + var state = state_mod.State.cold_start; + provider_loop: while (true) { + var needs_sign_in = false; + const credentials = release.loadCredentials(); + if (credentials == .missing_credentials) { + if (!move(&state, .auth_no_tokens, .signed_out)) return 1; + needs_sign_in = true; + } else if (credentials == .ok) { + if (!move(&state, .auth_tokens_found, .authenticating)) return 1; + } else { + return finish(&state, 1); + } - const return_event: state_mod.Event = if (outcome == .session_ended or outcome == .ok) - .session_ended - else - .session_failed; - switch (stage(release.resetSession())) { + switch (openCatalog(release, &state, needs_sign_in)) { .ok => {}, + .change_provider => { + if (!move(&state, .provider_changed, .cold_start)) return 1; + switch (stage(release.returnToProviderPicker())) { + .ok => continue :provider_loop, + .cancelled => return finish(&state, 0), + .failed => return finish(&state, 1), + } + }, .cancelled => return finish(&state, 0), .failed => return finish(&state, 1), } - if (!move(&state, return_event, .catalog)) return 1; + catalog_loop: while (true) { + const selection = release.pickTitle(); + if (selection == .change_provider) { + if (!move(&state, .provider_changed, .cold_start)) return 1; + switch (stage(release.switchProvider())) { + .ok => continue :provider_loop, + .cancelled => return finish(&state, 0), + .failed => return finish(&state, 1), + } + } + if (selection == .signed_out) { + if (release.signOut() != .ok or !move(&state, .auth_sign_out, .signed_out)) + return finish(&state, 1); + switch (openCatalog(release, &state, true)) { + .ok => continue :catalog_loop, + .change_provider => { + if (!move(&state, .provider_changed, .cold_start)) return 1; + switch (stage(release.returnToProviderPicker())) { + .ok => continue :provider_loop, + .cancelled => return finish(&state, 0), + .failed => return finish(&state, 1), + } + }, + .cancelled => return finish(&state, 0), + .failed => return finish(&state, 1), + } + } + switch (stage(selection)) { + .ok => if (!move(&state, .user_select_title, .provisioning)) return 1, + .cancelled => return finish(&state, 0), + .failed => return finish(&state, 1), + } + + const outcome = runSelectedSession(release, &state); + const return_event: state_mod.Event = switch (outcome) { + .cancelled => state_mod.sessionCancellation(state, release.quitRequested()), + .session_ended, .ok => .session_ended, + else => .session_failed, + }; + if (return_event == .user_quit) return finish(&state, 0); + switch (stage(release.resetSession())) { + .ok => {}, + .cancelled => return finish(&state, 0), + .failed => return finish(&state, 1), + } + if (!move(&state, return_event, .catalog)) return 1; + } } } diff --git a/src/media/audio/audio_pipeline.h b/src/media/audio/audio_pipeline.h index 42fb68d..233b21e 100644 --- a/src/media/audio/audio_pipeline.h +++ b/src/media/audio/audio_pipeline.h @@ -24,6 +24,7 @@ typedef struct { GoAudioPipeline* go_audio_pipeline_create(SDL_AudioDeviceID device); int go_audio_pipeline_start(GoAudioPipeline* pipeline); void go_audio_pipeline_stop(GoAudioPipeline* pipeline); +int go_audio_pipeline_set_payload_type(GoAudioPipeline* pipeline, int payload_type); void go_audio_pipeline_push_rtp(GoAudioPipeline* pipeline, const uint8_t* packet, size_t length); GoAudioStats go_audio_pipeline_stats(GoAudioPipeline* pipeline); void go_audio_pipeline_destroy(GoAudioPipeline* pipeline); diff --git a/src/media/audio/audio_pipeline.zig b/src/media/audio/audio_pipeline.zig index f76fcaf..8d4d1e3 100644 --- a/src/media/audio/audio_pipeline.zig +++ b/src/media/audio/audio_pipeline.zig @@ -6,7 +6,7 @@ const c = @cImport({ @cInclude("opus/opus.h"); }); -const payload_type = 111; +const default_payload_type = 111; const queue_capacity = 32; const target_pending_packets = 2; const packet_capacity = 2048; @@ -31,6 +31,7 @@ const Pipeline = struct { thread: ?std.Thread = null, stop: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), accepting_packets: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + payload_type: std.atomic.Value(u8) = std.atomic.Value(u8).init(default_payload_type), rtp_packets: std.atomic.Value(c_int) = std.atomic.Value(c_int).init(0), decoded_packets: std.atomic.Value(c_int) = std.atomic.Value(c_int).init(0), dropped_packets: std.atomic.Value(c_int) = std.atomic.Value(c_int).init(0), @@ -109,8 +110,10 @@ pub export fn go_audio_pipeline_create(device: c.SDL_AudioDeviceID) ?*Pipeline { c.opus_decoder_destroy(decoder); return null; } - errdefer c.opus_decoder_destroy(decoder); - const pipeline = std.heap.c_allocator.create(Pipeline) catch return null; + const pipeline = std.heap.c_allocator.create(Pipeline) catch { + c.opus_decoder_destroy(decoder); + return null; + }; pipeline.* = .{ .device = device, .decoder = decoder }; return pipeline; } @@ -149,6 +152,17 @@ pub export fn go_audio_pipeline_stop(pipeline_pointer: ?*Pipeline) void { pipeline.mutex.unlock(); } +pub export fn go_audio_pipeline_set_payload_type( + pipeline_pointer: ?*Pipeline, + payload_type: c_int, +) c_int { + const pipeline = pipeline_pointer orelse return -1; + if (payload_type < 0 or payload_type > 127 or pipeline.rtp_packets.load(.acquire) != 0) + return -1; + pipeline.payload_type.store(@intCast(payload_type), .release); + return 0; +} + pub export fn go_audio_pipeline_push_rtp( pipeline_pointer: ?*Pipeline, packet_pointer: ?[*]const u8, @@ -158,7 +172,8 @@ pub export fn go_audio_pipeline_push_rtp( const packet = packet_pointer orelse return; if (!pipeline.accepting_packets.load(.acquire)) return; const parsed = rtp.parse(packet[0..length]) catch return; - if (parsed.header.payload_type != payload_type or parsed.payload.len == 0) return; + if (parsed.header.payload_type != pipeline.payload_type.load(.acquire) or + parsed.payload.len == 0) return; _ = pipeline.rtp_packets.fetchAdd(1, .monotonic); if (parsed.payload.len > packet_capacity) { _ = pipeline.dropped_packets.fetchAdd(1, .monotonic); diff --git a/src/media/clip/decoder.zig b/src/media/clip/decoder.zig new file mode 100644 index 0000000..46b0513 --- /dev/null +++ b/src/media/clip/decoder.zig @@ -0,0 +1,196 @@ +const std = @import("std"); +pub const c = @cImport({ + @cInclude("libavcodec/avcodec.h"); + @cInclude("libavformat/avformat.h"); + @cInclude("libavutil/error.h"); + @cInclude("libswresample/swresample.h"); +}); + +const sample_rate = 48000; + +const Input = struct { + bytes: []const u8, + position: usize = 0, + + fn read(pointer: ?*anyopaque, output: [*c]u8, capacity: c_int) callconv(.c) c_int { + const self: *Input = @ptrCast(@alignCast(pointer.?)); + if (capacity <= 0) return c.AVERROR(c.EINVAL); + if (self.position == self.bytes.len) return c.AVERROR_EOF; + const count = @min(@as(usize, @intCast(capacity)), self.bytes.len - self.position); + @memcpy(output[0..count], self.bytes[self.position..][0..count]); + self.position += count; + return @intCast(count); + } + + fn seek(pointer: ?*anyopaque, offset: i64, origin: c_int) callconv(.c) i64 { + const self: *Input = @ptrCast(@alignCast(pointer.?)); + if (origin == c.AVSEEK_SIZE) return @intCast(self.bytes.len); + const base: i64 = switch (origin & ~@as(c_int, c.AVSEEK_FORCE)) { + c.SEEK_SET => 0, + c.SEEK_CUR => @intCast(self.position), + c.SEEK_END => @intCast(self.bytes.len), + else => return -1, + }; + const next = std.math.add(i64, base, offset) catch return -1; + if (next < 0 or next > self.bytes.len) return -1; + self.position = @intCast(next); + return next; + } +}; + +pub const Frame = struct { + video: bool, + timestamp_ms: i64, + frame: *c.AVFrame, +}; + +pub const Decoder = struct { + input: Input, + io: [*c]c.AVIOContext = null, + format: [*c]c.AVFormatContext = null, + contexts: [2][*c]c.AVCodecContext = .{ null, null }, + streams: [2]c_int = .{ -1, -1 }, + packet: [*c]c.AVPacket = null, + frame: [*c]c.AVFrame = null, + receiving: ?usize = null, + input_ended: bool = false, + flush_index: usize = 0, + resampler: ?*c.SwrContext = null, + start_ms: i64 = 0, + + pub fn open(bytes: []const u8) !*Decoder { + if (bytes.len == 0 or bytes.len > 96 * 1024 * 1024) return error.InvalidMediaSize; + const self = try std.heap.c_allocator.create(Decoder); + self.* = .{ .input = .{ .bytes = bytes } }; + errdefer self.destroy(); + const buffer: [*c]u8 = @ptrCast(c.av_malloc(32768)); + if (buffer == null) return error.OutOfMemory; + self.io = c.avio_alloc_context(buffer, 32768, 0, &self.input, Input.read, null, Input.seek); + if (self.io == null) { + c.av_free(buffer); + return error.OutOfMemory; + } + self.format = c.avformat_alloc_context(); + if (self.format == null) return error.OutOfMemory; + self.format.*.pb = self.io; + self.format.*.flags |= c.AVFMT_FLAG_CUSTOM_IO; + self.format.*.probesize = 1024 * 1024; + self.format.*.max_analyze_duration = 3 * c.AV_TIME_BASE; + const input_format = c.av_find_input_format("mov"); + if (input_format == null) return error.Mp4Unavailable; + if (c.avformat_open_input(&self.format, null, input_format, null) < 0 or + c.avformat_find_stream_info(self.format, null) < 0) return error.InvalidMp4; + if (self.format.*.duration > 120 * c.AV_TIME_BASE) return error.AdMediaTooLong; + if (self.format.*.start_time != c.AV_NOPTS_VALUE) + self.start_ms = @divTrunc(self.format.*.start_time, 1000); + if (self.format.*.nb_streams > 8) return error.TooManyStreams; + for ([_]c.AVMediaType{ c.AVMEDIA_TYPE_VIDEO, c.AVMEDIA_TYPE_AUDIO }, 0..) |kind, index| { + const stream_index = c.av_find_best_stream(self.format, kind, -1, -1, null, 0); + if (stream_index < 0) { + if (index == 0) return error.MissingVideo; + continue; + } + const parameters = self.format.*.streams[@intCast(stream_index)].*.codecpar; + if (parameters.*.codec_id != (if (index == 0) c.AV_CODEC_ID_H264 else c.AV_CODEC_ID_AAC)) + return error.UnsupportedCodec; + if (index == 0 and (parameters.*.width <= 0 or parameters.*.height <= 0 or + parameters.*.width > 1280 or parameters.*.height > 768)) return error.UnsupportedDimensions; + if (index == 1 and (parameters.*.sample_rate <= 0 or parameters.*.sample_rate > 96000 or + parameters.*.ch_layout.nb_channels <= 0 or parameters.*.ch_layout.nb_channels > 8)) + return error.UnsupportedAudio; + const codec = c.avcodec_find_decoder(parameters.*.codec_id); + if (codec == null) return error.CodecUnavailable; + const context = c.avcodec_alloc_context3(codec); + if (context == null) return error.OutOfMemory; + self.contexts[index] = context; + self.streams[index] = stream_index; + context.*.thread_count = 3; + context.*.max_pixels = 1280 * 768; + if (c.avcodec_parameters_to_context(context, parameters) < 0 or + c.avcodec_open2(context, codec, null) < 0) return error.DecoderUnavailable; + } + self.packet = c.av_packet_alloc(); + self.frame = c.av_frame_alloc(); + if (self.packet == null or self.frame == null) return error.OutOfMemory; + return self; + } + + pub fn next(self: *Decoder) !?Frame { + for (0..4096) |_| { + if (self.receiving) |index| { + c.av_frame_unref(self.frame); + const result = c.avcodec_receive_frame(self.contexts[index], self.frame); + if (result == 0) { + const time_base = self.format.*.streams[@intCast(self.streams[index])].*.time_base; + const pts = self.frame.*.best_effort_timestamp; + if (pts == c.AV_NOPTS_VALUE) return error.MissingTimestamp; + const timestamp_ms = std.math.sub(i64, c.av_rescale_q(pts, time_base, .{ .num = 1, .den = 1000 }), self.start_ms) catch return error.InvalidMediaTimestamp; + if (timestamp_ms < -1000 or timestamp_ms > 120000) return error.InvalidMediaTimestamp; + return .{ .video = index == 0, .frame = self.frame, .timestamp_ms = timestamp_ms }; + } + if (result != c.AVERROR(c.EAGAIN) and result != c.AVERROR_EOF) return error.DecodeFailed; + self.receiving = null; + } + if (self.input_ended) { + if (self.flush_index == 2) return null; + const index = self.flush_index; + self.flush_index += 1; + if (self.contexts[index] == null) continue; + if (c.avcodec_send_packet(self.contexts[index], null) < 0) return error.DecodeFailed; + self.receiving = index; + continue; + } + const result = c.av_read_frame(self.format, self.packet); + if (result == c.AVERROR_EOF) { + self.input_ended = true; + continue; + } + if (result < 0) return error.DemuxFailed; + defer c.av_packet_unref(self.packet); + for (self.streams, 0..) |stream, index| { + if (self.packet.*.stream_index != stream) continue; + if (c.avcodec_send_packet(self.contexts[index], self.packet) < 0) return error.DecodeFailed; + self.receiving = index; + break; + } + } + return error.NonProgressingMedia; + } + + pub fn audio(self: *Decoder, frame: *const c.AVFrame, output: []i16) ![]i16 { + if (frame.sample_rate <= 0 or frame.sample_rate > 96000 or frame.ch_layout.nb_channels <= 0 or + frame.ch_layout.nb_channels > 8 or frame.nb_samples < 0) return error.UnsupportedAudio; + if (self.resampler == null) { + var stereo: c.AVChannelLayout = undefined; + c.av_channel_layout_default(&stereo, 2); + defer c.av_channel_layout_uninit(&stereo); + if (c.swr_alloc_set_opts2(&self.resampler, &stereo, c.AV_SAMPLE_FMT_S16, sample_rate, &frame.ch_layout, @intCast(frame.format), frame.sample_rate, 0, null) < 0 or + c.swr_init(self.resampler) < 0) return error.ResamplerUnavailable; + } + const required = c.swr_get_out_samples(self.resampler, frame.nb_samples); + if (required < 0 or required > output.len / 2) return error.AudioLimitExceeded; + var destination = [_][*c]u8{@ptrCast(output.ptr)}; + const count = c.swr_convert(self.resampler, &destination, @intCast(output.len / 2), @ptrCast(frame.extended_data), frame.nb_samples); + if (count < 0) return error.ResampleFailed; + return output[0 .. @as(usize, @intCast(count)) * 2]; + } + + pub fn finishAudio(self: *Decoder, output: []i16) ![]i16 { + if (self.resampler == null) return output[0..0]; + var destination = [_][*c]u8{@ptrCast(output.ptr)}; + const count = c.swr_convert(self.resampler, &destination, @intCast(output.len / 2), null, 0); + if (count < 0) return error.ResampleFailed; + return output[0 .. @as(usize, @intCast(count)) * 2]; + } + + pub fn destroy(self: *Decoder) void { + c.swr_free(&self.resampler); + c.av_frame_free(&self.frame); + c.av_packet_free(&self.packet); + for (&self.contexts) |*context| c.avcodec_free_context(context); + c.avformat_close_input(&self.format); + if (self.io != null) c.av_freep(@ptrCast(&self.io.*.buffer)); + c.avio_context_free(&self.io); + std.heap.c_allocator.destroy(self); + } +}; diff --git a/src/media/clip/player.zig b/src/media/clip/player.zig new file mode 100644 index 0000000..b32d5dc --- /dev/null +++ b/src/media/clip/player.zig @@ -0,0 +1,277 @@ +const std = @import("std"); +const media = @import("decoder.zig"); +const c = @cImport({ + @cInclude("SDL2/SDL.h"); +}); + +const BufferedFrame = struct { frame: *media.c.AVFrame, pts: i64, duration: i64 }; +const queue_capacity = 16; +const bytes_per_second = 48000 * 2 * @sizeOf(i16); + +pub const Progress = struct { + played_ms: u64, + rendered_frames: u64, + dropped_frames: u64, + finished: bool, +}; + +pub const Player = struct { + bytes: []const u8, + device: c.SDL_AudioDeviceID, + renderer: *c.SDL_Renderer, + texture: ?*c.SDL_Texture = null, + texture_width: c_int = 0, + texture_height: c_int = 0, + thread: ?std.Thread = null, + stop: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + total_samples: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), + has_audio: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + ended: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + mutex: std.Thread.Mutex = .{}, + condition: std.Thread.Condition = .{}, + queue: [queue_capacity]BufferedFrame = undefined, + head: usize = 0, + count: usize = 0, + failure: ?anyerror = null, + started_at: ?u64 = null, + visible_end_ms: u64 = 0, + rendered_frames: u64 = 0, + dropped_frames: u64 = 0, + paused_at: ?u64 = null, + paused_ms: u64 = 0, + previous_yuv_mode: c.SDL_YUV_CONVERSION_MODE = c.SDL_YUV_CONVERSION_AUTOMATIC, + previous_audio_status: ?c.SDL_AudioStatus = null, + last_audio_position: u64 = 0, + last_audio_progress_at: u64 = 0, + last_position: u64 = 0, + + pub fn start(self: *Player) !void { + if (self.thread != null) return error.AlreadyPlaying; + self.previous_yuv_mode = c.SDL_GetYUVConversionMode(); + self.previous_audio_status = c.SDL_GetAudioDeviceStatus(self.device); + errdefer self.restoreDevice(); + c.SDL_PauseAudioDevice(self.device, 1); + c.SDL_ClearQueuedAudio(self.device); + self.thread = try std.Thread.spawn(.{}, worker, .{self}); + } + + fn worker(self: *Player) void { + self.decode() catch |err| { + self.mutex.lock(); + self.failure = err; + self.mutex.unlock(); + }; + self.ended.store(true, .release); + } + + fn decode(self: *Player) !void { + const decoder = try media.Decoder.open(self.bytes); + defer decoder.destroy(); + self.has_audio.store(decoder.contexts[1] != null, .release); + var pcm: [65536]i16 = undefined; + while (!self.stop.load(.acquire)) { + const event = try decoder.next() orelse { + while (true) { + const samples = try decoder.finishAudio(&pcm); + if (samples.len == 0) return; + try self.queueAudio(samples); + } + }; + if (event.video) { + const cloned = media.c.av_frame_clone(event.frame); + if (cloned == null) return error.OutOfMemory; + self.mutex.lock(); + while (self.count == queue_capacity and !self.stop.load(.acquire)) + self.condition.wait(&self.mutex); + if (self.stop.load(.acquire)) { + self.mutex.unlock(); + var frame = cloned; + media.c.av_frame_free(&frame); + return; + } + const duration = media.c.av_rescale_q(event.frame.duration, decoder.format.*.streams[@intCast(decoder.streams[0])].*.time_base, .{ .num = 1, .den = 1000 }); + const rate = decoder.format.*.streams[@intCast(decoder.streams[0])].*.avg_frame_rate; + const frame_duration = if (duration > 0) duration else if (rate.num > 0 and rate.den > 0) + media.c.av_rescale(1000, rate.den, rate.num) + else + 0; + if (frame_duration <= 0 or frame_duration > 1000) { + self.mutex.unlock(); + var frame = cloned; + media.c.av_frame_free(&frame); + return error.MissingFrameDuration; + } + self.queue[(self.head + self.count) % queue_capacity] = .{ + .frame = cloned, + .pts = @max(0, event.timestamp_ms), + .duration = frame_duration, + }; + self.count += 1; + self.mutex.unlock(); + } else { + const samples = try decoder.audio(event.frame, &pcm); + try self.queueAudio(samples); + } + } + } + + fn queueAudio(self: *Player, samples: []const i16) !void { + while (c.SDL_GetQueuedAudioSize(self.device) >= bytes_per_second / 2) { + if (self.stop.load(.acquire)) return error.Cancelled; + std.Thread.sleep(2 * std.time.ns_per_ms); + } + if (self.stop.load(.acquire)) return error.Cancelled; + if (c.SDL_QueueAudio(self.device, samples.ptr, @intCast(samples.len * @sizeOf(i16))) != 0) + return error.AudioQueueFailed; + _ = self.total_samples.fetchAdd(samples.len / 2, .release); + } + + pub fn pause(self: *Player, paused: bool) void { + if (self.started_at == null) return; + const now = c.SDL_GetTicks64(); + if (paused and self.paused_at == null) { + self.paused_at = now; + c.SDL_PauseAudioDevice(self.device, 1); + } else if (!paused and self.paused_at != null) { + self.paused_ms += now - self.paused_at.?; + self.paused_at = null; + if (self.started_at != null) c.SDL_PauseAudioDevice(self.device, 0); + } + } + + fn playhead(self: *Player, now: u64) u64 { + const start_time = self.started_at orelse return 0; + if (!self.has_audio.load(.acquire)) return (self.paused_at orelse now) - start_time - self.paused_ms; + const queued_samples = c.SDL_GetQueuedAudioSize(self.device) / (2 * @sizeOf(i16)); + const samples = self.total_samples.load(.acquire); + const audio_position = (samples -| queued_samples) * 1000 / 48000; + const effective_now = (self.paused_at orelse now) - self.paused_ms; + if (audio_position > self.last_audio_position or self.last_audio_progress_at == 0) { + self.last_audio_position = audio_position; + self.last_audio_progress_at = effective_now; + } + const position = audio_position + if (queued_samples == 0) effective_now - self.last_audio_progress_at else 0; + self.last_position = @max(self.last_position, position); + return self.last_position; + } + + fn render(self: *Player, frame: *const media.c.AVFrame) !void { + if (frame.width <= 0 or frame.height <= 0 or frame.width > 1280 or frame.height > 768) + return error.InvalidFrame; + if (frame.format != media.c.AV_PIX_FMT_YUV420P and frame.format != media.c.AV_PIX_FMT_YUVJ420P) + return error.UnsupportedPixelFormat; + if (self.texture == null or self.texture_width != frame.width or self.texture_height != frame.height) { + if (self.texture) |texture| c.SDL_DestroyTexture(texture); + self.texture = c.SDL_CreateTexture(self.renderer, c.SDL_PIXELFORMAT_IYUV, c.SDL_TEXTUREACCESS_STREAMING, frame.width, frame.height); + if (self.texture == null) return error.TextureUnavailable; + self.texture_width = frame.width; + self.texture_height = frame.height; + } + c.SDL_SetYUVConversionMode(if (frame.color_range == media.c.AVCOL_RANGE_JPEG) + c.SDL_YUV_CONVERSION_JPEG + else if (frame.colorspace == media.c.AVCOL_SPC_BT709) + c.SDL_YUV_CONVERSION_BT709 + else + c.SDL_YUV_CONVERSION_BT601); + if (c.SDL_UpdateYUVTexture(self.texture, null, frame.data[0], frame.linesize[0], frame.data[1], frame.linesize[1], frame.data[2], frame.linesize[2]) != 0) + return error.TextureUpdateFailed; + } + + pub fn draw(self: *Player) !void { + if (self.texture == null) return; + var width: c_int = undefined; + var height: c_int = undefined; + const size = try canvasSize(self.renderer); + width = size.width; + height = size.height; + const scale = @min(@as(f64, @floatFromInt(width)) / @as(f64, @floatFromInt(self.texture_width)), @as(f64, @floatFromInt(height)) / @as(f64, @floatFromInt(self.texture_height))); + const scaled_width: c_int = @intFromFloat(@as(f64, @floatFromInt(self.texture_width)) * scale); + const scaled_height: c_int = @intFromFloat(@as(f64, @floatFromInt(self.texture_height)) * scale); + const destination = c.SDL_Rect{ .x = @divTrunc(width - scaled_width, 2), .y = @divTrunc(height - scaled_height, 2), .w = scaled_width, .h = scaled_height }; + _ = c.SDL_SetRenderDrawColor(self.renderer, 0, 0, 0, 255); + _ = c.SDL_RenderClear(self.renderer); + if (c.SDL_RenderCopy(self.renderer, self.texture, null, &destination) != 0) return error.RenderFailed; + } + + pub fn tick(self: *Player) !Progress { + const now = c.SDL_GetTicks64(); + self.mutex.lock(); + const failure = self.failure; + const available = self.count; + self.mutex.unlock(); + if (failure) |err| return err; + if (self.paused_at == null and self.started_at == null and available > 0 and + (!self.has_audio.load(.acquire) or c.SDL_GetQueuedAudioSize(self.device) >= bytes_per_second / 5 or self.ended.load(.acquire))) + { + self.started_at = now; + c.SDL_PauseAudioDevice(self.device, 0); + } + if (self.started_at == null) return .{ .played_ms = 0, .rendered_frames = 0, .dropped_frames = 0, .finished = false }; + const position = self.playhead(now); + while (self.paused_at == null) { + self.mutex.lock(); + if (self.count == 0 or self.queue[self.head].pts > position) { + self.mutex.unlock(); + break; + } + const next = self.queue[self.head]; + self.head = (self.head + 1) % queue_capacity; + self.count -= 1; + const empty = self.count == 0; + self.condition.signal(); + self.mutex.unlock(); + var owned: [*c]media.c.AVFrame = next.frame; + defer media.c.av_frame_free(&owned); + if (position > @as(u64, @intCast(next.pts)) + 80 and !empty) { + self.dropped_frames += 1; + continue; + } + try self.render(next.frame); + self.rendered_frames += 1; + self.visible_end_ms = @intCast(next.pts + next.duration); + } + self.mutex.lock(); + const drained = self.count == 0; + self.mutex.unlock(); + const complete = self.ended.load(.acquire) and drained and + c.SDL_GetQueuedAudioSize(self.device) == 0 and position + 2 >= self.visible_end_ms and self.rendered_frames > 0; + return .{ .played_ms = @min(position, self.visible_end_ms), .rendered_frames = self.rendered_frames, .dropped_frames = self.dropped_frames, .finished = complete }; + } + + pub fn deinit(self: *Player) void { + self.stop.store(true, .release); + self.mutex.lock(); + self.condition.broadcast(); + self.mutex.unlock(); + if (self.thread) |thread| thread.join(); + self.thread = null; + while (self.count > 0) { + var frame: [*c]media.c.AVFrame = self.queue[self.head].frame; + media.c.av_frame_free(&frame); + self.head = (self.head + 1) % queue_capacity; + self.count -= 1; + } + self.restoreDevice(); + if (self.texture) |texture| c.SDL_DestroyTexture(texture); + self.texture = null; + } + + fn restoreDevice(self: *Player) void { + if (self.previous_audio_status) |status| { + c.SDL_ClearQueuedAudio(self.device); + c.SDL_SetYUVConversionMode(self.previous_yuv_mode); + c.SDL_PauseAudioDevice(self.device, @intFromBool(status != c.SDL_AUDIO_PLAYING)); + self.previous_audio_status = null; + } + } +}; + +pub fn canvasSize(renderer: *c.SDL_Renderer) !struct { width: c_int, height: c_int } { + var width: c_int = 0; + var height: c_int = 0; + c.SDL_RenderGetLogicalSize(renderer, &width, &height); + if ((width == 0 or height == 0) and c.SDL_GetRendererOutputSize(renderer, &width, &height) != 0) + return error.RendererUnavailable; + if (width <= 0 or height <= 0) return error.RendererUnavailable; + return .{ .width = width, .height = height }; +} diff --git a/src/media/video/cedar_bridge.c b/src/media/video/cedar_bridge.c index d64311f..ffc88b4 100644 --- a/src/media/video/cedar_bridge.c +++ b/src/media/video/cedar_bridge.c @@ -32,7 +32,7 @@ static size_t find_start_code(const uint8_t* data, size_t length, size_t offset) return length; } -static int submit_nal(GoCedarDecoder* decoder, const uint8_t* data, size_t length) { +static int submit_access_unit(GoCedarDecoder* decoder, const uint8_t* data, size_t length) { if (length == 0 || length > INT32_MAX) return -1; char* first = NULL; @@ -172,14 +172,9 @@ int go_cedar_v1_feed(GoCedarDecoder* decoder, const uint8_t* annex_b, size_t len set_error(decoder, "H.264 access unit is not Annex B"); return -1; } - while (start < length) { - size_t prefix = annex_b[start + 2] == 1 ? 3u : 4u; - size_t end = find_start_code(annex_b, length, start + prefix); - if (submit_nal(decoder, annex_b + start, end - start) != 0) { - set_error(decoder, "Cedar stream buffer rejected a NAL unit"); - return -1; - } - start = end; + if (submit_access_unit(decoder, annex_b + start, length - start) != 0) { + set_error(decoder, "Cedar stream buffer rejected an access unit"); + return -1; } for (int attempt = 0; attempt < 64; ++attempt) { @@ -187,6 +182,15 @@ int go_cedar_v1_feed(GoCedarDecoder* decoder, const uint8_t* annex_b, size_t len int copied = copy_newest_picture(decoder, frame); if (copied < 0) return -1; + if (result == VDECODE_RESULT_RESOLUTION_CHANGE) { + if (ReopenVideoEngine(decoder->decoder) != 0) { + set_error(decoder, "Cedar resolution change failed"); + return -1; + } + if (copied > 0) + return 1; + continue; + } if (copied > 0) return 1; if (result == VDECODE_RESULT_NO_BITSTREAM || result == VDECODE_RESULT_CONTINUE || diff --git a/src/media/video/video_pipeline.h b/src/media/video/video_pipeline.h index 9acf685..1690f14 100644 --- a/src/media/video/video_pipeline.h +++ b/src/media/video/video_pipeline.h @@ -59,8 +59,9 @@ typedef struct { GoVideoPipeline* go_video_pipeline_create(const GoVideoPipelineConfig* config); int go_video_pipeline_start(GoVideoPipeline* pipeline); void go_video_pipeline_stop(GoVideoPipeline* pipeline); +int go_video_pipeline_set_payload_type(GoVideoPipeline* pipeline, int payload_type); void go_video_pipeline_push_rtp(GoVideoPipeline* pipeline, const uint8_t* packet, size_t length); -void go_video_pipeline_render(GoVideoPipeline* pipeline); +int go_video_pipeline_render(GoVideoPipeline* pipeline); int go_video_pipeline_needs_keyframe(const GoVideoPipeline* pipeline); int go_video_pipeline_has_media(const GoVideoPipeline* pipeline); int go_video_pipeline_failed(const GoVideoPipeline* pipeline); diff --git a/src/media/video/video_pipeline.zig b/src/media/video/video_pipeline.zig index 9dded60..d7c31d3 100644 --- a/src/media/video/video_pipeline.zig +++ b/src/media/video/video_pipeline.zig @@ -354,7 +354,6 @@ fn decodeAccessUnit( fn processPacket(pipeline: *Pipeline, packet: []const u8) void { if (packet.len < 12 or pipeline.depacketizer == null) return; - _ = pipeline.rtp_packets.fetchAdd(1, .monotonic); const result = c.go_h264_depacketizer_feed( pipeline.depacketizer, packet.ptr, @@ -406,6 +405,7 @@ fn worker(pipeline: *Pipeline) void { const packet = pipeline.packet_queue[pipeline.packet_queue_head]; pipeline.packet_queue_head = (pipeline.packet_queue_head + 1) % queue_capacity; pipeline.packet_queue_count -= 1; + _ = pipeline.rtp_packets.fetchAdd(1, .monotonic); pipeline.packet_mutex.unlock(); if (pipeline.restart_epoch_pending.swap(false, .acq_rel)) c.go_h264_depacketizer_restart_decode_epoch(pipeline.depacketizer); @@ -552,7 +552,10 @@ pub export fn go_video_pipeline_create(config_pointer: ?*const c.GoVideoPipeline .max_width = config.max_width, .max_height = config.max_height, }; - errdefer _ = go_video_pipeline_destroy(pipeline); + var initialized = false; + defer if (!initialized) { + _ = go_video_pipeline_destroy(pipeline); + }; if (config.bootstrap_path != null) { const path = std.mem.span(config.bootstrap_path); if (path.len >= pipeline.bootstrap_path.len) return null; @@ -566,6 +569,7 @@ pub export fn go_video_pipeline_create(config_pointer: ?*const c.GoVideoPipeline if (pipeline.decoded_frame == null or pipeline.display_frame == null or pipeline.render_frame == null) return null; if (selectDecoder(pipeline, config.decoder_preference) != 0) return null; + initialized = true; return pipeline; } @@ -581,6 +585,21 @@ pub export fn go_video_pipeline_start(pipeline_pointer: ?*Pipeline) c_int { return 0; } +pub export fn go_video_pipeline_set_payload_type( + pipeline_pointer: ?*Pipeline, + payload_type: c_int, +) c_int { + const pipeline = pipeline_pointer orelse return -1; + if (payload_type < 0 or payload_type > 127) return -1; + pipeline.packet_mutex.lock(); + defer pipeline.packet_mutex.unlock(); + if (pipeline.packet_queue_count != 0 or pipeline.rtp_packets.load(.monotonic) != 0) return -1; + const replacement = c.go_h264_depacketizer_create(@intCast(payload_type)) orelse return -1; + c.go_h264_depacketizer_destroy(pipeline.depacketizer); + pipeline.depacketizer = replacement; + return loadBootstrap(pipeline); +} + pub export fn go_video_pipeline_stop(pipeline_pointer: ?*Pipeline) void { const pipeline = pipeline_pointer orelse return; pipeline.accepting_packets.store(false, .release); @@ -629,9 +648,9 @@ pub export fn go_video_pipeline_push_rtp( pipeline.packet_condition.signal(); } -pub export fn go_video_pipeline_render(pipeline_pointer: ?*Pipeline) void { - const pipeline = pipeline_pointer orelse return; - if (!pipeline.frame_ready.load(.acquire)) return; +pub export fn go_video_pipeline_render(pipeline_pointer: ?*Pipeline) c_int { + const pipeline = pipeline_pointer orelse return 0; + if (!pipeline.frame_ready.load(.acquire)) return 0; pipeline.frame_mutex.lock(); if (pipeline.frame_ready.load(.acquire)) { c.av_frame_unref(pipeline.render_frame); @@ -639,14 +658,14 @@ pub export fn go_video_pipeline_render(pipeline_pointer: ?*Pipeline) void { pipeline.frame_ready.store(false, .release); } pipeline.frame_mutex.unlock(); - const frame = pipeline.render_frame orelse return; + const frame = pipeline.render_frame orelse return 0; if (frame.data[0] == null or uploadFrame(pipeline, frame) < 0) { if (!pipeline.upload_error_reported) { std.debug.print("Frame upload failed: {s}\n", .{std.mem.span(c.SDL_GetError())}); pipeline.upload_error_reported = true; } c.av_frame_unref(frame); - return; + return 0; } var output_width: c_int = 0; var output_height: c_int = 0; @@ -663,9 +682,9 @@ pub export fn go_video_pipeline_render(pipeline_pointer: ?*Pipeline) void { _ = c.SDL_SetRenderDrawColor(pipeline.renderer, 0, 0, 0, 255); _ = c.SDL_RenderClear(pipeline.renderer); _ = c.SDL_RenderCopy(pipeline.renderer, pipeline.texture, &source, &destination); - c.SDL_RenderPresent(pipeline.renderer); _ = pipeline.rendered_frames.fetchAdd(1, .monotonic); c.av_frame_unref(frame); + return 1; } pub export fn go_video_pipeline_needs_keyframe(pipeline: ?*const Pipeline) c_int { diff --git a/src/net/form_writer.zig b/src/net/form_writer.zig index 7f73d4a..30e0abf 100644 --- a/src/net/form_writer.zig +++ b/src/net/form_writer.zig @@ -1,5 +1,10 @@ const std = @import("std"); +pub const Field = struct { + name: []const u8, + value: []const u8, +}; + fn isUnreserved(byte: u8) bool { return std.ascii.isAlphanumeric(byte) or byte == '-' or byte == '.' or byte == '_' or byte == '~'; @@ -38,6 +43,28 @@ pub fn encode(input: []const u8, output: []u8) !usize { return cursor; } +pub fn build(fields: []const Field, output: []u8) ![:0]u8 { + if (output.len == 0) return error.NoSpace; + + var cursor: usize = 0; + for (fields, 0..) |field, index| { + if (index != 0) { + if (cursor + 1 >= output.len) return error.NoSpace; + output[cursor] = '&'; + cursor += 1; + } + + cursor += try encode(field.name, output[cursor..]); + if (cursor + 1 >= output.len) return error.NoSpace; + output[cursor] = '='; + cursor += 1; + cursor += try encode(field.value, output[cursor..]); + } + + output[cursor] = 0; + return output[0..cursor :0]; +} + test "encodes application form values and terminates them" { var output: [256]u8 = undefined; const input = "xboxlive.signin openid service::http://Passport.NET/?a=b&c=d"; @@ -55,3 +82,27 @@ test "preserves unreserved bytes and rejects undersized output" { try std.testing.expectEqualStrings("a-._~Z", output[0..length]); try std.testing.expectError(error.NoSpace, encode("a/b", output[0..5])); } + +test "builds an encoded form body" { + var output: [128]u8 = undefined; + const body = try build(&.{ + .{ .name = "grant_type", .value = "refresh_token" }, + .{ .name = "scope", .value = "openid email" }, + .{ .name = "device/id", .value = "a&b" }, + }, &output); + try std.testing.expectEqualStrings( + "grant_type=refresh_token&scope=openid+email&device%2Fid=a%26b", + body, + ); +} + +test "build handles an empty form and rejects short destinations" { + var empty: [1]u8 = undefined; + try std.testing.expectEqualStrings("", try build(&.{}, &empty)); + + var short: [3]u8 = undefined; + try std.testing.expectError( + error.NoSpace, + build(&.{.{ .name = "a", .value = "b" }}, &short), + ); +} diff --git a/src/net/http_client.c b/src/net/http_client.c index f0b51f0..c11c338 100644 --- a/src/net/http_client.c +++ b/src/net/http_client.c @@ -1,6 +1,7 @@ #include "http_client.h" #include +#include #include #include #include @@ -21,7 +22,15 @@ typedef struct { void* context; } TransferCancel; -static const char* find_ca_bundle(void) { +static void print_http_result(FILE* stream, const char* prefix, const char* url, + const char* suffix) { + const char* query = strchr(url, '?'); + size_t visible_length = query ? (size_t)(query - url) : strlen(url); + int precision = visible_length > INT_MAX ? INT_MAX : (int)visible_length; + fprintf(stream, "%s%.*s%s%s", prefix, precision, url, query ? "?[redacted]" : "", suffix); +} + +const char* go_http_ca_bundle(void) { const char* environment_paths[] = { getenv("CURL_CA_BUNDLE"), getenv("SSL_CERT_FILE"), @@ -68,9 +77,8 @@ static size_t append_response(char* data, size_t size, size_t count, void* conte return length; } -static int transfer_cancelled(void* context, curl_off_t download_total, - curl_off_t download_current, curl_off_t upload_total, - curl_off_t upload_current) { +static int transfer_cancelled(void* context, curl_off_t download_total, curl_off_t download_current, + curl_off_t upload_total, curl_off_t upload_current) { (void)download_total; (void)download_current; (void)upload_total; @@ -90,10 +98,11 @@ int go_http_response_succeeded(const GoHttpResponse* response) { return response && response->status >= 200 && response->status < 300; } -GoHttpResponse* go_http_request_bounded_cancelable( - const char* method, const char* url, const char* body, const char** headers, - int header_count, size_t response_limit, GoHttpCancelRequested cancel_requested, - void* cancel_context) { +GoHttpResponse* go_http_request_bounded_cancelable(const char* method, const char* url, + const char* body, const char** headers, + int header_count, size_t response_limit, + GoHttpCancelRequested cancel_requested, + void* cancel_context) { if (!method || !url || response_limit == 0 || header_count < 0 || (header_count > 0 && !headers)) return NULL; @@ -120,14 +129,9 @@ GoHttpResponse* go_http_request_bounded_cancelable( } curl_easy_setopt(request, CURLOPT_URL, url); - if (strcmp(method, "POST") == 0) { - curl_easy_setopt(request, CURLOPT_POST, 1L); + curl_easy_setopt(request, CURLOPT_CUSTOMREQUEST, method); + if (body || strcmp(method, "POST") == 0) curl_easy_setopt(request, CURLOPT_POSTFIELDS, body ? body : ""); - } else if (strcmp(method, "DELETE") == 0) { - curl_easy_setopt(request, CURLOPT_CUSTOMREQUEST, "DELETE"); - } else if (strcmp(method, "GET") != 0) { - curl_easy_setopt(request, CURLOPT_CUSTOMREQUEST, method); - } if (request_headers) curl_easy_setopt(request, CURLOPT_HTTPHEADER, request_headers); ResponseWriter writer = {.response = response, .limit = response_limit}; @@ -135,7 +139,7 @@ GoHttpResponse* go_http_request_bounded_cancelable( curl_easy_setopt(request, CURLOPT_WRITEDATA, &writer); curl_easy_setopt(request, CURLOPT_TIMEOUT, 30L); curl_easy_setopt(request, CURLOPT_NOSIGNAL, 1L); - const char* ca_bundle = find_ca_bundle(); + const char* ca_bundle = go_http_ca_bundle(); if (ca_bundle) curl_easy_setopt(request, CURLOPT_CAINFO, ca_bundle); TransferCancel cancel = {.requested = cancel_requested, .context = cancel_context}; @@ -148,12 +152,23 @@ GoHttpResponse* go_http_request_bounded_cancelable( CURLcode result = curl_easy_perform(request); curl_easy_getinfo(request, CURLINFO_RESPONSE_CODE, &response->status); if (result != CURLE_OK) { - if (result != CURLE_ABORTED_BY_CALLBACK) - fprintf(stderr, "HTTP %s %s: %s\n", method, url, curl_easy_strerror(result)); + if (result != CURLE_ABORTED_BY_CALLBACK) { + char prefix[64]; + char suffix[160]; + snprintf(prefix, sizeof(prefix), "HTTP %s ", method); + snprintf(suffix, sizeof(suffix), ": %s\n", curl_easy_strerror(result)); + print_http_result(stderr, prefix, url, suffix); + } go_http_response_destroy(response); response = NULL; } else { - go_dbg("HTTP %ld %s (%zu bytes)\n", response->status, url, response->len); + if (go_debug_enabled()) { + char prefix[64]; + char suffix[64]; + snprintf(prefix, sizeof(prefix), "HTTP %ld ", response->status); + snprintf(suffix, sizeof(suffix), " (%zu bytes)\n", response->len); + print_http_result(stderr, prefix, url, suffix); + } } curl_slist_free_all(request_headers); diff --git a/src/net/http_client.h b/src/net/http_client.h index 1d23585..ed4a416 100644 --- a/src/net/http_client.h +++ b/src/net/http_client.h @@ -15,15 +15,18 @@ typedef struct { typedef int (*GoHttpCancelRequested)(void* context); +const char* go_http_ca_bundle(void); + GoHttpResponse* go_http_request(const char* method, const char* url, const char* body, const char** headers, int header_count); GoHttpResponse* go_http_request_bounded(const char* method, const char* url, const char* body, const char** headers, int header_count, size_t response_limit); -GoHttpResponse* go_http_request_bounded_cancelable( - const char* method, const char* url, const char* body, const char** headers, - int header_count, size_t response_limit, GoHttpCancelRequested cancel_requested, - void* cancel_context); +GoHttpResponse* go_http_request_bounded_cancelable(const char* method, const char* url, + const char* body, const char** headers, + int header_count, size_t response_limit, + GoHttpCancelRequested cancel_requested, + void* cancel_context); int go_http_response_succeeded(const GoHttpResponse* response); void go_http_response_destroy(GoHttpResponse* response); diff --git a/src/net/websocket_client.c b/src/net/websocket_client.c new file mode 100644 index 0000000..924c2ee --- /dev/null +++ b/src/net/websocket_client.c @@ -0,0 +1,175 @@ +#include "websocket_client.h" + +#include +#include +#include +#include + +#include "http_client.h" + +struct GoWebSocket { + CURL* handle; + struct curl_slist* headers; + char error[CURL_ERROR_SIZE]; +}; + +static unsigned int from_curl_flags(int flags) { + unsigned int result = 0; + if (flags & CURLWS_TEXT) + result |= GO_WEBSOCKET_TEXT; + if (flags & CURLWS_BINARY) + result |= GO_WEBSOCKET_BINARY; + if (flags & CURLWS_CONT) + result |= GO_WEBSOCKET_CONTINUATION; + if (flags & CURLWS_CLOSE) + result |= GO_WEBSOCKET_CLOSE; + return result; +} + +static int to_curl_flags(unsigned int flags, unsigned int* result) { + const unsigned int supported = + GO_WEBSOCKET_TEXT | GO_WEBSOCKET_BINARY | GO_WEBSOCKET_CONTINUATION | GO_WEBSOCKET_CLOSE; + if (!result || (flags & ~supported) != 0) + return -1; + *result = 0; + if (flags & GO_WEBSOCKET_TEXT) + *result |= CURLWS_TEXT; + if (flags & GO_WEBSOCKET_BINARY) + *result |= CURLWS_BINARY; + if (flags & GO_WEBSOCKET_CONTINUATION) + *result |= CURLWS_CONT; + if (flags & GO_WEBSOCKET_CLOSE) + *result |= CURLWS_CLOSE; + return 0; +} + +static int valid_header_value(const char* value) { + return value && value[0] != '\0' && !strchr(value, '\r') && !strchr(value, '\n'); +} + +static int append_header(GoWebSocket* socket, const char* name, const char* value) { + char header[1024]; + int length = snprintf(header, sizeof(header), "%s: %s", name, value); + if (length <= 0 || length >= (int)sizeof(header)) + return -1; + struct curl_slist* next = curl_slist_append(socket->headers, header); + if (!next) + return -1; + socket->headers = next; + return 0; +} + +static void copy_error(char* output, size_t capacity, const char* message) { + if (!output || capacity == 0) + return; + snprintf(output, capacity, "%s", message && message[0] ? message : "WebSocket request failed"); +} + +GoWebSocket* go_websocket_open(const char* url, const char* origin, const char* subprotocol, + const char* user_agent, char* error, size_t error_capacity) { + if (!valid_header_value(url) || !valid_header_value(origin) || + !valid_header_value(subprotocol) || !valid_header_value(user_agent)) { + copy_error(error, error_capacity, "Invalid WebSocket connection parameters"); + return NULL; + } + + GoWebSocket* socket = calloc(1, sizeof(*socket)); + if (!socket) { + copy_error(error, error_capacity, "WebSocket allocation failed"); + return NULL; + } + socket->handle = curl_easy_init(); + if (!socket->handle) + goto fail; + if (append_header(socket, "Origin", origin) != 0 || + append_header(socket, "Sec-WebSocket-Protocol", subprotocol) != 0) + goto fail; + + curl_easy_setopt(socket->handle, CURLOPT_URL, url); + curl_easy_setopt(socket->handle, CURLOPT_CONNECT_ONLY, 2L); + curl_easy_setopt(socket->handle, CURLOPT_HTTPHEADER, socket->headers); + curl_easy_setopt(socket->handle, CURLOPT_USERAGENT, user_agent); + curl_easy_setopt(socket->handle, CURLOPT_CONNECTTIMEOUT, 15L); + curl_easy_setopt(socket->handle, CURLOPT_TIMEOUT, 30L); + curl_easy_setopt(socket->handle, CURLOPT_NOSIGNAL, 1L); + curl_easy_setopt(socket->handle, CURLOPT_TCP_NODELAY, 1L); + curl_easy_setopt(socket->handle, CURLOPT_ERRORBUFFER, socket->error); + const char* ca_bundle = go_http_ca_bundle(); + if (ca_bundle) + curl_easy_setopt(socket->handle, CURLOPT_CAINFO, ca_bundle); + + CURLcode result = curl_easy_perform(socket->handle); + if (result != CURLE_OK) { + if (socket->error[0] == '\0') + snprintf(socket->error, sizeof(socket->error), "%s", curl_easy_strerror(result)); + goto fail; + } + return socket; + +fail: + copy_error(error, error_capacity, socket->error); + if (socket->handle) + curl_easy_cleanup(socket->handle); + curl_slist_free_all(socket->headers); + free(socket); + return NULL; +} + +int go_websocket_send(GoWebSocket* socket, const void* data, size_t length, unsigned int flags, + size_t* sent) { + if (!socket || !socket->handle || !sent || (length > 0 && !data)) + return -1; + unsigned int curl_flags = 0; + if (to_curl_flags(flags, &curl_flags) != 0) + return -1; + *sent = 0; + CURLcode result = curl_ws_send(socket->handle, data, length, sent, 0, curl_flags); + if (result == CURLE_AGAIN) + return 0; + if (result != CURLE_OK) { + snprintf(socket->error, sizeof(socket->error), "%s", curl_easy_strerror(result)); + return -1; + } + return 1; +} + +int go_websocket_receive(GoWebSocket* socket, void* output, size_t capacity, + GoWebSocketFrame* frame) { + if (!socket || !socket->handle || !output || capacity == 0 || !frame) + return -1; + memset(frame, 0, sizeof(*frame)); + size_t received = 0; + const struct curl_ws_frame* metadata = NULL; + CURLcode result = curl_ws_recv(socket->handle, output, capacity, &received, &metadata); + if (result == CURLE_AGAIN) + return 0; + if (result == CURLE_GOT_NOTHING) + return -2; + if (result != CURLE_OK || !metadata) { + snprintf(socket->error, sizeof(socket->error), "%s", curl_easy_strerror(result)); + return -1; + } + frame->length = received; + frame->offset = metadata->offset; + frame->bytes_left = metadata->bytesleft; + frame->flags = from_curl_flags(metadata->flags); + return (metadata->flags & CURLWS_CLOSE) ? -2 : 1; +} + +const char* go_websocket_last_error(const GoWebSocket* socket) { + if (!socket || socket->error[0] == '\0') + return "WebSocket request failed"; + return socket->error; +} + +void go_websocket_close(GoWebSocket* socket) { + if (!socket) + return; + if (socket->handle) { + size_t sent = 0; + (void)curl_ws_send(socket->handle, "", 0, &sent, 0, CURLWS_CLOSE); + curl_easy_cleanup(socket->handle); + } + curl_slist_free_all(socket->headers); + free(socket); +} diff --git a/src/net/websocket_client.h b/src/net/websocket_client.h new file mode 100644 index 0000000..380386c --- /dev/null +++ b/src/net/websocket_client.h @@ -0,0 +1,39 @@ +#ifndef GREENOVERCAST_WEBSOCKET_CLIENT_H +#define GREENOVERCAST_WEBSOCKET_CLIENT_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct GoWebSocket GoWebSocket; + +typedef struct { + size_t length; + long long offset; + long long bytes_left; + unsigned int flags; +} GoWebSocketFrame; + +enum { + GO_WEBSOCKET_TEXT = 1 << 0, + GO_WEBSOCKET_BINARY = 1 << 1, + GO_WEBSOCKET_CONTINUATION = 1 << 2, + GO_WEBSOCKET_CLOSE = 1 << 3, +}; + +GoWebSocket* go_websocket_open(const char* url, const char* origin, const char* subprotocol, + const char* user_agent, char* error, size_t error_capacity); +int go_websocket_send(GoWebSocket* socket, const void* data, size_t length, unsigned int flags, + size_t* sent); +int go_websocket_receive(GoWebSocket* socket, void* output, size_t capacity, + GoWebSocketFrame* frame); +const char* go_websocket_last_error(const GoWebSocket* socket); +void go_websocket_close(GoWebSocket* socket); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/net/xml_document.zig b/src/net/xml_document.zig new file mode 100644 index 0000000..1d5c0d2 --- /dev/null +++ b/src/net/xml_document.zig @@ -0,0 +1,148 @@ +const std = @import("std"); +const c = @cImport({ + @cInclude("expat.h"); +}); + +const maximum_bytes = 1024 * 1024; +const maximum_nodes = 8192; +const maximum_depth = 32; + +pub const Node = struct { + name: []const u8, + parent: ?*Node, + attributes: std.StringHashMapUnmanaged([]const u8) = .{}, + children: std.ArrayListUnmanaged(*Node) = .{}, + text: std.ArrayListUnmanaged(u8) = .{}, + + pub fn child(self: *const Node, name: []const u8) ?*Node { + for (self.children.items) |entry| if (std.mem.eql(u8, entry.name, name)) return entry; + return null; + } + + pub fn content(self: *const Node) []const u8 { + return std.mem.trim(u8, self.text.items, " \t\r\n"); + } +}; + +pub const Document = struct { + arena: std.heap.ArenaAllocator, + root: *Node, + + pub fn deinit(self: *Document) void { + self.arena.deinit(); + } +}; + +const Builder = struct { + allocator: std.mem.Allocator, + parser: c.XML_Parser, + root: ?*Node = null, + current: ?*Node = null, + failure: ?anyerror = null, + depth: usize = 0, + nodes: usize = 0, + text_bytes: usize = 0, + + fn fail(self: *Builder, err: anyerror) void { + self.failure = err; + _ = c.XML_StopParser(self.parser, c.XML_FALSE); + } + + fn open(self: *Builder, name: []const u8, attributes: [*c]const [*c]const u8) !void { + if (self.depth == maximum_depth or self.nodes == maximum_nodes) return error.XmlLimitExceeded; + const node = try self.allocator.create(Node); + node.* = .{ .name = try self.allocator.dupe(u8, name), .parent = self.current }; + var index: usize = 0; + while (attributes[index] != null) : (index += 2) { + if (index >= 128) return error.XmlLimitExceeded; + const key = try self.allocator.dupe(u8, std.mem.span(attributes[index])); + const value = try self.allocator.dupe(u8, std.mem.span(attributes[index + 1])); + try node.attributes.put(self.allocator, key, value); + } + if (self.current) |parent| { + try parent.children.append(self.allocator, node); + } else { + if (self.root != null) return error.InvalidXml; + self.root = node; + } + self.current = node; + self.depth += 1; + self.nodes += 1; + } +}; + +fn builder(context: ?*anyopaque) *Builder { + return @ptrCast(@alignCast(context.?)); +} + +fn start(context: ?*anyopaque, name: [*c]const u8, attributes: [*c]const [*c]const u8) callconv(.c) void { + const state = builder(context); + state.open(std.mem.span(name), attributes) catch |err| state.fail(err); +} + +fn end(context: ?*anyopaque, _: [*c]const u8) callconv(.c) void { + const state = builder(context); + if (state.current) |current| state.current = current.parent; + if (state.depth > 0) state.depth -= 1; +} + +fn text(context: ?*anyopaque, data: [*c]const u8, length: c_int) callconv(.c) void { + const state = builder(context); + const current = state.current orelse return; + const size: usize = @intCast(length); + if (size > maximum_bytes - state.text_bytes) { + state.fail(error.XmlLimitExceeded); + return; + } + state.text_bytes += size; + current.text.appendSlice(state.allocator, data[0..size]) catch |err| state.fail(err); +} + +fn doctype(context: ?*anyopaque, _: [*c]const u8, _: [*c]const u8, _: [*c]const u8, _: c_int) callconv(.c) void { + builder(context).fail(error.XmlDoctypeForbidden); +} + +pub fn parse(allocator: std.mem.Allocator, data: []const u8) !Document { + if (data.len > maximum_bytes) return error.XmlLimitExceeded; + var arena = std.heap.ArenaAllocator.init(allocator); + errdefer arena.deinit(); + const parser = c.XML_ParserCreate(null) orelse return error.OutOfMemory; + defer c.XML_ParserFree(parser); + var state = Builder{ .allocator = arena.allocator(), .parser = parser }; + c.XML_SetUserData(parser, &state); + c.XML_SetElementHandler(parser, start, end); + c.XML_SetCharacterDataHandler(parser, text); + c.XML_SetStartDoctypeDeclHandler(parser, doctype); + _ = c.XML_SetParamEntityParsing(parser, c.XML_PARAM_ENTITY_PARSING_NEVER); + const status = c.XML_Parse(parser, data.ptr, @intCast(data.len), c.XML_TRUE); + if (state.failure) |err| return err; + if (status != c.XML_STATUS_OK or state.root == null) return error.InvalidXml; + return .{ .arena = arena, .root = state.root.? }; +} + +test "empty VAST and CDATA/entity content are parsed, not searched as strings" { + var empty = try parse(std.testing.allocator, ""); + defer empty.deinit(); + try std.testing.expectEqualStrings("VAST", empty.root.name); + try std.testing.expectEqual(@as(usize, 0), empty.root.children.items.len); + var doc = try parse(std.testing.allocator, " https://example.invalid/?a=1&b=2"); + defer doc.deinit(); + const ad = doc.root.child("Ad").?; + try std.testing.expectEqualStrings("a&b", ad.attributes.get("id").?); + try std.testing.expectEqualStrings("https://example.invalid/?a=1&b=2&c=3", ad.child("Url").?.content()); +} + +test "malformed XML and external or expanding entities cannot be accepted" { + for ([_][]const u8{ "", "", "", "garbage", "", "&undefined;" }) |input| + try std.testing.expectError(error.InvalidXml, parse(std.testing.allocator, input)); + for ([_][]const u8{ "", "]>&a;" }) |input| + try std.testing.expectError(error.XmlDoctypeForbidden, parse(std.testing.allocator, input)); +} + +test "input and nesting limits are enforced with allocator cleanup" { + const data = try std.testing.allocator.alloc(u8, maximum_bytes + 1); + defer std.testing.allocator.free(data); + try std.testing.expectError(error.XmlLimitExceeded, parse(std.testing.allocator, data)); + const nested = "" ** (maximum_depth + 1) ++ "" ** (maximum_depth + 1); + try std.testing.expectError(error.XmlLimitExceeded, parse(std.testing.allocator, nested)); +} diff --git a/src/provider/geforce_now/ad_network.zig b/src/provider/geforce_now/ad_network.zig new file mode 100644 index 0000000..099a7cb --- /dev/null +++ b/src/provider/geforce_now/ad_network.zig @@ -0,0 +1,432 @@ +const std = @import("std"); +const vast = @import("vast.zig"); +const protocol = @import("ad_protocol.zig"); +const c = @cImport({ + @cInclude("curl/curl.h"); + @cInclude("http_client.h"); + @cInclude("stdlib.h"); +}); + +pub const Reporter = struct { + context: *anyopaque, + send: *const fn (*anyopaque, protocol.Update) anyerror!void, +}; +pub const Source = struct { + context: *anyopaque, + read: *const fn (*anyopaque, std.mem.Allocator, []const u8, usize, *std.atomic.Value(bool)) anyerror![]u8, +}; +pub const Fetch = struct { + done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + result: ?[]u8 = null, + failure: ?anyerror = null, +}; +const Job = union(enum) { + report: protocol.Update, + beacon: []u8, + fetch: struct { address: []const u8, limit: usize, output: *Fetch }, +}; + +pub const Worker = struct { + allocator: std.mem.Allocator, + reporter: Reporter, + source: ?Source = null, + abort: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + threads: [2]?std.Thread = .{ null, null }, + mutex: std.Thread.Mutex = .{}, + wake: std.Thread.Condition = .{}, + jobs: [256]Job = undefined, + count: usize = 0, + working: [2]bool = .{ false, false }, + closing: bool = false, + failure: ?anyerror = null, + beacon_failures: usize = 0, + + pub fn start(self: *Worker) !void { + if (self.threads[0] != null or self.closing) return error.AdWorkerStopped; + errdefer self.deinit(); + for (&self.threads, 0..) |*thread, index| thread.* = try std.Thread.spawn(.{}, run, .{ self, index }); + } + + fn enqueue(self: *Worker, job: Job) !void { + self.mutex.lock(); + defer self.mutex.unlock(); + if (self.closing or self.threads[1] == null) return error.AdWorkerStopped; + if (self.count == self.jobs.len) return error.AdReportQueueFull; + self.jobs[self.count] = job; + self.count += 1; + self.wake.broadcast(); + } + + pub fn report(self: *Worker, update: protocol.Update) !void { + try self.enqueue(.{ .report = update }); + } + pub fn beacon(self: *Worker, address: []const u8) !void { + const owned = try self.allocator.dupe(u8, address); + errdefer self.allocator.free(owned); + try self.enqueue(.{ .beacon = owned }); + } + pub fn fetch(self: *Worker, address: []const u8, limit: usize, output: *Fetch) !void { + try self.enqueue(.{ .fetch = .{ .address = address, .limit = limit, .output = output } }); + } + pub fn idle(self: *Worker) !bool { + self.mutex.lock(); + defer self.mutex.unlock(); + if (self.failure) |failure| return failure; + return self.count == 0 and !self.working[0] and !self.working[1]; + } + + pub fn reportsIdle(self: *Worker) !bool { + self.mutex.lock(); + defer self.mutex.unlock(); + if (self.failure) |failure| return failure; + if (self.working[0]) return false; + for (self.jobs[0..self.count]) |job| if (job == .report) return false; + return true; + } + + fn nextJob(self: *Worker, index: usize) ?Job { + var found: ?usize = null; + for (self.jobs[0..self.count], 0..) |job, position| { + if (index == 0 and job == .report) { + found = position; + break; + } + if (index == 1 and job != .report) { + if (found == null) found = position; + if (job == .fetch) { + found = position; + break; + } + } + } + const position = found orelse return null; + const job = self.jobs[position]; + std.mem.copyForwards(Job, self.jobs[position .. self.count - 1], self.jobs[position + 1 .. self.count]); + self.count -= 1; + return job; + } + + fn run(self: *Worker, index: usize) void { + while (true) { + self.mutex.lock(); + const job = while (true) { + if (self.nextJob(index)) |job| break job; + if (self.closing) { + self.mutex.unlock(); + return; + } + self.wake.wait(&self.mutex); + }; + self.working[index] = true; + self.mutex.unlock(); + switch (job) { + .report => |update| if (!self.abort.load(.acquire)) self.reporter.send(self.reporter.context, update) catch |err| { + self.mutex.lock(); + if (self.failure == null) self.failure = err; + self.mutex.unlock(); + self.abort.store(true, .release); + }, + .beacon => |address| { + defer self.allocator.free(address); + const bytes = self.download(address, 64 * 1024, 3) catch |err| { + if (err != error.Cancelled) self.beacon_failures += 1; + self.finishJob(index); + continue; + }; + self.allocator.free(bytes); + }, + .fetch => |request| { + request.output.result = self.download(request.address, request.limit, 30) catch |err| result: { + request.output.failure = err; + break :result null; + }; + request.output.done.store(true, .release); + }, + } + self.finishJob(index); + } + } + fn download(self: *Worker, address: []const u8, limit: usize, timeout_seconds: c_long) ![]u8 { + try vast.validateUrl(address); + if (self.abort.load(.acquire)) return error.Cancelled; + if (self.source) |source| { + const bytes = try source.read(source.context, self.allocator, address, limit, &self.abort); + if (bytes.len > limit) { + self.allocator.free(bytes); + return error.AdResponseTooLarge; + } + return bytes; + } + return get(self.allocator, address, limit, &self.abort, timeout_seconds); + } + fn finishJob(self: *Worker, index: usize) void { + self.mutex.lock(); + self.working[index] = false; + self.mutex.unlock(); + } + pub fn deinit(self: *Worker) void { + self.abort.store(true, .release); + self.mutex.lock(); + self.closing = true; + self.wake.broadcast(); + self.mutex.unlock(); + for (&self.threads) |*thread| { + if (thread.*) |running| running.join(); + thread.* = null; + } + if (self.beacon_failures > 0) std.debug.print("GeForce NOW ad tracking failures: {d}\n", .{self.beacon_failures}); + self.beacon_failures = 0; + } +}; + +const Transfer = struct { + bytes: std.ArrayList(u8), + location: ?[]u8 = null, + limit: usize, + abort: *std.atomic.Value(bool), + failure: ?anyerror = null, +}; +fn body(data: [*]const u8, size: usize, count: usize, context: ?*anyopaque) callconv(.c) usize { + const transfer: *Transfer = @ptrCast(@alignCast(context.?)); + const length = std.math.mul(usize, size, count) catch return 0; + if (length > transfer.limit - transfer.bytes.items.len) { + transfer.failure = error.AdResponseTooLarge; + return 0; + } + transfer.bytes.appendSlice(data[0..length]) catch |err| { + transfer.failure = err; + return 0; + }; + return length; +} +fn header(data: [*]const u8, size: usize, count: usize, context: ?*anyopaque) callconv(.c) usize { + const transfer: *Transfer = @ptrCast(@alignCast(context.?)); + const length = std.math.mul(usize, size, count) catch return 0; + const line = data[0..length]; + if (std.ascii.startsWithIgnoreCase(line, "Location:")) { + const address = std.mem.trim(u8, line[9..], " \t\r\n"); + if (address.len > 8192) return 0; + if (transfer.location) |previous| transfer.bytes.allocator.free(previous); + transfer.location = transfer.bytes.allocator.dupe(u8, address) catch return 0; + } + return length; +} +fn cancelled(context: ?*anyopaque, _: c.curl_off_t, _: c.curl_off_t, _: c.curl_off_t, _: c.curl_off_t) callconv(.c) c_int { + const transfer: *Transfer = @ptrCast(@alignCast(context.?)); + return @intFromBool(transfer.abort.load(.acquire)); +} +fn connected(_: ?*anyopaque, remote: [*c]const u8, _: [*c]const u8, _: c_int, _: c_int) callconv(.c) c_int { + return if (publicIp(std.mem.span(remote))) c.CURL_PREREQFUNC_OK else c.CURL_PREREQFUNC_ABORT; +} + +fn publicIp(address: []const u8) bool { + const parsed = std.net.Address.parseIp(address, 443) catch return false; + if (parsed.any.family == std.posix.AF.INET) { + const bytes = std.mem.asBytes(&parsed.in.sa.addr); + return bytes[0] != 0 and bytes[0] != 10 and bytes[0] != 127 and bytes[0] < 224 and + !(bytes[0] == 169 and bytes[1] == 254) and !(bytes[0] == 172 and bytes[1] >= 16 and bytes[1] <= 31) and + !(bytes[0] == 192 and bytes[1] == 168) and !(bytes[0] == 100 and bytes[1] >= 64 and bytes[1] <= 127) and + !(bytes[0] == 198 and (bytes[1] == 18 or bytes[1] == 19)); + } + // Only global unicast IPv6; excludes loopback, link-local and mapped IPv4. + return parsed.in6.sa.addr[0] & 0xe0 == 0x20; +} + +fn get(allocator: std.mem.Allocator, address: []const u8, limit: usize, abort: *std.atomic.Value(bool), timeout_seconds: c_long) ![]u8 { + var current = try allocator.dupeZ(u8, address); + defer allocator.free(current); + for (0..6) |_| { + if (abort.load(.acquire)) return error.Cancelled; + try vast.validateUrl(current); + var transfer = Transfer{ .bytes = std.ArrayList(u8).init(allocator), .limit = limit, .abort = abort }; + defer transfer.bytes.deinit(); + defer if (transfer.location) |location| allocator.free(location); + const handle = c.curl_easy_init() orelse return error.HttpUnavailable; + defer c.curl_easy_cleanup(handle); + if (c.curl_easy_setopt(handle, c.CURLOPT_URL, current.ptr) != c.CURLE_OK or + // The peer-address check must inspect the origin, not a proxy. + c.curl_easy_setopt(handle, c.CURLOPT_PROXY, @as([*:0]const u8, "")) != c.CURLE_OK or + c.curl_easy_setopt(handle, c.CURLOPT_PROTOCOLS_STR, @as([*:0]const u8, "https")) != c.CURLE_OK or + c.curl_easy_setopt(handle, c.CURLOPT_USERAGENT, @as([*:0]const u8, "GreenOvercast")) != c.CURLE_OK or + c.curl_easy_setopt(handle, c.CURLOPT_TIMEOUT, timeout_seconds) != c.CURLE_OK or + c.curl_easy_setopt(handle, c.CURLOPT_CONNECTTIMEOUT, @min(@as(c_long, 10), timeout_seconds)) != c.CURLE_OK or + c.curl_easy_setopt(handle, c.CURLOPT_NOSIGNAL, @as(c_long, 1)) != c.CURLE_OK or + c.curl_easy_setopt(handle, c.CURLOPT_WRITEFUNCTION, body) != c.CURLE_OK or + c.curl_easy_setopt(handle, c.CURLOPT_WRITEDATA, &transfer) != c.CURLE_OK or + c.curl_easy_setopt(handle, c.CURLOPT_HEADERFUNCTION, header) != c.CURLE_OK or + c.curl_easy_setopt(handle, c.CURLOPT_HEADERDATA, &transfer) != c.CURLE_OK or + c.curl_easy_setopt(handle, c.CURLOPT_NOPROGRESS, @as(c_long, 0)) != c.CURLE_OK or + c.curl_easy_setopt(handle, c.CURLOPT_XFERINFOFUNCTION, cancelled) != c.CURLE_OK or + c.curl_easy_setopt(handle, c.CURLOPT_XFERINFODATA, &transfer) != c.CURLE_OK or + c.curl_easy_setopt(handle, c.CURLOPT_PREREQFUNCTION, connected) != c.CURLE_OK) + return error.HttpConfigurationFailed; + const ca = c.go_http_ca_bundle(); + if (ca != null and c.curl_easy_setopt(handle, c.CURLOPT_CAINFO, ca) != c.CURLE_OK) + return error.HttpConfigurationFailed; + const result = c.curl_easy_perform(handle); + if (abort.load(.acquire)) return error.Cancelled; + if (transfer.failure) |failure| return failure; + if (result != c.CURLE_OK) return error.AdDownloadFailed; + var status: c_long = 0; + if (c.curl_easy_getinfo(handle, c.CURLINFO_RESPONSE_CODE, &status) != c.CURLE_OK) return error.HttpStatusUnavailable; + if (status >= 200 and status < 300) return transfer.bytes.toOwnedSlice(); + if (status < 300 or status >= 400) return error.AdDownloadRejected; + const location = transfer.location orelse return error.MissingRedirect; + // Redirects never carry account headers, cookies or shared curl state. + const next = try redirect(allocator, current, location); + allocator.free(current); + current = next; + } + return error.AdRedirectLimit; +} + +fn redirect(allocator: std.mem.Allocator, base: []const u8, location: []const u8) ![:0]u8 { + if (location.len == 0 or location.len > 8192) return error.InvalidAdUrl; + const buffer = try allocator.alloc(u8, 32768); + defer allocator.free(buffer); + var available = buffer; + const resolved = try std.Uri.resolve_inplace(try std.Uri.parse(base), location, &available); + const address = try std.fmt.allocPrintZ(allocator, "{}", .{resolved}); + errdefer allocator.free(address); + try vast.validateUrl(address); + return address; +} + +test "relative redirects are resolved without allowing credentials or HTTP" { + const address = try redirect(std.testing.allocator, "https://example.invalid/path/tag", "../media?key=one"); + defer std.testing.allocator.free(address); + try std.testing.expectEqualStrings("https://example.invalid/media?key=one", address); + try std.testing.expectError(error.InvalidAdUrl, redirect(std.testing.allocator, "https://example.invalid/", "http://example.invalid/media")); + try std.testing.expectError(error.InvalidAdUrl, redirect(std.testing.allocator, "https://example.invalid/", "https://user:pass@example.invalid/media")); +} + +test "ad connections reject local networks including IPv4 mapped IPv6" { + for ([_][]const u8{ "127.0.0.1", "10.0.0.1", "169.254.1.1", "172.16.0.1", "192.168.1.1", "::1", "::ffff:127.0.0.1", "fe80::1", "fc00::1" }) |value| + try std.testing.expect(!publicIp(value)); + try std.testing.expect(publicIp("8.8.8.8")); + try std.testing.expect(publicIp("2001:4860:4860::8888")); +} + +test "ad transfers do not delegate destination resolution to environment proxies" { + const names = [_][:0]const u8{ "https_proxy", "HTTPS_PROXY", "all_proxy", "ALL_PROXY", "no_proxy", "NO_PROXY" }; + var saved = [_]?[:0]u8{null} ** names.len; + defer for (names, saved) |name, value| { + if (value) |original| { + _ = c.setenv(name.ptr, original.ptr, 1); + std.testing.allocator.free(original); + } else _ = c.unsetenv(name.ptr); + }; + for (names, &saved) |name, *value| { + if (c.getenv(name.ptr)) |original| value.* = try std.testing.allocator.dupeZ(u8, std.mem.span(original)); + } + for (names) |name| try std.testing.expectEqual(@as(c_int, 0), c.unsetenv(name.ptr)); + var trap = try (try std.net.Address.parseIp("127.0.0.1", 0)).listen(.{ .force_nonblocking = true }); + defer trap.deinit(); + const proxy = try std.fmt.allocPrintZ(std.testing.allocator, "http://127.0.0.1:{d}", .{trap.listen_address.getPort()}); + defer std.testing.allocator.free(proxy); + var abort = std.atomic.Value(bool).init(false); + for (names[0..4]) |name| { + try std.testing.expectEqual(@as(c_int, 0), c.setenv(name.ptr, proxy.ptr, 1)); + try std.testing.expectError(error.AdDownloadFailed, get(std.testing.allocator, "https://ad-proxy-test.invalid/", 1024, &abort, 1)); + if (trap.accept()) |connection| { + connection.stream.close(); + return error.AdTransferUsedProxy; + } else |err| try std.testing.expectEqual(error.WouldBlock, err); + try std.testing.expectEqual(@as(c_int, 0), c.unsetenv(name.ptr)); + } +} + +test "report queue is bounded and stop joins an in-flight fetch" { + const Fake = struct { + entered: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + release: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + stop: *std.atomic.Value(bool), + received: usize = 0, + + fn send(context: *anyopaque, _: protocol.Update) !void { + const self: *@This() = @ptrCast(@alignCast(context)); + self.entered.store(true, .release); + while (!self.release.load(.acquire) and !self.stop.load(.acquire)) std.Thread.sleep(std.time.ns_per_ms); + self.received += 1; + } + fn read(context: *anyopaque, _: std.mem.Allocator, _: []const u8, _: usize, stop: *std.atomic.Value(bool)) ![]u8 { + const self: *@This() = @ptrCast(@alignCast(context)); + self.entered.store(true, .release); + while (!stop.load(.acquire)) std.Thread.sleep(std.time.ns_per_ms); + return error.Cancelled; + } + fn wait(self: *@This()) !void { + for (0..2000) |_| { + if (self.entered.load(.acquire)) return; + std.Thread.sleep(std.time.ns_per_ms); + } + return error.TestTimedOut; + } + }; + var stop = std.atomic.Value(bool).init(false); + var fake = Fake{ .stop = &stop }; + var worker = Worker{ .allocator = std.testing.allocator, .reporter = .{ .context = &fake, .send = Fake.send } }; + fake.stop = &worker.abort; + try worker.start(); + defer worker.deinit(); + try worker.report(.{ .action = .start }); + try fake.wait(); + for (0..256) |_| try worker.report(.{ .action = .event, .event = "test" }); + try std.testing.expectError(error.AdReportQueueFull, worker.report(.{ .action = .finish })); + fake.release.store(true, .release); + for (0..2000) |_| { + if (try worker.idle()) break; + std.Thread.sleep(std.time.ns_per_ms); + } + try std.testing.expect(try worker.idle()); + worker.deinit(); + try std.testing.expectEqual(@as(usize, 257), fake.received); + try std.testing.expectError(error.AdWorkerStopped, worker.report(.{ .action = .finish })); + + fake.entered.store(false, .release); + var fetch_worker = Worker{ .allocator = std.testing.allocator, .reporter = .{ .context = &fake, .send = Fake.send }, .source = .{ .context = &fake, .read = Fake.read } }; + try fetch_worker.start(); + defer fetch_worker.deinit(); + var result = Fetch{}; + try fetch_worker.fetch("https://example.invalid", 1024, &result); + try fake.wait(); + fetch_worker.deinit(); + try std.testing.expect(result.done.load(.acquire)); + try std.testing.expect(result.failure.? == error.Cancelled and result.result == null); +} + +test "slow advertiser tracking cannot block session reports" { + const Fake = struct { + entered: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + reported: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + fn read(context: *anyopaque, _: std.mem.Allocator, _: []const u8, _: usize, stop: *std.atomic.Value(bool)) ![]u8 { + const self: *@This() = @ptrCast(@alignCast(context)); + self.entered.store(true, .release); + while (!stop.load(.acquire)) std.Thread.sleep(std.time.ns_per_ms); + return error.Cancelled; + } + fn send(context: *anyopaque, _: protocol.Update) !void { + const self: *@This() = @ptrCast(@alignCast(context)); + self.reported.store(true, .release); + } + }; + var fake = Fake{}; + var worker = Worker{ .allocator = std.testing.allocator, .reporter = .{ .context = &fake, .send = Fake.send }, .source = .{ .context = &fake, .read = Fake.read } }; + try worker.start(); + defer worker.deinit(); + try worker.beacon("https://example.invalid/slow-tracker"); + for (0..2000) |_| { + if (fake.entered.load(.acquire)) break; + std.Thread.sleep(std.time.ns_per_ms); + } + try std.testing.expect(fake.entered.load(.acquire)); + try worker.report(.{ .action = .finish }); + for (0..2000) |_| { + if (fake.reported.load(.acquire)) break; + std.Thread.sleep(std.time.ns_per_ms); + } + try std.testing.expect(fake.reported.load(.acquire)); + try std.testing.expect(!try worker.idle()); + worker.deinit(); + try std.testing.expect(try worker.reportsIdle()); +} diff --git a/src/provider/geforce_now/ad_playback.zig b/src/provider/geforce_now/ad_playback.zig new file mode 100644 index 0000000..212fa8c --- /dev/null +++ b/src/provider/geforce_now/ad_playback.zig @@ -0,0 +1,382 @@ +const std = @import("std"); +const session = @import("session_client.zig"); +pub const protocol = @import("ad_protocol.zig"); +const vast = @import("vast.zig"); +pub const network = @import("ad_network.zig"); +const clip = @import("clip_player"); +const policy = @import("ad_policy.zig"); +const c = @cImport({ + @cInclude("handheld_ui.h"); + @cInclude("artwork_decoder.h"); +}); + +const Metadata = struct { + impressions: []const []const u8 = &.{}, + errors: []const []const u8 = &.{}, + tracks: []const vast.Track = &.{}, + icons: []const vast.Icon = &.{}, +}; +const Resolved = struct { creative: vast.Creative, metadata: Metadata }; +const Parent = struct { metadata: Metadata, follow_wrappers: bool, allow_multiple: bool }; +const ImageTexture = struct { handle: *c.SDL_Texture, width: u16, height: u16 }; +const IconTexture = struct { image: ImageTexture, fallback: ImageTexture, icon: vast.Icon, viewed: bool = false }; + +pub const Playback = struct { + renderer: *c.SDL_Renderer, + device: c.SDL_AudioDeviceID, + ui: *c.GoHandheldUi, + allocator: std.mem.Allocator = std.heap.c_allocator, + client: *session.Client = undefined, + opportunity: protocol.Opportunity = undefined, + provider: u8 = 0, + app_id: []const u8 = "", + watched_ms: u64 = 0, + paused_ms: u64 = 0, + duration_ms: u64 = 0, + + pub fn handle(context: *anyopaque, client: *session.Client, opportunity: protocol.Opportunity, provider: u8) !void { + const self: *Playback = @ptrCast(@alignCast(context)); + self.client = client; + self.opportunity = opportunity; + self.provider = provider; + self.app_id = client.app_id orelse ""; + self.watched_ms = 0; + self.paused_ms = 0; + self.duration_ms = 0; + var worker = network.Worker{ .allocator = self.allocator, .reporter = .{ .context = self, .send = report } }; + client.ad_stop = &worker.abort; + defer client.ad_stop = null; + try worker.start(); + defer worker.deinit(); + try self.execute(&worker); + } + + pub fn execute(self: *Playback, worker: *network.Worker) !void { + self.run(worker) catch |err| { + worker.abort.store(true, .release); + worker.deinit(); + worker.abort.store(false, .release); + const no_fill = err == error.VastEmptyResponse or err == error.VastNoAdsAfterWrapper; + const reason = if (err == error.VastEmptyResponse) "VAST_EMPTY_RESPONSE" else if (err == error.VastNoAdsAfterWrapper) + "VAST_NO_ADS_AFTER_WRAPPER" + else if (err == error.Cancelled) "USER_CANCELLED" else @errorName(err); + worker.reporter.send(worker.reporter.context, .{ .action = .cancel, .reason = reason, .watched_ms = self.watched_ms, .paused_ms = self.paused_ms, .duration_ms = self.duration_ms }) catch |report_error| { + std.debug.print("GeForce NOW ad cancellation failed: {s} after {s}\n", .{ @errorName(report_error), reason }); + return if (no_fill) report_error else err; + }; + std.debug.print("GeForce NOW ad: {s}, watched {d}ms\n", .{ reason, self.watched_ms }); + if (!no_fill) return err; + }; + } + + fn report(context: *anyopaque, update: protocol.Update) !void { + const self: *Playback = @ptrCast(@alignCast(context)); + try self.client.reportAd(self.opportunity.id, update); + } + + fn fetch(self: *Playback, worker: *network.Worker, url: []const u8, limit: usize) ![]u8 { + var task = network.Fetch{}; + try worker.fetch(url, limit, &task); + var was_cancelled = false; + while (!task.done.load(.acquire)) { + if (c.go_handheld_ui_cancel_requested(self.ui) != 0) { + worker.abort.store(true, .release); + was_cancelled = true; + } + c.SDL_Delay(16); + } + if (was_cancelled) { + if (task.result) |bytes| self.allocator.free(bytes); + return error.Cancelled; + } + if (task.failure) |failure| return failure; + return task.result orelse error.AdDownloadFailed; + } + + fn drain(self: *Playback, worker: *network.Worker) !void { + const started = c.SDL_GetTicks64(); + while (!try worker.idle()) { + if (c.go_handheld_ui_cancel_requested(self.ui) != 0) return error.Cancelled; + const elapsed = c.SDL_GetTicks64() - started; + if (elapsed >= 10000 and try worker.reportsIdle()) { + return; + } + if (elapsed >= 30000) return error.AdReportTimeout; + c.SDL_Delay(16); + } + } + + fn drainAfterFailure(self: *Playback, worker: *network.Worker) !void { + self.drain(worker) catch |err| { + if (err == error.Cancelled) return err; + }; + } + + fn run(self: *Playback, worker: *network.Worker) !void { + c.go_handheld_ui_draw_loading(self.ui, "GEFORCE NOW", "LOADING ADVERTISEMENT", c.GO_HANDHELD_UI_ACTION_CANCEL); + if (self.provider > 1) return error.UnsupportedAdProvider; + var arena = std.heap.ArenaAllocator.init(self.allocator); + defer arena.deinit(); + const storage = arena.allocator(); + var documents = std.ArrayList(vast.Manifest).init(self.allocator); + defer { + for (documents.items) |*document| document.deinit(); + documents.deinit(); + } + var pod = std.ArrayList(Resolved).init(storage); + if (self.provider == 1) { + const target = try policy.tagUrl(storage, self.opportunity.url, self.app_id); + try self.load(worker, &documents, &pod, storage, target, null, 0); + } else { + try vast.validateUrl(self.opportunity.url); + try pod.append(.{ .metadata = .{}, .creative = .{ .duration_ms = self.opportunity.duration_ms, .media = try storage.dupe(vast.Media, &.{.{ .url = self.opportunity.url, .width = 640, .height = 360, .bitrate = 0 }}), .tracks = &.{}, .icons = &.{} } }); + } + var length: u64 = 0; + for (pod.items) |ad| length += ad.creative.duration_ms; + if (length > 120000) return error.AdPodTooLong; + self.duration_ms = length; + for (pod.items, 0..) |ad, index| { + const prior = self.watched_ms; + self.play(worker, ad, index == 0) catch |err| { + if (err != error.Cancelled) for (ad.metadata.errors) |address| { + beacon(worker, storage, address, self.watched_ms - prior, policy.errorCode(err)) catch {}; + }; + try self.drainAfterFailure(worker); + return err; + }; + } + try self.event(worker, "ALL_ADS_COMPLETED"); + try worker.report(.{ .action = .finish, .watched_ms = self.watched_ms, .paused_ms = self.paused_ms, .duration_ms = self.duration_ms }); + try self.drain(worker); + std.debug.print("GeForce NOW ad completed: watched {d}ms\n", .{self.watched_ms}); + } + + fn load(self: *Playback, worker: *network.Worker, documents: *std.ArrayList(vast.Manifest), pod: *std.ArrayList(Resolved), storage: std.mem.Allocator, url: []const u8, parent: ?Parent, depth: usize) !void { + if (depth > 5 or documents.items.len == 32) { + try self.wrapperError(worker, storage, parent, 302); + return error.VastWrapperLimit; + } + const bytes = self.fetch(worker, url, 1024 * 1024) catch |err| { + if (err != error.Cancelled) try self.wrapperError(worker, storage, parent, policy.errorCode(err)); + return err; + }; + defer self.allocator.free(bytes); + var document = vast.parse(self.allocator, bytes) catch |err| { + try self.wrapperError(worker, storage, parent, policy.errorCode(err)); + return err; + }; + documents.append(document) catch |err| { + document.deinit(); + return err; + }; + if (document.ads.len == 0) { + for (document.errors) |address| try beacon(worker, storage, address, 0, 303); + if (parent) |previous| for (previous.metadata.errors) |address| try beacon(worker, storage, address, 0, 303); + try self.drain(worker); + return if (depth == 0) error.VastEmptyResponse else error.VastNoAdsAfterWrapper; + } + const count_before = pod.items.len; + const candidates = try policy.selectAds(storage, document.ads, if (parent) |previous| previous.allow_multiple else true, if (parent) |previous| previous.follow_wrappers else true); + var used_fallbacks = [_]bool{false} ** 16; + for (candidates) |ad| { + for (ad.verification_errors) |address| try worker.beacon(try policy.expand(storage, address, 0, 0, 2)); + const seed = if (parent) |previous| previous.metadata else Metadata{}; + const metadata = Metadata{ .impressions = try policy.merge([]const u8, storage, seed.impressions, ad.impressions), .errors = try policy.merge([]const u8, storage, seed.errors, ad.errors), .tracks = try policy.merge(vast.Track, storage, seed.tracks, ad.tracks), .icons = try policy.merge(vast.Icon, storage, seed.icons, ad.icons) }; + switch (ad.body) { + .wrapper => |wrapper| self.load(worker, documents, pod, storage, wrapper.url, .{ .metadata = metadata, .follow_wrappers = wrapper.follow_wrappers, .allow_multiple = wrapper.allow_multiple }, depth + 1) catch |err| { + if (err != error.VastNoAdsAfterWrapper) return err; + if (wrapper.fallback_on_no_ad) { + for (document.ads, 0..) |fallback, index| { + if (used_fallbacks[index] or fallback.sequence != null or fallback.body != .inline_ad) continue; + for (fallback.verification_errors) |address| try worker.beacon(try policy.expand(storage, address, 0, 0, 2)); + if (pod.items.len == 16) return error.AdPodTooLarge; + try pod.append(try resolveInline(storage, seed, fallback)); + used_fallbacks[index] = true; + break; + } + } + }, + .inline_ad => { + if (pod.items.len == 16) return error.AdPodTooLarge; + try pod.append(try resolveInline(storage, seed, ad)); + }, + } + } + if (pod.items.len == count_before) return error.VastNoAdsAfterWrapper; + } + + fn wrapperError(self: *Playback, worker: *network.Worker, storage: std.mem.Allocator, parent: ?Parent, code: u16) !void { + if (parent) |previous| { + for (previous.metadata.errors) |address| beacon(worker, storage, address, 0, code) catch {}; + try self.drainAfterFailure(worker); + } + } + + fn play(self: *Playback, worker: *network.Worker, ad: Resolved, first: bool) !void { + var icons = std.ArrayList(IconTexture).init(self.allocator); + defer { + for (icons.items) |icon| { + c.SDL_DestroyTexture(icon.image.handle); + c.SDL_DestroyTexture(icon.fallback.handle); + } + icons.deinit(); + } + for (ad.metadata.icons) |icon| { + const fallback_url = icon.fallback orelse return error.UnsupportedAdChoices; + const image = try self.loadTexture(worker, icon.image, 640, 480); + errdefer c.SDL_DestroyTexture(image.handle); + const fallback = try self.loadTexture(worker, fallback_url, vast.max_fallback_width, vast.max_fallback_height); + errdefer c.SDL_DestroyTexture(fallback.handle); + try icons.append(.{ .image = image, .fallback = fallback, .icon = icon }); + } + const media = policy.selectMedia(ad.creative.media) orelse return error.UnsupportedMedia; + const bytes = try self.fetch(worker, media.url, 96 * 1024 * 1024); + defer self.allocator.free(bytes); + var player = clip.Player{ .bytes = bytes, .device = self.device, .renderer = @ptrCast(self.renderer) }; + defer player.deinit(); + try player.start(); + try self.event(worker, "LOADED"); + const fired = try self.allocator.alloc(bool, ad.metadata.tracks.len); + defer self.allocator.free(fired); + @memset(fired, false); + const prior = self.watched_ms; + const prior_paused = self.paused_ms; + var started = false; + var modal = false; + var selected: usize = 0; + var last_progress = c.SDL_GetTicks64(); + var last_position: u64 = 0; + var quartiles = [_]bool{false} ** 3; + const started_at = last_progress; + while (true) { + const action = c.go_handheld_ui_ad_action(self.ui); + if (action < 0 or (action == 2 and !modal)) return error.Cancelled; + if (action == 2 and modal) { + modal = false; + player.pause(false); + last_progress = c.SDL_GetTicks64(); + self.paused_ms = prior_paused + player.paused_ms; + for (ad.metadata.tracks) |track| if (std.mem.eql(u8, track.event, "resume")) + try beacon(worker, self.allocator, track.url, last_position, 0); + try worker.report(.{ .action = .resume_playback, .watched_ms = self.watched_ms, .paused_ms = self.paused_ms, .duration_ms = self.duration_ms }); + } + const progress = try player.tick(); + self.watched_ms = prior + progress.played_ms; + self.paused_ms = prior_paused + player.paused_ms + if (player.paused_at) |paused| c.SDL_GetTicks64() - paused else 0; + if (progress.finished and progress.played_ms + 250 < ad.creative.duration_ms) return error.AdMediaTruncated; + if (progress.played_ms > last_position) { + last_progress = c.SDL_GetTicks64(); + last_position = progress.played_ms; + } + if ((!modal and c.SDL_GetTicks64() - last_progress > 10000) or c.SDL_GetTicks64() - started_at > 180000) + return error.AdPlaybackStalled; + if (progress.rendered_frames > 0) { + try player.draw(); + const size = try clip.canvasSize(@ptrCast(self.renderer)); + if (icons.items.len > 0) { + if (action == 3 and !modal) selected = (selected + icons.items.len - 1) % icons.items.len; + if (action == 4 and !modal) selected = (selected + 1) % icons.items.len; + } + for (icons.items, 0..) |*icon, index| { + if (!policy.iconVisible(icon.icon, progress.played_ms)) continue; + const rect = c.SDL_Rect{ .x = policy.iconPosition(icon.icon.x, size.width, icon.icon.width), .y = policy.iconPosition(icon.icon.y, size.height, icon.icon.height), .w = icon.icon.width, .h = icon.icon.height }; + if (c.SDL_RenderCopy(self.renderer, icon.image.handle, null, &rect) != 0) return error.AdIconRenderFailed; + if (index == selected and icons.items.len > 1 and !modal) { + const outline = c.SDL_Rect{ .x = rect.x - 1, .y = rect.y - 1, .w = rect.w + 2, .h = rect.h + 2 }; + _ = c.SDL_SetRenderDrawColor(self.renderer, 255, 255, 255, 255); + if (c.SDL_RenderDrawRect(self.renderer, &outline) != 0) return error.AdIconRenderFailed; + } + if (action == 1 and index == selected and !modal and started) { + modal = true; + player.pause(true); + for (ad.metadata.tracks) |track| if (std.mem.eql(u8, track.event, "pause")) + try beacon(worker, self.allocator, track.url, progress.played_ms, 0); + for (icon.icon.clicks) |url| try beacon(worker, self.allocator, url, progress.played_ms, 0); + try worker.report(.{ .action = .pause, .watched_ms = self.watched_ms, .paused_ms = self.paused_ms, .duration_ms = self.duration_ms }); + } + } + if (icons.items.len > 0) { + if (modal) { + _ = c.SDL_SetRenderDrawColor(self.renderer, 0, 0, 0, 255); + _ = c.SDL_RenderClear(self.renderer); + const icon = icons.items[selected]; + const fitted = try policy.fitImage(icon.fallback.width, icon.fallback.height, size.width, size.height - 40); + const rect = c.SDL_Rect{ .x = @divTrunc(size.width - fitted.width, 2), .y = @divTrunc(size.height - 40 - fitted.height, 2), .w = fitted.width, .h = fitted.height }; + if (c.SDL_RenderCopy(self.renderer, icon.fallback.handle, null, &rect) != 0) return error.AdIconRenderFailed; + } + var hint_unobscured = player.texture_height * 640 <= player.texture_width * 400; + for (icons.items) |icon| { + const y = policy.iconPosition(icon.icon.y, size.height, icon.icon.height); + if (policy.iconVisible(icon.icon, progress.played_ms) and y < 465 and y + icon.icon.height > 435) + hint_unobscured = false; + } + if (modal or hint_unobscured) c.go_handheld_ui_draw_ad_hint(self.ui, @intFromBool(modal)); + } + c.SDL_RenderPresent(self.renderer); + if (!modal) { + if (!started) { + started = true; + if (first) try worker.report(.{ .action = .start, .duration_ms = self.duration_ms }); + for (ad.metadata.impressions) |url| try beacon(worker, self.allocator, url, progress.played_ms, 0); + try self.event(worker, "STARTED"); + try self.event(worker, "IMPRESSION"); + } + for (icons.items) |*icon| { + if (!icon.viewed and policy.iconVisible(icon.icon, progress.played_ms)) { + for (icon.icon.views) |url| try beacon(worker, self.allocator, url, progress.played_ms, 0); + icon.viewed = true; + } + } + for (ad.metadata.tracks, fired) |track, *sent| { + if (sent.* or !policy.due(track, progress.played_ms, ad.creative.duration_ms, progress.finished)) continue; + try beacon(worker, self.allocator, track.url, progress.played_ms, 0); + sent.* = true; + } + for (&quartiles, 0..) |*sent, index| { + if (sent.* or ad.creative.duration_ms == 0 or progress.played_ms < ad.creative.duration_ms * (index + 1) / 4) continue; + try self.event(worker, ([_][]const u8{ "FIRST_QUARTILE", "MIDPOINT", "THIRD_QUARTILE" })[index]); + sent.* = true; + } + } + } + if (progress.finished and !modal) break; + _ = try worker.idle(); + c.SDL_Delay(4); + } + try self.event(worker, "COMPLETE"); + } + + fn event(self: *Playback, worker: *network.Worker, name: []const u8) !void { + if (self.provider == 1) try worker.report(.{ .action = .event, .event = name, .watched_ms = self.watched_ms, .paused_ms = self.paused_ms, .duration_ms = self.duration_ms }); + } + fn loadTexture(self: *Playback, worker: *network.Worker, url: []const u8, max_width: c_int, max_height: c_int) !ImageTexture { + const bytes = try self.fetch(worker, url, 4 * 1024 * 1024); + defer self.allocator.free(bytes); + var image = std.mem.zeroes(c.GoArtworkImage); + if (c.go_artwork_decode_rgba(bytes.ptr, bytes.len, max_width, max_height, &image) != 0) return error.InvalidAdIcon; + defer c.go_artwork_image_destroy(&image); + const texture = c.SDL_CreateTexture(self.renderer, c.SDL_PIXELFORMAT_RGBA32, c.SDL_TEXTUREACCESS_STATIC, image.width, image.height) orelse return error.AdIconTextureUnavailable; + errdefer c.SDL_DestroyTexture(texture); + if (c.SDL_UpdateTexture(texture, null, image.pixels, image.stride) != 0 or + c.SDL_SetTextureBlendMode(texture, c.SDL_BLENDMODE_BLEND) != 0) return error.AdIconTextureUnavailable; + return .{ .handle = texture, .width = @intCast(image.width), .height = @intCast(image.height) }; + } +}; + +fn resolveInline(storage: std.mem.Allocator, seed: Metadata, ad: vast.Ad) !Resolved { + const creative = ad.body.inline_ad; + const metadata = Metadata{ + .impressions = try policy.merge([]const u8, storage, seed.impressions, ad.impressions), + .errors = try policy.merge([]const u8, storage, seed.errors, ad.errors), + .tracks = try policy.merge(vast.Track, storage, seed.tracks, creative.tracks), + .icons = try policy.merge(vast.Icon, storage, seed.icons, creative.icons), + }; + if (metadata.icons.len > 8 or metadata.impressions.len > 128) return error.VastLimitExceeded; + return .{ .creative = creative, .metadata = metadata }; +} + +fn beacon(worker: *network.Worker, allocator: std.mem.Allocator, url: []const u8, played: u64, error_code: u16) !void { + const address = try policy.expand(allocator, url, played, error_code, 0); + defer allocator.free(address); + try worker.beacon(address); +} diff --git a/src/provider/geforce_now/ad_policy.zig b/src/provider/geforce_now/ad_policy.zig new file mode 100644 index 0000000..52b5472 --- /dev/null +++ b/src/provider/geforce_now/ad_policy.zig @@ -0,0 +1,267 @@ +const std = @import("std"); +const vast = @import("vast.zig"); + +pub fn selectAds(allocator: std.mem.Allocator, ads: []const vast.Ad, allow_multiple: bool, follow_wrappers: bool) ![]const vast.Ad { + var selected = std.ArrayList(vast.Ad).init(allocator); + errdefer selected.deinit(); + const has_pod = allow_multiple and for (ads) |ad| { + if (ad.sequence != null) break true; + } else false; + for (ads) |ad| { + if (!follow_wrappers and ad.body == .wrapper) continue; + if ((has_pod and ad.sequence == null) or (!has_pod and ad.sequence != null)) continue; + try selected.append(ad); + if (!has_pod) break; + } + return selected.toOwnedSlice(); +} + +pub fn errorCode(err: anyerror) u16 { + return switch (err) { + error.InvalidXml, error.InvalidVastRoot, error.XmlDoctypeForbidden => 100, + error.VastWrapperLimit => 302, + error.VastEmptyResponse, error.VastNoAdsAfterWrapper => 303, + error.UnsupportedMedia, error.UnsupportedCodec, error.UnsupportedPixelFormat => 403, + error.UnsupportedInteractiveAd => 409, + error.AdDownloadFailed, error.AdDownloadRejected => 401, + else => 405, + }; +} + +pub fn merge(comptime T: type, allocator: std.mem.Allocator, left: []const T, right: []const T) ![]const T { + if (left.len + right.len > 256) return error.VastLimitExceeded; + const result = try allocator.alloc(T, left.len + right.len); + @memcpy(result[0..left.len], left); + @memcpy(result[left.len..], right); + return result; +} +pub fn selectMedia(files: []const vast.Media) ?vast.Media { + var result: ?vast.Media = null; + for (files) |file| { + if (result == null or @as(u32, file.width) * file.height < @as(u32, result.?.width) * result.?.height) + result = file; + } + return result; +} +pub fn due(track: vast.Track, played: u64, length: u64, finished: bool) bool { + if (std.mem.eql(u8, track.event, "complete")) return finished; + const threshold: u64 = if (std.mem.eql(u8, track.event, "start") or std.mem.eql(u8, track.event, "creativeView")) 0 else if (std.mem.eql(u8, track.event, "firstQuartile")) length / 4 else if (std.mem.eql(u8, track.event, "midpoint")) length / 2 else if (std.mem.eql(u8, track.event, "thirdQuartile")) length * 3 / 4 else if (std.mem.eql(u8, track.event, "progress")) track.offset_ms orelse length * (track.offset_percent orelse return false) / 100 else return false; + return played >= threshold; +} +pub fn iconVisible(icon: vast.Icon, played: u64) bool { + return played >= icon.offset_ms and (icon.duration_ms == null or played - icon.offset_ms < icon.duration_ms.?); +} +pub fn iconPosition(position: vast.Position, extent: c_int, size: u16) c_int { + return switch (position) { + .start => 0, + .end => @max(0, extent - size), + .pixel => |value| @min(value, @max(0, extent - size)), + }; +} + +pub fn fitImage(width: u16, height: u16, max_width: c_int, max_height: c_int) !struct { width: c_int, height: c_int } { + if (width == 0 or height == 0 or max_width <= 0 or max_height <= 0) return error.InvalidImageSize; + const w: u64 = @intCast(@min(width, max_width)); + const h: u64 = @intCast(@min(height, max_height)); + if (w * height <= h * width) return .{ .width = @intCast(w), .height = @intCast(@max(1, w * height / width)) }; + return .{ .width = @intCast(@max(1, h * width / height)), .height = @intCast(h) }; +} +pub fn expand(allocator: std.mem.Allocator, url: []const u8, played: u64, error_code: u16, verification: u8) ![]u8 { + var result = std.ArrayList(u8).init(allocator); + errdefer result.deinit(); + var buffer: [64]u8 = undefined; + const macros = [_][]const u8{ "[CACHEBUSTING]", "[ERRORCODE]", "[CONTENTPLAYHEAD]", "[REASON]", "[TIMESTAMP]", "[ADPLAYHEAD]", "[MEDIAPLAYHEAD]" }; + var cursor: usize = 0; + while (cursor < url.len) { + var replaced = false; + for (macros, 0..) |macro, index| { + if (!std.mem.startsWith(u8, url[cursor..], macro)) continue; + const value = switch (index) { + 0 => try std.fmt.bufPrint(&buffer, "{d:0>8}", .{std.crypto.random.intRangeAtMost(u32, 10000000, 99999999)}), + 1 => try std.fmt.bufPrint(&buffer, "{d}", .{error_code}), + 2, 6 => "00%3A00%3A00.000", + 3 => try std.fmt.bufPrint(&buffer, "{d}", .{verification}), + 4 => try timestamp(&buffer, std.time.milliTimestamp()), + 5 => try std.fmt.bufPrint(&buffer, "{d:0>2}%3A{d:0>2}%3A{d:0>2}.{d:0>3}", .{ played / 3600000, played / 60000 % 60, played / 1000 % 60, played % 1000 }), + else => return error.UnsupportedTrackingMacro, + }; + try result.appendSlice(value); + cursor += macro.len; + replaced = true; + break; + } + if (!replaced) { + if (url[cursor] == '[') { + if (std.mem.indexOfScalarPos(u8, url, cursor + 1, ']')) |end| { + if (unavailableMacro(url[cursor + 1 .. end])) { + try result.appendSlice("-1"); + if (result.items.len > 8192) return error.InvalidAdUrl; + cursor = end + 1; + continue; + } + } + } + try result.append(url[cursor]); + cursor += 1; + } + if (result.items.len > 8192) return error.InvalidAdUrl; + } + return result.toOwnedSlice(); +} + +fn unavailableMacro(name: []const u8) bool { + // VAST's unknown-value marker applies to defined macros, not vendor tokens. + const optional = [_][]const u8{ + "BREAKPOSITION", "BLOCKEDADCATEGORIES", "ADCATEGORIES", "ADCOUNT", "TRANSACTIONID", + "PLACEMENTTYPE", "ADTYPE", "UNIVERSALADID", "BREAKMAXDURATION", "BREAKMAXADS", + "BREAKMINADLENGTH", "BREAKMAXADLENGTH", "IFA", "IFATYPE", "CLIENTUA", + "SERVERUA", "DEVICEUA", "SERVERSIDE", "DEVICEIP", "LATLONG", + "DOMAIN", "PAGEURL", "APPBUNDLE", "VASTVERSIONS", "APIFRAMEWORKS", + "EXTENSIONS", "VERIFICATIONVENDORS", "OMIDPARTNER", "MEDIAMIME", "PLAYERCAPABILITIES", + "CLICKTYPE", "PLAYERSTATE", "INVENTORYSTATE", "PLAYERSIZE", "ASSETURI", + "CONTENTID", "CONTENTURI", "PODSEQUENCE", "ADSERVINGID", "CLICKPOS", + "REGULATIONS", "GDPRCONSENT", "STOREID", "STOREURL", "PLAYBACKMETHODS", + "CONTENTCAT", "GPPSTRING", "GPPSECTIONID", "DSAREQUIRED", "DSAPARAMS", + "DSAPUBRENDER", + }; + for (optional) |macro| if (std.mem.eql(u8, name, macro)) return true; + return false; +} + +fn timestamp(buffer: []u8, milliseconds: i64) ![]const u8 { + if (milliseconds < 0) return error.InvalidTimestamp; + const seconds = std.time.epoch.EpochSeconds{ .secs = @intCast(@divTrunc(milliseconds, 1000)) }; + const day = seconds.getEpochDay().calculateYearDay(); + const month = day.calculateMonthDay(); + const time = seconds.getDaySeconds(); + return std.fmt.bufPrint(buffer, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}%3A{d:0>2}%3A{d:0>2}.{d:0>3}Z", .{ day.year, month.month.numeric(), month.day_index + 1, time.getHoursIntoDay(), time.getMinutesIntoHour(), time.getSecondsIntoMinute(), @as(u16, @intCast(@mod(milliseconds, 1000))) }); +} + +pub fn tagUrl(allocator: std.mem.Allocator, url: []const u8, app_id: []const u8) ![]u8 { + try vast.validateUrl(url); + for (app_id) |byte| if (!std.ascii.isDigit(byte)) return error.InvalidAppId; + var result = std.ArrayList(u8).init(allocator); + errdefer result.deinit(); + const query = std.mem.indexOfScalar(u8, url, '?'); + try result.appendSlice(url[0 .. query orelse url.len]); + try result.append('?'); + var buffer: [512]u8 = undefined; + const supplied = try std.fmt.bufPrint(&buffer, "correlator={d}&scor={d}&description_url=https%253A%252F%252Fplay.geforcenow.com%252Fgames%253Fgame-id%253D{s}&url=https%3A%2F%2Fgithub.com%2FProducdevity%2FGreenOvercast&plcmt=1&vpmute=0&dth=6", .{ std.crypto.random.intRangeAtMost(u64, 1, 9007199254740991), std.time.milliTimestamp(), app_id }); + var defaults = std.mem.splitScalar(u8, supplied, '&'); + const existing = if (query) |index| url[index + 1 ..] else ""; + var original_fields = std.mem.splitScalar(u8, existing, '&'); + while (original_fields.next()) |field| { + if (field.len == 0) continue; + const key_end = std.mem.indexOfScalar(u8, field, '=') orelse field.len; + const empty = key_end == field.len or key_end + 1 == field.len; + var replace_empty = false; + var default_fields = std.mem.splitScalar(u8, supplied, '&'); + while (default_fields.next()) |replacement| { + const end = std.mem.indexOfScalar(u8, replacement, '=').?; + if (empty and std.mem.eql(u8, field[0..key_end], replacement[0..end])) replace_empty = true; + } + if (!replace_empty) { + try result.appendSlice(field); + try result.append('&'); + } + } + while (defaults.next()) |field| { + const key_end = std.mem.indexOfScalar(u8, field, '=').?; + var fields = std.mem.splitScalar(u8, existing, '&'); + var present = false; + while (fields.next()) |candidate| { + const end = std.mem.indexOfScalar(u8, candidate, '=') orelse candidate.len; + if (end + 1 < candidate.len and std.mem.eql(u8, candidate[0..end], field[0..key_end])) { + present = true; + break; + } + } + if (!present) { + try result.appendSlice(field); + try result.append('&'); + } + } + if (result.items[result.items.len - 1] == '&') result.items.len -= 1; + if (result.items.len > 8192) return error.InvalidAdUrl; + return result.toOwnedSlice(); +} + +test "empty request-context fields are populated without changing consent" { + const address = try tagUrl(std.testing.allocator, "https://example.invalid/?correlator=&scor&gdpr_consent=&npa=1", "12"); + defer std.testing.allocator.free(address); + try std.testing.expect(std.mem.indexOf(u8, address, "gdpr_consent=&npa=1") != null); + try std.testing.expect(std.mem.indexOf(u8, address, "correlator=&") == null); + try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, address, "correlator=")); +} + +test "pod selection excludes the buffet and honors wrapper restrictions" { + const creative = vast.Creative{ .duration_ms = 5000, .media = &.{}, .tracks = &.{}, .icons = &.{} }; + const ads = [_]vast.Ad{ + .{ .sequence = 1, .impressions = &.{}, .errors = &.{}, .body = .{ .inline_ad = creative } }, + .{ .sequence = 2, .impressions = &.{}, .errors = &.{}, .body = .{ .inline_ad = creative } }, + .{ .sequence = null, .impressions = &.{}, .errors = &.{}, .body = .{ .inline_ad = creative } }, + }; + const pod = try selectAds(std.testing.allocator, &ads, true, true); + defer std.testing.allocator.free(pod); + try std.testing.expectEqual(@as(usize, 2), pod.len); + const single = try selectAds(std.testing.allocator, &ads, false, true); + defer std.testing.allocator.free(single); + try std.testing.expectEqual(@as(usize, 1), single.len); + try std.testing.expect(single[0].sequence == null); + const sequenced_only = try selectAds(std.testing.allocator, ads[0..1], false, true); + defer std.testing.allocator.free(sequenced_only); + try std.testing.expectEqual(@as(usize, 0), sequenced_only.len); + const wrapper = [_]vast.Ad{.{ .sequence = null, .impressions = &.{}, .errors = &.{}, .body = .{ .wrapper = .{ .url = "https://example.invalid", .follow_wrappers = true, .allow_multiple = false, .fallback_on_no_ad = false } } }}; + const ignored = try selectAds(std.testing.allocator, &wrapper, true, false); + defer std.testing.allocator.free(ignored); + try std.testing.expectEqual(@as(usize, 0), ignored.len); +} + +test "progress thresholds and completion follow playback, not wall time" { + const quarter = vast.Track{ .event = "firstQuartile", .url = "https://example.invalid" }; + try std.testing.expect(!due(quarter, 1249, 5000, false)); + try std.testing.expect(due(quarter, 1250, 5000, false)); + const complete = vast.Track{ .event = "complete", .url = "https://example.invalid" }; + try std.testing.expect(!due(complete, 10000, 5000, false)); + try std.testing.expect(due(complete, 5000, 5000, true)); + const offset = vast.Track{ .event = "progress", .url = "https://example.invalid", .offset_percent = 75 }; + try std.testing.expect(!due(offset, 3749, 5000, false)); + try std.testing.expect(due(offset, 3750, 5000, false)); +} + +test "AdChoices images fit without cropping or enlarging" { + const cases = [_]struct { width: u16, height: u16, expected_width: c_int, expected_height: c_int }{ + .{ .width = 1920, .height = 1080, .expected_width = 640, .expected_height = 360 }, + .{ .width = 400, .height = 150, .expected_width = 400, .expected_height = 150 }, + .{ .width = 600, .height = 1080, .expected_width = 244, .expected_height = 440 }, + .{ .width = 1, .height = 1080, .expected_width = 1, .expected_height = 440 }, + }; + for (cases) |case| { + const size = try fitImage(case.width, case.height, 640, 440); + try std.testing.expectEqual(case.expected_width, size.width); + try std.testing.expectEqual(case.expected_height, size.height); + } + try std.testing.expectError(error.InvalidImageSize, fitImage(0, 1, 640, 440)); + try std.testing.expectError(error.InvalidImageSize, fitImage(1, 1, 640, 0)); +} + +test "tracking macros distinguish ad time from the unstarted game" { + const encoded = try expand(std.testing.allocator, "https://example.invalid/?ad=[ADPLAYHEAD]&game=[MEDIAPLAYHEAD]&error=[ERRORCODE]&reason=[REASON]", 1250, 303, 2); + defer std.testing.allocator.free(encoded); + try std.testing.expectEqualStrings("https://example.invalid/?ad=00%3A00%3A01.250&game=00%3A00%3A00.000&error=303&reason=2", encoded); + var buffer: [64]u8 = undefined; + try std.testing.expectEqualStrings("1970-01-01T00%3A00%3A00.000Z", try timestamp(&buffer, 0)); + const unavailable = try expand(std.testing.allocator, "https://[2001:4860::8888]/?asset=[ASSETURI]&id=[ADSERVINGID]&ua=[DEVICEUA]&state=[PLAYERSTATE]&break=[BREAKPOSITION]&custom=[VENDOR_TOKEN]", 0, 0, 0); + defer std.testing.allocator.free(unavailable); + try std.testing.expectEqualStrings("https://[2001:4860::8888]/?asset=-1&id=-1&ua=-1&state=-1&break=-1&custom=[VENDOR_TOKEN]", unavailable); +} + +test "tag preparation preserves supplied consent, targeting and request identity" { + const address = try tagUrl(std.testing.allocator, "https://example.invalid/tag?npa=1<d=1&correlator=123&url=supplied", "12345"); + defer std.testing.allocator.free(address); + try std.testing.expect(std.mem.indexOf(u8, address, "npa=1<d=1&correlator=123&url=supplied") != null); + try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, address, "correlator=")); + try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, address, "&url=")); + try std.testing.expect(std.mem.indexOf(u8, address, "sdkv") == null); + try std.testing.expect(std.mem.indexOf(u8, address, "game-id%253D12345") != null); +} diff --git a/src/provider/geforce_now/ad_protocol.zig b/src/provider/geforce_now/ad_protocol.zig new file mode 100644 index 0000000..d7595f7 --- /dev/null +++ b/src/provider/geforce_now/ad_protocol.zig @@ -0,0 +1,181 @@ +const std = @import("std"); +const endpoint = @import("endpoint.zig"); + +pub const Opportunity = struct { + id: []const u8, + url: []const u8, + state: u8, + duration_ms: u64, +}; + +pub const Admission = struct { + arena: std.heap.ArenaAllocator, + required: bool, + provider: u8, + control_base: ?[]const u8, + opportunities: []const Opportunity, + + pub fn deinit(self: *Admission) void { + self.arena.deinit(); + } +}; + +pub const Action = enum(u8) { start = 1, pause = 2, resume_playback = 3, finish = 4, cancel = 5, event = 6 }; +pub const Update = struct { + action: Action, + watched_ms: u64 = 0, + paused_ms: u64 = 0, + duration_ms: u64 = 0, + reason: ?[]const u8 = null, + event: ?[]const u8 = null, +}; + +pub fn parse(allocator: std.mem.Allocator, session: std.json.ObjectMap) !Admission { + var arena = std.heap.ArenaAllocator.init(allocator); + errdefer arena.deinit(); + const storage = arena.allocator(); + const required = if (session.get("sessionAdsRequired")) |value| switch (value) { + .bool => |flag| flag, + .null => false, + else => return error.InvalidAdsRequired, + } else false; + if (!required) return .{ .arena = arena, .required = false, .provider = 0, .control_base = null, .opportunities = &.{} }; + const provider = if (session.get("adProvider")) |value| if (value == .null) 0 else try unsigned(u8, value) else 0; + const control_base = try controlBase(storage, session.get("sessionControlInfo")); + var ads = std.ArrayList(Opportunity).init(storage); + if (session.get("sessionAds")) |values| { + if (values != .array and values != .null) return error.InvalidAdList; + if (values == .array and values.array.items.len > 16) return error.InvalidAdList; + if (values == .array) for (values.array.items) |value| { + if (value != .object) return error.InvalidAd; + const fields = value.object; + const state = if (fields.get("adState")) |number| try unsigned(u8, number) else 0; + if (state > 6) return error.InvalidAdState; + if (state == 5 or state == 6) continue; + const id = fields.get("adId") orelse return error.MissingAdId; + const url = fields.get("adUrl") orelse return error.MissingAdUrl; + if (id != .string or id.string.len == 0 or id.string.len > 256 or + url != .string or url.string.len == 0 or url.string.len > 8192) + return error.InvalidAd; + const seconds = if (fields.get("adLengthInSeconds")) |number| try unsigned(u16, number) else 0; + if (seconds > 120) return error.InvalidAdDuration; + try ads.append(.{ .id = try storage.dupe(u8, id.string), .url = try storage.dupe(u8, url.string), .state = state, .duration_ms = @as(u64, seconds) * 1000 }); + }; + } + return .{ .arena = arena, .required = required, .provider = provider, .control_base = control_base, .opportunities = try ads.toOwnedSlice() }; +} + +fn controlBase(allocator: std.mem.Allocator, control: ?std.json.Value) !?[]const u8 { + const value = control orelse return null; + if (value != .object) return null; + const host = value.object.get("ip") orelse return null; + if (host != .string or std.mem.indexOfAny(u8, host.string, "/@%?#\\") != null) return null; + if (value.object.get("port")) |port| { + if ((unsigned(u16, port) catch return null) != 443) return null; + } + const address = try std.fmt.allocPrint(allocator, "https://{s}/", .{host.string}); + return endpoint.normalizeCloudMatchBase(allocator, address) catch |err| switch (err) { + error.OutOfMemory => return err, + else => null, + }; +} + +test "nullable ad metadata is not an invalid session while provisioning" { + const json = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, "{\"sessionAds\":null,\"sessionAdsRequired\":null,\"adProvider\":null,\"sessionControlInfo\":null}", .{}); + defer json.deinit(); + var admission = try parse(std.testing.allocator, json.value.object); + defer admission.deinit(); + try std.testing.expect(!admission.required); + try std.testing.expectEqual(@as(usize, 0), admission.opportunities.len); + try std.testing.expectEqual(@as(u8, 0), admission.provider); +} + +fn unsigned(comptime T: type, value: std.json.Value) !T { + if (value != .integer or value.integer < 0 or value.integer > std.math.maxInt(T)) return error.InvalidAdNumber; + return @intCast(value.integer); +} + +pub fn buildUpdate(allocator: std.mem.Allocator, id: []const u8, update: Update, timestamp: i64) ![]u8 { + if (id.len == 0 or id.len > 256 or timestamp < 0) return error.InvalidAdUpdate; + if (update.action == .cancel and update.reason == null) return error.MissingCancelReason; + if (update.action == .event and update.event == null) return error.MissingEvent; + return std.json.stringifyAlloc(allocator, .{ .action = 6, .adUpdates = &.{.{ + .adId = id, + .adAction = @intFromEnum(update.action), + .clientTimestamp = timestamp, + .watchedTimeInMs = update.watched_ms, + .pausedTimeInMs = update.paused_ms, + .adDurationInMs = update.duration_ms, + .cancelReason = update.reason, + .eventName = update.event, + }} }, .{ .emit_null_optional_fields = false }); +} + +test "no-fill cancellation uses the original pod ID and no fabricated playback" { + const body = try buildUpdate(std.testing.allocator, "opaque-pod", .{ .action = .cancel, .reason = "VAST_EMPTY_RESPONSE" }, 123); + defer std.testing.allocator.free(body); + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, body, .{}); + defer parsed.deinit(); + const update = parsed.value.object.get("adUpdates").?.array.items[0].object; + try std.testing.expectEqualStrings("opaque-pod", update.get("adId").?.string); + try std.testing.expectEqual(@as(i64, 5), update.get("adAction").?.integer); + try std.testing.expectEqual(@as(i64, 123), update.get("clientTimestamp").?.integer); + try std.testing.expectEqual(@as(i64, 0), update.get("watchedTimeInMs").?.integer); + try std.testing.expect(update.get("eventName") == null); +} + +test "ad metadata is owned and finished opportunities are excluded" { + const json = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, "{\"sessionAdsRequired\":true,\"adProvider\":1,\"sessionControlInfo\":{\"ip\":\"np.example.nvidiagrid.net\",\"port\":443},\"sessionAds\":[{\"adId\":\"pod\",\"adState\":1,\"adLengthInSeconds\":30,\"adUrl\":\"https://example.invalid/tag\"},{\"adState\":5}]}", .{}); + var admission = try parse(std.testing.allocator, json.value.object); + json.deinit(); + defer admission.deinit(); + try std.testing.expect(admission.required); + try std.testing.expectEqual(@as(usize, 1), admission.opportunities.len); + try std.testing.expectEqualStrings("pod", admission.opportunities[0].id); + try std.testing.expectEqualStrings("https://np.example.nvidiagrid.net/", admission.control_base.?); +} + +test "untrusted ad control hosts cannot receive account credentials" { + const json = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, "{\"sessionAdsRequired\":true,\"sessionControlInfo\":{\"ip\":\"evil.invalid\",\"port\":443}}", .{}); + defer json.deinit(); + var admission = try parse(std.testing.allocator, json.value.object); + defer admission.deinit(); + try std.testing.expect(admission.control_base == null); +} + +test "unsupported control addresses leave ad reporting on the trusted session endpoint" { + for ([_][]const u8{ "null", "123", "{\"ip\":123}", "{\"ip\":null}", "{\"ip\":{\"address\":\"192.0.2.1\"}}", "{\"ip\":\"192.0.2.1\"}", "{\"ip\":\"np.example.nvidiagrid.net\",\"port\":80}", "{\"ip\":\"np.example.nvidiagrid.net\",\"port\":\"invalid\"}" }) |control| { + const bytes = try std.fmt.allocPrint(std.testing.allocator, "{{\"sessionAdsRequired\":true,\"sessionControlInfo\":{s}}}", .{control}); + defer std.testing.allocator.free(bytes); + const json = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, bytes, .{}); + defer json.deinit(); + var admission = try parse(std.testing.allocator, json.value.object); + defer admission.deinit(); + try std.testing.expect(admission.required and admission.control_base == null); + } +} + +test "irrelevant ad metadata does not reject an ad-free session" { + const json = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, "{\"sessionAdsRequired\":false,\"adProvider\":\"invalid\",\"sessionAds\":42}", .{}); + defer json.deinit(); + var admission = try parse(std.testing.allocator, json.value.object); + defer admission.deinit(); + try std.testing.expect(!admission.required and admission.opportunities.len == 0); +} + +test "malformed required ads cannot bypass admission" { + const json = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, "{\"sessionAdsRequired\":true,\"sessionAds\":42}", .{}); + defer json.deinit(); + try std.testing.expectError(error.InvalidAdList, parse(std.testing.allocator, json.value.object)); +} + +test "ad admission preserves allocation failures" { + try std.testing.checkAllAllocationFailures(std.testing.allocator, struct { + fn run(allocator: std.mem.Allocator) !void { + const json = try std.json.parseFromSlice(std.json.Value, allocator, "{\"sessionAdsRequired\":true,\"sessionControlInfo\":{\"ip\":\"np.example.nvidiagrid.net\"},\"sessionAds\":[{\"adId\":\"one\",\"adUrl\":\"https://example.invalid/ad\"}]}", .{}); + defer json.deinit(); + var admission = try parse(allocator, json.value.object); + defer admission.deinit(); + } + }.run, .{}); +} diff --git a/src/provider/geforce_now/auth_client.zig b/src/provider/geforce_now/auth_client.zig new file mode 100644 index 0000000..6e90c0e --- /dev/null +++ b/src/provider/geforce_now/auth_client.zig @@ -0,0 +1,667 @@ +const std = @import("std"); +const protocol = @import("auth_protocol.zig"); +const provider_protocol = @import("provider_protocol.zig"); +const uuid = @import("uuid"); + +const c = @cImport({ + @cInclude("SDL2/SDL.h"); + @cInclude("handheld_ui.h"); + @cInclude("http_client.h"); + @cInclude("token_store_adapter.h"); +}); + +const max_config_length = 256; +const max_credential_length = 64 * 1024; + +// Public identifiers for NVIDIA's device-code flow and game catalog. +const default_oauth_client_id = "q61ddeJrVt7O90Nl-P-N7I36yctih4Ml6FyXLrb6j-U"; +const default_protocol_client_id = "ec7e38d4-03af-4b58-b131-cfb0495903ab"; + +pub const RefreshResult = enum { + ok, + reauth_required, + failed, +}; + +const TokenGrantResult = union(enum) { + ok: protocol.Tokens, + rejected, + failed, +}; + +const ClientTokenResult = enum { + ok, + rejected, + failed, +}; + +pub const Client = struct { + allocator: std.mem.Allocator, + ui: *c.GoHandheldUi, + oauth_client_id: []u8, + protocol_client_id: []u8, + credential_path: [:0]u8, + key_path: [:0]u8, + device_id_path: [:0]u8, + device_id: [37]u8, + provider: ?provider_protocol.Provider = null, + tokens: ?protocol.Tokens = null, + + pub fn create(allocator: std.mem.Allocator, ui_pointer: *anyopaque) !*Client { + const oauth_client_id = try loadConfig( + allocator, + std.posix.getenv("GREENOVERCAST_GFN_CLIENT_ID"), + std.posix.getenv("GREENOVERCAST_GFN_CLIENT_ID_FILE"), + default_oauth_client_id, + ); + errdefer allocator.free(oauth_client_id); + if (!validOpaqueId(oauth_client_id)) return error.InvalidClientId; + + const protocol_client_id = try loadConfig( + allocator, + std.posix.getenv("GREENOVERCAST_GFN_PROTOCOL_CLIENT_ID"), + std.posix.getenv("GREENOVERCAST_GFN_PROTOCOL_CLIENT_ID_FILE"), + default_protocol_client_id, + ); + errdefer allocator.free(protocol_client_id); + if (!validOpaqueId(protocol_client_id)) return error.InvalidClientId; + + const credential_path = try requiredEnvironmentCopy(allocator, "GREENOVERCAST_GFN_TOKEN_FILE"); + errdefer allocator.free(credential_path); + const key_path = try requiredEnvironmentCopy(allocator, "GREENOVERCAST_GFN_TOKEN_KEY_FILE"); + errdefer allocator.free(key_path); + const device_id_path = try requiredEnvironmentCopy(allocator, "GREENOVERCAST_GFN_DEVICE_ID_FILE"); + errdefer allocator.free(device_id_path); + + const client = try allocator.create(Client); + errdefer allocator.destroy(client); + client.* = .{ + .allocator = allocator, + .ui = @ptrCast(ui_pointer), + .oauth_client_id = oauth_client_id, + .protocol_client_id = protocol_client_id, + .credential_path = credential_path, + .key_path = key_path, + .device_id_path = device_id_path, + .device_id = [_]u8{0} ** 37, + }; + try client.loadOrCreateDeviceId(); + return client; + } + + pub fn destroy(self: *Client) void { + if (self.tokens) |*tokens| tokens.deinit(); + if (self.provider) |*provider| provider.deinit(); + self.allocator.free(self.oauth_client_id); + self.allocator.free(self.protocol_client_id); + self.allocator.free(self.credential_path); + self.allocator.free(self.key_path); + self.allocator.free(self.device_id_path); + const allocator = self.allocator; + allocator.destroy(self); + } + + pub fn loadCredentials(self: *Client) !bool { + var encrypted: [max_credential_length]u8 = [_]u8{0} ** max_credential_length; + defer std.crypto.secureZero(u8, &encrypted); + const result = c.go_token_store_load( + @ptrCast(self.credential_path.ptr), + @ptrCast(self.key_path.ptr), + @ptrCast(&encrypted), + encrypted.len, + ); + if (result < 0) return error.CredentialLoadFailed; + if (result == 0) return false; + const data = std.mem.sliceTo(&encrypted, 0); + var tokens = protocol.parseStoredTokens(self.allocator, data) catch |err| switch (err) { + error.MissingField => return false, + else => return err, + }; + errdefer tokens.deinit(); + if (self.tokens) |*previous| previous.deinit(); + self.tokens = tokens; + return true; + } + + pub fn signIn(self: *Client) !bool { + try self.discoverProvider(); + const provider = &self.provider.?; + var form_buffer: [4096]u8 = undefined; + const body = try protocol.buildDeviceAuthorizationForm(.{ + .client_id = self.oauth_client_id, + .device_id = std.mem.sliceTo(&self.device_id, 0), + .display_name = "GreenOvercast", + .idp_id = provider.idp_id, + }, &form_buffer); + + var dynamic_headers: [8][256]u8 = undefined; + var headers = [_][*c]const u8{ + "Accept: application/json, text/plain, */*", + "Content-Type: application/x-www-form-urlencoded", + "Origin: https://play.geforcenow.com", + "Referer: https://play.geforcenow.com/", + try header(&dynamic_headers[0], "x-device-id", std.mem.sliceTo(&self.device_id, 0)), + try header(&dynamic_headers[1], "nv-client-id", self.oauth_client_id), + "nv-client-streamer: WEBRTC", + "nv-client-type: BROWSER", + "nv-client-platform-name: browser", + "nv-browser-type: CHROME", + "nv-device-os: STEAMOS", + "nv-device-type: CONSOLE", + "nv-device-model: STEAMDECK", + "nv-device-make: VALVE", + "User-Agent: Mozilla/5.0 (X11; Linux x86_64; Steam Deck) AppleWebKit/537.36 Chrome/128.0.0.0 Safari/537.36", + }; + + c.go_handheld_ui_draw_loading( + self.ui, + "GEFORCE NOW SIGN IN", + "REQUESTING A DEVICE CODE", + c.GO_HANDHELD_UI_ACTION_BACK, + ); + var response = c.go_http_request( + "POST", + protocol.device_authorization_endpoint, + body.ptr, + @ptrCast(&headers), + headers.len, + ); + defer c.go_http_response_destroy(response); + if (!successful(response)) return error.DeviceAuthorizationFailed; + var challenge = try protocol.parseDeviceChallenge(self.allocator, responseData(response).?); + defer challenge.deinit(); + c.go_http_response_destroy(response); + response = null; + + const address_value = challenge.verification_uri; + var address_buffer: [256]u8 = undefined; + const address = try std.fmt.bufPrintZ(&address_buffer, "{s}", .{address_value}); + var code_buffer: [96]u8 = undefined; + const user_code = try std.fmt.bufPrintZ(&code_buffer, "{s}", .{challenge.user_code}); + var interval = @max(challenge.interval, 1); + const started = c.SDL_GetTicks(); + var last_poll = started; + while (true) { + const now = c.SDL_GetTicks(); + const remaining = challenge.secondsRemaining(started, now); + if (remaining == 0) return error.CodeExpired; + c.go_handheld_ui_draw_device_code_for( + self.ui, + "GEFORCE NOW SIGN IN", + address.ptr, + user_code.ptr, + "WAITING FOR APPROVAL", + remaining, + ); + if (c.go_handheld_ui_sign_in_action(self.ui) < 0) return false; + if ((now -% last_poll) / 1000 < interval) { + c.SDL_Delay(16); + continue; + } + + const poll_body = try protocol.buildDeviceTokenForm( + self.oauth_client_id, + challenge.device_code, + &form_buffer, + ); + response = c.go_http_request( + "POST", + protocol.token_endpoint, + poll_body.ptr, + @ptrCast(&headers), + headers.len, + ); + last_poll = c.SDL_GetTicks(); + if (successful(response)) { + var tokens = try protocol.parseGrantTokens( + self.allocator, + responseData(response).?, + std.time.timestamp(), + ); + c.go_http_response_destroy(response); + response = null; + _ = self.fetchClientToken(&tokens, tokens.access_token); + if (self.tokens) |*previous| previous.deinit(); + self.tokens = tokens; + self.saveCredentials() catch |err| { + self.tokens = null; + tokens.deinit(); + return err; + }; + c.go_handheld_ui_draw_loading( + self.ui, + "SIGNED IN", + "OPENING YOUR GEFORCE NOW LIBRARY", + c.GO_HANDHELD_UI_ACTION_NONE, + ); + c.SDL_Delay(700); + return true; + } + + const poll_error = if (responseData(response)) |data| + protocol.classifyPollError(self.allocator, data) catch .other + else + .other; + const status = if (response) |value| value.*.status else 0; + var error_code_buffer: [64]u8 = undefined; + const error_code = if (responseData(response)) |data| + protocol.writePollErrorCode(self.allocator, data, &error_code_buffer) + else + null; + c.go_http_response_destroy(response); + response = null; + switch (poll_error) { + .authorization_pending => {}, + .slow_down => interval +|= 5, + else => { + std.debug.print( + "GeForce NOW token exchange failed: HTTP {d} ({s})\n", + .{ status, error_code orelse @tagName(poll_error) }, + ); + return switch (poll_error) { + .expired_token => error.CodeExpired, + .access_denied => error.AccessDenied, + else => error.TokenRequestFailed, + }; + }, + } + } + } + + pub fn refresh(self: *Client) RefreshResult { + const previous = if (self.tokens) |*tokens| tokens else return .reauth_required; + if (self.provider == null) self.discoverProvider() catch return .failed; + if (!previous.needsRefresh(std.time.timestamp())) return .ok; + + var form_buffer: [max_credential_length]u8 = undefined; + if (previous.client_token == null) { + const token_value = previous.id_token orelse previous.access_token; + switch (self.fetchClientToken(previous, token_value)) { + .ok => {}, + .rejected => if (previous.refresh_token == null) return .reauth_required, + .failed => if (previous.refresh_token == null) return .failed, + } + } + + if (previous.client_token) |client_token| { + const subject = protocol.jwtSubject(self.allocator, previous.bearer()) catch null; + if (subject) |value| { + defer self.allocator.free(value); + const body = protocol.buildClientTokenForm( + self.oauth_client_id, + client_token, + value, + &form_buffer, + ) catch return .failed; + switch (self.performTokenGrant(body, previous)) { + .ok => |refreshed| return self.finishRefresh(refreshed), + .rejected, .failed => {}, + } + } + } + + const refresh_token = previous.refresh_token orelse return .reauth_required; + const body = protocol.buildRefreshTokenForm( + self.oauth_client_id, + refresh_token, + &form_buffer, + ) catch return .failed; + return switch (self.performTokenGrant(body, previous)) { + .ok => |refreshed| self.finishRefresh(refreshed), + .rejected => .reauth_required, + .failed => .failed, + }; + } + + pub fn signOut(self: *Client) !void { + if (c.go_token_store_delete( + @ptrCast(self.credential_path.ptr), + @ptrCast(self.key_path.ptr), + ) != 0) return error.CredentialDeleteFailed; + if (self.tokens) |*tokens| tokens.deinit(); + self.tokens = null; + } + + pub fn bearer(self: *const Client) ?[]const u8 { + return if (self.tokens) |*tokens| tokens.bearer() else null; + } + + pub fn userId(self: *const Client, allocator: std.mem.Allocator) ![]u8 { + return protocol.jwtSubject(allocator, self.bearer() orelse return error.MissingCredentials); + } + + pub fn fetchUserAge(self: *const Client) !u8 { + const tokens = if (self.tokens) |*value| value else return error.MissingCredentials; + const subject = try self.userId(self.allocator); + defer self.allocator.free(subject); + const authorization = try std.fmt.allocPrintZ(self.allocator, "Authorization: Bearer {s}", .{tokens.access_token}); + defer { + std.crypto.secureZero(u8, authorization); + self.allocator.free(authorization); + } + var headers = [_][*c]const u8{ authorization.ptr, "Accept: application/json" }; + const response = c.go_http_request_bounded_cancelable( + "GET", + protocol.user_info_endpoint, + null, + @ptrCast(&headers), + headers.len, + max_credential_length, + cancelUserInfoRequest, + self.ui, + ) orelse { + if (c.go_handheld_ui_cancelled(self.ui) != 0) return error.Cancelled; + return error.UserInfoRequestFailed; + }; + defer { + if (responseData(response)) |data| std.crypto.secureZero(u8, @constCast(data)); + c.go_http_response_destroy(response); + } + if (response.*.status != 200) { + std.debug.print("GeForce NOW account lookup failed: HTTP {d}\n", .{response.*.status}); + return error.UserInfoRequestFailed; + } + return protocol.parseUserAge(self.allocator, responseData(response) orelse return error.MissingUserAge, subject); + } + + pub fn protocolClientId(self: *const Client) []const u8 { + return self.protocol_client_id; + } + + pub fn streamingUrl(self: *const Client) ?[]const u8 { + return if (self.provider) |*provider| provider.streaming_url else null; + } + + pub fn stableDeviceId(self: *const Client) []const u8 { + return std.mem.sliceTo(&self.device_id, 0); + } + + fn discoverProvider(self: *Client) !void { + var headers = [_][*c]const u8{ + "Accept: application/json", + "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/128.0.0.0 Safari/537.36", + }; + const response = c.go_http_request( + "GET", + provider_protocol.service_urls_endpoint, + null, + @ptrCast(&headers), + headers.len, + ); + defer c.go_http_response_destroy(response); + if (!successful(response)) return error.ProviderDiscoveryFailed; + var provider = try provider_protocol.parsePreferredProvider( + self.allocator, + responseData(response).?, + ); + errdefer provider.deinit(); + if (self.provider) |*previous| previous.deinit(); + self.provider = provider; + } + + fn saveCredentials(self: *Client) !void { + const tokens = if (self.tokens) |*value| value else return error.MissingCredentials; + const serialized = try std.json.stringifyAlloc(self.allocator, .{ + .access_token = tokens.access_token, + .refresh_token = tokens.refresh_token, + .id_token = tokens.id_token, + .client_token = tokens.client_token, + .expires_at = tokens.expires_at, + }, .{ .emit_null_optional_fields = true }); + defer { + std.crypto.secureZero(u8, serialized); + self.allocator.free(serialized); + } + const terminated = try self.allocator.allocSentinel(u8, serialized.len, 0); + defer { + std.crypto.secureZero(u8, terminated); + self.allocator.free(terminated); + } + @memcpy(terminated[0..serialized.len], serialized); + if (c.go_token_store_save( + @ptrCast(self.credential_path.ptr), + @ptrCast(self.key_path.ptr), + terminated.ptr, + ) != 0) return error.CredentialSaveFailed; + } + + fn fetchClientToken( + self: *Client, + tokens: *protocol.Tokens, + token_value: []const u8, + ) ClientTokenResult { + if (tokens.client_token != null) return .ok; + const authorization = std.fmt.allocPrintZ( + self.allocator, + "Authorization: Bearer {s}", + .{token_value}, + ) catch return .failed; + defer { + std.crypto.secureZero(u8, authorization); + self.allocator.free(authorization); + } + var headers = [_][*c]const u8{ + authorization.ptr, + "Accept: application/json, text/plain, */*", + "Origin: https://play.geforcenow.com", + "Referer: https://play.geforcenow.com/", + }; + const response = c.go_http_request_bounded( + "GET", + protocol.client_token_endpoint, + null, + @ptrCast(&headers), + headers.len, + 1024 * 1024, + ); + defer c.go_http_response_destroy(response); + if (!successful(response)) { + if (response != null and (response.*.status == 401 or response.*.status == 403)) + return .rejected; + return .failed; + } + tokens.client_token = protocol.parseClientToken( + self.allocator, + responseData(response).?, + ) catch return .failed; + return .ok; + } + + fn performTokenGrant( + self: *Client, + body: [:0]const u8, + previous: *const protocol.Tokens, + ) TokenGrantResult { + var headers = [_][*c]const u8{ + "Accept: application/json, text/plain, */*", + "Content-Type: application/x-www-form-urlencoded", + "Origin: https://play.geforcenow.com", + "Referer: https://play.geforcenow.com/", + }; + const response = c.go_http_request( + "POST", + protocol.token_endpoint, + body.ptr, + @ptrCast(&headers), + headers.len, + ); + defer c.go_http_response_destroy(response); + if (!successful(response)) { + const status = if (response) |value| value.*.status else 0; + var error_code_buffer: [64]u8 = undefined; + const error_code = if (responseData(response)) |data| + protocol.writePollErrorCode(self.allocator, data, &error_code_buffer) + else + null; + std.debug.print( + "GeForce NOW token grant failed: HTTP {d} ({s})\n", + .{ status, error_code orelse "unknown" }, + ); + const failure = if (responseData(response)) |data| + protocol.classifyPollError(self.allocator, data) catch .other + else + .other; + return if (failure == .invalid_grant or failure == .access_denied) + .rejected + else + .failed; + } + const refreshed = protocol.mergeRefreshedTokens( + self.allocator, + previous, + responseData(response).?, + std.time.timestamp(), + ) catch return .failed; + return .{ .ok = refreshed }; + } + + fn finishRefresh(self: *Client, refreshed: protocol.Tokens) RefreshResult { + if (self.tokens) |*previous| previous.deinit(); + self.tokens = refreshed; + self.saveCredentials() catch return .failed; + return .ok; + } + + fn loadOrCreateDeviceId(self: *Client) !void { + const file = std.fs.cwd().openFile(self.device_id_path, .{}) catch |err| switch (err) { + error.FileNotFound => return self.createDeviceId(), + else => return err, + }; + defer file.close(); + var contents: [64]u8 = undefined; + const length = try file.readAll(&contents); + const value = std.mem.trim(u8, contents[0..length], " \t\r\n"); + if (!uuid.valid(value)) return error.InvalidDeviceId; + @memcpy(self.device_id[0..value.len], value); + } + + fn createDeviceId(self: *Client) !void { + uuid.generate(&self.device_id); + const value = std.mem.sliceTo(&self.device_id, 0); + var temporary_path_buffer: [std.fs.max_path_bytes]u8 = undefined; + const temporary_path = try std.fmt.bufPrint( + &temporary_path_buffer, + "{s}.tmp", + .{self.device_id_path}, + ); + const cwd = std.fs.cwd(); + errdefer cwd.deleteFile(temporary_path) catch {}; + { + var file = try cwd.createFile(temporary_path, .{ + .truncate = true, + .mode = 0o600, + }); + defer file.close(); + try file.writeAll(value); + try file.writeAll("\n"); + try file.sync(); + } + try cwd.rename(temporary_path, self.device_id_path); + } +}; + +fn cancelUserInfoRequest(context: ?*anyopaque) callconv(.c) c_int { + const ui: *c.GoHandheldUi = @ptrCast(@alignCast(context orelse return 1)); + return c.go_handheld_ui_cancel_requested(ui); +} + +fn loadConfig( + allocator: std.mem.Allocator, + configured_value: ?[]const u8, + configured_path: ?[]const u8, + default_value: []const u8, +) ![]u8 { + if (configured_value) |value| { + const trimmed = std.mem.trim(u8, value, " \t\r\n"); + if (trimmed.len == 0 or trimmed.len > max_config_length) return error.InvalidConfig; + return allocator.dupe(u8, trimmed); + } + const path = configured_path orelse return allocator.dupe(u8, default_value); + const file = try std.fs.cwd().openFile(path, .{}); + defer file.close(); + const data = try file.readToEndAlloc(allocator, max_config_length); + defer allocator.free(data); + const trimmed = std.mem.trim(u8, data, " \t\r\n"); + if (trimmed.len == 0) return error.InvalidConfig; + return allocator.dupe(u8, trimmed); +} + +fn requiredEnvironmentCopy(allocator: std.mem.Allocator, name: []const u8) ![:0]u8 { + const value = std.posix.getenv(name) orelse return error.MissingConfig; + if (value.len == 0 or std.mem.indexOfScalar(u8, value, 0) != null) return error.InvalidConfig; + return allocator.dupeZ(u8, value); +} + +fn validOpaqueId(value: []const u8) bool { + if (value.len < 20 or value.len > 128) return false; + for (value) |byte| { + if (!std.ascii.isAlphanumeric(byte) and byte != '-' and byte != '_') return false; + } + return true; +} + +fn successful(response: [*c]c.GoHttpResponse) bool { + return c.go_http_response_succeeded(response) != 0 and responseData(response) != null; +} + +fn responseData(response: [*c]c.GoHttpResponse) ?[]const u8 { + if (response == null or response.*.data == null) return null; + return response.*.data[0..response.*.len]; +} + +fn header(output: []u8, name: []const u8, value: []const u8) ![*c]const u8 { + const result = try std.fmt.bufPrintZ(output, "{s}: {s}", .{ name, value }); + return result.ptr; +} + +test "validates opaque service identifiers" { + try std.testing.expect(validOpaqueId(default_oauth_client_id)); + try std.testing.expect(validOpaqueId(default_protocol_client_id)); + try std.testing.expect(validOpaqueId("sample-client-id_0123456789")); + try std.testing.expect(validOpaqueId("12345678-1234-4abc-8def-123456789abc")); + try std.testing.expect(!validOpaqueId("short")); + try std.testing.expect(!validOpaqueId("invalid value with spaces")); +} + +test "service identifiers have defaults and explicit overrides take priority" { + const allocator = std.testing.allocator; + const fallback = try loadConfig(allocator, null, null, default_oauth_client_id); + defer allocator.free(fallback); + try std.testing.expectEqualStrings(default_oauth_client_id, fallback); + + const override = try loadConfig(allocator, " configured-id \n", "/nonexistent", default_oauth_client_id); + defer allocator.free(override); + try std.testing.expectEqualStrings("configured-id", override); + try std.testing.expectError(error.InvalidConfig, loadConfig(allocator, " \n", null, default_oauth_client_id)); + try std.testing.expectError(error.FileNotFound, loadConfig(allocator, null, "/nonexistent", default_oauth_client_id)); +} + +test "service identifier files are trimmed and cannot silently replace invalid values with defaults" { + const allocator = std.testing.allocator; + var directory = std.testing.tmpDir(.{}); + defer directory.cleanup(); + try directory.dir.writeFile(.{ .sub_path = "client-id", .data = " file-client-id\r\n" }); + const path = try directory.dir.realpathAlloc(allocator, "client-id"); + defer allocator.free(path); + const value = try loadConfig(allocator, null, path, default_oauth_client_id); + defer allocator.free(value); + try std.testing.expectEqualStrings("file-client-id", value); + try directory.dir.writeFile(.{ .sub_path = "client-id", .data = " \n" }); + try std.testing.expectError(error.InvalidConfig, loadConfig(allocator, null, path, default_oauth_client_id)); + try directory.dir.writeFile(.{ .sub_path = "client-id", .data = &([_]u8{'x'} ** (max_config_length + 1)) }); + try std.testing.expectError(error.FileTooBig, loadConfig(allocator, null, path, default_oauth_client_id)); +} + +test "validates stable device identifiers" { + try std.testing.expect(uuid.valid("12345678-1234-4abc-8def-123456789abc")); + try std.testing.expect(!uuid.valid("12345678-1234-4abc-8def")); +} + +test "account lookup preserves cancellation consumed by the transfer callback" { + const http = @import("gfn_http_fake"); + http.reset(); + const client = http.authClient(Client); + try std.testing.expectError(error.Cancelled, client.fetchUserAge()); + try std.testing.expectEqual(@as(usize, 1), http.requests); + try std.testing.expectEqual(@as(usize, 1), http.polls); +} diff --git a/src/provider/geforce_now/auth_protocol.zig b/src/provider/geforce_now/auth_protocol.zig new file mode 100644 index 0000000..53ae699 --- /dev/null +++ b/src/provider/geforce_now/auth_protocol.zig @@ -0,0 +1,586 @@ +const std = @import("std"); +const form = @import("form_writer"); + +pub const device_authorization_endpoint = "https://login.nvidia.com/device/authorize"; +pub const token_endpoint = "https://login.nvidia.com/token"; +pub const client_token_endpoint = "https://login.nvidia.com/client_token"; +pub const user_info_endpoint = "https://login.nvidia.com/userinfo"; +pub const scope = "openid consent email tk_client age"; +pub const token_refresh_window_seconds: i64 = 10 * 60; + +const default_token_lifetime_seconds: u32 = 24 * 60 * 60; + +pub const DeviceAuthorization = struct { + client_id: []const u8, + device_id: []const u8, + display_name: []const u8, + idp_id: ?[]const u8 = null, +}; + +pub const DeviceChallenge = struct { + allocator: std.mem.Allocator, + device_code: []u8, + user_code: []u8, + verification_uri: []u8, + verification_uri_complete: ?[]u8, + expires_in: u32, + interval: u32, + + pub fn secondsRemaining(self: *const DeviceChallenge, started: u32, now: u32) u32 { + return self.expires_in -| ((now -% started) / 1000); + } + + pub fn deinit(self: *DeviceChallenge) void { + secureFree(self.allocator, self.device_code); + secureFree(self.allocator, self.user_code); + self.allocator.free(self.verification_uri); + if (self.verification_uri_complete) |uri| self.allocator.free(uri); + self.* = undefined; + } +}; + +pub const Tokens = struct { + allocator: std.mem.Allocator, + access_token: []u8, + refresh_token: ?[]u8, + id_token: ?[]u8, + client_token: ?[]u8, + expires_at: i64, + + pub fn deinit(self: *Tokens) void { + secureFree(self.allocator, self.access_token); + if (self.refresh_token) |token| secureFree(self.allocator, token); + if (self.id_token) |token| secureFree(self.allocator, token); + if (self.client_token) |token| secureFree(self.allocator, token); + self.* = undefined; + } + + pub fn bearer(self: *const Tokens) []const u8 { + return self.id_token orelse self.access_token; + } + + pub fn needsRefresh(self: *const Tokens, now: i64) bool { + if (now < 0 or self.expires_at <= now) return true; + return self.expires_at - now <= token_refresh_window_seconds; + } +}; + +pub const PollError = enum { + authorization_pending, + slow_down, + expired_token, + access_denied, + invalid_grant, + other, +}; + +pub fn buildDeviceAuthorizationForm( + request: DeviceAuthorization, + output: []u8, +) ![:0]u8 { + if (request.idp_id) |idp_id| { + return form.build(&.{ + .{ .name = "client_id", .value = request.client_id }, + .{ .name = "scope", .value = scope }, + .{ .name = "device_id", .value = request.device_id }, + .{ .name = "display_name", .value = request.display_name }, + .{ .name = "idp_id", .value = idp_id }, + }, output); + } + return form.build(&.{ + .{ .name = "client_id", .value = request.client_id }, + .{ .name = "scope", .value = scope }, + .{ .name = "device_id", .value = request.device_id }, + .{ .name = "display_name", .value = request.display_name }, + }, output); +} + +pub fn buildDeviceTokenForm( + client_id: []const u8, + device_code: []const u8, + output: []u8, +) ![:0]u8 { + return form.build(&.{ + .{ .name = "client_id", .value = client_id }, + .{ .name = "device_code", .value = device_code }, + .{ .name = "grant_type", .value = "urn:ietf:params:oauth:grant-type:device_code" }, + }, output); +} + +pub fn buildRefreshTokenForm( + client_id: []const u8, + refresh_token: []const u8, + output: []u8, +) ![:0]u8 { + return form.build(&.{ + .{ .name = "client_id", .value = client_id }, + .{ .name = "grant_type", .value = "refresh_token" }, + .{ .name = "refresh_token", .value = refresh_token }, + .{ .name = "scope", .value = scope }, + }, output); +} + +pub fn buildClientTokenForm( + client_id: []const u8, + client_token: []const u8, + subject: []const u8, + output: []u8, +) ![:0]u8 { + return form.build(&.{ + .{ .name = "client_id", .value = client_id }, + .{ .name = "grant_type", .value = "urn:ietf:params:oauth:grant-type:client_token" }, + .{ .name = "client_token", .value = client_token }, + .{ .name = "sub", .value = subject }, + }, output); +} + +pub fn parseDeviceChallenge( + allocator: std.mem.Allocator, + data: []const u8, +) !DeviceChallenge { + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{}); + defer parsed.deinit(); + const object = try rootObject(parsed.value); + + const device_code = try duplicateRequiredString(allocator, object, "device_code"); + errdefer secureFree(allocator, device_code); + const user_code = try duplicateRequiredString(allocator, object, "user_code"); + errdefer secureFree(allocator, user_code); + const verification_uri = try duplicateRequiredString(allocator, object, "verification_uri"); + errdefer allocator.free(verification_uri); + const verification_uri_complete = try duplicateOptionalString( + allocator, + object, + "verification_uri_complete", + ); + errdefer if (verification_uri_complete) |uri| allocator.free(uri); + + return .{ + .allocator = allocator, + .device_code = device_code, + .user_code = user_code, + .verification_uri = verification_uri, + .verification_uri_complete = verification_uri_complete, + .expires_in = try requiredUnsigned(object, "expires_in"), + .interval = optionalUnsigned(object, "interval") orelse 5, + }; +} + +pub fn parseGrantTokens( + allocator: std.mem.Allocator, + data: []const u8, + issued_at: i64, +) !Tokens { + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{}); + defer parsed.deinit(); + const object = try rootObject(parsed.value); + + const lifetime = try tokenLifetime(object); + return parseTokensObject(allocator, object, try expiryFromLifetime(issued_at, lifetime)); +} + +pub fn parseStoredTokens(allocator: std.mem.Allocator, data: []const u8) !Tokens { + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{}); + defer parsed.deinit(); + const object = try rootObject(parsed.value); + return parseTokensObject(allocator, object, try requiredSigned(object, "expires_at")); +} + +fn parseTokensObject( + allocator: std.mem.Allocator, + object: std.json.ObjectMap, + expires_at: i64, +) !Tokens { + const access_token = try duplicateRequiredString(allocator, object, "access_token"); + errdefer secureFree(allocator, access_token); + const refresh_token = try duplicateOptionalString(allocator, object, "refresh_token"); + errdefer if (refresh_token) |token| secureFree(allocator, token); + const id_token = try duplicateOptionalString(allocator, object, "id_token"); + errdefer if (id_token) |token| secureFree(allocator, token); + const client_token = try duplicateOptionalString(allocator, object, "client_token"); + errdefer if (client_token) |token| secureFree(allocator, token); + + return .{ + .allocator = allocator, + .access_token = access_token, + .refresh_token = refresh_token, + .id_token = id_token, + .client_token = client_token, + .expires_at = expires_at, + }; +} + +pub fn parseClientToken(allocator: std.mem.Allocator, data: []const u8) ![]u8 { + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{}); + defer parsed.deinit(); + return duplicateRequiredString(allocator, try rootObject(parsed.value), "client_token"); +} + +pub fn jwtSubject(allocator: std.mem.Allocator, token: []const u8) ![]u8 { + var parts = std.mem.splitScalar(u8, token, '.'); + _ = parts.next() orelse return error.InvalidJwt; + const encoded = parts.next() orelse return error.InvalidJwt; + _ = parts.next() orelse return error.InvalidJwt; + if (parts.next() != null or encoded.len == 0) return error.InvalidJwt; + + const decoded_length = try std.base64.url_safe_no_pad.Decoder.calcSizeForSlice(encoded); + const decoded = try allocator.alloc(u8, decoded_length); + defer allocator.free(decoded); + try std.base64.url_safe_no_pad.Decoder.decode(decoded, encoded); + + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, decoded, .{}); + defer parsed.deinit(); + return duplicateRequiredString(allocator, try rootObject(parsed.value), "sub"); +} + +pub fn parseUserAge(allocator: std.mem.Allocator, data: []const u8, subject: []const u8) !u8 { + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{}); + defer parsed.deinit(); + const object = try rootObject(parsed.value); + const account = object.get("sub") orelse return error.MissingSubject; + if (account != .string or !std.mem.eql(u8, account.string, subject)) + return error.AccountMismatch; + const age = object.get("age") orelse return error.MissingUserAge; + if (age != .integer) return error.InvalidUserAge; + return std.math.cast(u8, age.integer) orelse error.InvalidUserAge; +} + +test "reads account age without substituting a default" { + for ([_]u8{ 0, 17, 37, 100 }) |age| { + var buffer: [64]u8 = undefined; + const data = try std.fmt.bufPrint(&buffer, "{{\"sub\":\"user-one\",\"age\":{d}}}", .{age}); + try std.testing.expectEqual(age, try parseUserAge(std.testing.allocator, data, "user-one")); + } + try std.testing.expectError(error.MissingUserAge, parseUserAge(std.testing.allocator, "{\"sub\":\"user-one\"}", "user-one")); + try std.testing.expectError(error.AccountMismatch, parseUserAge(std.testing.allocator, "{\"sub\":\"user-two\",\"age\":37}", "user-one")); + for ([_][]const u8{ "-1", "256", "null", "true", "37.5", "\"37\"" }) |value| { + var buffer: [64]u8 = undefined; + const data = try std.fmt.bufPrint(&buffer, "{{\"sub\":\"user-one\",\"age\":{s}}}", .{value}); + try std.testing.expectError(error.InvalidUserAge, parseUserAge(std.testing.allocator, data, "user-one")); + } +} + +pub fn mergeRefreshedTokens( + allocator: std.mem.Allocator, + previous: *const Tokens, + data: []const u8, + issued_at: i64, +) !Tokens { + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{}); + defer parsed.deinit(); + const object = try rootObject(parsed.value); + + const access_token = try duplicateRequiredString(allocator, object, "access_token"); + errdefer secureFree(allocator, access_token); + const refresh_token = try duplicateResponseOrPrevious( + allocator, + object, + "refresh_token", + previous.refresh_token, + ); + errdefer if (refresh_token) |token| secureFree(allocator, token); + const id_token = try duplicateOptionalString(allocator, object, "id_token"); + errdefer if (id_token) |token| secureFree(allocator, token); + const client_token = try duplicateResponseOrPrevious( + allocator, + object, + "client_token", + previous.client_token, + ); + errdefer if (client_token) |token| secureFree(allocator, token); + const lifetime = try tokenLifetime(object); + + return .{ + .allocator = allocator, + .access_token = access_token, + .refresh_token = refresh_token, + .id_token = id_token, + .client_token = client_token, + .expires_at = try expiryFromLifetime(issued_at, lifetime), + }; +} + +pub fn classifyPollError(allocator: std.mem.Allocator, data: []const u8) !PollError { + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{}); + defer parsed.deinit(); + const object = try rootObject(parsed.value); + const code = try requiredString(object, "error"); + + if (std.mem.eql(u8, code, "authorization_pending")) return .authorization_pending; + if (std.mem.eql(u8, code, "slow_down")) return .slow_down; + if (std.mem.eql(u8, code, "expired_token")) return .expired_token; + if (std.mem.eql(u8, code, "access_denied")) return .access_denied; + if (std.mem.eql(u8, code, "invalid_grant")) return .invalid_grant; + return .other; +} + +pub fn writePollErrorCode( + allocator: std.mem.Allocator, + data: []const u8, + output: []u8, +) ?[]const u8 { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch return null; + defer parsed.deinit(); + const code = requiredString(rootObject(parsed.value) catch return null, "error") catch return null; + if (code.len == 0 or code.len > output.len) return null; + for (code, 0..) |byte, index| { + if (!std.ascii.isAlphanumeric(byte) and byte != '_' and byte != '-' and byte != '.') + return null; + output[index] = byte; + } + return output[0..code.len]; +} + +fn rootObject(value: std.json.Value) !std.json.ObjectMap { + return switch (value) { + .object => |object| object, + else => error.ExpectedObject, + }; +} + +fn requiredString(object: std.json.ObjectMap, name: []const u8) ![]const u8 { + const value = object.get(name) orelse return error.MissingField; + return switch (value) { + .string => |text| if (text.len == 0) error.MissingField else text, + else => error.InvalidField, + }; +} + +fn duplicateRequiredString( + allocator: std.mem.Allocator, + object: std.json.ObjectMap, + name: []const u8, +) ![]u8 { + return allocator.dupe(u8, try requiredString(object, name)); +} + +fn duplicateOptionalString( + allocator: std.mem.Allocator, + object: std.json.ObjectMap, + name: []const u8, +) !?[]u8 { + const value = object.get(name) orelse return null; + const text = switch (value) { + .string => |candidate| candidate, + .null => return null, + else => return error.InvalidField, + }; + if (text.len == 0) return null; + return try allocator.dupe(u8, text); +} + +fn duplicateResponseOrPrevious( + allocator: std.mem.Allocator, + object: std.json.ObjectMap, + name: []const u8, + previous: ?[]const u8, +) !?[]u8 { + if (try duplicateOptionalString(allocator, object, name)) |value| return value; + if (previous) |value| return try allocator.dupe(u8, value); + return null; +} + +fn requiredUnsigned(object: std.json.ObjectMap, name: []const u8) !u32 { + return optionalUnsigned(object, name) orelse error.MissingField; +} + +fn requiredSigned(object: std.json.ObjectMap, name: []const u8) !i64 { + const value = object.get(name) orelse return error.MissingField; + return switch (value) { + .integer => |number| number, + else => error.InvalidField, + }; +} + +fn optionalUnsigned(object: std.json.ObjectMap, name: []const u8) ?u32 { + const value = object.get(name) orelse return null; + return switch (value) { + .integer => |number| if (number >= 0 and number <= std.math.maxInt(u32)) + @intCast(number) + else + null, + else => null, + }; +} + +fn tokenLifetime(object: std.json.ObjectMap) !u32 { + if (!object.contains("expires_in")) return default_token_lifetime_seconds; + return optionalUnsigned(object, "expires_in") orelse error.InvalidField; +} + +fn secureFree(allocator: std.mem.Allocator, value: []u8) void { + std.crypto.secureZero(u8, value); + allocator.free(value); +} + +fn expiryFromLifetime(issued_at: i64, lifetime: u32) !i64 { + if (issued_at < 0) return error.InvalidTimestamp; + return std.math.add(i64, issued_at, @as(i64, lifetime)) catch error.InvalidTimestamp; +} + +test "builds NVIDIA device authorization and token forms" { + var output: [512]u8 = undefined; + const authorization = try buildDeviceAuthorizationForm(.{ + .client_id = "client id", + .device_id = "device/1", + .display_name = "GreenOvercast", + .idp_id = "provider", + }, &output); + try std.testing.expectEqualStrings( + "client_id=client+id&scope=openid+consent+email+tk_client+age&device_id=device%2F1&display_name=GreenOvercast&idp_id=provider", + authorization, + ); + + const token = try buildDeviceTokenForm("client", "code+value", &output); + try std.testing.expectEqualStrings( + "client_id=client&device_code=code%2Bvalue&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code", + token, + ); + + const client_token = try buildClientTokenForm("client", "device token", "user/id", &output); + try std.testing.expectEqualStrings( + "client_id=client&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Aclient_token&client_token=device+token&sub=user%2Fid", + client_token, + ); +} + +test "parses a device challenge" { + var challenge = try parseDeviceChallenge(std.testing.allocator, + \\{"device_code":"secret","user_code":"ABCD-EFGH","verification_uri":"https://example.test/link","expires_in":900} + ); + defer challenge.deinit(); + + try std.testing.expectEqualStrings("secret", challenge.device_code); + try std.testing.expectEqualStrings("ABCD-EFGH", challenge.user_code); + try std.testing.expectEqualStrings("https://example.test/link", challenge.verification_uri); + try std.testing.expectEqual(@as(u32, 900), challenge.expires_in); + try std.testing.expectEqual(@as(u32, 5), challenge.interval); +} + +test "device challenge expiry handles delayed draws and tick wrap" { + var challenge = try parseDeviceChallenge(std.testing.allocator, + \\{"device_code":"test","user_code":"1234","verification_uri":"https://example.test/link","expires_in":2,"interval":5} + ); + defer challenge.deinit(); + const started: u32 = std.math.maxInt(u32) - 500; + try std.testing.expectEqual(@as(u32, 2), challenge.secondsRemaining(started, started)); + try std.testing.expectEqual(@as(u32, 1), challenge.secondsRemaining(started, started +% 1000)); + try std.testing.expectEqual(@as(u32, 0), challenge.secondsRemaining(started, started +% 2000)); + try std.testing.expectEqual(@as(u32, 0), challenge.secondsRemaining(started, started +% 4000)); +} + +test "refresh retains renewal credentials but never reuses an old bearer" { + var previous = try parseGrantTokens( + std.testing.allocator, + "{\"access_token\":\"access-one\",\"refresh_token\":\"refresh-one\",\"id_token\":\"id-one\",\"client_token\":\"client-one\",\"expires_in\":3600}", + 1_000, + ); + defer previous.deinit(); + + var refreshed = try mergeRefreshedTokens( + std.testing.allocator, + &previous, + "{\"access_token\":\"access-two\",\"expires_in\":7200}", + 2_000, + ); + defer refreshed.deinit(); + + try std.testing.expectEqualStrings("access-two", refreshed.access_token); + try std.testing.expectEqualStrings("refresh-one", refreshed.refresh_token.?); + try std.testing.expect(refreshed.id_token == null); + try std.testing.expectEqualStrings("client-one", refreshed.client_token.?); + try std.testing.expectEqualStrings("access-two", refreshed.bearer()); + try std.testing.expectEqual(@as(i64, 9_200), refreshed.expires_at); +} + +test "stored tokens retain their absolute expiry" { + var tokens = try parseStoredTokens(std.testing.allocator, + \\{"access_token":"access","expires_at":1234567890} + ); + defer tokens.deinit(); + + try std.testing.expectEqual(@as(i64, 1_234_567_890), tokens.expires_at); + try std.testing.expect(!tokens.needsRefresh(1_234_566_000)); + try std.testing.expect(tokens.needsRefresh(1_234_567_400)); + try std.testing.expect(tokens.needsRefresh(1_234_567_890)); +} + +test "token lifetime defaults only when omitted in grant and refresh responses" { + var previous = try parseGrantTokens(std.testing.allocator, "{\"access_token\":\"old\"}", 1000); + defer previous.deinit(); + try std.testing.expectEqual(1000 + @as(i64, default_token_lifetime_seconds), previous.expires_at); + + var refreshed = try mergeRefreshedTokens(std.testing.allocator, &previous, "{\"access_token\":\"new\"}", 2000); + defer refreshed.deinit(); + try std.testing.expectEqual(2000 + @as(i64, default_token_lifetime_seconds), refreshed.expires_at); + + for ([_]u32{ 0, 3600, std.math.maxInt(u32) }) |lifetime| { + var buffer: [128]u8 = undefined; + const data = try std.fmt.bufPrint(&buffer, "{{\"access_token\":\"new\",\"expires_in\":{d}}}", .{lifetime}); + var grant = try parseGrantTokens(std.testing.allocator, data, 2000); + defer grant.deinit(); + var refresh = try mergeRefreshedTokens(std.testing.allocator, &previous, data, 2000); + defer refresh.deinit(); + try std.testing.expectEqual(2000 + @as(i64, lifetime), grant.expires_at); + try std.testing.expectEqual(grant.expires_at, refresh.expires_at); + } + for ([_][]const u8{ "-1", "4294967296", "3600.5", "null", "true", "\"3600\"" }) |value| { + var buffer: [128]u8 = undefined; + const data = try std.fmt.bufPrint(&buffer, "{{\"access_token\":\"new\",\"expires_in\":{s}}}", .{value}); + try std.testing.expectError(error.InvalidField, parseGrantTokens(std.testing.allocator, data, 2000)); + try std.testing.expectError(error.InvalidField, mergeRefreshedTokens(std.testing.allocator, &previous, data, 2000)); + } +} + +test "stored tokens require an expiry" { + try std.testing.expectError( + error.MissingField, + parseStoredTokens(std.testing.allocator, "{\"access_token\":\"access\"}"), + ); +} + +test "rejects incomplete responses and classifies polling errors" { + try std.testing.expectError( + error.MissingField, + parseDeviceChallenge(std.testing.allocator, "{\"user_code\":\"ABCD\"}"), + ); + try std.testing.expectEqual( + PollError.authorization_pending, + try classifyPollError(std.testing.allocator, "{\"error\":\"authorization_pending\"}"), + ); + try std.testing.expectEqual( + PollError.other, + try classifyPollError(std.testing.allocator, "{\"error\":\"server_error\"}"), + ); + + var error_buffer: [32]u8 = undefined; + try std.testing.expectEqualStrings( + "invalid_client", + writePollErrorCode( + std.testing.allocator, + "{\"error\":\"invalid_client\",\"error_description\":\"details omitted\"}", + &error_buffer, + ).?, + ); + try std.testing.expect(writePollErrorCode( + std.testing.allocator, + "{\"error\":\"unsafe value\"}", + &error_buffer, + ) == null); +} + +test "parses a client token and JWT subject" { + const client_token = try parseClientToken( + std.testing.allocator, + "{\"client_token\":\"device-credential\",\"expires_in\":86400}", + ); + defer std.testing.allocator.free(client_token); + try std.testing.expectEqualStrings("device-credential", client_token); + + const subject = try jwtSubject( + std.testing.allocator, + "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyLTEyMyJ9.signature", + ); + defer std.testing.allocator.free(subject); + try std.testing.expectEqualStrings("user-123", subject); +} diff --git a/src/provider/geforce_now/catalog_protocol.zig b/src/provider/geforce_now/catalog_protocol.zig new file mode 100644 index 0000000..f489956 --- /dev/null +++ b/src/provider/geforce_now/catalog_protocol.zig @@ -0,0 +1,355 @@ +const std = @import("std"); +const catalog = @import("catalog_parser"); + +pub const endpoint = "https://games.geforce.com/graphql"; +pub const page_size: u32 = 200; + +const LibraryFilters = struct { + variants: struct { + gfn: struct { + library: struct { + status: struct { + notEquals: []const u8, + }, + }, + }, + }, +}; + +const library_filters = LibraryFilters{ + .variants = .{ + .gfn = .{ + .library = .{ + .status = .{ .notEquals = "NOT_OWNED" }, + }, + }, + }, +}; + +const query = + \\query GetCatalogApps($vpcId:String!,$locale:String!,$sortString:String!,$fetchCount:Int!,$cursor:String!,$filters:AppFilterFields!){ + \\ apps(vpcId:$vpcId,language:$locale,orderBy:$sortString,first:$fetchCount,after:$cursor,filters:$filters){ + \\ items{id title variants{id appStore gfn{status library{status selected}}} images{GAME_BOX_ART KEY_IMAGE KEY_ART}} + \\ pageInfo{hasNextPage endCursor totalCount} + \\ } + \\} +; + +pub const Page = struct { + allocator: std.mem.Allocator, + titles: []catalog.Title, + next_cursor: ?[]u8, + total_count: ?usize, + + pub fn deinit(self: *Page) void { + self.allocator.free(self.titles); + if (self.next_cursor) |cursor| self.allocator.free(cursor); + self.* = undefined; + } +}; + +pub fn buildRequest( + allocator: std.mem.Allocator, + vpc_id: []const u8, + cursor: []const u8, +) ![]u8 { + if (vpc_id.len == 0) return error.InvalidVpcId; + return std.json.stringifyAlloc(allocator, .{ + .query = query, + .variables = .{ + .vpcId = vpc_id, + .locale = "en_US", + .sortString = "variants.gfn.library.lastPlayedDate:DESC,computedValues.libraryAddedDate:DESC,sortName:ASC", + .fetchCount = page_size, + .cursor = cursor, + .filters = library_filters, + }, + }, .{}); +} + +pub fn parsePage(allocator: std.mem.Allocator, data: []const u8) !Page { + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{}); + defer parsed.deinit(); + const root = try object(parsed.value); + if (root.get("errors")) |errors| { + const values = try array(errors); + if (values.items.len > 0) return error.GraphQlRejected; + } + const response_data = try object(root.get("data") orelse return error.MissingData); + const apps = try object(response_data.get("apps") orelse return error.MissingApps); + const items = try array(apps.get("items") orelse return error.MissingItems); + + var titles = std.ArrayList(catalog.Title).init(allocator); + errdefer titles.deinit(); + for (items.items) |value| { + const item = object(value) catch continue; + const name = optionalString(item, "title") catch continue orelse continue; + var title = std.mem.zeroes(catalog.Title); + const launch_id = launchId(item) catch continue orelse continue; + if (!writeIdentifier(&title.title_id, launch_id)) continue; + if (item.get("id")) |stable_id| { + if (!writeIdentifier(&title.product_id, stable_id)) + _ = catalog.writeCString(&title.product_id, catalog.cString(&title.title_id)); + } else { + _ = catalog.writeCString(&title.product_id, catalog.cString(&title.title_id)); + } + _ = catalog.writeDisplayCString(&title.name, name); + if (artworkUrl(item)) |url| _ = writeArtwork(&title.artwork_url, url); + try titles.append(title); + } + + var next_cursor: ?[]u8 = null; + var total_count: ?usize = null; + if (apps.get("pageInfo")) |page_info_value| { + const page_info = try object(page_info_value); + total_count = optionalUnsigned(page_info, "totalCount"); + if (optionalBool(page_info, "hasNextPage") orelse false) { + if (try optionalString(page_info, "endCursor")) |cursor| { + if (cursor.len > 0) next_cursor = try allocator.dupe(u8, cursor); + } + } + } + errdefer if (next_cursor) |cursor| allocator.free(cursor); + + return .{ + .allocator = allocator, + .titles = try titles.toOwnedSlice(), + .next_cursor = next_cursor, + .total_count = total_count, + }; +} + +pub fn writeErrorSummary( + allocator: std.mem.Allocator, + data: []const u8, + output: []u8, +) ?[]const u8 { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch return null; + defer parsed.deinit(); + const root = object(parsed.value) catch return null; + const errors = array(root.get("errors") orelse return null) catch return null; + if (errors.items.len == 0) return null; + const first = object(errors.items[0]) catch return null; + const message = (optionalString(first, "message") catch return null) orelse return null; + const length = @min(message.len, output.len); + for (message[0..length], 0..) |byte, index| { + output[index] = if (byte >= 0x20 and byte != 0x7f) byte else ' '; + } + return output[0..length]; +} + +fn launchId(item: std.json.ObjectMap) !?std.json.Value { + if (item.get("variants")) |variants_value| { + const variants = try array(variants_value); + for (variants.items) |variant_value| { + const variant = object(variant_value) catch continue; + const value = variant.get("id") orelse continue; + if (numericIdentifier(value) and selectedOwnedLibraryVariant(variant)) return value; + } + for (variants.items) |variant_value| { + const variant = object(variant_value) catch continue; + const value = variant.get("id") orelse continue; + if (numericIdentifier(value) and ownedLibraryVariant(variant)) return value; + } + } + return null; +} + +fn selectedOwnedLibraryVariant(variant: std.json.ObjectMap) bool { + const gfn = object(variant.get("gfn") orelse return false) catch return false; + const library = object(gfn.get("library") orelse return false) catch return false; + return (optionalBool(library, "selected") orelse false) and ownedLibraryStatus(library); +} + +fn ownedLibraryVariant(variant: std.json.ObjectMap) bool { + const gfn = object(variant.get("gfn") orelse return false) catch return false; + const library = object(gfn.get("library") orelse return false) catch return false; + return ownedLibraryStatus(library); +} + +fn ownedLibraryStatus(library: std.json.ObjectMap) bool { + const status = (optionalString(library, "status") catch return false) orelse return false; + return std.mem.eql(u8, status, "MANUAL") or + std.mem.eql(u8, status, "PLATFORM_SYNC") or + std.mem.eql(u8, status, "IN_LIBRARY"); +} + +fn artworkUrl(item: std.json.ObjectMap) ?[]const u8 { + const images = object(item.get("images") orelse return null) catch return null; + for ([_][]const u8{ "GAME_BOX_ART", "KEY_IMAGE", "KEY_ART" }) |key| { + const value = images.get(key) orelse continue; + switch (value) { + .string => |text| if (text.len > 0) return text, + .array => |values| if (values.items.len > 0) switch (values.items[0]) { + .string => |text| if (text.len > 0) return text, + else => {}, + }, + else => {}, + } + } + return null; +} + +fn writeArtwork(output: []u8, url: []const u8) bool { + if (!std.mem.startsWith(u8, url, "https://")) return false; + const suffix = if (std.mem.indexOf(u8, url, "img.nvidiagrid.net") != null) + ";f=jpeg;w=256" + else + ""; + if (url.len + suffix.len >= output.len) return false; + @memset(output, 0); + @memcpy(output[0..url.len], url); + @memcpy(output[url.len..][0..suffix.len], suffix); + return true; +} + +fn numericIdentifier(value: std.json.Value) bool { + return switch (value) { + .integer => |number| number >= 0, + .string => |text| numeric: { + if (text.len == 0) break :numeric false; + for (text) |byte| if (!std.ascii.isDigit(byte)) break :numeric false; + break :numeric true; + }, + else => false, + }; +} + +fn writeIdentifier(output: []u8, value: std.json.Value) bool { + return switch (value) { + .string => |text| catalog.writeCString(output, text), + .integer => |number| if (number >= 0) blk: { + var buffer: [32]u8 = undefined; + const text = std.fmt.bufPrint(&buffer, "{d}", .{number}) catch break :blk false; + break :blk catalog.writeCString(output, text); + } else false, + else => false, + }; +} + +fn object(value: std.json.Value) !std.json.ObjectMap { + return switch (value) { + .object => |result| result, + else => error.ExpectedObject, + }; +} + +fn array(value: std.json.Value) !std.json.Array { + return switch (value) { + .array => |result| result, + else => error.ExpectedArray, + }; +} + +fn optionalString(value: std.json.ObjectMap, key: []const u8) !?[]const u8 { + const field = value.get(key) orelse return null; + return switch (field) { + .string => |text| if (text.len > 0) text else null, + .null => null, + else => error.InvalidField, + }; +} + +fn optionalUnsigned(value: std.json.ObjectMap, key: []const u8) ?usize { + const field = value.get(key) orelse return null; + return switch (field) { + .integer => |number| if (number >= 0 and number <= std.math.maxInt(usize)) @intCast(number) else null, + else => null, + }; +} + +fn optionalBool(value: std.json.ObjectMap, key: []const u8) ?bool { + const field = value.get(key) orelse return null; + return switch (field) { + .bool => |enabled| enabled, + else => null, + }; +} + +test "builds a paged catalog request" { + const request = try buildRequest(std.testing.allocator, "GFN-PC", "cursor/value"); + defer std.testing.allocator.free(request); + try std.testing.expect(std.mem.indexOf(u8, request, "\"vpcId\":\"GFN-PC\"") != null); + try std.testing.expect(std.mem.indexOf(u8, request, "\"cursor\":\"cursor/value\"") != null); + try std.testing.expect(std.mem.indexOf(u8, request, "\"notEquals\":\"NOT_OWNED\"") != null); + try std.testing.expect(std.mem.indexOf(u8, request, "GAME_BOX_ART") != null); + try std.testing.expect(std.mem.indexOf(u8, request, "library{status selected}") != null); +} + +test "parses launch variants artwork and pagination" { + var page = try parsePage(std.testing.allocator, + \\{"data":{"apps":{"items":[ + \\ {"id":"game-one","title":"Fixture Racer","variants":[{"id":"variant-name"},{"id":"202","gfn":{"library":{"status":"MANUAL"}}}],"images":{"GAME_BOX_ART":"https://img.nvidiagrid.net/box"}}, + \\ {"id":303,"title":"Second Game","variants":[{"id":303,"gfn":{"library":{"status":"IN_LIBRARY"}}}],"images":{"KEY_IMAGE":["https://example.invalid/key.jpg"]}} + \\],"pageInfo":{"hasNextPage":true,"endCursor":"next","totalCount":402}}}} + ); + defer page.deinit(); + + try std.testing.expectEqual(@as(usize, 2), page.titles.len); + try std.testing.expectEqualStrings("202", catalog.cString(&page.titles[0].title_id)); + try std.testing.expectEqualStrings("game-one", catalog.cString(&page.titles[0].product_id)); + try std.testing.expectEqualStrings("Fixture Racer", catalog.cString(&page.titles[0].name)); + try std.testing.expectEqualStrings( + "https://img.nvidiagrid.net/box;f=jpeg;w=256", + catalog.cString(&page.titles[0].artwork_url), + ); + try std.testing.expectEqualStrings("303", catalog.cString(&page.titles[1].title_id)); + try std.testing.expectEqualStrings("next", page.next_cursor.?); + try std.testing.expectEqual(@as(?usize, 402), page.total_count); +} + +test "prefers the account-selected numeric launch variant" { + var page = try parsePage(std.testing.allocator, + \\{"data":{"apps":{"items":[ + \\ {"id":"game-one","title":"Fixture Racer","variants":[ + \\ {"id":"202","appStore":"STEAM","gfn":{"library":{"status":"NOT_OWNED","selected":false}}}, + \\ {"id":"303","appStore":"EPIC","gfn":{"library":{"status":"MANUAL","selected":true}}} + \\ ]} + \\],"pageInfo":{"hasNextPage":false}}}} + ); + defer page.deinit(); + try std.testing.expectEqualStrings("303", catalog.cString(&page.titles[0].title_id)); +} + +test "does not launch an unowned selected store variant" { + var page = try parsePage(std.testing.allocator, + \\{"data":{"apps":{"items":[ + \\ {"id":"game-one","title":"Fixture Racer","variants":[ + \\ {"id":"202","appStore":"STEAM","gfn":{"library":{"status":"NOT_OWNED","selected":true}}}, + \\ {"id":"303","appStore":"XBOX","gfn":{"library":{"status":"PLATFORM_SYNC","selected":false}}} + \\ ]} + \\],"pageInfo":{"hasNextPage":false}}}} + ); + defer page.deinit(); + try std.testing.expectEqualStrings("303", catalog.cString(&page.titles[0].title_id)); +} + +test "rejects GraphQL errors" { + try std.testing.expectError( + error.GraphQlRejected, + parsePage(std.testing.allocator, "{\"errors\":[{\"message\":\"bad\"}]}"), + ); +} + +test "does not infer library ownership from a numeric id or missing status" { + var page = try parsePage(std.testing.allocator, + \\{"data":{"apps":{"items":[ + \\ {"id":101,"title":"Unowned","variants":[{"id":101,"gfn":{"library":{"status":"NOT_OWNED","selected":true}}}]}, + \\ {"id":202,"title":"Unknown","variants":[{"id":202,"gfn":{"library":{"selected":true}}}]}, + \\ {"id":303,"title":"No variants","variants":[]}, + \\ {"id":404,"title":"Invalid launch id","variants":[{"id":"not-numeric","gfn":{"library":{"status":"MANUAL"}}}]} + \\],"pageInfo":{"hasNextPage":false}}}} + ); + defer page.deinit(); + try std.testing.expectEqual(@as(usize, 0), page.titles.len); +} + +test "writes a bounded single-line GraphQL error" { + var output: [18]u8 = undefined; + const summary = writeErrorSummary( + std.testing.allocator, + "{\"errors\":[{\"message\":\"Invalid query\\nwith details\"}]}", + &output, + ).?; + try std.testing.expectEqualStrings("Invalid query with", summary); +} diff --git a/src/provider/geforce_now/catalog_service.zig b/src/provider/geforce_now/catalog_service.zig new file mode 100644 index 0000000..19d5949 --- /dev/null +++ b/src/provider/geforce_now/catalog_service.zig @@ -0,0 +1,339 @@ +const std = @import("std"); +const catalog = @import("catalog_parser"); +const auth_client = @import("auth_client.zig"); +const cloudmatch = @import("cloudmatch_protocol.zig"); +const protocol = @import("catalog_protocol.zig"); + +const c = @cImport({ + @cInclude("handheld_ui.h"); + @cInclude("http_client.h"); +}); + +const maximum_pages = 64; +const maximum_bearer_length = 64 * 1024; + +pub const PickResult = union(enum) { + title: struct { + id: []const u8, + name: []const u8, + }, + cancelled, + change_provider, + sign_out, +}; + +pub const Service = struct { + allocator: std.mem.Allocator, + auth: *auth_client.Client, + ui: *c.GoHandheldUi, + titles: ?[]catalog.Title = null, + + pub fn create( + allocator: std.mem.Allocator, + auth: *auth_client.Client, + ui_pointer: *anyopaque, + ) !*Service { + const service = try allocator.create(Service); + service.* = .{ + .allocator = allocator, + .auth = auth, + .ui = @ptrCast(ui_pointer), + }; + return service; + } + + pub fn destroy(self: *Service) void { + if (self.titles) |titles| self.allocator.free(titles); + const allocator = self.allocator; + allocator.destroy(self); + } + + pub fn load(self: *Service) !void { + if (self.titles != null) return error.AlreadyLoaded; + const bearer = self.auth.bearer() orelse return error.MissingCredentials; + const streaming_url = self.auth.streamingUrl() orelse return error.MissingProvider; + const vpc_id = self.fetchVpcId(bearer, streaming_url) catch |err| { + std.debug.print("GeForce NOW server discovery failed: {s}\n", .{@errorName(err)}); + return err; + }; + defer self.allocator.free(vpc_id); + + var loaded = std.ArrayList(catalog.Title).init(self.allocator); + errdefer loaded.deinit(); + var cursor: ?[]u8 = null; + defer if (cursor) |value| self.allocator.free(value); + var page_index: usize = 0; + while (page_index < maximum_pages) : (page_index += 1) { + const body = try protocol.buildRequest( + self.allocator, + vpc_id, + cursor orelse "", + ); + defer self.allocator.free(body); + const response = try self.catalogRequest(bearer, body); + defer c.go_http_response_destroy(response); + var page = try protocol.parsePage( + self.allocator, + response.*.data[0..response.*.len], + ); + defer page.deinit(); + try loaded.appendSlice(page.titles); + + var progress_buffer: [96]u8 = undefined; + const progress = try std.fmt.bufPrintZ( + &progress_buffer, + "{d} OF {d} GAMES", + .{ loaded.items.len, page.total_count orelse loaded.items.len }, + ); + c.go_handheld_ui_draw_loading( + self.ui, + "LOADING GEFORCE NOW", + progress.ptr, + c.GO_HANDHELD_UI_ACTION_BACK, + ); + if (c.go_handheld_ui_cancel_requested(self.ui) != 0) return error.Cancelled; + + if (cursor) |value| self.allocator.free(value); + cursor = null; + cursor = if (page.next_cursor) |value| try self.allocator.dupe(u8, value) else null; + if (cursor == null) break; + } + if (cursor != null) return error.CatalogPageLimit; + if (loaded.items.len == 0) return error.EmptyCatalog; + + std.mem.sort(catalog.Title, loaded.items, {}, titleIdLessThan); + removeDuplicateLaunchIds(&loaded); + std.mem.sort(catalog.Title, loaded.items, {}, titleLessThan); + self.titles = try loaded.toOwnedSlice(); + } + + pub fn titleCount(self: *const Service) usize { + return if (self.titles) |titles| titles.len else 0; + } + + pub fn pick(self: *Service, requested: []const u8) !PickResult { + const titles = self.titles orelse return error.NotLoaded; + if (titles.len == 0 or titles.len > std.math.maxInt(c_int)) return error.InvalidCatalog; + var requested_buffer: [128]u8 = [_]u8{0} ** 128; + if (requested.len >= requested_buffer.len) return error.InvalidRequestedTitle; + @memcpy(requested_buffer[0..requested.len], requested); + const selected = c.go_handheld_ui_pick_title( + self.ui, + @ptrCast(titles.ptr), + @intCast(titles.len), + @ptrCast(&requested_buffer), + ); + if (selected == c.GO_HANDHELD_UI_PICK_CHANGE_PROVIDER) return .change_provider; + if (selected == c.GO_HANDHELD_UI_PICK_SIGN_OUT) return .sign_out; + if (selected == c.GO_HANDHELD_UI_PICK_CANCELLED) return .cancelled; + if (selected < 0 or selected >= titles.len) return error.InvalidSelection; + const title = &titles[@intCast(selected)]; + std.debug.print("Selected title: {s} ({s})\n", .{ + displayName(title), + catalog.cString(&title.title_id), + }); + return .{ .title = .{ + .id = catalog.cString(&title.title_id), + .name = displayName(title), + } }; + } + + fn fetchVpcId(self: *Service, bearer: []const u8, streaming_url: []const u8) ![]u8 { + var url_buffer: [512]u8 = undefined; + const url = try std.fmt.bufPrintZ( + &url_buffer, + "{s}v2/serverInfo", + .{streaming_url}, + ); + const authorization = try authorizationHeader(self.allocator, bearer); + defer secureFree(self.allocator, authorization); + var client_id_header: [512]u8 = undefined; + var headers = [_][*c]const u8{ + authorization.ptr, + try header(&client_id_header, "nv-client-id", null, self.auth.protocolClientId()), + "Accept: application/json", + "nv-client-type: NATIVE", + "nv-client-streamer: NVIDIA-CLASSIC", + "nv-client-version: 2.0.86.124", + "nv-device-os: WINDOWS", + "nv-device-type: DESKTOP", + "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/128.0.0.0 Safari/537.36", + }; + const response = c.go_http_request_bounded_cancelable( + "GET", + url.ptr, + null, + @ptrCast(&headers), + headers.len, + 1024 * 1024, + cancelRequest, + self.ui, + ); + defer c.go_http_response_destroy(response); + if (!successful(response)) { + if (c.go_handheld_ui_cancelled(self.ui) != 0) return error.Cancelled; + return error.ServerInfoRequestFailed; + } + return cloudmatch.parseVpcId(self.allocator, response.*.data[0..response.*.len]); + } + + fn catalogRequest(self: *Service, bearer: []const u8, body: []const u8) ![*c]c.GoHttpResponse { + const terminated = try self.allocator.dupeZ(u8, body); + defer self.allocator.free(terminated); + const authorization = try authorizationHeader(self.allocator, bearer); + defer secureFree(self.allocator, authorization); + var client_id_header: [512]u8 = undefined; + var headers = [_][*c]const u8{ + authorization.ptr, + try header(&client_id_header, "nv-client-id", null, self.auth.protocolClientId()), + "Accept: application/json, text/plain, */*", + "Content-Type: application/json", + "Origin: https://play.geforcenow.com", + "Referer: https://play.geforcenow.com/", + "nv-browser-type: CHROME", + "nv-client-type: NATIVE", + "nv-client-streamer: NVIDIA-CLASSIC", + "nv-client-version: 2.0.86.124", + "nv-device-make: UNKNOWN", + "nv-device-model: UNKNOWN", + "nv-device-os: WINDOWS", + "nv-device-type: DESKTOP", + "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/128.0.0.0 Safari/537.36", + }; + const response = c.go_http_request_bounded_cancelable( + "POST", + protocol.endpoint, + terminated.ptr, + @ptrCast(&headers), + headers.len, + 8 * 1024 * 1024, + cancelRequest, + self.ui, + ); + if (!successful(response)) { + defer c.go_http_response_destroy(response); + if (c.go_handheld_ui_cancelled(self.ui) != 0) return error.Cancelled; + if (response != null) { + std.debug.print("GeForce NOW catalog request returned HTTP {d}\n", .{response.*.status}); + if (response.*.data != null) { + var summary_buffer: [256]u8 = undefined; + if (protocol.writeErrorSummary( + self.allocator, + response.*.data[0..response.*.len], + &summary_buffer, + )) |summary| std.debug.print("GeForce NOW catalog error: {s}\n", .{summary}); + } + } + return error.CatalogRequestFailed; + } + return response; + } +}; + +fn cancelRequest(context: ?*anyopaque) callconv(.c) c_int { + return c.go_handheld_ui_cancel_requested(@ptrCast(@alignCast(context))); +} + +fn authorizationHeader(allocator: std.mem.Allocator, bearer: []const u8) ![:0]u8 { + if (bearer.len == 0 or bearer.len > maximum_bearer_length) + return error.InvalidBearerToken; + return std.fmt.allocPrintZ(allocator, "Authorization: GFNJWT {s}", .{bearer}); +} + +fn secureFree(allocator: std.mem.Allocator, value: [:0]u8) void { + std.crypto.secureZero(u8, value); + allocator.free(value); +} + +fn header( + output: []u8, + name: []const u8, + scheme: ?[]const u8, + value: []const u8, +) ![*c]const u8 { + const result = if (scheme) |prefix| + try std.fmt.bufPrintZ(output, "{s}: {s} {s}", .{ name, prefix, value }) + else + try std.fmt.bufPrintZ(output, "{s}: {s}", .{ name, value }); + return result.ptr; +} + +fn successful(response: [*c]c.GoHttpResponse) bool { + return c.go_http_response_succeeded(response) != 0 and response.*.data != null; +} + +fn displayName(title: *const catalog.Title) []const u8 { + const name = catalog.cString(&title.name); + return if (name.len > 0) name else catalog.cString(&title.title_id); +} + +fn titleLessThan(_: void, left: catalog.Title, right: catalog.Title) bool { + return std.ascii.lessThanIgnoreCase(displayName(&left), displayName(&right)); +} + +fn titleIdLessThan(_: void, left: catalog.Title, right: catalog.Title) bool { + return std.mem.lessThan( + u8, + catalog.cString(&left.title_id), + catalog.cString(&right.title_id), + ); +} + +fn removeDuplicateLaunchIds(titles: *std.ArrayList(catalog.Title)) void { + var write_index: usize = 0; + for (titles.items) |title| { + const duplicate = write_index > 0 and std.mem.eql( + u8, + catalog.cString(&titles.items[write_index - 1].title_id), + catalog.cString(&title.title_id), + ); + if (!duplicate) { + titles.items[write_index] = title; + write_index += 1; + } + } + titles.shrinkRetainingCapacity(write_index); +} + +test "deduplicates launch ids after sorting" { + var titles = std.ArrayList(catalog.Title).init(std.testing.allocator); + defer titles.deinit(); + for ([_][]const u8{ "two", "one", "one" }) |id| { + var title = std.mem.zeroes(catalog.Title); + _ = catalog.writeCString(&title.title_id, id); + _ = catalog.writeCString(&title.name, id); + try titles.append(title); + } + std.mem.sort(catalog.Title, titles.items, {}, titleIdLessThan); + removeDuplicateLaunchIds(&titles); + try std.testing.expectEqual(@as(usize, 2), titles.items.len); +} + +test "authorization header accepts realistic token lengths" { + const bearer = try std.testing.allocator.alloc(u8, 2048); + defer std.testing.allocator.free(bearer); + @memset(bearer, 'a'); + const value = try authorizationHeader(std.testing.allocator, bearer); + defer secureFree(std.testing.allocator, value); + try std.testing.expectEqual(@as(usize, 2070), value.len); + try std.testing.expect(std.mem.endsWith(u8, value, bearer)); +} + +test "catalog requests preserve cancellation during HTTP transfers" { + const http = @import("gfn_http_fake"); + var auth = http.authClient(auth_client.Client); + var service = Service{ + .allocator = std.testing.allocator, + .auth = &auth, + .ui = @ptrCast(auth.ui), + }; + http.reset(); + try std.testing.expectError(error.Cancelled, service.fetchVpcId("test", "https://example.invalid/")); + try std.testing.expectEqual(@as(usize, 1), http.requests); + try std.testing.expectEqual(@as(usize, 1), http.polls); + + http.reset(); + try std.testing.expectError(error.Cancelled, service.catalogRequest("test", "{}")); + try std.testing.expectEqual(@as(usize, 1), http.requests); + try std.testing.expectEqual(@as(usize, 1), http.polls); +} diff --git a/src/provider/geforce_now/cloudmatch_protocol.zig b/src/provider/geforce_now/cloudmatch_protocol.zig new file mode 100644 index 0000000..5f746ae --- /dev/null +++ b/src/provider/geforce_now/cloudmatch_protocol.zig @@ -0,0 +1,841 @@ +const std = @import("std"); +const endpoint = @import("endpoint.zig"); +const ad_protocol = @import("ad_protocol.zig"); + +pub const IceServer = struct { + urls: [][]u8, + username: ?[]u8, + credential: ?[]u8, + + fn deinit(self: *IceServer, allocator: std.mem.Allocator) void { + for (self.urls) |value| allocator.free(value); + allocator.free(self.urls); + if (self.username) |value| allocator.free(value); + if (self.credential) |value| allocator.free(value); + } +}; + +pub const Session = struct { + allocator: std.mem.Allocator, + id: []u8, + status: u32, + queue_position: ?u32, + setup_step: ?u32, + signaling_url: ?[]u8, + media_ip: ?[]u8, + media_port: ?u16, + ice_servers: []IceServer, + ads: ?ad_protocol.Admission = null, + + pub fn ready(self: *const Session) bool { + return self.status == 2 or self.status == 3; + } + + pub fn ended(self: *const Session) bool { + // Status 6 is cleanup before provisioning can resume. + return self.status > 3 and self.status != 6; + } + + pub fn deinit(self: *Session) void { + if (self.ads) |*ads| ads.deinit(); + self.allocator.free(self.id); + if (self.signaling_url) |value| self.allocator.free(value); + if (self.media_ip) |value| self.allocator.free(value); + for (self.ice_servers) |*server| server.deinit(self.allocator); + self.allocator.free(self.ice_servers); + self.* = undefined; + } +}; + +pub const ActiveSessionSummary = struct { + active: usize = 0, + same_device: usize = 0, + matching_app: usize = 0, +}; + +pub fn parseVpcId(allocator: std.mem.Allocator, data: []const u8) ![]u8 { + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{}); + defer parsed.deinit(); + const root = try object(parsed.value); + const status = try object(root.get("requestStatus") orelse return error.MissingStatus); + if ((optionalUnsigned(status, "statusCode") orelse 0) != 1) return error.RequestRejected; + const server_id = try requiredString(status, "serverId"); + return allocator.dupe(u8, server_id); +} + +pub fn parseLocalRegionUrl(allocator: std.mem.Allocator, data: []const u8) !?[]u8 { + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{}); + defer parsed.deinit(); + const root = try object(parsed.value); + const metadata = array(root.get("metaData") orelse return null) catch return null; + var local_region: ?[]const u8 = null; + for (metadata.items) |entry_value| { + const entry = object(entry_value) catch continue; + const key = (optionalString(entry, "key") catch continue) orelse continue; + if (!std.mem.eql(u8, key, "local-region")) continue; + local_region = (optionalString(entry, "value") catch continue) orelse continue; + break; + } + const region = local_region orelse return null; + for (metadata.items) |entry_value| { + const entry = object(entry_value) catch continue; + const key = (optionalString(entry, "key") catch continue) orelse continue; + if (!std.mem.eql(u8, key, region)) continue; + const url = (optionalString(entry, "value") catch continue) orelse continue; + return try endpoint.normalizeCloudMatchBase(allocator, url); + } + return null; +} + +pub fn buildSessionRequest( + allocator: std.mem.Allocator, + app_id: []const u8, + internal_title: []const u8, + device_id: []const u8, + width: u16, + height: u16, + frames_per_second: u16, + user_age: u8, +) ![]u8 { + if (app_id.len == 0 or device_id.len == 0 or + width < 320 or height < 240 or frames_per_second == 0) + return error.InvalidSessionRequest; + const numeric_app_id = std.fmt.parseUnsigned(u64, app_id, 10) catch + return error.InvalidSessionRequest; + + const title: ?[]const u8 = if (internal_title.len > 0) internal_title else null; + + return std.json.stringifyAlloc(allocator, .{ + .sessionRequestData = .{ + .appId = numeric_app_id, + .cmsId = app_id, + .internalTitle = title, + .availableSupportedControllers = &[_]struct {}{}, + .networkTestSessionId = null, + .parentSessionId = null, + .clientIdentification = "GFN-PC", + .deviceHashId = device_id, + .clientVersion = "30.0", + .sdkVersion = "1.0", + .streamerVersion = 1, + .clientPlatformName = "windows", + .clientRequestMonitorSettings = &.{.{ + .monitorId = 0, + .positionX = 0, + .positionY = 0, + .widthInPixels = width, + .heightInPixels = height, + .framesPerSecond = frames_per_second, + .sdrHdrMode = 0, + .displayData = null, + .hdr10PlusGamingData = null, + .dpi = 100, + }}, + .useOps = true, + .audioMode = 2, + .metaData = &.{ + .{ .key = "SubSessionId", .value = device_id }, + .{ .key = "wssignaling", .value = "1" }, + .{ .key = "GSStreamerType", .value = "WebRTC" }, + }, + .sdrHdrMode = 0, + .clientDisplayHdrCapabilities = null, + .surroundAudioInfo = 0, + .remoteControllersBitmap = 1, + .clientTimezoneOffset = 0, + .enhancedStreamMode = 1, + .appLaunchMode = 1, + .secureRTSPSupported = false, + .partnerCustomData = "", + // The catalog only exposes owned library variants. + .accountLinked = true, + .enablePersistingInGameSettings = false, + .userAge = user_age, + .requestedStreamingFeatures = .{ + .reflex = false, + .bitDepth = 0, + .cloudGsync = false, + .enabledL4S = false, + .mouseMovementFlags = 0, + .trueHdr = false, + .supportedHidDevices = 0, + .profile = 0, + .fallbackToLogicalResolution = false, + .hidDevices = null, + .chromaFormat = 0, + .prefilterMode = 0, + .prefilterSharpness = 0, + .prefilterNoiseReduction = 0, + .hudStreamingMode = 0, + .sdrColorSpace = 2, + .hdrColorSpace = 0, + }, + }, + }, .{ .emit_null_optional_fields = true }); +} + +pub fn writeErrorSummary( + allocator: std.mem.Allocator, + data: []const u8, + output: []u8, +) ?[]const u8 { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch return null; + defer parsed.deinit(); + const root = object(parsed.value) catch return null; + const request_status = object(root.get("requestStatus") orelse return null) catch return null; + const description = (optionalString(request_status, "statusDescription") catch return null) orelse + return null; + const status_code = optionalUnsigned(request_status, "statusCode"); + const unified_code = optionalUnsigned(request_status, "unifiedErrorCode"); + const session_code = nestedUnsigned(root, "session.errorCode"); + + var sanitized: [256]u8 = undefined; + const description_length = @min(description.len, sanitized.len); + for (description[0..description_length], 0..) |byte, index| { + sanitized[index] = if (byte >= 0x20 and byte != 0x7f) byte else ' '; + } + + return if (session_code) |session_error| + std.fmt.bufPrint(output, "{s} (status {d}, code {d}, session code {d})", .{ + sanitized[0..description_length], + status_code orelse 0, + unified_code orelse 0, + session_error, + }) catch null + else if (unified_code) |code| + std.fmt.bufPrint(output, "{s} (status {d}, code {d})", .{ + sanitized[0..description_length], + status_code orelse 0, + code, + }) catch null + else if (status_code) |code| + std.fmt.bufPrint(output, "{s} (status {d})", .{ + sanitized[0..description_length], + code, + }) catch null + else + std.fmt.bufPrint(output, "{s}", .{sanitized[0..description_length]}) catch null; +} + +pub fn sessionFailureIsRetryable(allocator: std.mem.Allocator, data: []const u8) ?bool { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch + return null; + defer parsed.deinit(); + const root = object(parsed.value) catch return null; + const request_status = object(root.get("requestStatus") orelse return null) catch return null; + const status_code = optionalUnsigned(request_status, "statusCode") orelse return null; + if (status_code == 1 or status_code >= 255) return false; + + const server_error_base: usize = 3_237_093_632; + var code = server_error_base + status_code; + if (status_code == 0 or status_code == 4) { + if (optionalUnsigned(request_status, "unifiedErrorCode")) |unified| code = unified; + } + + return switch (code) { + 3_237_089_282, + 3_237_093_635, + 3_237_093_636, + 3_237_093_683, + 3_237_093_690, + 3_237_093_717, + 3_237_101_584, + 3_237_101_585, + 3_237_101_586, + => true, + else => false, + }; +} + +pub fn parseActiveSessionSummary( + allocator: std.mem.Allocator, + data: []const u8, + device_id: []const u8, + app_id: []const u8, +) !ActiveSessionSummary { + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{}); + defer parsed.deinit(); + const root = try object(parsed.value); + const request_status = try object(root.get("requestStatus") orelse return error.MissingStatus); + if ((optionalUnsigned(request_status, "statusCode") orelse 0) != 1) + return error.RequestRejected; + const sessions = try array(root.get("sessions") orelse return error.MissingSessions); + + var summary = ActiveSessionSummary{}; + for (sessions.items) |session_value| { + const session = object(session_value) catch continue; + const status = optionalUnsigned(session, "status") orelse continue; + if (status < 1 or status > 3) continue; + summary.active += 1; + const request_data = object(session.get("sessionRequestData") orelse continue) catch continue; + if (try identifierEquals(request_data.get("appId"), app_id)) + summary.matching_app += 1; + if (try identifierEquals(request_data.get("deviceHashId"), device_id)) + summary.same_device += 1; + } + return summary; +} + +pub fn parseSession(allocator: std.mem.Allocator, data: []const u8) !Session { + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{}); + defer parsed.deinit(); + const root = try object(parsed.value); + const request_status = try object(root.get("requestStatus") orelse return error.MissingStatus); + if ((optionalUnsigned(request_status, "statusCode") orelse 0) != 1) + return error.RequestRejected; + const session = try object(root.get("session") orelse return error.MissingSession); + const id = try duplicateIdentifier(allocator, session.get("sessionId") orelse return error.MissingSessionId); + errdefer allocator.free(id); + + const status = sessionStatus(session.get("status")) orelse return error.InvalidSessionStatus; + const queue_position = nestedUnsigned(session, "queuePosition") orelse + nestedUnsigned(session, "seatSetupInfo.queuePosition") orelse + nestedUnsigned(session, "sessionProgress.queuePosition") orelse + nestedUnsigned(session, "progressInfo.queuePosition") orelse + nestedUnsigned(root, "queuePosition"); + const setup_step = nestedUnsigned(session, "seatSetupStep") orelse + nestedUnsigned(session, "seatSetupInfo.seatSetupStep"); + + var signaling_ip: ?[]u8 = null; + var signaling_path: ?[]u8 = null; + var media_ip: ?[]u8 = null; + var media_port: ?u16 = null; + errdefer if (signaling_ip) |value| allocator.free(value); + errdefer if (signaling_path) |value| allocator.free(value); + errdefer if (media_ip) |value| allocator.free(value); + if (session.get("connectionInfo")) |connections_value| { + const connections = switch (connections_value) { + .array => |values| values, + else => null, + }; + if (connections) |values| for (values.items) |connection_value| { + const connection = object(connection_value) catch continue; + const usage = optionalUnsigned(connection, "usage") orelse continue; + const ip = if (connection.get("ip")) |value| duplicateIp(allocator, value) catch |err| switch (err) { + error.OutOfMemory => return err, + else => null, + } else null; + errdefer if (ip) |value| allocator.free(value); + const port_value = optionalUnsigned(connection, "port"); + const port: ?u16 = if (port_value != null and port_value.? <= std.math.maxInt(u16)) @intCast(port_value.?) else null; + const path = duplicateOptionalString(allocator, connection, "resourcePath") catch |err| switch (err) { + error.OutOfMemory => return err, + else => null, + }; + errdefer if (path) |value| allocator.free(value); + if (usage == 14 and signaling_ip == null) { + if (signaling_path) |value| allocator.free(value); + signaling_ip = ip; + signaling_path = path; + } else if ((usage == 2 or usage == 17) and media_ip == null and ip != null and port != null) { + media_ip = ip; + media_port = port; + if (path) |value| allocator.free(value); + } else { + if (ip) |value| allocator.free(value); + if (path) |value| allocator.free(value); + } + }; + } + if (signaling_ip == null) { + if (session.get("sessionControlInfo")) |control_value| { + const control = object(control_value) catch null; + if (control) |value| { + if (value.get("ip")) |ip| signaling_ip = duplicateIp(allocator, ip) catch |err| switch (err) { + error.OutOfMemory => return err, + else => null, + }; + } + } + } + const signaling_url = if (signaling_ip) |ip| + try buildSignalingUrl(allocator, ip, signaling_path) + else + null; + errdefer if (signaling_url) |value| allocator.free(value); + if (signaling_ip) |value| allocator.free(value); + if (signaling_path) |value| allocator.free(value); + signaling_ip = null; + signaling_path = null; + + const ice_servers = try parseIceServers(allocator, session); + errdefer { + for (ice_servers) |*server| server.deinit(allocator); + allocator.free(ice_servers); + } + + return .{ + .allocator = allocator, + .id = id, + .status = status, + .queue_position = boundedU32(queue_position), + .setup_step = boundedU32(setup_step), + .signaling_url = signaling_url, + .media_ip = media_ip, + .media_port = media_port, + .ice_servers = ice_servers, + .ads = try ad_protocol.parse(allocator, session), + }; +} + +fn boundedU32(value: ?u64) ?u32 { + const number = value orelse return null; + if (number > std.math.maxInt(u32)) return null; + return @intCast(number); +} + +fn buildSignalingUrl( + allocator: std.mem.Allocator, + ip: []const u8, + path: ?[]const u8, +) ![]u8 { + const resource = path orelse "/nvst/"; + if (std.mem.startsWith(u8, resource, "wss://")) return allocator.dupe(u8, resource); + if (std.mem.startsWith(u8, resource, "rtsps://") or + std.mem.startsWith(u8, resource, "rtsp://")) + { + const delimiter = std.mem.indexOf(u8, resource, "://").? + 3; + const end = std.mem.indexOfScalarPos(u8, resource, delimiter, '/') orelse resource.len; + const host = resource[delimiter..end]; + return std.fmt.allocPrint(allocator, "wss://{s}/nvst/", .{host}); + } + if (std.mem.startsWith(u8, resource, "/")) + return std.fmt.allocPrint(allocator, "wss://{s}:443{s}", .{ ip, resource }); + return std.fmt.allocPrint(allocator, "wss://{s}:443/nvst/", .{ip}); +} + +fn parseIceServers(allocator: std.mem.Allocator, session: std.json.ObjectMap) ![]IceServer { + const configuration_value = session.get("iceServerConfiguration") orelse + return allocator.alloc(IceServer, 0); + const configuration = object(configuration_value) catch return allocator.alloc(IceServer, 0); + const servers_value = configuration.get("iceServers") orelse + return allocator.alloc(IceServer, 0); + const values = array(servers_value) catch return allocator.alloc(IceServer, 0); + var result = std.ArrayList(IceServer).init(allocator); + errdefer { + for (result.items) |*server| server.deinit(allocator); + result.deinit(); + } + for (values.items) |server_value| { + const server = object(server_value) catch continue; + var urls = std.ArrayList([]u8).init(allocator); + errdefer { + for (urls.items) |url| allocator.free(url); + urls.deinit(); + } + if (server.get("urls")) |urls_value| switch (urls_value) { + .string => |url| try appendIceUrl(allocator, &urls, url), + .array => |array_value| for (array_value.items) |url_value| switch (url_value) { + .string => |url| try appendIceUrl(allocator, &urls, url), + else => {}, + }, + else => {}, + }; + if (urls.items.len == 0) { + urls.deinit(); + continue; + } + const owned_urls = try urls.toOwnedSlice(); + errdefer { + for (owned_urls) |url| allocator.free(url); + allocator.free(owned_urls); + } + const username = try duplicateOptionalString(allocator, server, "username"); + errdefer if (username) |value| allocator.free(value); + const credential = try duplicateOptionalString(allocator, server, "credential"); + errdefer if (credential) |value| allocator.free(value); + try result.append(.{ + .urls = owned_urls, + .username = username, + .credential = credential, + }); + } + return result.toOwnedSlice(); +} + +fn appendIceUrl(allocator: std.mem.Allocator, urls: *std.ArrayList([]u8), url: []const u8) !void { + if (url.len == 0) return; + const owned = try allocator.dupe(u8, url); + errdefer allocator.free(owned); + try urls.append(owned); +} + +fn nestedUnsigned(root: std.json.ObjectMap, path: []const u8) ?usize { + var fields = std.mem.splitScalar(u8, path, '.'); + var current = root; + while (fields.next()) |field| { + const value = current.get(field) orelse return null; + if (fields.peek() == null) return switch (value) { + .integer => |number| if (number >= 0 and number <= std.math.maxInt(usize)) @intCast(number) else null, + else => null, + }; + current = object(value) catch return null; + } + return null; +} + +fn sessionStatus(value: ?std.json.Value) ?u32 { + const actual = value orelse return null; + return switch (actual) { + .integer => |number| if (number >= 0 and number <= std.math.maxInt(u32)) @intCast(number) else null, + .string => |text| if (std.ascii.eqlIgnoreCase(text, "queued")) + 0 + else if (std.ascii.eqlIgnoreCase(text, "provisioning") or + std.ascii.eqlIgnoreCase(text, "initializing") or + std.ascii.eqlIgnoreCase(text, "setup") or + std.ascii.eqlIgnoreCase(text, "setting_up") or + std.ascii.eqlIgnoreCase(text, "launching") or + std.ascii.eqlIgnoreCase(text, "launching_game")) + 1 + else if (std.ascii.eqlIgnoreCase(text, "active") or + std.ascii.eqlIgnoreCase(text, "ready") or + std.ascii.eqlIgnoreCase(text, "paused")) + 2 + else if (std.ascii.eqlIgnoreCase(text, "streaming") or + std.ascii.eqlIgnoreCase(text, "playing") or + std.ascii.eqlIgnoreCase(text, "connected")) + 3 + else + null, + else => null, + }; +} + +fn duplicateIdentifier(allocator: std.mem.Allocator, value: std.json.Value) ![]u8 { + return switch (value) { + .string => |text| if (text.len > 0) allocator.dupe(u8, text) else error.InvalidIdentifier, + .integer => |number| if (number >= 0) + std.fmt.allocPrint(allocator, "{d}", .{number}) + else + error.InvalidIdentifier, + else => error.InvalidIdentifier, + }; +} + +fn identifierEquals(value: ?std.json.Value, expected: []const u8) !bool { + const actual = value orelse return false; + return switch (actual) { + .string => |text| std.mem.eql(u8, text, expected), + .integer => |number| if (number >= 0) result: { + var buffer: [32]u8 = undefined; + const text = try std.fmt.bufPrint(&buffer, "{d}", .{number}); + break :result std.mem.eql(u8, text, expected); + } else false, + else => false, + }; +} + +fn duplicateIp(allocator: std.mem.Allocator, value: std.json.Value) ![]u8 { + return switch (value) { + .string => |text| if (text.len > 0) allocator.dupe(u8, text) else error.InvalidIp, + .integer => |number| if (number >= 0 and number <= std.math.maxInt(u32)) blk: { + const ip: u32 = @intCast(number); + break :blk std.fmt.allocPrint(allocator, "{d}.{d}.{d}.{d}", .{ + ip >> 24, + (ip >> 16) & 0xff, + (ip >> 8) & 0xff, + ip & 0xff, + }); + } else error.InvalidIp, + .array => |values| if (values.items.len > 0) duplicateIp(allocator, values.items[0]) else error.InvalidIp, + .object => |fields| if (fields.get("value")) |nested| duplicateIp(allocator, nested) else error.InvalidIp, + else => error.InvalidIp, + }; +} + +fn duplicateOptionalString( + allocator: std.mem.Allocator, + value: std.json.ObjectMap, + key: []const u8, +) !?[]u8 { + if (try optionalString(value, key)) |text| return try allocator.dupe(u8, text); + return null; +} + +fn object(value: std.json.Value) !std.json.ObjectMap { + return switch (value) { + .object => |result| result, + else => error.ExpectedObject, + }; +} + +fn array(value: std.json.Value) !std.json.Array { + return switch (value) { + .array => |result| result, + else => error.ExpectedArray, + }; +} + +fn requiredString(value: std.json.ObjectMap, key: []const u8) ![]const u8 { + return (try optionalString(value, key)) orelse error.MissingField; +} + +fn optionalString(value: std.json.ObjectMap, key: []const u8) !?[]const u8 { + const field = value.get(key) orelse return null; + return switch (field) { + .string => |text| if (text.len > 0) text else null, + .null => null, + else => error.InvalidField, + }; +} + +fn optionalUnsigned(value: std.json.ObjectMap, key: []const u8) ?usize { + const field = value.get(key) orelse return null; + return switch (field) { + .integer => |number| if (number >= 0 and number <= std.math.maxInt(usize)) @intCast(number) else null, + else => null, + }; +} + +test "parses VPC id from server info" { + const id = try parseVpcId(std.testing.allocator, + \\{"requestStatus":{"statusCode":1,"serverId":"GFN-PC"}} + ); + defer std.testing.allocator.free(id); + try std.testing.expectEqualStrings("GFN-PC", id); +} + +test "queue and cleanup states remain pending while terminal states end the wait" { + for ([_]u32{ 0, 1, 2, 3, 4, 5, 6, 7 }) |status| { + const data = try std.fmt.allocPrint(std.testing.allocator, + \\{{"requestStatus":{{"statusCode":1}},"session":{{"sessionId":"test","status":{d}}}}} + , .{status}); + defer std.testing.allocator.free(data); + var session = try parseSession(std.testing.allocator, data); + defer session.deinit(); + try std.testing.expectEqual(status == 2 or status == 3, session.ready()); + try std.testing.expectEqual(status == 4 or status == 5 or status == 7, session.ended()); + } +} + +test "invalid session states are not treated as a queue" { + for ([_][]const u8{ "null", "-1", "4294967296", "\"unrecognized\"" }) |status| { + const data = try std.fmt.allocPrint(std.testing.allocator, + \\{{"requestStatus":{{"statusCode":1}},"session":{{"sessionId":"test","status":{s}}}}} + , .{status}); + defer std.testing.allocator.free(data); + try std.testing.expectError(error.InvalidSessionStatus, parseSession(std.testing.allocator, data)); + } +} + +test "parses and validates the advertised local CloudMatch region" { + const url = (try parseLocalRegionUrl(std.testing.allocator, + \\{"metaData":[ + \\ {"key":"local-region","value":"np-ams-06"}, + \\ {"key":"np-ams-06","value":"https://np-ams-06.cloudmatchbeta.nvidiagrid.net"} + \\]} + )).?; + defer std.testing.allocator.free(url); + try std.testing.expectEqualStrings("https://np-ams-06.cloudmatchbeta.nvidiagrid.net/", url); + + try std.testing.expectError( + error.UntrustedCloudMatchUrl, + parseLocalRegionUrl(std.testing.allocator, + \\{"metaData":[ + \\ {"key":"local-region","value":"np-ams-06"}, + \\ {"key":"np-ams-06","value":"https://example.invalid"} + \\]} + ), + ); +} + +test "summarizes active sessions without retaining identifiers" { + const summary = try parseActiveSessionSummary( + std.testing.allocator, + \\{"requestStatus":{"statusCode":1},"sessions":[ + \\ {"status":3,"sessionRequestData":{"appId":1001,"deviceHashId":"device-a"}}, + \\ {"status":1,"sessionRequestData":{"appId":"2002","deviceHashId":"device-b"}}, + \\ {"status":4,"sessionRequestData":{"appId":"1001","deviceHashId":"device-a"}} + \\]} + , + "device-a", + "1001", + ); + try std.testing.expectEqual(@as(usize, 2), summary.active); + try std.testing.expectEqual(@as(usize, 1), summary.same_device); + try std.testing.expectEqual(@as(usize, 1), summary.matching_app); +} + +test "builds a display-matched WebRTC session request" { + const body = try buildSessionRequest( + std.testing.allocator, + "202", + "Test Game", + "device-one", + 640, + 480, + 30, + 37, + ); + defer std.testing.allocator.free(body); + try std.testing.expect(std.mem.indexOf(u8, body, "\"widthInPixels\":640") != null); + try std.testing.expect(std.mem.indexOf(u8, body, "\"heightInPixels\":480") != null); + try std.testing.expect(std.mem.indexOf(u8, body, "GSStreamerType") != null); + try std.testing.expect(std.mem.indexOf(u8, body, "\"appId\":202") != null); + try std.testing.expect(std.mem.indexOf(u8, body, "\"cmsId\":\"202\"") != null); + try std.testing.expect(std.mem.indexOf(u8, body, "\"internalTitle\":\"Test Game\"") != null); + try std.testing.expect(std.mem.indexOf(u8, body, "\"availableSupportedControllers\":[]") != null); + try std.testing.expect(std.mem.indexOf(u8, body, "\"displayData\":null") != null); + try std.testing.expect(std.mem.indexOf(u8, body, "\"dpi\":100") != null); + try std.testing.expect(std.mem.indexOf(u8, body, "\"remoteControllersBitmap\":1") != null); + try std.testing.expect(std.mem.indexOf(u8, body, "\"prefilterMode\":0") != null); + try std.testing.expect(std.mem.indexOf(u8, body, "\"sdrColorSpace\":2") != null); + try std.testing.expect(std.mem.indexOf(u8, body, "\"maxBitrateKbps\"") == null); + try std.testing.expect(std.mem.indexOf(u8, body, "\"codec\"") == null); + try std.testing.expect(std.mem.indexOf(u8, body, "\"userAge\":37") != null); + try std.testing.expect(std.mem.indexOf(u8, body, "\"accountLinked\":true") != null); +} + +test "writes a bounded CloudMatch error summary" { + var output: [128]u8 = undefined; + const summary = writeErrorSummary( + std.testing.allocator, + \\{"requestStatus":{"statusCode":0,"statusDescription":"Unsupported request\n","unifiedErrorCode":42}} + , + &output, + ) orelse return error.MissingSummary; + try std.testing.expectEqualStrings("Unsupported request (status 0, code 42)", summary); +} + +test "includes a session error code in the CloudMatch summary" { + var output: [160]u8 = undefined; + const summary = writeErrorSummary( + std.testing.allocator, + \\{"requestStatus":{"statusCode":4,"statusDescription":"INTERNAL_ERROR_STATUS","unifiedErrorCode":0},"session":{"errorCode":2324439040}} + , + &output, + ) orelse return error.MissingSummary; + try std.testing.expectEqualStrings( + "INTERNAL_ERROR_STATUS (status 4, code 0, session code 2324439040)", + summary, + ); +} + +test "classifies CloudMatch session failures before retrying" { + try std.testing.expectEqual( + false, + sessionFailureIsRetryable(std.testing.allocator, + \\{"requestStatus":{"statusCode":81,"statusDescription":"STREAMING_NOT_ALLOWED_IN_LIMITED_MODE"}} + ).?, + ); + try std.testing.expectEqual( + true, + sessionFailureIsRetryable(std.testing.allocator, + \\{"requestStatus":{"statusCode":58,"statusDescription":"INSUFFICIENT_VM_CAPACITY"}} + ).?, + ); + try std.testing.expectEqual( + false, + sessionFailureIsRetryable(std.testing.allocator, + \\{"requestStatus":{"statusCode":4,"unifiedErrorCode":3237093713}} + ).?, + ); + try std.testing.expect(sessionFailureIsRetryable(std.testing.allocator, "not json") == null); +} + +test "parses queue and connection state" { + var session = try parseSession(std.testing.allocator, + \\{ + \\ "requestStatus":{"statusCode":1}, + \\ "session":{ + \\ "sessionId":"session-one","status":2, + \\ "seatSetupInfo":{"queuePosition":4,"seatSetupStep":1}, + \\ "connectionInfo":[ + \\ {"usage":14,"ip":{"value":"203.0.113.10"},"port":443,"resourcePath":"/nvst/"}, + \\ {"usage":2,"ip":3405803787,"port":49005} + \\ ], + \\ "iceServerConfiguration":{"iceServers":[{"urls":"stun:example.invalid:3478","username":"user","credential":"secret"}]} + \\ } + \\} + ); + defer session.deinit(); + + try std.testing.expect(session.ready()); + try std.testing.expectEqual(@as(?u32, 4), session.queue_position); + try std.testing.expectEqual(@as(?u32, 1), session.setup_step); + try std.testing.expectEqualStrings("wss://203.0.113.10:443/nvst/", session.signaling_url.?); + try std.testing.expectEqualStrings("203.0.113.11", session.media_ip.?); + try std.testing.expectEqual(@as(?u16, 49005), session.media_port); + try std.testing.expectEqual(@as(usize, 1), session.ice_servers.len); +} + +test "accepts a queued response before connection details are assigned" { + var session = try parseSession(std.testing.allocator, + \\{ + \\ "requestStatus":{"statusCode":1}, + \\ "queuePosition":42, + \\ "session":{"sessionId":"session-one","status":"QUEUED","connectionInfo":null} + \\} + ); + defer session.deinit(); + + try std.testing.expectEqual(@as(u32, 0), session.status); + try std.testing.expectEqual(@as(?u32, 42), session.queue_position); + try std.testing.expectEqual(@as(?[]u8, null), session.signaling_url); +} + +test "numeric and structured control addresses preserve signaling and ad admission" { + for ([_][]const u8{ "3405803787", "{\"value\":\"203.0.113.11\"}" }) |ip| { + for ([_]bool{ false, true }) |required| { + const bytes = try std.fmt.allocPrint( + std.testing.allocator, + "{{\"requestStatus\":{{\"statusCode\":1}},\"session\":{{\"sessionId\":\"one\",\"status\":2,\"sessionAdsRequired\":{},\"sessionControlInfo\":{{\"ip\":{s}}}}}}}", + .{ required, ip }, + ); + defer std.testing.allocator.free(bytes); + var session = try parseSession(std.testing.allocator, bytes); + defer session.deinit(); + try std.testing.expectEqualStrings("wss://203.0.113.11:443/nvst/", session.signaling_url.?); + try std.testing.expectEqual(required, session.ads.?.required); + try std.testing.expect(session.ads.?.control_base == null); + } + } +} + +test "ignores queue values outside the supported range" { + var session = try parseSession(std.testing.allocator, + \\{ + \\ "requestStatus":{"statusCode":1}, + \\ "session":{ + \\ "sessionId":"session-one","status":"QUEUED", + \\ "queuePosition":4294967296,"seatSetupStep":4294967296 + \\ } + \\} + ); + defer session.deinit(); + + try std.testing.expectEqual(@as(?u32, null), session.queue_position); + try std.testing.expectEqual(@as(?u32, null), session.setup_step); +} + +test "session parsing replaces incomplete signaling endpoints without leaking" { + var session = try parseSession(std.testing.allocator, + \\{"requestStatus":{"statusCode":1},"session":{ + \\"sessionId":"session-one","status":2,"connectionInfo":[ + \\ {"usage":14,"resourcePath":"/incomplete/"}, + \\ {"usage":14,"ip":"203.0.113.10","resourcePath":"/nvst/"} + \\]}} + ); + defer session.deinit(); + try std.testing.expectEqualStrings("wss://203.0.113.10:443/nvst/", session.signaling_url.?); +} + +test "session parsing releases partial allocations" { + try std.testing.checkAllAllocationFailures(std.testing.allocator, struct { + fn run(allocator: std.mem.Allocator) !void { + var session = try parseSession(allocator, + \\{"requestStatus":{"statusCode":1},"session":{ + \\"sessionId":"session-one","status":2, + \\"connectionInfo":[ + \\ {"usage":14,"ip":"203.0.113.10","resourcePath":"/nvst/"}, + \\ {"usage":2,"ip":3405803787,"port":49005} + \\], + \\"iceServerConfiguration":{"iceServers":[ + \\ {"urls":"stun:example.invalid:3478"}, + \\ {"urls":["turn:example.invalid:3478","turns:example.invalid:443"],"username":"user","credential":"test"} + \\]}}} + ); + defer session.deinit(); + } + }.run, .{}); +} diff --git a/src/provider/geforce_now/endpoint.zig b/src/provider/geforce_now/endpoint.zig new file mode 100644 index 0000000..611a4c2 --- /dev/null +++ b/src/provider/geforce_now/endpoint.zig @@ -0,0 +1,65 @@ +const std = @import("std"); + +pub fn normalizeCloudMatchBase( + allocator: std.mem.Allocator, + url: []const u8, +) ![]u8 { + const parsed = std.Uri.parse(url) catch return error.UntrustedCloudMatchUrl; + if (!std.ascii.eqlIgnoreCase(parsed.scheme, "https") or + parsed.user != null or parsed.password != null or + (parsed.port != null and parsed.port.? != 443) or + parsed.query != null or parsed.fragment != null) + return error.UntrustedCloudMatchUrl; + + const host = componentBytes(parsed.host orelse return error.UntrustedCloudMatchUrl) orelse + return error.UntrustedCloudMatchUrl; + const path = componentBytes(parsed.path) orelse return error.UntrustedCloudMatchUrl; + if ((path.len != 0 and !std.mem.eql(u8, path, "/")) or !trustedHost(host)) + return error.UntrustedCloudMatchUrl; + + return if (std.mem.endsWith(u8, url, "/")) + allocator.dupe(u8, url) + else + std.fmt.allocPrint(allocator, "{s}/", .{url}); +} + +fn trustedHost(host: []const u8) bool { + const root = "nvidiagrid.net"; + return std.ascii.eqlIgnoreCase(host, root) or + (host.len > root.len and host[host.len - root.len - 1] == '.' and + std.ascii.endsWithIgnoreCase(host, root)); +} + +fn componentBytes(component: std.Uri.Component) ?[]const u8 { + return switch (component) { + .raw => |value| value, + .percent_encoded => |value| if (std.mem.indexOfScalar(u8, value, '%') == null) value else null, + }; +} + +test "normalizes trusted CloudMatch endpoints" { + const normalized = try normalizeCloudMatchBase( + std.testing.allocator, + "https://np-ams-06.cloudmatchbeta.nvidiagrid.net", + ); + defer std.testing.allocator.free(normalized); + try std.testing.expectEqualStrings( + "https://np-ams-06.cloudmatchbeta.nvidiagrid.net/", + normalized, + ); +} + +test "rejects endpoints that could receive an account token" { + for ([_][]const u8{ + "http://prod.cloudmatchbeta.nvidiagrid.net", + "https://example.invalid", + "https://user@prod.cloudmatchbeta.nvidiagrid.net", + "https://prod.cloudmatchbeta.nvidiagrid.net/path", + "https://prod.cloudmatchbeta.nvidiagrid.net/?query=1", + }) |url| { + try std.testing.expectError( + error.UntrustedCloudMatchUrl, + normalizeCloudMatchBase(std.testing.allocator, url), + ); + } +} diff --git a/src/provider/geforce_now/input_protocol.zig b/src/provider/geforce_now/input_protocol.zig new file mode 100644 index 0000000..9e781f5 --- /dev/null +++ b/src/provider/geforce_now/input_protocol.zig @@ -0,0 +1,268 @@ +const std = @import("std"); + +pub const Button = struct { + pub const dpad_up: u16 = 0x0001; + pub const dpad_down: u16 = 0x0002; + pub const dpad_left: u16 = 0x0004; + pub const dpad_right: u16 = 0x0008; + pub const start: u16 = 0x0010; + pub const back: u16 = 0x0020; + pub const left_stick: u16 = 0x0040; + pub const right_stick: u16 = 0x0080; + pub const left_shoulder: u16 = 0x0100; + pub const right_shoulder: u16 = 0x0200; + pub const guide: u16 = 0x0400; + pub const a: u16 = 0x1000; + pub const b: u16 = 0x2000; + pub const x: u16 = 0x4000; + pub const y: u16 = 0x8000; +}; + +pub const GamepadState = struct { + controller_id: u8 = 0, + gamepad_bitmap: u16 = 0x0101, + buttons: u16 = 0, + left_trigger: u8 = 0, + right_trigger: u8 = 0, + left_x: i16 = 0, + left_y: i16 = 0, + right_x: i16 = 0, + right_y: i16 = 0, + timestamp_us: u64 = 0, +}; + +pub const Encoder = struct { + protocol_version: u16 = 2, + + pub fn setProtocolVersion(self: *Encoder, version: u16) void { + self.protocol_version = version; + } + + pub fn encodeHeartbeat(_: *const Encoder, output: []u8) ![]const u8 { + if (output.len < heartbeat_size) return error.NoSpace; + std.mem.writeInt(u32, output[0..heartbeat_size], 2, .little); + return output[0..heartbeat_size]; + } + + pub fn encodeMousePosition(self: *const Encoder, output: []u8, x: u16, y: u16, width: u16, height: u16, timestamp_us: u64) ![]const u8 { + if (width == 0 or height == 0) return error.InvalidPointerDimensions; + const body = try self.mousePacket(output, 26, true, timestamp_us); + std.mem.writeInt(u32, body[0..4], 5, .little); + std.mem.writeInt(u16, body[4..6], @min(x, width - 1), .big); + std.mem.writeInt(u16, body[6..8], @min(y, height - 1), .big); + std.mem.writeInt(u16, body[10..12], width, .big); + std.mem.writeInt(u16, body[12..14], height, .big); + std.mem.writeInt(u64, body[18..26], timestamp_us, .big); + return output[0..self.mousePacketSize(26, true)]; + } + + pub fn encodeMouseButton(self: *const Encoder, output: []u8, button: MouseButton, down: bool, timestamp_us: u64) ![]const u8 { + const body = try self.mousePacket(output, 18, false, timestamp_us); + std.mem.writeInt(u32, body[0..4], if (down) 8 else 9, .little); + body[4] = @intFromEnum(button); + std.mem.writeInt(u64, body[10..18], timestamp_us, .big); + return output[0..self.mousePacketSize(18, false)]; + } + + pub fn encodeMouseWheel(self: *const Encoder, output: []u8, delta: i16, timestamp_us: u64) ![]const u8 { + const body = try self.mousePacket(output, 22, false, timestamp_us); + std.mem.writeInt(u32, body[0..4], 10, .little); + std.mem.writeInt(i16, body[6..8], delta, .big); + std.mem.writeInt(u64, body[14..22], timestamp_us, .big); + return output[0..self.mousePacketSize(22, false)]; + } + + fn mousePacketSize(self: *const Encoder, body_size: usize, batch: bool) usize { + return body_size + if (self.protocol_version >= 3) @as(usize, if (batch) 12 else 10) else 0; + } + + fn mousePacket(self: *const Encoder, output: []u8, body_size: u16, batch: bool, timestamp_us: u64) ![]u8 { + const size = self.mousePacketSize(body_size, batch); + if (output.len < size) return error.NoSpace; + @memset(output[0..size], 0); + if (self.protocol_version >= 3) { + output[0] = 0x23; + std.mem.writeInt(u64, output[1..9], timestamp_us, .big); + output[9] = if (batch) 0x21 else 0x22; + if (batch) std.mem.writeInt(u16, output[10..12], body_size, .big); + } + return output[size - body_size .. size]; + } + + pub fn encodeReliableGamepad( + self: *const Encoder, + output: []u8, + state: GamepadState, + ) ![]const u8 { + const payload_offset: usize = if (self.protocol_version >= 3) reliable_wrapper_size else 0; + const packet_size = payload_offset + gamepad_packet_size; + if (output.len < packet_size) return error.NoSpace; + @memset(output[0..packet_size], 0); + + if (self.protocol_version >= 3) { + output[0] = 0x23; + std.mem.writeInt(u64, output[1..9], state.timestamp_us, .big); + output[9] = 0x21; + std.mem.writeInt(u16, output[10..12], gamepad_packet_size, .big); + } + encodeGamepadBody(output[payload_offset..packet_size], state); + return output[0..packet_size]; + } +}; + +pub const heartbeat_size: usize = 4; +pub const MouseButton = enum(u8) { left = 1, right = 3 }; +pub const gamepad_packet_size: usize = 38; +pub const maximum_packet_size: usize = reliable_wrapper_size + gamepad_packet_size; + +const reliable_wrapper_size: usize = 12; + +pub fn parseHandshakeVersion(data: []const u8) ?u16 { + if (data.len < 2) return null; + const first_word = std.mem.readInt(u16, data[0..2], .little); + if (first_word == 526) { + if (data.len < 4) return 2; + const version = std.mem.readInt(u16, data[2..4], .little); + return if (version == 2 or version == 3) version else null; + } + if (data[0] == 0x0e and (data[1] == 2 or data[1] == 3)) return data[1]; + return null; +} + +fn encodeGamepadBody(output: []u8, state: GamepadState) void { + std.debug.assert(output.len >= gamepad_packet_size); + std.mem.writeInt(u32, output[0..4], 12, .little); + std.mem.writeInt(u16, output[4..6], 26, .little); + std.mem.writeInt(u16, output[6..8], state.controller_id & 3, .little); + std.mem.writeInt(u16, output[8..10], state.gamepad_bitmap, .little); + std.mem.writeInt(u16, output[10..12], 20, .little); + std.mem.writeInt(u16, output[12..14], state.buttons, .little); + output[14] = state.left_trigger; + output[15] = state.right_trigger; + std.mem.writeInt(i16, output[16..18], state.left_x, .little); + std.mem.writeInt(i16, output[18..20], state.left_y, .little); + std.mem.writeInt(i16, output[20..22], state.right_x, .little); + std.mem.writeInt(i16, output[22..24], state.right_y, .little); + output[26] = 0x55; + std.mem.writeInt(u64, output[30..38], state.timestamp_us, .little); +} + +test "heartbeat remains unwrapped for protocol version three" { + var encoder = Encoder{}; + encoder.setProtocolVersion(3); + var output: [maximum_packet_size]u8 = undefined; + try std.testing.expectEqualSlices( + u8, + &.{ 0x02, 0x00, 0x00, 0x00 }, + try encoder.encodeHeartbeat(&output), + ); +} + +test "protocol version two gamepad fields use the expected byte order" { + const encoder = Encoder{}; + var output: [maximum_packet_size]u8 = undefined; + const packet = try encoder.encodeReliableGamepad(&output, .{ + .controller_id = 7, + .gamepad_bitmap = 0x0305, + .buttons = 0xa55a, + .left_trigger = 0x12, + .right_trigger = 0xfe, + .left_x = -2, + .left_y = 0x1234, + .right_x = std.math.minInt(i16), + .right_y = std.math.maxInt(i16), + .timestamp_us = 0x0102030405060708, + }); + + try std.testing.expectEqual(gamepad_packet_size, packet.len); + try std.testing.expectEqual(@as(u32, 12), std.mem.readInt(u32, packet[0..4], .little)); + try std.testing.expectEqual(@as(u16, 26), std.mem.readInt(u16, packet[4..6], .little)); + try std.testing.expectEqual(@as(u16, 3), std.mem.readInt(u16, packet[6..8], .little)); + try std.testing.expectEqual(@as(u16, 0x0305), std.mem.readInt(u16, packet[8..10], .little)); + try std.testing.expectEqual(@as(u16, 0xa55a), std.mem.readInt(u16, packet[12..14], .little)); + try std.testing.expectEqual(@as(i16, -2), std.mem.readInt(i16, packet[16..18], .little)); + try std.testing.expectEqual(@as(u8, 0x55), packet[26]); + try std.testing.expectEqual( + @as(u64, 0x0102030405060708), + std.mem.readInt(u64, packet[30..38], .little), + ); + for ([_]usize{ 24, 25, 27, 28, 29 }) |index| + try std.testing.expectEqual(@as(u8, 0), packet[index]); +} + +test "default gamepad state identifies the first connected controller" { + const encoder = Encoder{}; + var output: [maximum_packet_size]u8 = undefined; + const packet = try encoder.encodeReliableGamepad(&output, .{}); + + try std.testing.expectEqual(@as(u16, 0), std.mem.readInt(u16, packet[6..8], .little)); + try std.testing.expectEqual(@as(u16, 0x0101), std.mem.readInt(u16, packet[8..10], .little)); +} + +test "protocol version three reliable gamepad wraps the complete legacy packet" { + var encoder = Encoder{}; + encoder.setProtocolVersion(3); + var output: [maximum_packet_size]u8 = undefined; + const packet = try encoder.encodeReliableGamepad(&output, .{ + .buttons = Button.a, + .timestamp_us = 0x0102030405060708, + }); + + try std.testing.expectEqual(@as(usize, 50), packet.len); + try std.testing.expectEqual(@as(u8, 0x23), packet[0]); + try std.testing.expectEqual( + @as(u64, 0x0102030405060708), + std.mem.readInt(u64, packet[1..9], .big), + ); + try std.testing.expectEqual(@as(u8, 0x21), packet[9]); + try std.testing.expectEqual(@as(u16, 38), std.mem.readInt(u16, packet[10..12], .big)); + try std.testing.expectEqual(@as(u32, 12), std.mem.readInt(u32, packet[12..16], .little)); +} + +test "parses both observed input-channel handshake formats" { + try std.testing.expectEqual(@as(?u16, 3), parseHandshakeVersion(&.{ 0x0e, 0x02, 0x03, 0x00 })); + try std.testing.expectEqual(@as(?u16, 2), parseHandshakeVersion(&.{ 0x0e, 0x02 })); + try std.testing.expectEqual(@as(?u16, 3), parseHandshakeVersion(&.{ 0x0e, 0x03 })); + try std.testing.expectEqual(@as(?u16, null), parseHandshakeVersion(&.{ 0x0e, 0xff })); + try std.testing.expectEqual(@as(?u16, null), parseHandshakeVersion(&.{ 0x01, 0x00 })); +} + +test "absolute mouse packet uses big endian coordinates inside the legacy header" { + var output: [maximum_packet_size]u8 = undefined; + const encoder = Encoder{}; + const packet = try encoder.encodeMousePosition(&output, 320, 240, 640, 480, 17); + try std.testing.expectEqual(@as(usize, 26), packet.len); + try std.testing.expectEqual(@as(u32, 5), std.mem.readInt(u32, packet[0..4], .little)); + try std.testing.expectEqual(@as(u16, 320), std.mem.readInt(u16, packet[4..6], .big)); + try std.testing.expectEqual(@as(u16, 240), std.mem.readInt(u16, packet[6..8], .big)); + try std.testing.expectEqual(@as(u16, 640), std.mem.readInt(u16, packet[10..12], .big)); + try std.testing.expectEqual(@as(u16, 480), std.mem.readInt(u16, packet[12..14], .big)); + try std.testing.expectEqual(@as(u64, 17), std.mem.readInt(u64, packet[18..26], .big)); + for ([_]usize{ 8, 9, 14, 15, 16, 17 }) |i| try std.testing.expectEqual(@as(u8, 0), packet[i]); + try std.testing.expectError(error.InvalidPointerDimensions, encoder.encodeMousePosition(&output, 0, 0, 0, 1, 0)); + try std.testing.expectError(error.NoSpace, encoder.encodeMousePosition(output[0..25], 0, 0, 1, 1, 0)); +} + +test "v3 wraps mouse motion as a batch and button edges as single events" { + const encoder = Encoder{ .protocol_version = 3 }; + var output: [maximum_packet_size]u8 = undefined; + const position = try encoder.encodeMousePosition(&output, 65535, 65535, 640, 480, 7); + try std.testing.expectEqual(@as(usize, 38), position.len); + try std.testing.expectEqual(@as(u8, 0x23), position[0]); + try std.testing.expectEqual(@as(u8, 0x21), position[9]); + try std.testing.expectEqual(@as(u16, 26), std.mem.readInt(u16, position[10..12], .big)); + try std.testing.expectEqual(@as(u16, 639), std.mem.readInt(u16, position[16..18], .big)); + try std.testing.expectEqual(@as(u16, 479), std.mem.readInt(u16, position[18..20], .big)); + for ([_]bool{ true, false }) |down| { + const button = try encoder.encodeMouseButton(&output, .right, down, 123); + try std.testing.expectEqual(@as(usize, 28), button.len); + try std.testing.expectEqual(@as(u8, 0x22), button[9]); + try std.testing.expectEqual(@as(u32, if (down) 8 else 9), std.mem.readInt(u32, button[10..14], .little)); + try std.testing.expectEqual(@as(u8, 3), button[14]); + try std.testing.expectEqual(@as(u64, 123), std.mem.readInt(u64, button[20..28], .big)); + } + const wheel = try encoder.encodeMouseWheel(&output, -120, 321); + try std.testing.expectEqual(@as(usize, 32), wheel.len); + try std.testing.expectEqual(@as(i16, -120), std.mem.readInt(i16, wheel[16..18], .big)); + try std.testing.expectEqual(@as(u64, 321), std.mem.readInt(u64, wheel[24..32], .big)); +} diff --git a/src/provider/geforce_now/pointer_input.zig b/src/provider/geforce_now/pointer_input.zig new file mode 100644 index 0000000..601a236 --- /dev/null +++ b/src/provider/geforce_now/pointer_input.zig @@ -0,0 +1,114 @@ +const std = @import("std"); +const protocol = @import("input_protocol.zig"); +const Button = protocol.Button; + +pub const State = struct { + enabled: bool = false, + x: f32 = 0.5, + y: f32 = 0.5, + buttons: u2 = 0, + chord_held: bool = false, + wait_for_release: bool = false, + previous_time: ?u64 = null, + next_scroll: u64 = 0, + + pub const Update = struct { + gamepad: protocol.GamepadState, + moved: bool = false, + changed_buttons: u2 = 0, + wheel: i16 = 0, + toggled: bool = false, + }; + + pub fn update(self: *State, gamepad: protocol.GamepadState) Update { + const now = gamepad.timestamp_us; + const elapsed = if (self.previous_time) |previous| @min(now -| previous, 50_000) else 0; + self.previous_time = now; + const chord_mask = Button.back | Button.y; + const chord = gamepad.buttons & chord_mask == chord_mask; + var result = Update{ .gamepad = gamepad }; + if (chord and !self.chord_held) { + self.enabled = !self.enabled; + self.wait_for_release = true; + result.toggled = true; + result.moved = self.enabled; + } + self.chord_held = chord; + if (self.wait_for_release and gamepad.buttons == 0) self.wait_for_release = false; + const old_buttons = self.buttons; + self.buttons = 0; + if (self.enabled or self.wait_for_release) { + result.gamepad = .{ .timestamp_us = now }; + if (self.enabled and !self.wait_for_release) { + const dx = direction(gamepad.left_x, gamepad.buttons, Button.dpad_left, Button.dpad_right); + const dy = direction(-@as(i32, gamepad.left_y), gamepad.buttons, Button.dpad_up, Button.dpad_down); + const seconds = @as(f32, @floatFromInt(elapsed)) / 1_000_000; + const x = std.math.clamp(self.x + dx * seconds * 0.7, 0, 1); + const y = std.math.clamp(self.y + dy * seconds * 0.7, 0, 1); + result.moved = result.moved or x != self.x or y != self.y; + self.x = x; + self.y = y; + if (gamepad.buttons & Button.a != 0) self.buttons |= 1; + if (gamepad.buttons & Button.b != 0) self.buttons |= 2; + const scroll: i16 = @as(i16, @intFromBool(gamepad.buttons & Button.left_shoulder != 0)) - + @as(i16, @intFromBool(gamepad.buttons & Button.right_shoulder != 0)); + if (scroll == 0) self.next_scroll = 0 else if (now >= self.next_scroll) { + result.wheel = scroll * 120; + self.next_scroll = now +| 150_000; + } + } + } + result.changed_buttons = old_buttons ^ self.buttons; + return result; + } +}; + +fn direction(axis: i32, buttons: u16, negative: u16, positive: u16) f32 { + const digital = @as(i32, @intFromBool(buttons & positive != 0)) - @as(i32, @intFromBool(buttons & negative != 0)); + if (digital != 0) return @floatFromInt(digital); + const magnitude = @abs(axis); + if (magnitude <= 6000) return 0; + const strength = @as(f32, @floatFromInt(@min(magnitude, 32767) - 6000)) / 26767; + return (if (axis < 0) -strength else strength) * strength; +} + +test "pointer chord toggles once and consumes controls until release" { + var pointer = State{}; + const chord = protocol.GamepadState{ .buttons = Button.back | Button.y }; + try std.testing.expect(pointer.update(chord).toggled); + try std.testing.expect(pointer.enabled); + try std.testing.expect(!pointer.update(chord).toggled); + try std.testing.expectEqual(@as(u16, 0), pointer.update(.{ .buttons = Button.y }).gamepad.buttons); + _ = pointer.update(.{}); + const click = pointer.update(.{ .buttons = Button.a }); + try std.testing.expectEqual(@as(u2, 1), click.changed_buttons); + try std.testing.expectEqual(@as(u16, 0), click.gamepad.buttons); + const exit = pointer.update(chord); + try std.testing.expect(!pointer.enabled); + try std.testing.expectEqual(@as(u2, 1), exit.changed_buttons); + _ = pointer.update(.{}); + try std.testing.expectEqual(Button.a, pointer.update(.{ .buttons = Button.a }).gamepad.buttons); +} + +test "pointer motion is time based, bounded and has a deadzone" { + var pointer = State{ .enabled = true }; + _ = pointer.update(.{ .left_x = 5000 }); + try std.testing.expect(!pointer.update(.{ .left_x = 5000, .timestamp_us = 20_000 }).moved); + _ = pointer.update(.{ .left_x = 32767, .left_y = 32767, .timestamp_us = 40_000 }); + try std.testing.expect(pointer.x > 0.5 and pointer.y < 0.5); + const previous = pointer.x; + _ = pointer.update(.{ .left_x = 32767, .timestamp_us = 60_000_000 }); + try std.testing.expect(pointer.x - previous <= 0.036); + pointer.x = 1; + _ = pointer.update(.{ .buttons = Button.dpad_right, .timestamp_us = 60_020_000 }); + try std.testing.expectEqual(@as(f32, 1), pointer.x); +} + +test "scroll repeats are bounded and disconnected controls release mouse buttons" { + var pointer = State{ .enabled = true }; + try std.testing.expectEqual(@as(i16, -120), pointer.update(.{ .buttons = Button.right_shoulder }).wheel); + try std.testing.expectEqual(@as(i16, 0), pointer.update(.{ .buttons = Button.right_shoulder, .timestamp_us = 10_000 }).wheel); + try std.testing.expectEqual(@as(i16, -120), pointer.update(.{ .buttons = Button.right_shoulder, .timestamp_us = 150_000 }).wheel); + _ = pointer.update(.{ .buttons = Button.a | Button.b }); + try std.testing.expectEqual(@as(u2, 3), pointer.update(.{}).changed_buttons); +} diff --git a/src/provider/geforce_now/provider_protocol.zig b/src/provider/geforce_now/provider_protocol.zig new file mode 100644 index 0000000..4f25f57 --- /dev/null +++ b/src/provider/geforce_now/provider_protocol.zig @@ -0,0 +1,134 @@ +const std = @import("std"); +const endpoint_rules = @import("endpoint.zig"); + +pub const service_urls_endpoint = "https://pcs.geforcenow.com/v1/serviceUrls"; + +pub const Provider = struct { + allocator: std.mem.Allocator, + code: []u8, + display_name: []u8, + idp_id: []u8, + streaming_url: []u8, + + pub fn deinit(self: *Provider) void { + self.allocator.free(self.code); + self.allocator.free(self.display_name); + self.allocator.free(self.idp_id); + self.allocator.free(self.streaming_url); + self.* = undefined; + } +}; + +pub fn parsePreferredProvider(allocator: std.mem.Allocator, data: []const u8) !Provider { + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{}); + defer parsed.deinit(); + const root = try object(parsed.value); + const info = try object(root.get("gfnServiceInfo") orelse return error.MissingServiceInfo); + const endpoints = try array(info.get("gfnServiceEndpoints") orelse return error.MissingEndpoints); + if (endpoints.items.len == 0) return error.MissingEndpoints; + + const default_code = optionalString(info, "defaultProvider") catch null; + var selected: ?std.json.ObjectMap = null; + if (default_code) |preferred| { + for (endpoints.items) |entry| { + const endpoint = object(entry) catch continue; + const code = optionalString(endpoint, "loginProviderCode") catch continue; + if (code != null and std.ascii.eqlIgnoreCase(code.?, preferred)) { + selected = endpoint; + break; + } + } + } + const provider_entry = selected orelse try object(endpoints.items[0]); + + const code = try allocator.dupe(u8, try requiredString(provider_entry, "loginProviderCode")); + errdefer allocator.free(code); + const display_name = try allocator.dupe(u8, try requiredString(provider_entry, "loginProviderDisplayName")); + errdefer allocator.free(display_name); + const idp_id = try allocator.dupe(u8, try requiredString(provider_entry, "idpId")); + errdefer allocator.free(idp_id); + const raw_url = try requiredString(provider_entry, "streamingServiceUrl"); + const streaming_url = try endpoint_rules.normalizeCloudMatchBase(allocator, raw_url); + errdefer allocator.free(streaming_url); + + return .{ + .allocator = allocator, + .code = code, + .display_name = display_name, + .idp_id = idp_id, + .streaming_url = streaming_url, + }; +} + +fn object(value: std.json.Value) !std.json.ObjectMap { + return switch (value) { + .object => |result| result, + else => error.ExpectedObject, + }; +} + +fn array(value: std.json.Value) !std.json.Array { + return switch (value) { + .array => |result| result, + else => error.ExpectedArray, + }; +} + +fn requiredString(value: std.json.ObjectMap, key: []const u8) ![]const u8 { + return (try optionalString(value, key)) orelse error.MissingField; +} + +fn optionalString(value: std.json.ObjectMap, key: []const u8) !?[]const u8 { + const field = value.get(key) orelse return null; + return switch (field) { + .string => |text| if (text.len > 0) text else null, + .null => null, + else => error.InvalidField, + }; +} + +test "selects the advertised default provider" { + var provider = try parsePreferredProvider(std.testing.allocator, + \\{ + \\ "gfnServiceInfo": { + \\ "defaultProvider": "BPC", + \\ "gfnServiceEndpoints": [ + \\ {"loginProviderCode":"NVIDIA","loginProviderDisplayName":"NVIDIA","idpId":"one","streamingServiceUrl":"https://one.cloudmatchbeta.nvidiagrid.net/"}, + \\ {"loginProviderCode":"BPC","loginProviderDisplayName":"bro.game","idpId":"two","streamingServiceUrl":"https://two.cloudmatchbeta.nvidiagrid.net"} + \\ ] + \\ } + \\} + ); + defer provider.deinit(); + + try std.testing.expectEqualStrings("BPC", provider.code); + try std.testing.expectEqualStrings("bro.game", provider.display_name); + try std.testing.expectEqualStrings("two", provider.idp_id); + try std.testing.expectEqualStrings("https://two.cloudmatchbeta.nvidiagrid.net/", provider.streaming_url); +} + +test "falls back to the first provider" { + var provider = try parsePreferredProvider(std.testing.allocator, + \\{"gfnServiceInfo":{"gfnServiceEndpoints":[{"loginProviderCode":"NVIDIA","loginProviderDisplayName":"NVIDIA","idpId":"idp","streamingServiceUrl":"https://prod.cloudmatchbeta.nvidiagrid.net/"}]}} + ); + defer provider.deinit(); + try std.testing.expectEqualStrings("NVIDIA", provider.code); +} + +test "rejects an untrusted streaming service endpoint" { + try std.testing.expectError( + error.UntrustedCloudMatchUrl, + parsePreferredProvider(std.testing.allocator, + \\{"gfnServiceInfo":{"gfnServiceEndpoints":[{"loginProviderCode":"NVIDIA","loginProviderDisplayName":"NVIDIA","idpId":"idp","streamingServiceUrl":"https://example.invalid/"}]}} + ), + ); +} + +test "rejects an empty provider list" { + try std.testing.expectError( + error.MissingEndpoints, + parsePreferredProvider(std.testing.allocator, + \\{"gfnServiceInfo":{"gfnServiceEndpoints":[]}} + ), + ); +} diff --git a/src/provider/geforce_now/sdp_protocol.zig b/src/provider/geforce_now/sdp_protocol.zig new file mode 100644 index 0000000..b528462 --- /dev/null +++ b/src/provider/geforce_now/sdp_protocol.zig @@ -0,0 +1,499 @@ +const std = @import("std"); + +const InputCapabilities = struct { + threshold_ms: u32 = 300, + hid_mask: u32 = std.math.maxInt(u32), + gamepad_mask: u32 = 0x0f, + partial_hid_mask: u32 = std.math.maxInt(u32), +}; + +const IceCredentials = struct { + username_fragment: []const u8, + password: []const u8, + fingerprint: []const u8, +}; + +pub fn sanitizeOffer( + allocator: std.mem.Allocator, + offer: []const u8, + media_ip: ?[]const u8, +) ![]u8 { + if (offer.len == 0) return error.InvalidOffer; + var lines = std.ArrayList([]const u8).init(allocator); + defer lines.deinit(); + var iterator = std.mem.splitScalar(u8, offer, '\n'); + while (iterator.next()) |line| try lines.append(std.mem.trimRight(u8, line, "\r")); + + const first_media = for (lines.items, 0..) |line, index| { + if (std.mem.startsWith(u8, line, "m=")) break index; + } else return error.MissingMedia; + var shared = std.ArrayList([]const u8).init(allocator); + defer shared.deinit(); + for (lines.items[0..first_media]) |line| { + if (isSharedAttribute(line)) try shared.append(line); + } + + var output = std.ArrayList(u8).init(allocator); + errdefer output.deinit(); + const writer = output.writer(); + for (lines.items, 0..) |line, index| { + try writeFixedAddress(writer, line, media_ip); + try writer.writeAll("\r\n"); + if (!std.mem.startsWith(u8, line, "m=")) continue; + for (shared.items) |shared_attribute| { + if (sectionHasAttribute(lines.items, index, shared_attribute)) continue; + try writer.writeAll(shared_attribute); + try writer.writeAll("\r\n"); + } + } + return output.toOwnedSlice(); +} + +pub fn buildNvstAnswer( + allocator: std.mem.Allocator, + answer: []const u8, + offer: []const u8, + width: u16, + height: u16, + frames_per_second: u16, + maximum_bitrate_kbps: u32, +) ![]u8 { + if (width < 320 or height < 240 or frames_per_second == 0 or maximum_bitrate_kbps < 4000) + return error.InvalidStreamProfile; + const credentials = try extractIceCredentials(answer); + const input = parseInputCapabilities(offer); + const minimum_bitrate: u32 = 4000; + const initial_bitrate = @max(minimum_bitrate, maximum_bitrate_kbps / 4); + return std.fmt.allocPrint( + allocator, + "v=0\r\n" ++ + "o=GreenOvercast 1 1 IN IP4 127.0.0.1\r\n" ++ + "s=-\r\nt=0 0\r\n" ++ + "a=general.icePassword:{s}\r\n" ++ + "a=general.iceUserNameFragment:{s}\r\n" ++ + "a=general.dtlsFingerprint:{s}\r\n" ++ + "m=video 0 RTP/AVP\r\n" ++ + "a=msid:fbc-video-0\r\n" ++ + "a=video.enableRtpNack:1\r\n" ++ + "a=video.packetSize:1140\r\n" ++ + "a=video.rtpNackQueueLength:1024\r\n" ++ + "a=video.rtpNackQueueMaxPackets:512\r\n" ++ + "a=video.rtpNackMaxPacketCount:25\r\n" ++ + "a=video.clientViewportWd:{d}\r\n" ++ + "a=video.clientViewportHt:{d}\r\n" ++ + "a=video.maxFPS:{d}\r\n" ++ + "a=video.maxNumReferenceFrames:4\r\n" ++ + "a=video.mapRtpTimestampsToFrames:1\r\n" ++ + "a=video.bitDepth:8\r\n" ++ + "a=video.initialBitrateKbps:{d}\r\n" ++ + "a=video.initialPeakBitrateKbps:{d}\r\n" ++ + "a=vqos.bw.minimumBitrateKbps:{d}\r\n" ++ + "a=vqos.bw.maximumBitrateKbps:{d}\r\n" ++ + "m=audio 0 RTP/AVP\r\n" ++ + "a=msid:audio\r\n" ++ + "m=application 0 RTP/AVP\r\n" ++ + "a=msid:input_1\r\n" ++ + "a=ri.partialReliableThresholdMs:{d}\r\n" ++ + "a=ri.hidDeviceMask:{d}\r\n" ++ + "a=ri.enablePartiallyReliableTransferGamepad:{d}\r\n" ++ + "a=ri.enablePartiallyReliableTransferHid:{d}\r\n", + .{ + credentials.password, + credentials.username_fragment, + credentials.fingerprint, + width, + height, + frames_per_second, + initial_bitrate, + initial_bitrate, + minimum_bitrate, + maximum_bitrate_kbps, + input.threshold_ms, + input.hid_mask, + input.gamepad_mask, + input.partial_hid_mask, + }, + ); +} + +pub fn iceCandidateMid(sdp: []const u8, mid: ?[]const u8, media_index: ?u32) ![]const u8 { + if (mid) |value| { + if (value.len != 0) return value; + } + var remaining = media_index orelse return "0"; + var in_section = false; + var lines = std.mem.splitScalar(u8, sdp, '\n'); + while (lines.next()) |raw| { + const line = std.mem.trimRight(u8, raw, "\r"); + if (std.mem.startsWith(u8, line, "m=")) { + if (in_section) break; + if (remaining == 0) { + in_section = true; + } else { + remaining -= 1; + } + } else if (in_section and std.mem.startsWith(u8, line, "a=mid:")) { + if (line.len == "a=mid:".len) break; + return line["a=mid:".len..]; + } + } + return if (in_section) error.IceMediaIdMissing else error.IceMediaSectionNotFound; +} + +pub fn codecPayloadType(sdp: []const u8, codec: []const u8) ?u8 { + var lines = std.mem.splitScalar(u8, sdp, '\n'); + while (lines.next()) |raw_line| { + const line = std.mem.trim(u8, raw_line, " \t\r"); + if (!std.mem.startsWith(u8, line, "a=rtpmap:")) continue; + const mapping = line["a=rtpmap:".len..]; + const separator = std.mem.indexOfScalar(u8, mapping, ' ') orelse continue; + const payload_type = std.fmt.parseInt(u8, mapping[0..separator], 10) catch continue; + const encoding = mapping[separator + 1 ..]; + if (std.ascii.startsWithIgnoreCase(encoding, codec)) return payload_type; + } + return null; +} + +pub fn narrowAnswerToH264Opus( + allocator: std.mem.Allocator, + answer: []const u8, + offer: []const u8, +) ![]u8 { + const video_payload = codecPayloadType(offer, "H264/") orelse return error.H264NotOffered; + const audio_payload = codecPayloadType(offer, "OPUS/") orelse return error.OpusNotOffered; + + const Media = enum { other, video, audio }; + var media = Media.other; + var output = std.ArrayList(u8).init(allocator); + errdefer output.deinit(); + const writer = output.writer(); + + var lines = std.mem.splitScalar(u8, answer, '\n'); + while (lines.next()) |raw_line| { + const line = std.mem.trimRight(u8, raw_line, "\r"); + if (line.len == 0) continue; + + if (std.mem.startsWith(u8, line, "m=")) { + if (std.mem.startsWith(u8, line, "m=video ")) { + media = .video; + try writeSinglePayloadMediaLine(writer, line, video_payload); + continue; + } + if (std.mem.startsWith(u8, line, "m=audio ")) { + media = .audio; + try writeSinglePayloadMediaLine(writer, line, audio_payload); + continue; + } + media = .other; + } + + const selected_payload = switch (media) { + .video => video_payload, + .audio => audio_payload, + .other => null, + }; + if (selected_payload) |payload| { + if (codecAttributePayload(line)) |attribute_payload| { + if (attribute_payload != payload) continue; + } + } + try writer.writeAll(line); + try writer.writeAll("\r\n"); + } + return output.toOwnedSlice(); +} + +fn writeSinglePayloadMediaLine(writer: anytype, line: []const u8, payload: u8) !void { + var fields = std.mem.tokenizeScalar(u8, line, ' '); + const kind = fields.next() orelse return error.InvalidMediaLine; + const port = fields.next() orelse return error.InvalidMediaLine; + const protocol = fields.next() orelse return error.InvalidMediaLine; + try writer.print("{s} {s} {s} {d}\r\n", .{ kind, port, protocol, payload }); +} + +fn codecAttributePayload(line: []const u8) ?u8 { + const prefixes = [_][]const u8{ "a=rtpmap:", "a=fmtp:", "a=rtcp-fb:" }; + for (prefixes) |prefix| { + if (!std.mem.startsWith(u8, line, prefix)) continue; + const value = line[prefix.len..]; + if (value.len == 0 or value[0] == '*') return null; + const end = std.mem.indexOfAny(u8, value, " \t") orelse value.len; + return std.fmt.parseUnsigned(u8, value[0..end], 10) catch null; + } + return null; +} + +pub fn mediaHostCandidate( + allocator: std.mem.Allocator, + address: []const u8, + port: u16, +) !?[:0]u8 { + if (port <= 1024 or port == 443) return null; + + var address_buffer: [15]u8 = undefined; + const ip = publicIpv4(address, &address_buffer) orelse return null; + return try std.fmt.allocPrintZ( + allocator, + "candidate:1 1 UDP 2122260223 {s} {d} typ host", + .{ ip, port }, + ); +} + +fn extractIceCredentials(sdp: []const u8) !IceCredentials { + return .{ + .username_fragment = attribute(sdp, "a=ice-ufrag:") orelse + return error.MissingIceUsername, + .password = attribute(sdp, "a=ice-pwd:") orelse + return error.MissingIcePassword, + .fingerprint = attribute(sdp, "a=fingerprint:") orelse + return error.MissingFingerprint, + }; +} + +fn parseInputCapabilities(sdp: []const u8) InputCapabilities { + var result = InputCapabilities{}; + result.threshold_ms = integerAttribute(sdp, "ri.partialReliableThresholdMs") orelse result.threshold_ms; + result.hid_mask = integerAttribute(sdp, "ri.hidDeviceMask") orelse result.hid_mask; + result.gamepad_mask = integerAttribute(sdp, "ri.enablePartiallyReliableTransferGamepad") orelse result.gamepad_mask; + result.partial_hid_mask = integerAttribute(sdp, "ri.enablePartiallyReliableTransferHid") orelse result.partial_hid_mask; + result.threshold_ms = std.math.clamp(result.threshold_ms, 1, 5000); + return result; +} + +fn integerAttribute(sdp: []const u8, name: []const u8) ?u32 { + var lines = std.mem.splitScalar(u8, sdp, '\n'); + while (lines.next()) |raw_line| { + const line = std.mem.trim(u8, raw_line, " \t\r"); + if (!std.mem.startsWith(u8, line, "a=")) continue; + const value_start = 2 + name.len + 1; + if (line.len <= value_start or !std.mem.eql(u8, line[2 .. 2 + name.len], name) or + line[2 + name.len] != ':') continue; + const value = std.mem.trim(u8, line[value_start..], " \t"); + if (std.mem.startsWith(u8, value, "0x") or std.mem.startsWith(u8, value, "0X")) + return std.fmt.parseInt(u32, value[2..], 16) catch null; + return std.fmt.parseInt(u32, value, 10) catch null; + } + return null; +} + +fn attribute(sdp: []const u8, prefix: []const u8) ?[]const u8 { + var lines = std.mem.splitScalar(u8, sdp, '\n'); + while (lines.next()) |raw_line| { + const line = std.mem.trim(u8, raw_line, " \t\r"); + if (std.mem.startsWith(u8, line, prefix) and line.len > prefix.len) + return line[prefix.len..]; + } + return null; +} + +fn publicIpv4(address: []const u8, buffer: *[15]u8) ?[]const u8 { + _ = std.net.Ip4Address.parse(address, 0) catch { + const label_end = std.mem.indexOfScalar(u8, address, '.') orelse address.len; + var parts = std.mem.splitScalar(u8, address[0..label_end], '-'); + var octets: [4]u8 = undefined; + for (&octets) |*octet| { + const part = parts.next() orelse return null; + octet.* = std.fmt.parseInt(u8, part, 10) catch return null; + } + if (parts.next() != null) return null; + return std.fmt.bufPrint(buffer, "{d}.{d}.{d}.{d}", .{ + octets[0], octets[1], octets[2], octets[3], + }) catch return null; + }; + return address; +} + +fn isSharedAttribute(line: []const u8) bool { + return std.mem.startsWith(u8, line, "a=ice-ufrag:") or + std.mem.startsWith(u8, line, "a=ice-pwd:") or + std.mem.startsWith(u8, line, "a=fingerprint:") or + std.mem.startsWith(u8, line, "a=setup:"); +} + +fn sectionHasAttribute(lines: []const []const u8, start: usize, attribute_line: []const u8) bool { + const prefix = attribute_line[0 .. std.mem.indexOfScalar(u8, attribute_line, ':').? + 1]; + var index = start + 1; + while (index < lines.len and !std.mem.startsWith(u8, lines[index], "m=")) : (index += 1) { + if (std.mem.startsWith(u8, lines[index], prefix)) return true; + } + return false; +} + +fn writeFixedAddress(writer: anytype, line: []const u8, media_ip: ?[]const u8) !void { + const ip = media_ip orelse return writer.writeAll(line); + if (std.mem.eql(u8, line, "c=IN IP4 0.0.0.0")) + return writer.print("c=IN IP4 {s}", .{ip}); + if (!std.mem.startsWith(u8, line, "a=candidate:")) return writer.writeAll(line); + + var tokens = std.mem.tokenizeScalar(u8, line, ' '); + var index: usize = 0; + var first = true; + while (tokens.next()) |token| : (index += 1) { + if (!first) try writer.writeByte(' '); + first = false; + try writer.writeAll(if (index == 4 and std.mem.eql(u8, token, "0.0.0.0")) ip else token); + } +} + +test "offer sanitation fixes the media address and copies shared attributes" { + const offer = + "v=0\r\n" ++ + "a=ice-ufrag:remote\r\n" ++ + "a=ice-pwd:secret\r\n" ++ + "a=fingerprint:sha-256 AA:BB\r\n" ++ + "a=setup:actpass\r\n" ++ + "m=video 9 UDP/TLS/RTP/SAVPF 102\r\n" ++ + "c=IN IP4 0.0.0.0\r\n" ++ + "a=candidate:1 1 udp 1 0.0.0.0 5000 typ host\r\n"; + const result = try sanitizeOffer(std.testing.allocator, offer, "192.0.2.10"); + defer std.testing.allocator.free(result); + try std.testing.expect(std.mem.indexOf(u8, result, "c=IN IP4 192.0.2.10") != null); + try std.testing.expect(std.mem.indexOf(u8, result, " 192.0.2.10 5000 ") != null); + try std.testing.expectEqual(@as(usize, 2), std.mem.count(u8, result, "a=ice-ufrag:remote")); +} + +test "media overrides preserve the remaining session attributes" { + const offer = "v=0\r\n" ++ + "a=ice-ufrag:shared\r\na=ice-pwd:password\r\n" ++ + "a=fingerprint:sha-256 AA:BB\r\na=setup:actpass\r\n" ++ + "m=video 9 UDP/TLS/RTP/SAVPF 96\r\na=setup:passive\r\n" ++ + "m=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=ice-ufrag:audio\r\n"; + const result = try sanitizeOffer(std.testing.allocator, offer, null); + defer std.testing.allocator.free(result); + const video_start = std.mem.indexOf(u8, result, "m=video").?; + const audio_start = std.mem.indexOf(u8, result, "m=audio").?; + const video = result[video_start..audio_start]; + const audio = result[audio_start..]; + try std.testing.expect(std.mem.indexOf(u8, video, "a=setup:actpass") == null); + try std.testing.expect(std.mem.indexOf(u8, video, "a=setup:passive") != null); + try std.testing.expect(std.mem.indexOf(u8, video, "a=ice-ufrag:shared") != null); + try std.testing.expect(std.mem.indexOf(u8, video, "a=ice-pwd:password") != null); + try std.testing.expect(std.mem.indexOf(u8, video, "a=fingerprint:sha-256 AA:BB") != null); + try std.testing.expect(std.mem.indexOf(u8, audio, "a=ice-ufrag:shared") == null); + try std.testing.expect(std.mem.indexOf(u8, audio, "a=ice-ufrag:audio") != null); + try std.testing.expect(std.mem.indexOf(u8, audio, "a=ice-pwd:password") != null); + try std.testing.expect(std.mem.indexOf(u8, audio, "a=setup:actpass") != null); +} + +test "NVST answer uses negotiated dimensions and remote input capabilities" { + const answer = + "v=0\r\na=ice-ufrag:local\r\na=ice-pwd:password\r\n" ++ + "a=fingerprint:sha-256 CC:DD\r\n"; + const offer = "a=ri.partialReliableThresholdMs:250\r\n" ++ + "a=ri.enablePartiallyReliableTransferGamepad:0x03\r\n"; + const result = try buildNvstAnswer(std.testing.allocator, answer, offer, 640, 480, 30, 6000); + defer std.testing.allocator.free(result); + try std.testing.expect(std.mem.indexOf(u8, result, "a=video.clientViewportWd:640") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "a=ri.partialReliableThresholdMs:250") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "a=ri.enablePartiallyReliableTransferGamepad:3") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "a=video.initialBitrateKbps:4000\r\n") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "a=video.initialPeakBitrateKbps:4000\r\n") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "a=vqos.bw.minimumBitrateKbps:4000\r\n") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "a=vqos.bw.maximumBitrateKbps:6000\r\n") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "enableBandwidthEstimation") == null); + try std.testing.expectError(error.InvalidStreamProfile, buildNvstAnswer(std.testing.allocator, answer, offer, 640, 480, 30, 2000)); +} + +test "codec payload types are matched without assuming Xbox values" { + const sdp = "a=rtpmap:96 H264/90000\r\na=rtpmap:109 opus/48000/2\r\n"; + try std.testing.expectEqual(@as(?u8, 96), codecPayloadType(sdp, "h264/")); + try std.testing.expectEqual(@as(?u8, 109), codecPayloadType(sdp, "OPUS/")); +} + +test "answer advertises only H264 and Opus payloads" { + const offer = + "m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100\r\n" ++ + "a=rtpmap:96 H264/90000\r\n" ++ + "a=rtpmap:97 rtx/90000\r\n" ++ + "a=fmtp:97 apt=96\r\n" ++ + "a=rtpmap:98 H265/90000\r\n" ++ + "a=rtpmap:99 rtx/90000\r\n" ++ + "a=fmtp:99 apt=98\r\n" ++ + "m=audio 9 UDP/TLS/RTP/SAVPF 111 63 0\r\n" ++ + "a=rtpmap:111 opus/48000/2\r\n"; + const answer = + "v=0\r\n" ++ + "m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100\r\n" ++ + "a=mid:1\r\n" ++ + "a=rtpmap:96 H264/90000\r\n" ++ + "a=fmtp:96 packetization-mode=1\r\n" ++ + "a=rtcp-fb:96 nack pli\r\n" ++ + "a=rtpmap:98 H265/90000\r\n" ++ + "a=fmtp:99 apt=98\r\n" ++ + "a=rtcp-fb:* transport-cc\r\n" ++ + "m=audio 9 UDP/TLS/RTP/SAVPF 111 63 0\r\n" ++ + "a=mid:0\r\n" ++ + "a=rtpmap:111 opus/48000/2\r\n" ++ + "a=rtpmap:63 red/48000/2\r\n" ++ + "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" ++ + "a=mid:2\r\n"; + const result = try narrowAnswerToH264Opus(std.testing.allocator, answer, offer); + defer std.testing.allocator.free(result); + try std.testing.expect(std.mem.indexOf(u8, result, "m=video 9 UDP/TLS/RTP/SAVPF 96\r\n") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "H265/") == null); + try std.testing.expect(std.mem.indexOf(u8, result, "red/48000") == null); + try std.testing.expect(std.mem.indexOf(u8, result, "a=rtcp-fb:* transport-cc") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "m=application 9 UDP/DTLS/SCTP webrtc-datachannel") != null); +} + +test "media endpoint becomes a UDP host candidate" { + const candidate = (try mediaHostCandidate( + std.testing.allocator, + "203.0.113.10", + 49005, + )).?; + defer std.testing.allocator.free(candidate); + try std.testing.expectEqualStrings( + "candidate:1 1 UDP 2122260223 203.0.113.10 49005 typ host", + candidate, + ); +} + +test "ICE media indices resolve to SDP mids rather than numeric identifiers" { + const offer = "v=0\r\na=mid:session\r\n" ++ + "m=video 9 UDP/TLS/RTP/SAVPF 96\r\na=mid:video-main\r\n" ++ + "m=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=mid:42\r\n" ++ + "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\na=mid:input\r\n"; + try std.testing.expectEqualStrings("video-main", try iceCandidateMid(offer, null, 0)); + try std.testing.expectEqualStrings("42", try iceCandidateMid(offer, null, 1)); + try std.testing.expectEqualStrings("input", try iceCandidateMid(offer, "", 2)); + try std.testing.expectEqualStrings("explicit", try iceCandidateMid(offer, "explicit", 100)); + try std.testing.expectEqualStrings("0", try iceCandidateMid(offer, null, null)); + try std.testing.expectError(error.IceMediaSectionNotFound, iceCandidateMid(offer, null, 3)); + try std.testing.expectError(error.IceMediaSectionNotFound, iceCandidateMid(offer, null, std.math.maxInt(u32))); +} + +test "ICE media lookup does not borrow a mid from a different section" { + const offer = "v=0\na=mid:session\n" ++ + "m=video 0 RTP/AVP 96\n" ++ + "m=audio 9 RTP/AVP 111\na=mid:audio\n" ++ + "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\na=mid:\n"; + try std.testing.expectError(error.IceMediaIdMissing, iceCandidateMid(offer, null, 0)); + try std.testing.expectEqualStrings("audio", try iceCandidateMid(offer, null, 1)); + try std.testing.expectError(error.IceMediaIdMissing, iceCandidateMid(offer, null, 2)); + try std.testing.expectError(error.IceMediaSectionNotFound, iceCandidateMid("", null, 0)); +} + +test "media candidate accepts an Alliance host and rejects control ports" { + const candidate = (try mediaHostCandidate( + std.testing.allocator, + "203-0-113-10.cloudmatch.example", + 49005, + )).?; + defer std.testing.allocator.free(candidate); + try std.testing.expectEqualStrings( + "candidate:1 1 UDP 2122260223 203.0.113.10 49005 typ host", + candidate, + ); + try std.testing.expect((try mediaHostCandidate( + std.testing.allocator, + "203.0.113.10", + 443, + )) == null); + try std.testing.expect((try mediaHostCandidate( + std.testing.allocator, + "not-an-address", + 49005, + )) == null); +} diff --git a/src/provider/geforce_now/session_client.zig b/src/provider/geforce_now/session_client.zig new file mode 100644 index 0000000..cb98eed --- /dev/null +++ b/src/provider/geforce_now/session_client.zig @@ -0,0 +1,840 @@ +const std = @import("std"); +const auth_client = @import("auth_client.zig"); +const protocol = @import("cloudmatch_protocol.zig"); +const subscription = @import("subscription_protocol.zig"); +const uuid = @import("uuid"); +const ads = @import("ad_protocol.zig"); + +pub const AdHandler = struct { + context: *anyopaque, + run: *const fn (*anyopaque, *Client, ads.Opportunity, u8) anyerror!void, +}; + +const c = @cImport({ + @cInclude("handheld_ui.h"); + @cInclude("http_client.h"); +}); + +const maximum_create_attempts = 6; +const client_version = "2.0.80.173"; +const user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " ++ + "(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 NVIDIACEFClient/HEAD/debb5919f6 " ++ + "GFN-PC/" ++ client_version; + +const fallback_stream_width: u16 = 1024; +const fallback_stream_height: u16 = 768; +const preferred_frames_per_second: u16 = 30; +const maximum_stream_width: u16 = 1280; +const maximum_stream_height: u16 = 768; +const maximum_bearer_length = 64 * 1024; + +pub const Client = struct { + allocator: std.mem.Allocator, + auth: *auth_client.Client, + ui: *c.GoHandheldUi, + client_id: [uuid.string_length + 1]u8, + session: ?protocol.Session = null, + streaming_url: ?[]u8 = null, + last_http_status: c_long = 0, + last_response_retryable: ?bool = null, + stream_width: u16 = fallback_stream_width, + stream_height: u16 = fallback_stream_height, + stream_frames_per_second: u16 = preferred_frames_per_second, + ad_handler: ?AdHandler = null, + ad_stop: ?*std.atomic.Value(bool) = null, + completed_ads: std.ArrayListUnmanaged([]u8) = .{}, + app_id: ?[]u8 = null, + + pub fn create( + allocator: std.mem.Allocator, + auth: *auth_client.Client, + ui_pointer: *anyopaque, + ) !*Client { + const client = try allocator.create(Client); + client.* = .{ + .allocator = allocator, + .auth = auth, + .ui = @ptrCast(ui_pointer), + .client_id = undefined, + }; + uuid.generate(&client.client_id); + return client; + } + + pub fn destroy(self: *Client) void { + if (self.app_id) |id| self.allocator.free(id); + self.clearAds(); + if (self.session) |*session| session.deinit(); + if (self.streaming_url) |url| self.allocator.free(url); + const allocator = self.allocator; + allocator.destroy(self); + } + + pub fn start( + self: *Client, + app_id: []const u8, + internal_title: []const u8, + width: u16, + height: u16, + ) !void { + if (self.session != null) return error.SessionAlreadyActive; + self.clearAds(); + uuid.generate(&self.client_id); + if (self.app_id) |id| self.allocator.free(id); + self.app_id = null; + self.app_id = try self.allocator.dupe(u8, app_id); + const bearer = self.auth.bearer() orelse return error.MissingCredentials; + const provider_url = self.auth.streamingUrl() orelse return error.MissingProvider; + const user_age = try self.auth.fetchUserAge(); + try self.selectStreamMode(provider_url, bearer, width, height); + if (self.streaming_url) |url| self.allocator.free(url); + self.streaming_url = null; + self.streaming_url = self.resolveStreamingUrl(provider_url, bearer) catch |err| fallback: { + if (err == error.Cancelled) return err; + std.debug.print("GeForce NOW region discovery failed: {s}\n", .{@errorName(err)}); + break :fallback try self.allocator.dupe(u8, provider_url); + }; + const local_url = self.streaming_url.?; + if (!std.mem.eql(u8, local_url, provider_url)) + std.debug.print("GeForce NOW local streaming region selected\n", .{}); + + const body = try protocol.buildSessionRequest( + self.allocator, + app_id, + internal_title, + self.auth.stableDeviceId(), + self.stream_width, + self.stream_height, + self.stream_frames_per_second, + user_age, + ); + defer self.allocator.free(body); + self.session = self.createSessionWithRetry(local_url, body, bearer) catch |err| retry: { + if (err == error.Cancelled) return err; + if (std.mem.eql(u8, local_url, provider_url) or + !retryableRequestFailure( + err, + self.last_http_status, + self.last_response_retryable, + )) + { + self.logActiveSessions(provider_url, bearer, app_id); + return err; + } + std.debug.print("GeForce NOW local region rejected the session; retrying provider endpoint\n", .{}); + const fallback_url = try self.allocator.dupe(u8, provider_url); + self.allocator.free(self.streaming_url.?); + self.streaming_url = fallback_url; + break :retry self.createSession(provider_url, body, bearer) catch |provider_error| { + self.logActiveSessions(provider_url, bearer, app_id); + return provider_error; + }; + }; + } + + pub fn waitUntilReady(self: *Client) !void { + var consecutive_server_errors: usize = 0; + while (true) { + const current = if (self.session) |*session| session else return error.MissingSession; + if (current.ended()) return error.SessionEnded; + if (current.ads) |admission| { + if (admission.required) for (admission.opportunities) |opportunity| { + var handled = false; + for (self.completed_ads.items) |id| if (std.mem.eql(u8, id, opportunity.id)) { + handled = true; + break; + }; + if (handled) continue; + if (self.completed_ads.items.len == 64) return error.TooManyAdOpportunities; + const handler = self.ad_handler orelse return error.AdPlayerUnavailable; + const id = try self.allocator.dupe(u8, opportunity.id); + errdefer self.allocator.free(id); + try handler.run(handler.context, self, opportunity, admission.provider); + try self.completed_ads.append(self.allocator, id); + }; + if (current.ready() and current.signaling_url != null and !admission.required) return; + } else if (current.ready() and current.signaling_url != null) return; + self.drawProgress(current); + if (c.go_handheld_ui_wait(self.ui, pollDelay(consecutive_server_errors)) != 0) + return error.Cancelled; + + const bearer = self.auth.bearer() orelse return error.MissingCredentials; + const base = self.streaming_url orelse return error.MissingProvider; + var url_buffer: [768]u8 = undefined; + const url = try std.fmt.bufPrintZ( + &url_buffer, + "{s}v2/session/{s}", + .{ base, current.id }, + ); + const response = self.request("GET", url, null, bearer) catch |err| { + if (!retryableRequestFailure(err, self.last_http_status, self.last_response_retryable)) + return err; + consecutive_server_errors += 1; + if (consecutive_server_errors > 12) return error.SessionPollFailed; + continue; + }; + defer c.go_http_response_destroy(response); + consecutive_server_errors = 0; + const next = try protocol.parseSession( + self.allocator, + response.*.data[0..response.*.len], + ); + current.deinit(); + self.session = next; + } + } + + pub fn stop(self: *Client) !void { + const session = if (self.session) |*value| value else return; + defer { + session.deinit(); + self.session = null; + } + const bearer = self.auth.bearer() orelse return error.MissingCredentials; + const base = self.streaming_url orelse return error.MissingProvider; + var url_buffer: [768]u8 = undefined; + const url = try std.fmt.bufPrintZ( + &url_buffer, + "{s}v2/session/{s}", + .{ base, session.id }, + ); + const response = try self.request("DELETE", url, null, bearer); + c.go_http_response_destroy(response); + } + + fn clearAds(self: *Client) void { + for (self.completed_ads.items) |id| self.allocator.free(id); + self.completed_ads.deinit(self.allocator); + self.completed_ads = .{}; + } + + pub fn reportAd(self: *Client, id: []const u8, update: ads.Update) !void { + const session = self.session orelse return error.MissingSession; + const admission = session.ads orelse return error.MissingAdMetadata; + const base = admission.control_base orelse self.streaming_url orelse return error.MissingProvider; + const bearer = self.auth.bearer() orelse return error.MissingCredentials; + const body = try ads.buildUpdate(self.allocator, id, update, std.time.timestamp()); + defer self.allocator.free(body); + var buffer: [1024]u8 = undefined; + const url = try std.fmt.bufPrintZ(&buffer, "{s}v2/session/{s}", .{ base, session.id }); + const response = try self.request("PUT", url, body, bearer); + defer c.go_http_response_destroy(response); + const parsed = try std.json.parseFromSlice(std.json.Value, self.allocator, response.*.data[0..response.*.len], .{}); + defer parsed.deinit(); + if (parsed.value != .object) return error.AdUpdateRejected; + const status = parsed.value.object.get("requestStatus") orelse return error.AdUpdateRejected; + if (status != .object) return error.AdUpdateRejected; + const code = status.object.get("statusCode") orelse return error.AdUpdateRejected; + if (code != .integer or code.integer != 1) return error.AdUpdateRejected; + } + + pub fn sessionInfo(self: *Client) ?*const protocol.Session { + return if (self.session) |*session| session else null; + } + + pub fn clientId(self: *const Client) []const u8 { + return std.mem.sliceTo(&self.client_id, 0); + } + + pub fn streamWidth(self: *const Client) u16 { + return self.stream_width; + } + + pub fn streamHeight(self: *const Client) u16 { + return self.stream_height; + } + + pub fn streamFramesPerSecond(self: *const Client) u16 { + return self.stream_frames_per_second; + } + + fn request( + self: *Client, + method: [*:0]const u8, + url: [:0]const u8, + body: ?[]const u8, + bearer: []const u8, + ) ![*c]c.GoHttpResponse { + const terminated_body = if (body) |value| try self.allocator.dupeZ(u8, value) else null; + defer if (terminated_body) |value| self.allocator.free(value); + const authorization = try authorizationHeader(self.allocator, bearer); + defer secureFree(self.allocator, authorization); + var dynamic_headers: [2][256]u8 = undefined; + var headers = [_][*c]const u8{ + authorization.ptr, + try header(&dynamic_headers[0], "nv-client-id", null, self.auth.protocolClientId()), + try header(&dynamic_headers[1], "x-device-id", null, self.auth.stableDeviceId()), + "Accept: application/json", + "Content-Type: application/json", + "Connection: close", + "Origin: https://play.geforcenow.com", + "Referer: https://play.geforcenow.com/", + "nv-browser-type: CHROME", + "nv-client-streamer: NVIDIA-CLASSIC", + "nv-client-type: NATIVE", + "nv-client-version: 30.0", + "nv-device-make: UNKNOWN", + "nv-device-model: UNKNOWN", + "nv-device-os: WINDOWS", + "nv-device-type: DESKTOP", + "User-Agent: " ++ user_agent, + }; + self.last_http_status = 0; + self.last_response_retryable = null; + // Session deletion must finish after a local stop request. + const cancelable = !std.mem.eql(u8, std.mem.span(method), "DELETE"); + const response = c.go_http_request_bounded_cancelable( + method, + url.ptr, + if (terminated_body) |value| value.ptr else null, + @ptrCast(&headers), + headers.len, + 8 * 1024 * 1024, + if (cancelable) requestCancelled else null, + self, + ); + if (response == null) { + if (cancelable and c.go_handheld_ui_cancelled(self.ui) != 0) return error.Cancelled; + return error.HttpRequestFailed; + } + self.last_http_status = response.*.status; + if (response.*.status < 200 or response.*.status >= 300 or + (cancelable and response.*.data == null)) + { + const status = response.*.status; + std.debug.print("GeForce NOW session request failed: HTTP {d}\n", .{status}); + if (response.*.data != null) { + self.last_response_retryable = protocol.sessionFailureIsRetryable( + self.allocator, + response.*.data[0..response.*.len], + ); + var summary_buffer: [384]u8 = undefined; + if (protocol.writeErrorSummary( + self.allocator, + response.*.data[0..response.*.len], + &summary_buffer, + )) |summary| std.debug.print("GeForce NOW session error: {s}\n", .{summary}); + } + c.go_http_response_destroy(response); + return error.HttpRequestRejected; + } + return response; + } + + fn createSession( + self: *Client, + base: []const u8, + body: []const u8, + bearer: []const u8, + ) !protocol.Session { + var url_buffer: [768]u8 = undefined; + const url = try buildCreateSessionUrl(&url_buffer, base); + const response = try self.request("POST", url, body, bearer); + defer c.go_http_response_destroy(response); + return protocol.parseSession( + self.allocator, + response.*.data[0..response.*.len], + ); + } + + fn createSessionWithRetry( + self: *Client, + base: []const u8, + body: []const u8, + bearer: []const u8, + ) !protocol.Session { + var attempt: usize = 0; + while (attempt < maximum_create_attempts) : (attempt += 1) { + if (self.createSession(base, body, bearer)) |session| { + return session; + } else |err| { + if (err == error.Cancelled or + attempt + 1 >= maximum_create_attempts or + !retryableRequestFailure( + err, + self.last_http_status, + self.last_response_retryable, + )) + return err; + + const delay = createRetryDelay(attempt); + std.debug.print( + "GeForce NOW session creation retry {d}/{d} in {d} seconds\n", + .{ attempt + 2, maximum_create_attempts, delay / 1000 }, + ); + c.go_handheld_ui_draw_loading( + self.ui, + "NVIDIA SERVER BUSY", + "RETRYING SESSION REQUEST", + c.GO_HANDHELD_UI_ACTION_CANCEL, + ); + if (c.go_handheld_ui_wait(self.ui, delay) != 0) + return error.Cancelled; + } + } + unreachable; + } + + fn logActiveSessions( + self: *Client, + base: []const u8, + bearer: []const u8, + app_id: []const u8, + ) void { + var url_buffer: [768]u8 = undefined; + const url = std.fmt.bufPrintZ(&url_buffer, "{s}v2/session", .{base}) catch return; + const response = self.request("GET", url, null, bearer) catch return; + defer c.go_http_response_destroy(response); + const summary = protocol.parseActiveSessionSummary( + self.allocator, + response.*.data[0..response.*.len], + self.auth.stableDeviceId(), + app_id, + ) catch return; + std.debug.print( + "GeForce NOW active sessions: {d} total, {d} from this device, {d} for this game\n", + .{ summary.active, summary.same_device, summary.matching_app }, + ); + } + + fn selectStreamMode( + self: *Client, + provider_url: []const u8, + bearer: []const u8, + display_width: u16, + display_height: u16, + ) !void { + self.stream_width = fallback_stream_width; + self.stream_height = fallback_stream_height; + self.stream_frames_per_second = preferred_frames_per_second; + const summary = self.fetchSubscriptionStatus(provider_url, bearer) catch |err| { + if (err == error.Cancelled) return err; + std.debug.print("GeForce NOW membership check unavailable: {s}\n", .{@errorName(err)}); + return; + }; + std.debug.print( + "GeForce NOW membership: {s}, state: {s}, gameplay allowed: {s}, entitled resolutions: {d}\n", + .{ + summary.tier(), + summary.state(), + if (summary.gameplay_allowed) |allowed| + if (allowed) "yes" else "no" + else + "unknown", + summary.entitled_resolutions, + }, + ); + if (summary.bestForDisplayWithin( + display_width, + display_height, + preferred_frames_per_second, + maximum_stream_width, + maximum_stream_height, + )) |selected| { + self.stream_width = selected.width; + self.stream_height = selected.height; + self.stream_frames_per_second = selected.frames_per_second; + std.debug.print( + "GeForce NOW stream mode: {d}x{d}@{d} for {d}x{d} display\n", + .{ + selected.width, + selected.height, + selected.frames_per_second, + display_width, + display_height, + }, + ); + } + } + + fn fetchSubscriptionStatus( + self: *Client, + provider_url: []const u8, + bearer: []const u8, + ) !subscription.Summary { + const user_id = try self.auth.userId(self.allocator); + defer self.allocator.free(user_id); + if (!validQueryValue(user_id)) return error.InvalidUserId; + + var server_info_url_buffer: [768]u8 = undefined; + const server_info_url = try std.fmt.bufPrintZ( + &server_info_url_buffer, + "{s}v2/serverInfo", + .{provider_url}, + ); + const server_info_response = try self.request("GET", server_info_url, null, bearer); + defer c.go_http_response_destroy(server_info_response); + const vpc_id = try protocol.parseVpcId( + self.allocator, + server_info_response.*.data[0..server_info_response.*.len], + ); + defer self.allocator.free(vpc_id); + if (!validQueryValue(vpc_id)) return error.InvalidVpcId; + + var url_buffer: [1024]u8 = undefined; + const url = try std.fmt.bufPrintZ( + &url_buffer, + "https://mes.geforcenow.com/v4/subscriptions?serviceName=gfn_pc&languageCode=en_US&vpcId={s}&userId={s}", + .{ vpc_id, user_id }, + ); + const authorization = try authorizationHeader(self.allocator, bearer); + defer secureFree(self.allocator, authorization); + var client_id_buffer: [256]u8 = undefined; + var headers = [_][*c]const u8{ + authorization.ptr, + try header(&client_id_buffer, "nv-client-id", null, self.auth.protocolClientId()), + "Accept: application/json", + "nv-client-streamer: NVIDIA-CLASSIC", + "nv-client-type: NATIVE", + "nv-client-version: " ++ client_version, + "nv-device-os: LINUX", + "nv-device-type: DESKTOP", + "User-Agent: " ++ user_agent, + }; + const response = c.go_http_request_bounded_cancelable( + "GET", + url.ptr, + null, + @ptrCast(&headers), + headers.len, + 1024 * 1024, + requestCancelled, + self, + ); + defer c.go_http_response_destroy(response); + if (response == null and c.go_handheld_ui_cancelled(self.ui) != 0) + return error.Cancelled; + if (response == null or response.*.status < 200 or response.*.status >= 300 or + response.*.data == null) + { + const status = if (response) |value| value.*.status else 0; + std.debug.print("GeForce NOW membership check failed: HTTP {d}\n", .{status}); + return error.SubscriptionRequestFailed; + } + return subscription.parse( + self.allocator, + response.*.data[0..response.*.len], + ); + } + + fn resolveStreamingUrl( + self: *Client, + provider_url: []const u8, + bearer: []const u8, + ) ![]u8 { + var url_buffer: [768]u8 = undefined; + const url = try std.fmt.bufPrintZ(&url_buffer, "{s}v2/serverInfo", .{provider_url}); + const authorization = try authorizationHeader(self.allocator, bearer); + defer secureFree(self.allocator, authorization); + var dynamic_headers: [2][256]u8 = undefined; + var headers = [_][*c]const u8{ + authorization.ptr, + try header(&dynamic_headers[0], "nv-client-id", null, self.auth.protocolClientId()), + try header(&dynamic_headers[1], "x-device-id", null, self.auth.stableDeviceId()), + "Accept: application/json", + "nv-client-streamer: NVIDIA-CLASSIC", + "nv-client-type: NATIVE", + "nv-client-version: " ++ client_version, + "nv-device-os: LINUX", + "nv-device-type: DESKTOP", + "User-Agent: " ++ user_agent, + }; + const response = c.go_http_request_bounded_cancelable( + "GET", + url.ptr, + null, + @ptrCast(&headers), + headers.len, + 1024 * 1024, + requestCancelled, + self, + ); + defer c.go_http_response_destroy(response); + if (response == null and c.go_handheld_ui_cancelled(self.ui) != 0) + return error.Cancelled; + if (response == null or response.*.status < 200 or response.*.status >= 300 or + response.*.data == null) + return error.ServerInfoRequestFailed; + return (try protocol.parseLocalRegionUrl( + self.allocator, + response.*.data[0..response.*.len], + )) orelse self.allocator.dupe(u8, provider_url); + } + + fn drawProgress(self: *Client, session: *const protocol.Session) void { + var detail_buffer: [96]u8 = undefined; + const detail = if (session.queue_position) |position| + std.fmt.bufPrintZ(&detail_buffer, "QUEUE POSITION {d}", .{position}) catch return + else switch (session.setup_step orelse 0) { + 1 => std.fmt.bufPrintZ(&detail_buffer, "WAITING FOR A STREAMING RIG", .{}) catch return, + 5 => std.fmt.bufPrintZ(&detail_buffer, "CLOSING THE PREVIOUS SESSION", .{}) catch return, + 6 => std.fmt.bufPrintZ(&detail_buffer, "PREPARING GAME STORAGE", .{}) catch return, + else => std.fmt.bufPrintZ(&detail_buffer, "PREPARING THE GAME", .{}) catch return, + }; + c.go_handheld_ui_draw_loading( + self.ui, + "STARTING GEFORCE NOW", + detail.ptr, + c.GO_HANDHELD_UI_ACTION_CANCEL, + ); + } +}; + +fn requestCancelled(context: ?*anyopaque) callconv(.c) c_int { + const client: *Client = @ptrCast(@alignCast(context orelse return 1)); + if (client.ad_stop) |stop| return @intFromBool(stop.load(.acquire)); + return c.go_handheld_ui_cancel_requested(client.ui); +} + +fn retryableRequestFailure(err: anyerror, status: c_long, service_retryable: ?bool) bool { + if (err == error.HttpRequestFailed) return true; + if (err != error.HttpRequestRejected) return false; + if (service_retryable) |retryable| return retryable; + return switch (status) { + 408, 425, 429, 500, 502, 503, 504 => true, + else => false, + }; +} + +fn createRetryDelay(attempt: usize) c_uint { + return switch (attempt) { + 0 => 4000, + 1 => 8000, + else => 16000, + }; +} + +fn pollDelay(consecutive_errors: usize) c_uint { + if (consecutive_errors == 0) return 2000; + const shift: u4 = @intCast(@min(consecutive_errors - 1, 3)); + return @min(@as(c_uint, 2000) << shift, 15000); +} + +fn buildCreateSessionUrl(output: []u8, streaming_url: []const u8) ![:0]u8 { + return std.fmt.bufPrintZ( + output, + "{s}v2/session?keyboardLayout=en-US_qwerty&languageCode=en_US", + .{streaming_url}, + ); +} + +fn header( + output: []u8, + name: []const u8, + scheme: ?[]const u8, + value: []const u8, +) ![*c]const u8 { + const result = if (scheme) |prefix| + try std.fmt.bufPrintZ(output, "{s}: {s} {s}", .{ name, prefix, value }) + else + try std.fmt.bufPrintZ(output, "{s}: {s}", .{ name, value }); + return result.ptr; +} + +fn authorizationHeader(allocator: std.mem.Allocator, bearer: []const u8) ![:0]u8 { + if (bearer.len == 0 or bearer.len > maximum_bearer_length) + return error.InvalidBearerToken; + return std.fmt.allocPrintZ(allocator, "Authorization: GFNJWT {s}", .{bearer}); +} + +fn secureFree(allocator: std.mem.Allocator, value: [:0]u8) void { + std.crypto.secureZero(u8, value); + allocator.free(value); +} + +fn validQueryValue(value: []const u8) bool { + if (value.len == 0 or value.len > 128) return false; + for (value) |byte| { + if (!std.ascii.isAlphanumeric(byte) and byte != '-' and byte != '_' and byte != '.') + return false; + } + return true; +} + +test "session polling backs off after server failures" { + try std.testing.expectEqual(@as(c_uint, 2000), pollDelay(0)); + try std.testing.expectEqual(@as(c_uint, 2000), pollDelay(1)); + try std.testing.expectEqual(@as(c_uint, 4000), pollDelay(2)); + try std.testing.expectEqual(@as(c_uint, 15000), pollDelay(9)); +} + +test "session requests retry only temporary transport and server failures" { + try std.testing.expect(retryableRequestFailure(error.HttpRequestFailed, 0, null)); + try std.testing.expect(retryableRequestFailure(error.HttpRequestRejected, 429, null)); + try std.testing.expect(retryableRequestFailure(error.HttpRequestRejected, 500, true)); + try std.testing.expect(!retryableRequestFailure(error.HttpRequestRejected, 500, false)); + try std.testing.expect(!retryableRequestFailure(error.HttpRequestRejected, 401, null)); + try std.testing.expect(!retryableRequestFailure(error.RequestRejected, 200, null)); + try std.testing.expect(!retryableRequestFailure(error.Cancelled, 0, null)); +} + +test "session creation retry delay is bounded" { + try std.testing.expectEqual(@as(c_uint, 4000), createRetryDelay(0)); + try std.testing.expectEqual(@as(c_uint, 8000), createRetryDelay(1)); + try std.testing.expectEqual(@as(c_uint, 16000), createRetryDelay(2)); + try std.testing.expectEqual(@as(c_uint, 16000), createRetryDelay(9)); +} + +test "session creation uses the resolved streaming endpoint" { + var output: [256]u8 = undefined; + const url = try buildCreateSessionUrl( + &output, + "https://np-ams-01.cloudmatchbeta.nvidiagrid.net/", + ); + try std.testing.expectEqualStrings( + "https://np-ams-01.cloudmatchbeta.nvidiagrid.net/v2/session?keyboardLayout=en-US_qwerty&languageCode=en_US", + url, + ); +} + +test "accepts only query-safe service identifiers" { + try std.testing.expect(validQueryValue("NP-AMS-08")); + try std.testing.expect(validQueryValue("12345678-1234-4abc-8def-123456789abc")); + try std.testing.expect(!validQueryValue("value&other=1")); +} + +test "authorization header accepts realistic token lengths" { + const bearer = try std.testing.allocator.alloc(u8, 16 * 1024); + defer std.testing.allocator.free(bearer); + @memset(bearer, 'a'); + const value = try authorizationHeader(std.testing.allocator, bearer); + defer secureFree(std.testing.allocator, value); + try std.testing.expect(std.mem.endsWith(u8, value, bearer)); +} + +test "queue polling preserves cancellation without retrying the request" { + const http = @import("gfn_http_fake"); + http.reset(); + var auth = http.authClient(auth_client.Client); + var client = Client{ + .allocator = std.testing.allocator, + .auth = &auth, + .ui = @ptrCast(auth.ui), + .client_id = [_]u8{0} ** 37, + .streaming_url = @constCast("https://example.invalid/"), + .session = try protocol.parseSession(std.testing.allocator, + \\{"requestStatus":{"statusCode":1},"session":{"sessionId":"test-session","status":1}} + ), + }; + defer client.session.?.deinit(); + try std.testing.expectError(error.Cancelled, client.waitUntilReady()); + try std.testing.expectEqual(@as(usize, 1), http.requests); + try std.testing.expectEqual(@as(usize, 1), http.polls); + + http.reset(); + try std.testing.expectError(error.Cancelled, client.selectStreamMode( + "https://example.invalid/", + auth.bearer().?, + 640, + 480, + )); + try std.testing.expectEqual(@as(usize, 1), http.requests); + try std.testing.expectEqual(@as(usize, 1), http.polls); +} + +test "ended sessions stop polling and deletion accepts an empty success response" { + const http = @import("gfn_http_fake"); + http.reset(); + var auth = http.authClient(auth_client.Client); + var client = Client{ + .allocator = std.testing.allocator, + .auth = &auth, + .ui = @ptrCast(auth.ui), + .client_id = [_]u8{0} ** 37, + .streaming_url = @constCast("https://example.invalid/"), + .session = try protocol.parseSession(std.testing.allocator, + \\{"requestStatus":{"statusCode":1},"session":{"sessionId":"test-session","status":4}} + ), + }; + defer if (client.session) |*session| session.deinit(); + try std.testing.expectError(error.SessionEnded, client.waitUntilReady()); + try std.testing.expectEqual(@as(usize, 0), http.requests); + + http.cancelled = true; + http.replyNoContent(); + try client.stop(); + try std.testing.expect(client.session == null); + try std.testing.expectEqual(@as(usize, 1), http.requests); + try std.testing.expectEqual(@as(usize, 0), http.polls); +} + +test "membership and region discovery requests preserve cancellation" { + const http = @import("gfn_http_fake"); + http.reset(); + var auth = http.authClient(auth_client.Client); + var client = Client{ + .allocator = std.testing.allocator, + .auth = &auth, + .ui = @ptrCast(auth.ui), + .client_id = [_]u8{0} ** 37, + }; + http.cancel_on_poll = 2; + http.replyJson( + \\{"requestStatus":{"statusCode":1,"serverId":"TEST"}} + ); + try std.testing.expectError(error.Cancelled, client.selectStreamMode( + "https://example.invalid/", + auth.bearer().?, + 640, + 480, + )); + try std.testing.expectEqual(@as(usize, 2), http.requests); + try std.testing.expectEqual(@as(usize, 2), http.polls); + + http.reset(); + try std.testing.expectError(error.Cancelled, client.resolveStreamingUrl( + "https://example.invalid/", + auth.bearer().?, + )); + try std.testing.expectEqual(@as(usize, 1), http.requests); + try std.testing.expectEqual(@as(usize, 1), http.polls); +} + +test "ready sessions still require ad admission and repeated pod IDs play only once" { + const http = @import("gfn_http_fake"); + http.reset(); + http.cancel_on_poll = std.math.maxInt(usize); + const Handler = struct { + calls: usize = 0, + fn run(pointer: *anyopaque, client: *Client, opportunity: ads.Opportunity, provider: u8) !void { + const self: *@This() = @ptrCast(@alignCast(pointer)); + self.calls += 1; + try std.testing.expectEqualStrings("pod", opportunity.id); + try std.testing.expectEqual(@as(u8, 1), provider); + http.replyJson("{\"requestStatus\":{\"statusCode\":1}}"); + try client.reportAd(opportunity.id, .{ .action = .cancel, .reason = "VAST_EMPTY_RESPONSE" }); + http.replyJson( + \\{"requestStatus":{"statusCode":1},"session":{"sessionId":"test-session","status":2,"sessionAdsRequired":false,"sessionControlInfo":{"ip":"np.example.nvidiagrid.net","port":443}}} + ); + } + }; + var handler = Handler{}; + var auth = http.authClient(auth_client.Client); + var client = Client{ .allocator = std.testing.allocator, .auth = &auth, .ui = @ptrCast(auth.ui), .client_id = [_]u8{0} ** 37, .streaming_url = @constCast("https://np.example.nvidiagrid.net/"), .ad_handler = .{ .context = &handler, .run = Handler.run }, .session = try protocol.parseSession(std.testing.allocator, + \\{"requestStatus":{"statusCode":1},"session":{"sessionId":"test-session","status":2,"sessionAdsRequired":true,"adProvider":1,"sessionControlInfo":{"ip":"np.example.nvidiagrid.net","port":443},"sessionAds":[{"adId":"pod","adUrl":"https://example.invalid/tag"},{"adId":"pod","adUrl":"https://example.invalid/tag"}]}} + ) }; + defer client.session.?.deinit(); + defer client.clearAds(); + try client.waitUntilReady(); + try std.testing.expectEqual(@as(usize, 1), handler.calls); + try std.testing.expectEqual(@as(usize, 2), http.requests); + try std.testing.expect(!client.session.?.ads.?.required); +} + +test "ad reports require a positive CloudMatch acknowledgement" { + const http = @import("gfn_http_fake"); + http.reset(); + http.cancel_on_poll = std.math.maxInt(usize); + var auth = http.authClient(auth_client.Client); + var client = Client{ .allocator = std.testing.allocator, .auth = &auth, .ui = @ptrCast(auth.ui), .client_id = [_]u8{0} ** 37, .streaming_url = @constCast("https://np.example.nvidiagrid.net/"), .session = try protocol.parseSession(std.testing.allocator, + \\{"requestStatus":{"statusCode":1},"session":{"sessionId":"test-session","status":1}} + ) }; + defer client.session.?.deinit(); + const update = ads.Update{ .action = .finish, .watched_ms = 5000, .duration_ms = 5000 }; + for ([_][]const u8{ "{}", "{\"requestStatus\":{\"statusCode\":2}}", "{\"requestStatus\":{\"statusCode\":null}}" }) |body| { + http.replyJson(body); + try std.testing.expectError(error.AdUpdateRejected, client.reportAd("pod", update)); + } + http.replyJson("{\"requestStatus\":{\"statusCode\":1}}"); + try client.reportAd("pod", update); +} diff --git a/src/provider/geforce_now/signaling_client.zig b/src/provider/geforce_now/signaling_client.zig new file mode 100644 index 0000000..e91abf2 --- /dev/null +++ b/src/provider/geforce_now/signaling_client.zig @@ -0,0 +1,218 @@ +const std = @import("std"); +const protocol = @import("signaling_protocol.zig"); + +const c = @cImport({ + @cInclude("websocket_client.h"); +}); + +const origin = "https://play.geforcenow.com"; +const user_agent = "Mozilla/5.0 (X11; Linux aarch64) AppleWebKit/537.36 Chrome/128.0.0.0 Safari/537.36"; +const maximum_message_size = 128 * 1024; +const maximum_send_attempts = 500; + +pub const Client = struct { + allocator: std.mem.Allocator, + socket: *c.GoWebSocket, + peer_name: []u8, + receive_buffer: std.ArrayList(u8), + local_peer_id: u32 = 0, + remote_peer_id: u32 = 1, + acknowledgement_counter: u32 = 0, + last_heartbeat_ms: i64, + receiving_fragmented_text: bool = false, + + pub fn connect( + allocator: std.mem.Allocator, + signaling_url: []const u8, + session_id: []const u8, + peer_name: []const u8, + width: u16, + height: u16, + ) !*Client { + const url = try protocol.buildSignInUrl(allocator, signaling_url, session_id, peer_name); + defer allocator.free(url); + const subprotocol = try protocol.buildSubprotocol(allocator, session_id); + defer allocator.free(subprotocol); + const url_z = try allocator.dupeZ(u8, url); + defer allocator.free(url_z); + const subprotocol_z = try allocator.dupeZ(u8, subprotocol); + defer allocator.free(subprotocol_z); + + var error_buffer: [256]u8 = [_]u8{0} ** 256; + const socket = c.go_websocket_open( + url_z.ptr, + origin, + subprotocol_z.ptr, + user_agent, + &error_buffer, + error_buffer.len, + ) orelse { + const message = std.mem.sliceTo(&error_buffer, 0); + if (message.len > 0) std.debug.print("GeForce NOW signaling: {s}\n", .{message}); + return error.SignalingConnectionFailed; + }; + errdefer c.go_websocket_close(socket); + + const client = try allocator.create(Client); + errdefer allocator.destroy(client); + client.* = .{ + .allocator = allocator, + .socket = socket, + .peer_name = try allocator.dupe(u8, peer_name), + .receive_buffer = std.ArrayList(u8).init(allocator), + .last_heartbeat_ms = std.time.milliTimestamp(), + }; + errdefer allocator.free(client.peer_name); + try client.sendPeerInfo(width, height); + return client; + } + + pub fn destroy(self: *Client) void { + c.go_websocket_close(self.socket); + self.receive_buffer.deinit(); + self.allocator.free(self.peer_name); + const allocator = self.allocator; + allocator.destroy(self); + } + + pub fn poll(self: *Client) !?protocol.DecodedMessage { + if (std.time.milliTimestamp() - self.last_heartbeat_ms >= 5000) { + const heartbeat = try protocol.encodeHeartbeat(self.allocator); + defer self.allocator.free(heartbeat); + try self.sendText(heartbeat); + self.last_heartbeat_ms = std.time.milliTimestamp(); + } + + var chunk: [4096]u8 = undefined; + var frame: c.GoWebSocketFrame = undefined; + while (true) { + const result = c.go_websocket_receive(self.socket, &chunk, chunk.len, &frame); + if (result == 0) return null; + if (result == -2) return error.SignalingClosed; + if (result < 0) { + std.debug.print("GeForce NOW signaling: {s}\n", .{ + std.mem.span(c.go_websocket_last_error(self.socket)), + }); + return error.SignalingReceiveFailed; + } + if (frame.flags & c.GO_WEBSOCKET_TEXT != 0 and + frame.offset == 0 and !self.receiving_fragmented_text) + self.receive_buffer.clearRetainingCapacity(); + if (frame.flags & c.GO_WEBSOCKET_TEXT == 0) { + continue; + } + if (frame.length > maximum_message_size - self.receive_buffer.items.len) + return error.SignalingMessageTooLarge; + try self.receive_buffer.appendSlice(chunk[0..frame.length]); + if (frame.bytes_left != 0) continue; + if (frame.flags & c.GO_WEBSOCKET_CONTINUATION != 0) { + self.receiving_fragmented_text = true; + continue; + } + self.receiving_fragmented_text = false; + + var message = try protocol.decode(self.allocator, self.receive_buffer.items); + errdefer message.deinit(); + if (message.peer_info) |peer| { + if (peer.name) |name| { + if (std.mem.eql(u8, name, self.peer_name)) self.local_peer_id = peer.id; + } + } + if (message.peer_from) |from| self.remote_peer_id = from; + if (message.acknowledgement_id) |id| { + const own_echo = if (message.peer_info) |peer| + peer.id == self.local_peer_id + else + false; + if (!own_echo) { + const acknowledgement = try protocol.encodeAcknowledgement(self.allocator, id); + defer self.allocator.free(acknowledgement); + try self.sendText(acknowledgement); + } + } + if (message.heartbeat) { + const heartbeat = try protocol.encodeHeartbeat(self.allocator); + defer self.allocator.free(heartbeat); + try self.sendText(heartbeat); + self.last_heartbeat_ms = std.time.milliTimestamp(); + } + return message; + } + } + + pub fn sendAnswer(self: *Client, sdp: []const u8, nvst_sdp: []const u8) !void { + const message = try protocol.encodeAnswer( + self.allocator, + sdp, + nvst_sdp, + self.local_peer_id, + self.remote_peer_id, + self.nextAcknowledgement(), + ); + defer self.allocator.free(message); + try self.sendText(message); + } + + pub fn sendIceCandidate( + self: *Client, + candidate: []const u8, + sdp_mid: ?[]const u8, + ) !void { + if (protocol.isTcpIceCandidate(candidate)) return; + const message = try protocol.encodeIceCandidate( + self.allocator, + candidate, + sdp_mid, + null, + null, + self.local_peer_id, + self.remote_peer_id, + self.nextAcknowledgement(), + ); + defer self.allocator.free(message); + try self.sendText(message); + } + + fn sendPeerInfo(self: *Client, width: u16, height: u16) !void { + const message = try protocol.encodePeerInfo( + self.allocator, + self.peer_name, + self.local_peer_id, + self.nextAcknowledgement(), + width, + height, + ); + defer self.allocator.free(message); + try self.sendText(message); + } + + fn sendText(self: *Client, message: []const u8) !void { + var offset: usize = 0; + var attempts: usize = 0; + while (offset < message.len and attempts < maximum_send_attempts) : (attempts += 1) { + var sent: usize = 0; + const result = c.go_websocket_send( + self.socket, + message.ptr + offset, + message.len - offset, + c.GO_WEBSOCKET_TEXT, + &sent, + ); + offset += sent; + if (result < 0) { + std.debug.print("GeForce NOW signaling: {s}\n", .{ + std.mem.span(c.go_websocket_last_error(self.socket)), + }); + return error.SignalingSendFailed; + } + if (offset < message.len) std.Thread.sleep(std.time.ns_per_ms); + } + if (offset != message.len) return error.SignalingSendTimeout; + } + + fn nextAcknowledgement(self: *Client) u32 { + self.acknowledgement_counter +%= 1; + if (self.acknowledgement_counter == 0) self.acknowledgement_counter = 1; + return self.acknowledgement_counter; + } +}; diff --git a/src/provider/geforce_now/signaling_protocol.zig b/src/provider/geforce_now/signaling_protocol.zig new file mode 100644 index 0000000..f95314e --- /dev/null +++ b/src/provider/geforce_now/signaling_protocol.zig @@ -0,0 +1,459 @@ +const std = @import("std"); + +pub const PeerInfo = struct { + id: u32, + name: ?[]u8, +}; + +pub const IceCandidate = struct { + candidate: []u8, + sdp_mid: ?[]u8, + sdp_m_line_index: ?u32, + username_fragment: ?[]u8, +}; + +pub const Payload = union(enum) { + none, + bye, + offer: []u8, + ice: IceCandidate, + unknown: ?[]u8, +}; + +pub const DecodedMessage = struct { + allocator: std.mem.Allocator, + peer_info: ?PeerInfo = null, + acknowledgement_id: ?u32 = null, + heartbeat: bool = false, + peer_removed: bool = false, + peer_from: ?u32 = null, + payload: Payload = .none, + + pub fn deinit(self: *DecodedMessage) void { + if (self.peer_info) |peer| { + if (peer.name) |name| self.allocator.free(name); + } + switch (self.payload) { + .offer => |sdp| self.allocator.free(sdp), + .ice => |candidate| { + self.allocator.free(candidate.candidate); + if (candidate.sdp_mid) |mid| self.allocator.free(mid); + if (candidate.username_fragment) |fragment| self.allocator.free(fragment); + }, + .unknown => |message_type| { + if (message_type) |value| self.allocator.free(value); + }, + .none, .bye => {}, + } + self.* = undefined; + } +}; + +pub fn buildSignInUrl( + allocator: std.mem.Allocator, + signaling_url: []const u8, + session_id: []const u8, + peer_name: []const u8, +) ![]u8 { + const trimmed = std.mem.trim(u8, signaling_url, " \t\r\n"); + if (trimmed.len == 0 or session_id.len == 0 or peer_name.len == 0) return error.InvalidUrl; + + const query_index = std.mem.indexOfAny(u8, trimmed, "?#") orelse trimmed.len; + const without_query = trimmed[0..query_index]; + var base = without_query; + const scheme: []const u8 = "wss://"; + if (std.mem.startsWith(u8, base, "https://")) { + base = base["https://".len..]; + } else if (std.mem.startsWith(u8, base, "wss://")) { + base = base["wss://".len..]; + } else if (std.mem.startsWith(u8, base, "http://") or + std.mem.startsWith(u8, base, "ws://")) + { + return error.InsecureUrl; + } + + base = std.mem.trimRight(u8, base, "/"); + if (std.mem.endsWith(u8, base, "/sign_in")) + base = std.mem.trimRight(u8, base[0 .. base.len - "/sign_in".len], "/"); + if (base.len == 0) return error.InvalidUrl; + + var result = std.ArrayList(u8).init(allocator); + errdefer result.deinit(); + const writer = result.writer(); + try writer.print("{s}{s}/sign_in?peer_id=", .{ scheme, base }); + try writeQueryValue(writer, peer_name); + try writer.writeAll("&version=2&peer_role=1&pairing_id="); + try writeQueryValue(writer, session_id); + + const url = try result.toOwnedSlice(); + errdefer allocator.free(url); + const parsed = std.Uri.parse(url) catch return error.InvalidUrl; + if (parsed.host == null or parsed.user != null or parsed.password != null) + return error.InvalidUrl; + return url; +} + +pub fn buildSubprotocol(allocator: std.mem.Allocator, session_id: []const u8) ![]u8 { + if (session_id.len == 0) return error.InvalidSessionId; + for (session_id) |byte| { + if (!std.ascii.isAlphanumeric(byte) and byte != '-' and byte != '_' and byte != '.') + return error.InvalidSessionId; + } + return std.fmt.allocPrint(allocator, "x-nv-sessionid.{s}", .{session_id}); +} + +pub fn decode(allocator: std.mem.Allocator, text: []const u8) !DecodedMessage { + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, text, .{}); + defer parsed.deinit(); + const root = try objectValue(parsed.value); + + var message = DecodedMessage{ .allocator = allocator }; + errdefer message.deinit(); + + if (root.get("peer_info")) |raw_peer_info| { + const peer_object = try objectValue(raw_peer_info); + message.peer_info = .{ + .id = try requiredUnsigned(peer_object, "id"), + .name = try duplicateOptionalString(allocator, peer_object, "name"), + }; + } + message.acknowledgement_id = try optionalUnsigned(root, "ackid"); + message.heartbeat = root.get("hb") != null; + if (try optionalString(root, "error")) |error_name| + message.peer_removed = std.mem.eql(u8, error_name, "peerRemoved"); + + const raw_peer_message = root.get("peer_msg") orelse return message; + const peer_message = try objectValue(raw_peer_message); + message.peer_from = try optionalUnsigned(peer_message, "from"); + const nested_text = try requiredString(peer_message, "msg"); + const trimmed = std.mem.trim(u8, nested_text, " \t\r\n"); + if (std.mem.eql(u8, trimmed, "BYE")) { + message.payload = .bye; + return message; + } + + const nested = std.json.parseFromSlice(std.json.Value, allocator, trimmed, .{}) catch + return error.InvalidPeerPayload; + defer nested.deinit(); + const payload = objectValue(nested.value) catch return error.InvalidPeerPayload; + const message_type = try optionalString(payload, "type"); + if (message_type != null and std.mem.eql(u8, message_type.?, "offer")) { + message.payload = .{ .offer = try duplicateRequiredString(allocator, payload, "sdp") }; + return message; + } + if (payload.get("candidate") != null) { + const candidate = try duplicateRequiredString(allocator, payload, "candidate"); + errdefer allocator.free(candidate); + const sdp_mid = try duplicateOptionalString(allocator, payload, "sdpMid"); + errdefer if (sdp_mid) |mid| allocator.free(mid); + const username_fragment = try duplicateOptionalString( + allocator, + payload, + "usernameFragment", + ); + errdefer if (username_fragment) |fragment| allocator.free(fragment); + message.payload = .{ .ice = .{ + .candidate = candidate, + .sdp_mid = sdp_mid, + .sdp_m_line_index = try optionalUnsigned(payload, "sdpMLineIndex"), + .username_fragment = username_fragment, + } }; + return message; + } + + message.payload = .{ .unknown = if (message_type) |value| + try allocator.dupe(u8, value) + else + null }; + return message; +} + +pub fn encodeHeartbeat(allocator: std.mem.Allocator) ![]u8 { + return std.json.stringifyAlloc(allocator, .{ .hb = 1 }, .{}); +} + +pub fn encodeAcknowledgement(allocator: std.mem.Allocator, id: u32) ![]u8 { + return std.json.stringifyAlloc(allocator, .{ .ack = id }, .{}); +} + +pub fn encodePeerInfo( + allocator: std.mem.Allocator, + peer_name: []const u8, + peer_id: u32, + acknowledgement_id: u32, + width: u16, + height: u16, +) ![]u8 { + const resolution = try std.fmt.allocPrint(allocator, "{d}x{d}", .{ width, height }); + defer allocator.free(resolution); + return std.json.stringifyAlloc(allocator, .{ + .ackid = acknowledgement_id, + .peer_info = .{ + .browser = "Chrome", + .browserVersion = "131", + .connected = true, + .id = peer_id, + .name = peer_name, + .peerRole = 0, + .resolution = resolution, + .version = 2, + }, + }, .{}); +} + +pub fn encodeAnswer( + allocator: std.mem.Allocator, + sdp: []const u8, + nvst_sdp: ?[]const u8, + from: u32, + to: u32, + acknowledgement_id: u32, +) ![]u8 { + if (nvst_sdp) |nvst| { + return encodePeerPayload(allocator, .{ + .type = "answer", + .sdp = sdp, + .nvstSdp = nvst, + }, from, to, acknowledgement_id); + } + return encodePeerPayload(allocator, .{ + .type = "answer", + .sdp = sdp, + }, from, to, acknowledgement_id); +} + +pub fn encodeIceCandidate( + allocator: std.mem.Allocator, + candidate: []const u8, + sdp_mid: ?[]const u8, + sdp_m_line_index: ?u32, + username_fragment: ?[]const u8, + from: u32, + to: u32, + acknowledgement_id: u32, +) ![]u8 { + var object = std.json.ObjectMap.init(allocator); + defer object.deinit(); + try object.put("candidate", .{ .string = candidate }); + if (sdp_mid) |value| try object.put("sdpMid", .{ .string = value }); + if (sdp_m_line_index) |value| try object.put("sdpMLineIndex", .{ .integer = value }); + if (username_fragment) |value| + try object.put("usernameFragment", .{ .string = value }); + return encodePeerPayload( + allocator, + std.json.Value{ .object = object }, + from, + to, + acknowledgement_id, + ); +} + +pub fn isTcpIceCandidate(candidate: []const u8) bool { + var tokens = std.mem.tokenizeAny(u8, candidate, " \t\r\n"); + _ = tokens.next() orelse return false; + _ = tokens.next() orelse return false; + const transport = tokens.next() orelse return false; + return std.ascii.eqlIgnoreCase(transport, "tcp"); +} + +fn encodePeerPayload( + allocator: std.mem.Allocator, + payload: anytype, + from: u32, + to: u32, + acknowledgement_id: u32, +) ![]u8 { + const payload_text = try std.json.stringifyAlloc(allocator, payload, .{}); + defer allocator.free(payload_text); + return std.json.stringifyAlloc(allocator, .{ + .peer_msg = .{ + .from = from, + .to = to, + .msg = payload_text, + }, + .ackid = acknowledgement_id, + }, .{}); +} + +fn writeQueryValue(writer: anytype, value: []const u8) !void { + const hex = "0123456789ABCDEF"; + for (value) |byte| { + if (std.ascii.isAlphanumeric(byte) or byte == '-' or byte == '.' or byte == '_' or byte == '~') { + try writer.writeByte(byte); + } else { + try writer.writeByte('%'); + try writer.writeByte(hex[byte >> 4]); + try writer.writeByte(hex[byte & 0x0f]); + } + } +} + +fn objectValue(value: std.json.Value) !std.json.ObjectMap { + return switch (value) { + .object => |object| object, + else => error.ExpectedObject, + }; +} + +fn requiredString(object: std.json.ObjectMap, name: []const u8) ![]const u8 { + return (try optionalString(object, name)) orelse error.MissingField; +} + +fn optionalString(object: std.json.ObjectMap, name: []const u8) !?[]const u8 { + const value = object.get(name) orelse return null; + return switch (value) { + .string => |text| text, + .null => null, + else => error.InvalidField, + }; +} + +fn duplicateRequiredString( + allocator: std.mem.Allocator, + object: std.json.ObjectMap, + name: []const u8, +) ![]u8 { + return allocator.dupe(u8, try requiredString(object, name)); +} + +fn duplicateOptionalString( + allocator: std.mem.Allocator, + object: std.json.ObjectMap, + name: []const u8, +) !?[]u8 { + if (try optionalString(object, name)) |value| return try allocator.dupe(u8, value); + return null; +} + +fn requiredUnsigned(object: std.json.ObjectMap, name: []const u8) !u32 { + return (try optionalUnsigned(object, name)) orelse error.MissingField; +} + +fn optionalUnsigned(object: std.json.ObjectMap, name: []const u8) !?u32 { + const value = object.get(name) orelse return null; + return switch (value) { + .integer => |number| if (number >= 0 and number <= std.math.maxInt(u32)) + @intCast(number) + else + error.InvalidField, + .null => null, + else => error.InvalidField, + }; +} + +test "normalizes the sign-in URL and escapes identifiers" { + const url = try buildSignInUrl( + std.testing.allocator, + "https://example.test/nvst/sign_in?old=1", + "session/one", + "peer name", + ); + defer std.testing.allocator.free(url); + try std.testing.expectEqualStrings( + "wss://example.test/nvst/sign_in?peer_id=peer%20name&version=2&peer_role=1&pairing_id=session%2Fone", + url, + ); + + const protocol = try buildSubprotocol(std.testing.allocator, "session-one"); + defer std.testing.allocator.free(protocol); + try std.testing.expectEqualStrings("x-nv-sessionid.session-one", protocol); + try std.testing.expectError( + error.InvalidSessionId, + buildSubprotocol(std.testing.allocator, "session one"), + ); + try std.testing.expectError( + error.InsecureUrl, + buildSignInUrl(std.testing.allocator, "http://example.test/nvst", "session", "peer"), + ); + try std.testing.expectError( + error.InvalidUrl, + buildSignInUrl(std.testing.allocator, "https://user@example.test/nvst", "session", "peer"), + ); +} + +test "decodes offer and ICE peer messages" { + var offer = try decode(std.testing.allocator, + \\{"peer_msg":{"from":7,"to":2,"msg":"{\"type\":\"offer\",\"sdp\":\"v=0\\r\\n\"}"}} + ); + defer offer.deinit(); + try std.testing.expectEqual(@as(?u32, 7), offer.peer_from); + try std.testing.expectEqualStrings("v=0\r\n", offer.payload.offer); + + var ice = try decode(std.testing.allocator, + \\{"peer_msg":{"from":7,"msg":"{\"candidate\":\"candidate:1 1 UDP 1 192.0.2.1 10000 typ host\",\"sdpMid\":\"video\",\"sdpMLineIndex\":2}"}} + ); + defer ice.deinit(); + try std.testing.expectEqualStrings("video", ice.payload.ice.sdp_mid.?); + try std.testing.expectEqual(@as(?u32, 2), ice.payload.ice.sdp_m_line_index); +} + +test "decodes envelope metadata and disconnect messages" { + var metadata = try decode(std.testing.allocator, + \\{"peer_info":{"id":9,"name":"peer-nine"},"ackid":41,"hb":1} + ); + defer metadata.deinit(); + try std.testing.expectEqual(@as(u32, 9), metadata.peer_info.?.id); + try std.testing.expectEqualStrings("peer-nine", metadata.peer_info.?.name.?); + try std.testing.expectEqual(@as(?u32, 41), metadata.acknowledgement_id); + try std.testing.expect(metadata.heartbeat); + + var bye = try decode(std.testing.allocator, "{\"peer_msg\":{\"msg\":\"BYE\"}}"); + defer bye.deinit(); + try std.testing.expect(bye.payload == .bye); + + var removed = try decode(std.testing.allocator, "{\"error\":\"peerRemoved\"}"); + defer removed.deinit(); + try std.testing.expect(removed.peer_removed); +} + +test "encodes answer and acknowledgement envelopes" { + const peer_info = try encodePeerInfo(std.testing.allocator, "peer-one", 0, 1, 1280, 720); + defer std.testing.allocator.free(peer_info); + try std.testing.expect(std.mem.indexOf(u8, peer_info, "\"peerRole\":0") != null); + + const answer = try encodeAnswer( + std.testing.allocator, + "v=0\r\n", + "nvst", + 2, + 1, + 8, + ); + defer std.testing.allocator.free(answer); + + var decoded = try decode(std.testing.allocator, answer); + defer decoded.deinit(); + try std.testing.expectEqual(@as(?u32, 2), decoded.peer_from); + try std.testing.expectEqual(@as(?u32, 8), decoded.acknowledgement_id); + try std.testing.expect(decoded.payload == .unknown); + + const acknowledgement = try encodeAcknowledgement(std.testing.allocator, 41); + defer std.testing.allocator.free(acknowledgement); + try std.testing.expectEqualStrings("{\"ack\":41}", acknowledgement); +} + +test "recognizes only TCP ICE transport tokens" { + try std.testing.expect(isTcpIceCandidate( + "candidate:1 1 TCP 1 192.0.2.1 9 typ host tcptype active", + )); + try std.testing.expect(!isTcpIceCandidate( + "candidate:2 1 udp 1 192.0.2.1 10000 typ host", + )); + try std.testing.expect(!isTcpIceCandidate("tcp-but-not-a-candidate")); +} + +test "rejects malformed and incomplete peer messages" { + try std.testing.expectError(error.UnexpectedEndOfInput, decode(std.testing.allocator, "{")); + try std.testing.expectError( + error.MissingField, + decode(std.testing.allocator, "{\"peer_info\":{}}"), + ); + try std.testing.expectError( + error.MissingField, + decode(std.testing.allocator, "{\"peer_msg\":{}}"), + ); + try std.testing.expectError( + error.InvalidPeerPayload, + decode(std.testing.allocator, "{\"peer_msg\":{\"msg\":\"{\"}}"), + ); +} diff --git a/src/provider/geforce_now/subscription_protocol.zig b/src/provider/geforce_now/subscription_protocol.zig new file mode 100644 index 0000000..3f5aa46 --- /dev/null +++ b/src/provider/geforce_now/subscription_protocol.zig @@ -0,0 +1,289 @@ +const std = @import("std"); + +pub const maximum_resolutions = 64; + +pub const Resolution = struct { + width: u16, + height: u16, + frames_per_second: u16, +}; + +pub const Summary = struct { + tier_buffer: [32]u8, + tier_length: usize, + state_buffer: [32]u8, + state_length: usize, + gameplay_allowed: ?bool, + entitled_resolutions: usize, + resolutions: [maximum_resolutions]Resolution, + resolution_count: usize, + + pub fn tier(self: *const Summary) []const u8 { + return self.tier_buffer[0..self.tier_length]; + } + + pub fn state(self: *const Summary) []const u8 { + return self.state_buffer[0..self.state_length]; + } + + pub fn bestForDisplayWithin( + self: *const Summary, + display_width: u16, + display_height: u16, + preferred_frames_per_second: u16, + maximum_width: u16, + maximum_height: u16, + ) ?Resolution { + if (display_width == 0 or display_height == 0 or self.resolution_count == 0) + return null; + + var result: ?Resolution = null; + for (self.resolutions[0..self.resolution_count]) |resolution| { + if (resolution.width > maximum_width or resolution.height > maximum_height) continue; + if (resolution.frames_per_second != preferred_frames_per_second) continue; + if (betterResolution(resolution, result, display_width, display_height)) + result = resolution; + } + if (result != null) return result; + + var fallback_frames_per_second: ?u16 = null; + for (self.resolutions[0..self.resolution_count]) |resolution| { + if (resolution.width > maximum_width or resolution.height > maximum_height) continue; + if (fallback_frames_per_second == null or + resolution.frames_per_second < fallback_frames_per_second.?) + fallback_frames_per_second = resolution.frames_per_second; + } + const fallback_rate = fallback_frames_per_second orelse return null; + for (self.resolutions[0..self.resolution_count]) |resolution| { + if (resolution.width > maximum_width or resolution.height > maximum_height) continue; + if (resolution.frames_per_second != fallback_rate) continue; + if (betterResolution(resolution, result, display_width, display_height)) + result = resolution; + } + return result; + } +}; + +fn betterResolution( + candidate: Resolution, + current: ?Resolution, + display_width: u16, + display_height: u16, +) bool { + const previous = current orelse return true; + const candidate_fits = candidate.width >= display_width and candidate.height >= display_height; + const previous_fits = previous.width >= display_width and previous.height >= display_height; + if (candidate_fits != previous_fits) return candidate_fits; + + const candidate_error = aspectError(candidate, display_width, display_height); + const previous_error = aspectError(previous, display_width, display_height); + const candidate_scaled_error = candidate_error * previous.height; + const previous_scaled_error = previous_error * candidate.height; + if (candidate_scaled_error != previous_scaled_error) + return candidate_scaled_error < previous_scaled_error; + + const candidate_pixels = @as(u32, candidate.width) * candidate.height; + const previous_pixels = @as(u32, previous.width) * previous.height; + if (candidate_pixels != previous_pixels) + return if (candidate_fits) + candidate_pixels < previous_pixels + else + candidate_pixels > previous_pixels; + return candidate.frames_per_second < previous.frames_per_second; +} + +fn aspectError(resolution: Resolution, display_width: u16, display_height: u16) u64 { + const scaled_width = @as(u64, resolution.width) * display_height; + const scaled_height = @as(u64, display_width) * resolution.height; + return if (scaled_width >= scaled_height) + scaled_width - scaled_height + else + scaled_height - scaled_width; +} + +pub fn parse(allocator: std.mem.Allocator, data: []const u8) !Summary { + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{}); + defer parsed.deinit(); + const root = try object(parsed.value); + var entitled: usize = 0; + var entitled_modes = [_]Resolution{.{ + .width = 0, + .height = 0, + .frames_per_second = 0, + }} ** maximum_resolutions; + var resolution_count: usize = 0; + if (optionalObject(root, "features")) |features| { + if (optionalArray(features, "resolutions")) |resolutions| { + for (resolutions.items) |value| { + const resolution = object(value) catch continue; + if (!(optionalBool(resolution, "isEntitled") orelse false)) continue; + entitled += 1; + if (resolution_count >= maximum_resolutions) continue; + const width = optionalUnsigned(resolution, "widthInPixels") orelse continue; + const height = optionalUnsigned(resolution, "heightInPixels") orelse continue; + const frames_per_second = optionalUnsigned(resolution, "framesPerSecond") orelse continue; + if (width == 0 or width > std.math.maxInt(u16) or + height == 0 or height > std.math.maxInt(u16) or + frames_per_second == 0 or frames_per_second > std.math.maxInt(u16)) + continue; + entitled_modes[resolution_count] = .{ + .width = @intCast(width), + .height = @intCast(height), + .frames_per_second = @intCast(frames_per_second), + }; + resolution_count += 1; + } + } + } + const current_state = optionalObject(root, "currentSubscriptionState"); + const gameplay_allowed = if (current_state) |state| optionalBool(state, "isGamePlayAllowed") else null; + const state = if (current_state) |value| optionalString(value, "state") orelse "UNKNOWN" else "UNKNOWN"; + const tier = optionalString(root, "membershipTier") orelse "UNKNOWN"; + var summary = Summary{ + .tier_buffer = [_]u8{0} ** 32, + .tier_length = @min(tier.len, 32), + .state_buffer = [_]u8{0} ** 32, + .state_length = @min(state.len, 32), + .gameplay_allowed = gameplay_allowed, + .entitled_resolutions = entitled, + .resolutions = entitled_modes, + .resolution_count = resolution_count, + }; + @memcpy(summary.tier_buffer[0..summary.tier_length], tier[0..summary.tier_length]); + @memcpy(summary.state_buffer[0..summary.state_length], state[0..summary.state_length]); + return summary; +} + +fn object(value: std.json.Value) !std.json.ObjectMap { + return switch (value) { + .object => |result| result, + else => error.ExpectedObject, + }; +} + +fn optionalString(value: std.json.ObjectMap, key: []const u8) ?[]const u8 { + const field = value.get(key) orelse return null; + return switch (field) { + .string => |text| if (text.len > 0) text else null, + else => null, + }; +} + +fn optionalObject(value: std.json.ObjectMap, key: []const u8) ?std.json.ObjectMap { + const field = value.get(key) orelse return null; + return switch (field) { + .object => |result| result, + else => null, + }; +} + +fn optionalArray(value: std.json.ObjectMap, key: []const u8) ?std.json.Array { + const field = value.get(key) orelse return null; + return switch (field) { + .array => |result| result, + else => null, + }; +} + +fn optionalBool(value: std.json.ObjectMap, key: []const u8) ?bool { + const field = value.get(key) orelse return null; + return switch (field) { + .bool => |result| result, + else => null, + }; +} + +fn optionalUnsigned(value: std.json.ObjectMap, key: []const u8) ?u64 { + const field = value.get(key) orelse return null; + return switch (field) { + .integer => |result| if (result >= 0) @intCast(result) else null, + else => null, + }; +} + +test "summarizes membership state without retaining account data" { + const summary = try parse(std.testing.allocator, + \\{ + \\ "membershipTier":"FREE", + \\ "currentSubscriptionState":{"state":"ACTIVE","isGamePlayAllowed":true}, + \\ "features":{"resolutions":[ + \\ {"widthInPixels":1280,"heightInPixels":720,"framesPerSecond":60,"isEntitled":true}, + \\ {"widthInPixels":1920,"heightInPixels":1080,"framesPerSecond":60,"isEntitled":false} + \\ ]} + \\} + ); + try std.testing.expectEqualStrings("FREE", summary.tier()); + try std.testing.expectEqualStrings("ACTIVE", summary.state()); + try std.testing.expectEqual(@as(?bool, true), summary.gameplay_allowed); + try std.testing.expectEqual(@as(usize, 1), summary.entitled_resolutions); + try std.testing.expectEqualSlices(Resolution, &.{.{ + .width = 1280, + .height = 720, + .frames_per_second = 60, + }}, summary.resolutions[0..summary.resolution_count]); +} + +test "accepts subscriptions without optional state details" { + const summary = try parse(std.testing.allocator, "{\"membershipTier\":\"FREE\"}"); + try std.testing.expectEqualStrings("FREE", summary.tier()); + try std.testing.expectEqualStrings("UNKNOWN", summary.state()); + try std.testing.expectEqual(@as(?bool, null), summary.gameplay_allowed); + try std.testing.expectEqual(@as(usize, 0), summary.entitled_resolutions); + try std.testing.expectEqual(@as(usize, 0), summary.resolution_count); +} + +test "selects the lowest 30 fps mode matching the display" { + var summary = try parse(std.testing.allocator, + \\{ + \\ "features":{"resolutions":[ + \\ {"widthInPixels":1280,"heightInPixels":720,"framesPerSecond":30,"isEntitled":true}, + \\ {"widthInPixels":1920,"heightInPixels":1080,"framesPerSecond":30,"isEntitled":true}, + \\ {"widthInPixels":1024,"heightInPixels":768,"framesPerSecond":30,"isEntitled":true}, + \\ {"widthInPixels":800,"heightInPixels":600,"framesPerSecond":60,"isEntitled":true} + \\ ]} + \\} + ); + + try std.testing.expectEqual(Resolution{ + .width = 1024, + .height = 768, + .frames_per_second = 30, + }, summary.bestForDisplayWithin(640, 480, 30, 1280, 768).?); + try std.testing.expectEqual(Resolution{ + .width = 1280, + .height = 720, + .frames_per_second = 30, + }, summary.bestForDisplayWithin(640, 360, 30, 1280, 768).?); +} + +test "uses another frame rate only when the preferred rate is unavailable" { + var summary = try parse(std.testing.allocator, + \\{ + \\ "features":{"resolutions":[ + \\ {"widthInPixels":800,"heightInPixels":600,"framesPerSecond":120,"isEntitled":true}, + \\ {"widthInPixels":1024,"heightInPixels":768,"framesPerSecond":60,"isEntitled":true} + \\ ]} + \\} + ); + + try std.testing.expectEqual(Resolution{ + .width = 1024, + .height = 768, + .frames_per_second = 60, + }, summary.bestForDisplayWithin(640, 480, 30, 1280, 768).?); +} + +test "does not select a mode larger than the decoder supports" { + var summary = try parse(std.testing.allocator, + \\{ + \\ "features":{"resolutions":[ + \\ {"widthInPixels":1920,"heightInPixels":1080,"framesPerSecond":30,"isEntitled":true} + \\ ]} + \\} + ); + + try std.testing.expectEqual( + @as(?Resolution, null), + summary.bestForDisplayWithin(640, 360, 30, 1280, 768), + ); +} diff --git a/src/provider/geforce_now/vast.zig b/src/provider/geforce_now/vast.zig new file mode 100644 index 0000000..185bfe4 --- /dev/null +++ b/src/provider/geforce_now/vast.zig @@ -0,0 +1,355 @@ +const std = @import("std"); +const xml = @import("xml_document"); + +pub const Track = struct { + event: []const u8, + url: []const u8, + offset_ms: ?u64 = null, + offset_percent: ?u8 = null, +}; +pub const Position = union(enum) { start, end, pixel: u16 }; +pub const Media = struct { url: []const u8, width: u16, height: u16, bitrate: u32 }; +pub const max_fallback_width = 1920; +pub const max_fallback_height = 1080; +pub const Icon = struct { + program: []const u8, + image: []const u8, + fallback: ?[]const u8, + click_through: ?[]const u8, + views: []const []const u8, + clicks: []const []const u8, + offset_ms: u64, + duration_ms: ?u64, + width: u16, + height: u16, + x: Position, + y: Position, + fallback_width: u16 = 0, + fallback_height: u16 = 0, +}; +pub const Creative = struct { + duration_ms: u64, + media: []const Media, + tracks: []const Track, + icons: []const Icon, +}; +pub const Ad = struct { + sequence: ?u32, + impressions: []const []const u8, + errors: []const []const u8, + tracks: []const Track = &.{}, + icons: []const Icon = &.{}, + verification_errors: []const []const u8 = &.{}, + body: union(enum) { inline_ad: Creative, wrapper: Wrapper }, +}; +pub const Wrapper = struct { + url: []const u8, + follow_wrappers: bool, + allow_multiple: bool, + fallback_on_no_ad: bool, +}; +pub const Manifest = struct { + document: xml.Document, + ads: []const Ad, + errors: []const []const u8, + + pub fn deinit(self: *Manifest) void { + self.document.deinit(); + } +}; + +pub fn duration(input: []const u8) !u64 { + var fields = std.mem.splitScalar(u8, input, ':'); + const hours = std.fmt.parseUnsigned(u64, fields.next() orelse return error.InvalidDuration, 10) catch return error.InvalidDuration; + const minutes = std.fmt.parseUnsigned(u64, fields.next() orelse return error.InvalidDuration, 10) catch return error.InvalidDuration; + var seconds = std.mem.splitScalar(u8, fields.next() orelse return error.InvalidDuration, '.'); + const whole = std.fmt.parseUnsigned(u64, seconds.next().?, 10) catch return error.InvalidDuration; + if (minutes >= 60 or whole >= 60 or hours > 1 or fields.next() != null) return error.InvalidDuration; + var fraction: u64 = 0; + if (seconds.next()) |decimal| { + if (decimal.len == 0 or decimal.len > 3 or seconds.next() != null) return error.InvalidDuration; + fraction = std.fmt.parseUnsigned(u64, decimal, 10) catch return error.InvalidDuration; + for (decimal.len..3) |_| fraction *= 10; + } + return (hours * 3600 + minutes * 60 + whole) * 1000 + fraction; +} + +pub fn validateUrl(address: []const u8) !void { + if (address.len == 0 or address.len > 8192) return error.InvalidAdUrl; + for (address) |byte| if (byte < 0x20 or byte == 0x7f) return error.InvalidAdUrl; + const uri = std.Uri.parse(address) catch return error.InvalidAdUrl; + if (!std.ascii.eqlIgnoreCase(uri.scheme, "https") or uri.host == null or + uri.user != null or uri.password != null or uri.fragment != null or + (uri.port != null and uri.port.? != 443)) return error.InvalidAdUrl; +} + +fn url(node: *const xml.Node) ![]const u8 { + const result = node.content(); + try validateUrl(result); + return result; +} + +fn urls(allocator: std.mem.Allocator, node: *const xml.Node, name: []const u8) ![]const []const u8 { + var result = std.ArrayList([]const u8).init(allocator); + for (node.children.items) |child| if (std.mem.eql(u8, child.name, name)) { + if (result.items.len == 64) return error.VastLimitExceeded; + try result.append(try url(child)); + }; + return result.toOwnedSlice(); +} + +fn unsignedAttribute(comptime T: type, node: *const xml.Node, name: []const u8) !T { + return std.fmt.parseUnsigned(T, node.attributes.get(name) orelse return error.MissingAttribute, 10); +} + +fn parseIcon(allocator: std.mem.Allocator, node: *const xml.Node) !Icon { + const image = node.child("StaticResource") orelse return error.UnsupportedIcon; + const kind = image.attributes.get("creativeType") orelse return error.UnsupportedIcon; + if (!std.mem.eql(u8, kind, "image/png") and !std.mem.eql(u8, kind, "image/jpeg")) return error.UnsupportedIcon; + const clicks = node.child("IconClicks"); + var fallback: ?[]const u8 = null; + var fallback_width: u16 = 0; + var fallback_height: u16 = 0; + const program = node.attributes.get("program") orelse ""; + var fallback_images = if (clicks) |click| click.child("IconClickFallbackImages") else null; + if (fallback_images == null) { + var ancestor: ?*const xml.Node = node.parent; + while (ancestor) |content| : (ancestor = content.parent) { + if (!std.mem.eql(u8, content.name, "InLine") and !std.mem.eql(u8, content.name, "Wrapper")) continue; + if (content.child("Extensions")) |extensions| for (extensions.children.items) |extension| { + if (!std.mem.eql(u8, extension.attributes.get("type") orelse "", "IconClickFallbackImages")) continue; + for (extension.children.items) |images| { + if (std.mem.eql(u8, images.name, "IconClickFallbackImages") and + std.mem.eql(u8, images.attributes.get("program") orelse "", program)) fallback_images = images; + } + }; + break; + } + } + if (fallback_images) |images| for (images.children.items) |candidate| { + if (!std.mem.eql(u8, candidate.name, "IconClickFallbackImage")) continue; + const resource = candidate.child("StaticResource") orelse continue; + const resource_type = resource.attributes.get("creativeType") orelse continue; + if (!std.mem.eql(u8, resource_type, "image/png") and !std.mem.eql(u8, resource_type, "image/jpeg")) continue; + const width = try unsignedAttribute(u16, candidate, "width"); + const height = try unsignedAttribute(u16, candidate, "height"); + if (width == 0 or height == 0 or width > max_fallback_width or height > max_fallback_height) continue; + if (fallback != null and @as(u32, width) * height >= @as(u32, fallback_width) * fallback_height) continue; + fallback = try url(resource); + fallback_width = width; + fallback_height = height; + }; + return .{ + .program = program, + .image = try url(image), + .fallback = fallback, + .click_through = if (clicks) |click| if (click.child("IconClickThrough")) |target| try url(target) else null else null, + .views = try urls(allocator, node, "IconViewTracking"), + .clicks = if (clicks) |click| try urls(allocator, click, "IconClickTracking") else &.{}, + .offset_ms = if (node.attributes.get("offset")) |offset| try duration(offset) else 0, + .duration_ms = if (node.attributes.get("duration")) |length| try duration(length) else null, + .width = try unsignedAttribute(u16, node, "width"), + .height = try unsignedAttribute(u16, node, "height"), + .x = try position(node.attributes.get("xPosition") orelse "right"), + .y = try position(node.attributes.get("yPosition") orelse "top"), + .fallback_width = fallback_width, + .fallback_height = fallback_height, + }; +} + +fn position(value: []const u8) !Position { + if (std.mem.eql(u8, value, "left") or std.mem.eql(u8, value, "top")) return .start; + if (std.mem.eql(u8, value, "right") or std.mem.eql(u8, value, "bottom")) return .end; + const pixel = try std.fmt.parseUnsigned(u16, value, 10); + if (pixel > 4096) return error.InvalidIconPosition; + return .{ .pixel = pixel }; +} + +fn parseTracks(allocator: std.mem.Allocator, node: *const xml.Node, duration_ms: ?u64) ![]const Track { + var tracks = std.ArrayList(Track).init(allocator); + if (node.child("TrackingEvents")) |events| for (events.children.items) |event| { + if (!std.mem.eql(u8, event.name, "Tracking")) continue; + if (tracks.items.len == 128) return error.VastLimitExceeded; + const name = event.attributes.get("event") orelse return error.MissingEvent; + var track = Track{ .event = name, .url = try url(event) }; + if (std.mem.eql(u8, name, "progress")) { + const offset = event.attributes.get("offset") orelse return error.MissingOffset; + if (std.mem.endsWith(u8, offset, "%")) { + const percent = try std.fmt.parseUnsigned(u8, offset[0 .. offset.len - 1], 10); + if (percent > 100) return error.InvalidOffset; + if (duration_ms) |length| track.offset_ms = length * percent / 100 else track.offset_percent = percent; + } else track.offset_ms = try duration(offset); + } + try tracks.append(track); + }; + return tracks.toOwnedSlice(); +} + +fn parseIcons(allocator: std.mem.Allocator, node: *const xml.Node) ![]Icon { + var icons = std.ArrayList(Icon).init(allocator); + if (node.child("Icons")) |group| for (group.children.items) |icon| { + if (!std.mem.eql(u8, icon.name, "Icon")) continue; + if (icons.items.len == 8) return error.VastLimitExceeded; + const parsed = try parseIcon(allocator, icon); + if (parsed.width == 0 or parsed.height == 0 or parsed.width > 640 or parsed.height > 480) return error.UnsupportedIcon; + try icons.append(parsed); + }; + return icons.toOwnedSlice(); +} + +fn parseCreative(allocator: std.mem.Allocator, node: *const xml.Node) !Creative { + const length = node.child("Duration") orelse return error.MissingDuration; + const duration_ms = try duration(length.content()); + if (duration_ms == 0 or duration_ms > 120000) return error.InvalidDuration; + const media_files = node.child("MediaFiles") orelse return error.MissingMedia; + var media = std.ArrayList(Media).init(allocator); + for (media_files.children.items) |file| { + if (!std.mem.eql(u8, file.name, "MediaFile")) continue; + const kind = file.attributes.get("type") orelse continue; + const delivery = file.attributes.get("delivery") orelse continue; + if (!std.mem.eql(u8, kind, "video/mp4") or !std.mem.eql(u8, delivery, "progressive")) continue; + const width = try unsignedAttribute(u16, file, "width"); + const height = try unsignedAttribute(u16, file, "height"); + if (width == 0 or height == 0 or width > 1280 or height > 768) continue; + if (media.items.len == 32) return error.VastLimitExceeded; + try media.append(.{ .url = try url(file), .width = width, .height = height, .bitrate = unsignedAttribute(u32, file, "bitrate") catch 0 }); + } + if (media.items.len == 0) return error.UnsupportedMedia; + if (node.child("InteractiveCreativeFile") != null) return error.UnsupportedInteractiveAd; + return .{ .duration_ms = duration_ms, .media = try media.toOwnedSlice(), .tracks = try parseTracks(allocator, node, duration_ms), .icons = try parseIcons(allocator, node) }; +} + +fn booleanAttribute(node: *const xml.Node, name: []const u8, default: bool) !bool { + const value = node.attributes.get(name) orelse return default; + if (std.mem.eql(u8, value, "true") or std.mem.eql(u8, value, "1")) return true; + if (std.mem.eql(u8, value, "false") or std.mem.eql(u8, value, "0")) return false; + return error.InvalidWrapperAttribute; +} + +pub fn parse(allocator: std.mem.Allocator, bytes: []const u8) !Manifest { + var document = try xml.parse(allocator, bytes); + errdefer document.deinit(); + if (!std.mem.eql(u8, document.root.name, "VAST")) return error.InvalidVastRoot; + const storage = document.arena.allocator(); + var ads = std.ArrayList(Ad).init(storage); + for (document.root.children.items) |node| { + if (!std.mem.eql(u8, node.name, "Ad")) continue; + if (ads.items.len == 16) return error.VastLimitExceeded; + const content = node.child("InLine") orelse node.child("Wrapper") orelse return error.InvalidAd; + if (node.child("InLine") != null and node.child("Wrapper") != null) return error.InvalidAd; + const impressions = try urls(storage, content, "Impression"); + const errors = try urls(storage, content, "Error"); + var verification = std.ArrayList([]const u8).init(storage); + if (content.child("AdVerifications")) |group| for (group.children.items) |entry| { + if (entry.child("TrackingEvents")) |events| for (events.children.items) |event| { + if (std.mem.eql(u8, event.attributes.get("event") orelse "", "verificationNotExecuted")) { + if (verification.items.len == 64) return error.VastLimitExceeded; + try verification.append(try url(event)); + } + }; + }; + const verification_errors = try verification.toOwnedSlice(); + const sequence: ?u32 = if (node.attributes.get("sequence")) |value| try std.fmt.parseUnsigned(u32, value, 10) else null; + if (sequence == 0) return error.InvalidAdSequence; + if (sequence) |number| for (ads.items) |prior| { + if (prior.sequence == number) return error.DuplicateAdSequence; + }; + if (std.mem.eql(u8, content.name, "Wrapper")) { + const destination = content.child("VASTAdTagURI") orelse return error.MissingWrapperUrl; + var tracks: []const Track = &.{}; + var icons: []const Icon = &.{}; + if (content.child("Creatives")) |creatives| for (creatives.children.items) |creative| { + if (creative.child("Linear")) |linear| { + tracks = try parseTracks(storage, linear, null); + icons = try parseIcons(storage, linear); + break; + } + }; + try ads.append(.{ .sequence = sequence, .impressions = impressions, .errors = errors, .tracks = tracks, .icons = icons, .verification_errors = verification_errors, .body = .{ .wrapper = .{ .url = try url(destination), .follow_wrappers = try booleanAttribute(content, "followAdditionalWrappers", true), .allow_multiple = try booleanAttribute(content, "allowMultipleAds", false), .fallback_on_no_ad = try booleanAttribute(content, "fallbackOnNoAd", false) } } }); + continue; + } + const creatives = content.child("Creatives") orelse return error.MissingCreative; + var selected: ?Creative = null; + for (creatives.children.items) |creative| { + if (!std.mem.eql(u8, creative.name, "Creative")) continue; + const linear = creative.child("Linear") orelse continue; + if (selected != null) return error.MultipleLinearCreatives; + selected = try parseCreative(storage, linear); + } + try ads.append(.{ .sequence = sequence, .impressions = impressions, .errors = errors, .verification_errors = verification_errors, .body = .{ .inline_ad = selected orelse return error.MissingLinearCreative } }); + } + const errors = try urls(storage, document.root, "Error"); + std.mem.sort(Ad, ads.items, {}, struct { + fn less(_: void, lhs: Ad, rhs: Ad) bool { + return (lhs.sequence orelse std.math.maxInt(u32)) < (rhs.sequence orelse std.math.maxInt(u32)); + } + }.less); + return .{ .document = document, .ads = try ads.toOwnedSlice(), .errors = errors }; +} + +test "empty inventory, wrappers and inline MP4 metadata are distinct" { + var empty = try parse(std.testing.allocator, ""); + defer empty.deinit(); + try std.testing.expectEqual(@as(usize, 0), empty.ads.len); + var wrapper = try parse(std.testing.allocator, "https://example.invalid/viewhttps://example.invalid/tag"); + defer wrapper.deinit(); + try std.testing.expectEqualStrings("https://example.invalid/tag", wrapper.ads[0].body.wrapper.url); + var inline_ad = try parse(std.testing.allocator, "00:00:05.250" ++ + "https://example.invalid/ad.mp4" ++ + "https://example.invalid/progress" ++ + ""); + defer inline_ad.deinit(); + try std.testing.expectEqual(@as(u64, 5250), inline_ad.ads[0].body.inline_ad.duration_ms); + try std.testing.expectEqual(@as(u64, 1312), inline_ad.ads[0].body.inline_ad.tracks[0].offset_ms.?); +} + +test "malformed ads and dangerous URLs never become no-fill" { + try std.testing.expectError(error.InvalidAd, parse(std.testing.allocator, "")); + try std.testing.expectError(error.InvalidVastRoot, parse(std.testing.allocator, "")); + for ([_][]const u8{ "file:///tmp/video", "http://example.invalid/ad", "https://user:pass@example.invalid/ad", "https://example.invalid:444/ad" }) |value| + try std.testing.expectError(error.InvalidAdUrl, validateUrl(value)); + try std.testing.expectEqual(@as(u64, 1250), try duration("00:00:01.25")); + for ([_][]const u8{ "00:61:00", "00:00:61", "00:00:01.", "00:00:01.1234", "-1:00:00", "00:00:01:02" }) |value| + try std.testing.expectError(error.InvalidDuration, duration(value)); +} + +test "VAST 3 AdChoices selects the smallest supported fallback for its program" { + var manifest = try parse(std.testing.allocator, "00:00:05" ++ + "https://example.invalid/video" ++ + "" ++ + "https://example.invalid/icon" ++ + "" ++ + "https://example.invalid/wrong" ++ + "" ++ + "https://example.invalid/large" ++ + "https://example.invalid/fallback" ++ + ""); + defer manifest.deinit(); + const icon = manifest.ads[0].body.inline_ad.icons[0]; + try std.testing.expectEqualStrings("https://example.invalid/fallback", icon.fallback.?); + try std.testing.expectEqual(@as(u16, 400), icon.fallback_width); +} + +test "full HD AdChoices artwork is accepted within explicit decode bounds" { + for ([_][2]u16{ .{ 1920, 1080 }, .{ 1921, 1080 }, .{ 1920, 1081 }, .{ 0, 150 } }, 0..) |size, index| { + const bytes = try std.fmt.allocPrint(std.testing.allocator, "00:00:05" ++ + "https://example.invalid/video" ++ + "https://example.invalid/icon" ++ + "" ++ + "https://example.invalid/panel" ++ + "", .{ size[0], size[1] }); + defer std.testing.allocator.free(bytes); + var manifest = try parse(std.testing.allocator, bytes); + defer manifest.deinit(); + try std.testing.expectEqual(index == 0, manifest.ads[0].body.inline_ad.icons[0].fallback != null); + } +} + +test "wrapper flags and invalid sequence values are checked" { + var manifest = try parse(std.testing.allocator, "" ++ + "https://example.invalid/tag"); + defer manifest.deinit(); + const wrapper = manifest.ads[0].body.wrapper; + try std.testing.expect(!wrapper.follow_wrappers and wrapper.allow_multiple and wrapper.fallback_on_no_ad); + try std.testing.expectError(error.InvalidAdSequence, parse(std.testing.allocator, "https://example.invalid/tag")); +} diff --git a/src/provider/geforce_now/webrtc_session.zig b/src/provider/geforce_now/webrtc_session.zig new file mode 100644 index 0000000..788b6c5 --- /dev/null +++ b/src/provider/geforce_now/webrtc_session.zig @@ -0,0 +1,896 @@ +const std = @import("std"); +const cloudmatch = @import("cloudmatch_protocol.zig"); +const input_protocol = @import("input_protocol.zig"); +const pointer_input = @import("pointer_input.zig"); +const sdp_protocol = @import("sdp_protocol.zig"); +const signaling_client = @import("signaling_client.zig"); +const signaling_protocol = @import("signaling_protocol.zig"); + +const c = @cImport({ + @cInclude("SDL2/SDL.h"); + @cInclude("rtc/rtc.h"); + @cInclude("audio_pipeline.h"); + @cInclude("controller.h"); + @cInclude("handheld_ui.h"); + @cInclude("video_pipeline.h"); +}); + +const event_capacity = 64; +const maximum_tracks = 8; +const maximum_channels = 8; +const maximum_bitrate_kbps = 6000; + +const EarlyIceCandidates = struct { + messages: std.BoundedArray(signaling_protocol.DecodedMessage, 32) = .{}, + + fn append(self: *EarlyIceCandidates, message: *signaling_protocol.DecodedMessage) !void { + const candidate = message.payload.ice; + if (signaling_protocol.isTcpIceCandidate(candidate.candidate)) return; + self.messages.append(message.*) catch return error.TooManyEarlyIceCandidates; + message.* = .{ .allocator = message.allocator }; + } + + fn deinit(self: *EarlyIceCandidates) void { + for (self.messages.slice()) |*message| message.deinit(); + self.messages.len = 0; + } +}; + +fn debugEnabled() bool { + return std.posix.getenv("GREENOVERCAST_DEBUG") != null; +} + +fn debug(comptime format: []const u8, args: anytype) void { + if (debugEnabled()) std.debug.print(format, args); +} + +const EventKind = enum { + description, + candidate, + state, + track, + data_channel, + channel_open, + channel_message, +}; + +const Event = struct { + kind: EventKind, + id: c_int = -1, + state: c.rtcState = 0, + first: ?[:0]u8 = null, + second: ?[:0]u8 = null, + data: ?[]u8 = null, + + fn deinit(self: *Event) void { + if (self.first) |value| std.heap.c_allocator.free(value); + if (self.second) |value| std.heap.c_allocator.free(value); + if (self.data) |value| std.heap.c_allocator.free(value); + self.* = undefined; + } +}; + +const EventQueue = struct { + events: [event_capacity]Event = undefined, + head: usize = 0, + tail: usize = 0, + count: usize = 0, + mutex: std.Thread.Mutex = .{}, + overflowed: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + + fn push(self: *EventQueue, event: Event) void { + self.mutex.lock(); + defer self.mutex.unlock(); + if (self.count == self.events.len) { + var dropped = event; + dropped.deinit(); + self.overflowed.store(true, .release); + return; + } + self.events[self.tail] = event; + self.tail = (self.tail + 1) % self.events.len; + self.count += 1; + } + + fn pop(self: *EventQueue) ?Event { + self.mutex.lock(); + defer self.mutex.unlock(); + if (self.count == 0) return null; + const event = self.events[self.head]; + self.head = (self.head + 1) % self.events.len; + self.count -= 1; + return event; + } + + fn deinit(self: *EventQueue) void { + while (self.pop()) |event_value| { + var event = event_value; + event.deinit(); + } + } +}; + +pub const Session = struct { + allocator: std.mem.Allocator, + video: *c.GoVideoPipeline, + audio: *c.GoAudioPipeline, + controller: *c.GoControllerInput, + ui: *c.GoHandheldUi, + cloud: *const cloudmatch.Session, + width: u16, + height: u16, + frames_per_second: u16, + peer: c_int = -1, + input_channel: c_int = -1, + video_track: c_int = -1, + audio_track: c_int = -1, + tracks: [maximum_tracks]c_int = [_]c_int{-1} ** maximum_tracks, + track_count: usize = 0, + channels: [maximum_channels]c_int = [_]c_int{-1} ** maximum_channels, + channel_count: usize = 0, + signaling: ?*signaling_client.Client = null, + offer: ?[]u8 = null, + events: EventQueue = .{}, + encoder: input_protocol.Encoder = .{}, + pointer: pointer_input.State = .{}, + answer_sent: bool = false, + input_ready: bool = false, + connected: bool = false, + closed: bool = false, + failed: bool = false, + shutting_down: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + started_at: u32 = 0, + last_input_heartbeat: u32 = 0, + bitrate_requested: bool = false, + video_packets: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), + audio_packets: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), + gamepad_logged: bool = false, + + pub fn create( + allocator: std.mem.Allocator, + video_pointer: *anyopaque, + audio_pointer: *anyopaque, + controller_pointer: *anyopaque, + ui_pointer: *anyopaque, + cloud: *const cloudmatch.Session, + width: u16, + height: u16, + frames_per_second: u16, + ) !*Session { + if (cloud.signaling_url == null or cloud.id.len == 0) return error.MissingSignalingData; + const session = try allocator.create(Session); + session.* = .{ + .allocator = allocator, + .video = @ptrCast(video_pointer), + .audio = @ptrCast(audio_pointer), + .controller = @ptrCast(controller_pointer), + .ui = @ptrCast(ui_pointer), + .cloud = cloud, + .width = width, + .height = height, + .frames_per_second = frames_per_second, + }; + return session; + } + + pub fn setup(self: *Session) !void { + var peer_name_buffer: [48]u8 = undefined; + var random: [8]u8 = undefined; + std.crypto.random.bytes(&random); + const peer_name = try std.fmt.bufPrint( + &peer_name_buffer, + "peer-{x:0>16}", + .{std.mem.readInt(u64, &random, .little)}, + ); + self.signaling = try signaling_client.Client.connect( + self.allocator, + self.cloud.signaling_url.?, + self.cloud.id, + peer_name, + self.width, + self.height, + ); + + var early_candidates: EarlyIceCandidates = .{}; + defer early_candidates.deinit(); + const offer = try self.waitForOffer(&early_candidates); + defer self.allocator.free(offer); + const sanitized = try sdp_protocol.sanitizeOffer( + self.allocator, + offer, + self.cloud.media_ip, + ); + self.offer = sanitized; + + const video_payload = sdp_protocol.codecPayloadType(sanitized, "H264/") orelse + return error.H264NotOffered; + const audio_payload = sdp_protocol.codecPayloadType(sanitized, "OPUS/") orelse + return error.OpusNotOffered; + debug("GeForce NOW offer: {d} bytes, H.264 PT {d}, Opus PT {d}\n", .{ + sanitized.len, + video_payload, + audio_payload, + }); + if (c.go_video_pipeline_set_payload_type(self.video, video_payload) != 0 or + c.go_audio_pipeline_set_payload_type(self.audio, audio_payload) != 0) + return error.MediaPipelineAlreadyActive; + + try self.createPeer(); + const offer_z = try self.allocator.dupeZ(u8, sanitized); + defer self.allocator.free(offer_z); + if (c.rtcSetRemoteDescription(self.peer, offer_z.ptr, "offer") < 0) + return error.RemoteDescriptionFailed; + for (early_candidates.messages.constSlice()) |*message| try self.handleSignaling(message); + early_candidates.deinit(); + if (self.cloud.media_ip != null and self.cloud.media_port != null) { + if (try sdp_protocol.mediaHostCandidate( + self.allocator, + self.cloud.media_ip.?, + self.cloud.media_port.?, + )) |candidate| { + defer self.allocator.free(candidate); + if (c.rtcAddRemoteCandidate(self.peer, candidate.ptr, "0") < 0) + std.debug.print("GeForce NOW media ICE candidate was rejected\n", .{}); + } + } + self.input_channel = c.rtcCreateDataChannel(self.peer, "input_channel_v1"); + if (self.input_channel < 0) return error.InputChannelFailed; + try self.rememberChannel(self.input_channel); + c.rtcSetUserPointer(self.input_channel, self); + _ = c.rtcSetOpenCallback(self.input_channel, onChannelOpen); + _ = c.rtcSetMessageCallback(self.input_channel, onChannelMessage); + if (c.rtcSetLocalDescription(self.peer, "answer") < 0) + return error.LocalDescriptionFailed; + + const deadline = c.SDL_GetTicks() +% 15_000; + while (!self.answer_sent and !self.failed and !self.closed) { + try self.pump(); + if (deadlineReached(c.SDL_GetTicks(), deadline)) return error.AnswerTimeout; + if (c.go_handheld_ui_wait(self.ui, 16) != 0) return error.Cancelled; + } + if (!self.answer_sent) return error.AnswerFailed; + self.started_at = c.SDL_GetTicks(); + self.last_input_heartbeat = self.started_at; + } + + pub fn pump(self: *Session) !void { + if (self.events.overflowed.swap(false, .acq_rel)) return error.WebrtcEventOverflow; + var processed: usize = 0; + while (processed < event_capacity) : (processed += 1) { + const value = self.events.pop() orelse break; + var event = value; + defer event.deinit(); + try self.handleEvent(&event); + } + var signaling_messages: usize = 0; + while (signaling_messages < 32) : (signaling_messages += 1) { + var message = (try self.signaling.?.poll()) orelse break; + defer message.deinit(); + try self.handleSignaling(&message); + } + + const now = c.SDL_GetTicks(); + if (self.input_ready and now -% self.last_input_heartbeat >= 2000) { + var packet: [input_protocol.maximum_packet_size]u8 = undefined; + const heartbeat = try self.encoder.encodeHeartbeat(&packet); + if (c.rtcSendMessage(self.input_channel, @ptrCast(heartbeat.ptr), @intCast(heartbeat.len)) < 0) + return error.InputHeartbeatFailed; + self.last_input_heartbeat = now; + } + } + + pub fn isConnected(self: *const Session) bool { + return self.connected; + } + + pub fn isClosed(self: *const Session) bool { + return self.closed; + } + + pub fn hasFailed(self: *const Session) bool { + return self.failed; + } + + pub fn sendInput(self: *Session) !void { + if (!self.input_ready or self.input_channel < 0) return; + var controller_state = std.mem.zeroes(c.GoControllerState); + _ = c.go_controller_input_sample(self.controller, &controller_state); + const timestamp_us: u64 = @as(u64, c.SDL_GetTicks() -% self.started_at) * 1000; + var packet_buffer: [input_protocol.maximum_packet_size]u8 = undefined; + const update = self.pointer.update(.{ + .buttons = geforceButtons(controller_state.buttons), + .left_trigger = scaleTrigger(controller_state.left_trigger), + .right_trigger = scaleTrigger(controller_state.right_trigger), + .left_x = controller_state.left_x, + .left_y = controller_state.left_y, + .right_x = controller_state.right_x, + .right_y = controller_state.right_y, + .timestamp_us = timestamp_us, + }); + try self.sendInputPacket(try self.encoder.encodeReliableGamepad(&packet_buffer, update.gamepad)); + if (update.moved) try self.sendInputPacket(try self.encoder.encodeMousePosition( + &packet_buffer, + @intFromFloat(self.pointer.x * @as(f32, @floatFromInt(self.width - 1))), + @intFromFloat(self.pointer.y * @as(f32, @floatFromInt(self.height - 1))), + self.width, + self.height, + timestamp_us, + )); + for ([_]input_protocol.MouseButton{ .left, .right }, 0..) |button, index| { + const mask = @as(u2, 1) << @as(u1, @intCast(index)); + if (update.changed_buttons & mask != 0) + try self.sendInputPacket(try self.encoder.encodeMouseButton( + &packet_buffer, + button, + self.pointer.buttons & mask != 0, + timestamp_us, + )); + } + if (update.wheel != 0) try self.sendInputPacket(try self.encoder.encodeMouseWheel(&packet_buffer, update.wheel, timestamp_us)); + if (update.toggled) debug("GeForce NOW input mode: {s}\n", .{if (self.pointer.enabled) "mouse" else "gamepad"}); + if (!self.gamepad_logged and controllerStateIsActive(controller_state)) { + debug("GeForce NOW gamepad input active: protocol {d}, buttons 0x{x}\n", .{ + self.encoder.protocol_version, + controller_state.buttons, + }); + self.gamepad_logged = true; + } + } + + fn sendInputPacket(self: *Session, packet: []const u8) !void { + if (c.rtcSendMessage(self.input_channel, @ptrCast(packet.ptr), @intCast(packet.len)) < 0) + return error.InputSendFailed; + } + + pub fn requestKeyframe(self: *Session) void { + if (self.video_track >= 0) { + _ = c.rtcRequestKeyframe(self.video_track); + c.go_video_pipeline_note_keyframe_request(self.video); + } + } + + pub fn requestBitrate(self: *Session) void { + if (self.bitrate_requested or self.video_track < 0 or + c.go_video_pipeline_has_media(self.video) == 0) return; + if (c.rtcRequestBitrate(self.video_track, maximum_bitrate_kbps * 1000) >= 0) + self.bitrate_requested = true; + } + + pub fn destroy(self: *Session) void { + self.shutting_down.store(true, .release); + if (self.peer >= 0) { + _ = c.rtcClosePeerConnection(self.peer); + for (self.channels[0..self.channel_count]) |channel| _ = c.rtcDeleteDataChannel(channel); + for (self.tracks[0..self.track_count]) |track| _ = c.rtcDeleteTrack(track); + _ = c.rtcDeletePeerConnection(self.peer); + c.rtcCleanup(); + } + if (self.signaling) |signaling| signaling.destroy(); + if (self.offer) |offer| self.allocator.free(offer); + self.events.deinit(); + const allocator = self.allocator; + allocator.destroy(self); + } + + fn waitForOffer(self: *Session, early_candidates: *EarlyIceCandidates) ![]u8 { + const deadline = c.SDL_GetTicks() +% 30_000; + while (!deadlineReached(c.SDL_GetTicks(), deadline)) { + if (try self.signaling.?.poll()) |message_value| { + var message = message_value; + defer message.deinit(); + if (message.peer_removed) return error.RemotePeerRemoved; + switch (message.payload) { + .offer => |offer| return self.allocator.dupe(u8, offer), + .ice => try early_candidates.append(&message), + .bye => return error.SignalingClosed, + else => {}, + } + } + if (c.go_handheld_ui_wait(self.ui, 16) != 0) return error.Cancelled; + } + return error.OfferTimeout; + } + + fn createPeer(self: *Session) !void { + const ice_urls = try buildIceUrls(self.allocator, self.cloud.ice_servers); + defer freeIceUrls(self.allocator, ice_urls); + var pointers = try self.allocator.alloc([*c]const u8, ice_urls.len); + defer self.allocator.free(pointers); + for (ice_urls, 0..) |url, index| pointers[index] = url.ptr; + + var configuration = std.mem.zeroes(c.rtcConfiguration); + configuration.iceServers = if (pointers.len > 0) @ptrCast(pointers.ptr) else null; + configuration.iceServersCount = @intCast(pointers.len); + configuration.disableAutoNegotiation = true; + configuration.maxMessageSize = 262_144; + self.peer = c.rtcCreatePeerConnection(&configuration); + if (self.peer < 0) return error.PeerCreationFailed; + c.rtcSetUserPointer(self.peer, self); + _ = c.rtcSetLocalDescriptionCallback(self.peer, onDescription); + _ = c.rtcSetLocalCandidateCallback(self.peer, onCandidate); + _ = c.rtcSetStateChangeCallback(self.peer, onStateChange); + _ = c.rtcSetTrackCallback(self.peer, onTrack); + _ = c.rtcSetDataChannelCallback(self.peer, onDataChannel); + } + + fn handleEvent(self: *Session, event: *Event) !void { + switch (event.kind) { + .description => { + const sdp = event.first orelse return; + const kind = event.second orelse return; + if (!std.mem.eql(u8, std.mem.sliceTo(kind, 0), "answer")) return; + const narrowed_sdp = try sdp_protocol.narrowAnswerToH264Opus( + self.allocator, + std.mem.sliceTo(sdp, 0), + self.offer.?, + ); + defer self.allocator.free(narrowed_sdp); + const nvst = try sdp_protocol.buildNvstAnswer( + self.allocator, + narrowed_sdp, + self.offer.?, + self.width, + self.height, + self.frames_per_second, + maximum_bitrate_kbps, + ); + defer self.allocator.free(nvst); + try self.signaling.?.sendAnswer(narrowed_sdp, nvst); + self.answer_sent = true; + }, + .candidate => { + const candidate = event.first orelse return; + try self.signaling.?.sendIceCandidate( + std.mem.sliceTo(candidate, 0), + if (event.second) |mid| std.mem.sliceTo(mid, 0) else null, + ); + }, + .state => switch (event.state) { + c.RTC_CONNECTED => { + self.connected = true; + self.logTransport(); + }, + c.RTC_FAILED => { + self.connected = false; + self.failed = true; + }, + c.RTC_CLOSED => { + self.connected = false; + self.closed = true; + }, + else => {}, + }, + .track => try self.configureTrack(event.id), + .data_channel => try self.configureIncomingChannel(event.id), + .channel_open => { + if (event.id == self.input_channel) { + debug("GeForce NOW input channel open\n", .{}); + } + }, + .channel_message => if (event.id == self.input_channel and event.data != null) { + if (input_protocol.parseHandshakeVersion(event.data.?)) |version| { + self.encoder.setProtocolVersion(version); + self.input_ready = true; + debug("GeForce NOW input handshake: protocol {d}\n", .{version}); + } + }, + } + } + + fn handleSignaling(self: *Session, message: *const signaling_protocol.DecodedMessage) !void { + if (message.peer_removed) { + self.closed = true; + return; + } + switch (message.payload) { + .ice => |candidate| { + if (signaling_protocol.isTcpIceCandidate(candidate.candidate)) return; + const value = try self.allocator.dupeZ(u8, candidate.candidate); + defer self.allocator.free(value); + const mid_value = try sdp_protocol.iceCandidateMid( + self.offer orelse return error.MissingOffer, + candidate.sdp_mid, + candidate.sdp_m_line_index, + ); + const mid = try self.allocator.dupeZ(u8, mid_value); + defer self.allocator.free(mid); + if (c.rtcAddRemoteCandidate(self.peer, value.ptr, mid.ptr) < 0) + return error.RemoteCandidateRejected; + }, + .bye => self.closed = true, + else => {}, + } + } + + fn configureTrack(self: *Session, track: c_int) !void { + if (track < 0) return; + self.rememberTrack(track) catch |err| { + _ = c.rtcDeleteTrack(track); + return err; + }; + c.rtcSetUserPointer(track, self); + var description_buffer: [4096]u8 = undefined; + const length = c.rtcGetTrackDescription(track, &description_buffer, description_buffer.len); + if (length <= 0) return error.TrackDescriptionUnavailable; + const description = description_buffer[0..@intCast(length - 1)]; + var mid_buffer: [64]u8 = undefined; + const mid_length = c.rtcGetTrackMid(track, &mid_buffer, mid_buffer.len); + const mid = if (mid_length > 0) + std.mem.sliceTo(mid_buffer[0..@intCast(mid_length)], 0) + else + "?"; + if (std.ascii.indexOfIgnoreCase(description, "H264/") != null) { + if (self.video_track >= 0) return error.MultipleVideoTracks; + self.video_track = track; + if (c.rtcChainRtcpReceivingSession(track) < 0) return error.VideoTrackFailed; + _ = c.rtcSetMessageCallback(track, onVideoMessage); + debug("GeForce NOW video track: mid {s}\n", .{mid}); + } else if (std.ascii.indexOfIgnoreCase(description, "opus/") != null) { + const role = mediaStreamTrackId(description) orelse { + debug("Ignoring GeForce NOW audio track without a role: mid {s}\n", .{mid}); + return; + }; + if (!std.ascii.eqlIgnoreCase(role, "audio")) { + debug("Ignoring GeForce NOW audio track {s}: mid {s}\n", .{ role, mid }); + return; + } + if (self.audio_track >= 0) return error.MultipleGameAudioTracks; + self.audio_track = track; + if (c.rtcChainRtcpReceivingSession(track) < 0) return error.AudioTrackFailed; + _ = c.rtcSetMessageCallback(track, onAudioMessage); + debug("GeForce NOW audio track: mid {s}\n", .{mid}); + } else { + debug("GeForce NOW unhandled track: mid {s}\n", .{mid}); + } + } + + fn configureIncomingChannel(self: *Session, channel: c_int) !void { + if (channel < 0) return; + self.rememberChannel(channel) catch |err| { + _ = c.rtcDeleteDataChannel(channel); + return err; + }; + c.rtcSetUserPointer(channel, self); + _ = c.rtcSetOpenCallback(channel, onChannelOpen); + _ = c.rtcSetMessageCallback(channel, onChannelMessage); + } + + fn rememberTrack(self: *Session, track: c_int) !void { + for (self.tracks[0..self.track_count]) |existing| if (existing == track) return; + if (self.track_count == self.tracks.len) return error.TooManyTracks; + self.tracks[self.track_count] = track; + self.track_count += 1; + } + + fn rememberChannel(self: *Session, channel: c_int) !void { + for (self.channels[0..self.channel_count]) |existing| if (existing == channel) return; + if (self.channel_count == self.channels.len) return error.TooManyChannels; + self.channels[self.channel_count] = channel; + self.channel_count += 1; + } + + fn logTransport(self: *Session) void { + if (!debugEnabled() or self.peer < 0) return; + var local = [_]u8{0} ** 128; + var remote = [_]u8{0} ** 128; + const result = c.rtcGetSelectedCandidatePair( + self.peer, + &local, + local.len, + &remote, + remote.len, + ); + if (result >= 0) { + debug("GeForce NOW transport connected: {s} -> {s}\n", .{ + std.mem.sliceTo(&local, 0), + std.mem.sliceTo(&remote, 0), + }); + } else { + debug("GeForce NOW transport connected; selected ICE pair unavailable\n", .{}); + } + } +}; + +fn sessionFromContext(context: ?*anyopaque) ?*Session { + return @ptrCast(@alignCast(context orelse return null)); +} + +fn enqueueStrings(session: *Session, kind: EventKind, first: [*c]const u8, second: [*c]const u8) void { + if (session.shutting_down.load(.acquire) or first == null) return; + const first_copy = std.heap.c_allocator.dupeZ(u8, std.mem.span(first)) catch return; + const second_copy = if (second != null) + std.heap.c_allocator.dupeZ(u8, std.mem.span(second)) catch { + std.heap.c_allocator.free(first_copy); + return; + } + else + null; + session.events.push(.{ .kind = kind, .first = first_copy, .second = second_copy }); +} + +fn onDescription(_: c_int, sdp: [*c]const u8, kind: [*c]const u8, context: ?*anyopaque) callconv(.c) void { + const session = sessionFromContext(context) orelse return; + enqueueStrings(session, .description, sdp, kind); +} + +fn onCandidate(_: c_int, candidate: [*c]const u8, mid: [*c]const u8, context: ?*anyopaque) callconv(.c) void { + const session = sessionFromContext(context) orelse return; + enqueueStrings(session, .candidate, candidate, mid); +} + +fn onStateChange(_: c_int, state: c.rtcState, context: ?*anyopaque) callconv(.c) void { + const session = sessionFromContext(context) orelse return; + if (!session.shutting_down.load(.acquire)) + session.events.push(.{ .kind = .state, .state = state }); +} + +fn onTrack(_: c_int, track: c_int, context: ?*anyopaque) callconv(.c) void { + const session = sessionFromContext(context) orelse return; + if (!session.shutting_down.load(.acquire)) + session.events.push(.{ .kind = .track, .id = track }); +} + +fn onDataChannel(_: c_int, channel: c_int, context: ?*anyopaque) callconv(.c) void { + const session = sessionFromContext(context) orelse return; + if (!session.shutting_down.load(.acquire)) + session.events.push(.{ .kind = .data_channel, .id = channel }); +} + +fn onChannelOpen(channel: c_int, context: ?*anyopaque) callconv(.c) void { + const session = sessionFromContext(context) orelse return; + if (!session.shutting_down.load(.acquire)) + session.events.push(.{ .kind = .channel_open, .id = channel }); +} + +fn onChannelMessage(channel: c_int, data: [*c]const u8, size: c_int, context: ?*anyopaque) callconv(.c) void { + const session = sessionFromContext(context) orelse return; + if (session.shutting_down.load(.acquire) or data == null or size == 0) return; + const signed_size: i64 = size; + const length: usize = @intCast(if (signed_size < 0) -signed_size else signed_size); + if (length > 4096) return; + const copy = std.heap.c_allocator.dupe(u8, data[0..length]) catch return; + session.events.push(.{ .kind = .channel_message, .id = channel, .data = copy }); +} + +fn onVideoMessage(track: c_int, data: [*c]const u8, size: c_int, context: ?*anyopaque) callconv(.c) void { + const session = sessionFromContext(context) orelse return; + if (session.shutting_down.load(.acquire) or track != session.video_track or data == null or size <= 0) return; + if (session.video_packets.fetchAdd(1, .monotonic) == 0) + debug("GeForce NOW first video RTP packet: {d} bytes\n", .{size}); + c.go_video_pipeline_push_rtp(session.video, @ptrCast(data), @intCast(size)); +} + +fn onAudioMessage(track: c_int, data: [*c]const u8, size: c_int, context: ?*anyopaque) callconv(.c) void { + const session = sessionFromContext(context) orelse return; + if (session.shutting_down.load(.acquire) or track != session.audio_track or data == null or size <= 0) return; + if (session.audio_packets.fetchAdd(1, .monotonic) == 0) + debug("GeForce NOW first audio RTP packet: {d} bytes\n", .{size}); + c.go_audio_pipeline_push_rtp(session.audio, @ptrCast(data), @intCast(size)); +} + +fn deadlineReached(now: u32, deadline: u32) bool { + return @as(i32, @bitCast(now -% deadline)) >= 0; +} + +fn mediaStreamTrackId(description: []const u8) ?[]const u8 { + var lines = std.mem.splitScalar(u8, description, '\n'); + while (lines.next()) |raw_line| { + const line = std.mem.trim(u8, raw_line, " \t\r"); + if (!std.mem.startsWith(u8, line, "a=msid:")) continue; + var fields = std.mem.tokenizeAny(u8, line["a=msid:".len..], " \t"); + const stream_id = fields.next() orelse continue; + return fields.next() orelse stream_id; + } + return null; +} + +fn scaleTrigger(value: u16) u8 { + return @intCast((@as(u32, value) * 255 + 32767) / 65535); +} + +fn geforceButtons(source: u32) u16 { + var result: u16 = 0; + const mappings = [_]struct { u32, u16 }{ + .{ c.GO_CONTROLLER_BUTTON_DPAD_UP, input_protocol.Button.dpad_up }, + .{ c.GO_CONTROLLER_BUTTON_DPAD_DOWN, input_protocol.Button.dpad_down }, + .{ c.GO_CONTROLLER_BUTTON_DPAD_LEFT, input_protocol.Button.dpad_left }, + .{ c.GO_CONTROLLER_BUTTON_DPAD_RIGHT, input_protocol.Button.dpad_right }, + .{ c.GO_CONTROLLER_BUTTON_START, input_protocol.Button.start }, + .{ c.GO_CONTROLLER_BUTTON_BACK, input_protocol.Button.back }, + .{ c.GO_CONTROLLER_BUTTON_LEFT_STICK, input_protocol.Button.left_stick }, + .{ c.GO_CONTROLLER_BUTTON_RIGHT_STICK, input_protocol.Button.right_stick }, + .{ c.GO_CONTROLLER_BUTTON_LEFT_SHOULDER, input_protocol.Button.left_shoulder }, + .{ c.GO_CONTROLLER_BUTTON_RIGHT_SHOULDER, input_protocol.Button.right_shoulder }, + .{ c.GO_CONTROLLER_BUTTON_GUIDE, input_protocol.Button.guide }, + .{ c.GO_CONTROLLER_BUTTON_A, input_protocol.Button.a }, + .{ c.GO_CONTROLLER_BUTTON_B, input_protocol.Button.b }, + .{ c.GO_CONTROLLER_BUTTON_X, input_protocol.Button.x }, + .{ c.GO_CONTROLLER_BUTTON_Y, input_protocol.Button.y }, + }; + for (mappings) |mapping| { + if (source & mapping[0] != 0) result |= mapping[1]; + } + return result; +} + +fn controllerStateIsActive(state: c.GoControllerState) bool { + return state.buttons != 0 or state.left_trigger != 0 or state.right_trigger != 0 or + state.left_x != 0 or state.left_y != 0 or state.right_x != 0 or state.right_y != 0; +} + +fn buildIceUrls( + allocator: std.mem.Allocator, + servers: []const cloudmatch.IceServer, +) ![][:0]u8 { + var result = std.ArrayList([:0]u8).init(allocator); + errdefer { + for (result.items) |url| allocator.free(url); + result.deinit(); + } + for (servers) |server| { + for (server.urls) |url| { + const configured = if (server.username != null and server.credential != null and + (std.ascii.startsWithIgnoreCase(url, "turn:") or + std.ascii.startsWithIgnoreCase(url, "turns:"))) + try addIceCredentials(allocator, url, server.username.?, server.credential.?) + else + try allocator.dupeZ(u8, url); + errdefer allocator.free(configured); + try result.append(configured); + } + } + return result.toOwnedSlice(); +} + +fn freeIceUrls(allocator: std.mem.Allocator, urls: [][:0]u8) void { + for (urls) |url| allocator.free(url); + allocator.free(urls); +} + +fn addIceCredentials( + allocator: std.mem.Allocator, + url: []const u8, + username: []const u8, + credential: []const u8, +) ![:0]u8 { + const separator = std.mem.indexOfScalar(u8, url, ':') orelse return error.InvalidIceUrl; + var result = std.ArrayList(u8).init(allocator); + errdefer result.deinit(); + try result.appendSlice(url[0 .. separator + 1]); + try writeUrlComponent(result.writer(), username); + try result.append(':'); + try writeUrlComponent(result.writer(), credential); + try result.append('@'); + try result.appendSlice(url[separator + 1 ..]); + return result.toOwnedSliceSentinel(0); +} + +fn writeUrlComponent(writer: anytype, value: []const u8) !void { + const hex = "0123456789ABCDEF"; + for (value) |byte| { + if (std.ascii.isAlphanumeric(byte) or byte == '-' or byte == '_' or byte == '.' or byte == '~') { + try writer.writeByte(byte); + } else { + try writer.writeByte('%'); + try writer.writeByte(hex[byte >> 4]); + try writer.writeByte(hex[byte & 0x0f]); + } + } +} + +test "early ICE candidates retain ownership and arrival order until the offer" { + var pending: EarlyIceCandidates = .{}; + defer pending.deinit(); + const inputs = [_][]const u8{ + \\{"peer_msg":{"from":7,"msg":"{\"candidate\":\"candidate:1 1 UDP 1 192.0.2.1 10000 typ host\",\"sdpMid\":\"video\"}"}} + , + \\{"peer_msg":{"from":7,"msg":"{\"candidate\":\"candidate:2 1 UDP 1 192.0.2.2 10001 typ host\"}"}} + }; + for (inputs) |input| { + var message = try signaling_protocol.decode(std.testing.allocator, input); + defer message.deinit(); + try pending.append(&message); + try std.testing.expect(message.payload == .none); + } + try std.testing.expectEqual(@as(usize, 2), pending.messages.len); + const messages = pending.messages.constSlice(); + try std.testing.expectEqualStrings("candidate:1 1 UDP 1 192.0.2.1 10000 typ host", messages[0].payload.ice.candidate); + try std.testing.expectEqualStrings("video", messages[0].payload.ice.sdp_mid.?); + try std.testing.expectEqualStrings("candidate:2 1 UDP 1 192.0.2.2 10001 typ host", messages[1].payload.ice.candidate); + try std.testing.expect(messages[1].payload.ice.sdp_mid == null); + pending.deinit(); + try std.testing.expectEqual(@as(usize, 0), pending.messages.len); +} + +test "early ICE ignores TCP and leaves ownership with the caller on overflow" { + var pending: EarlyIceCandidates = .{}; + defer pending.deinit(); + var tcp = try signaling_protocol.decode(std.testing.allocator, + \\{"peer_msg":{"msg":"{\"candidate\":\"candidate:1 1 TCP 1 192.0.2.1 10000 typ host tcptype passive\"}"}} + ); + defer tcp.deinit(); + try pending.append(&tcp); + try std.testing.expectEqual(@as(usize, 0), pending.messages.len); + try std.testing.expect(tcp.payload == .ice); + + for (0..pending.messages.buffer.len + 1) |index| { + var message = try signaling_protocol.decode(std.testing.allocator, + \\{"peer_info":{"id":7,"name":"server"},"peer_msg":{"msg":"{\"candidate\":\"candidate:1 1 UDP 1 192.0.2.1 10000 typ host\",\"usernameFragment\":\"test\"}"}} + ); + defer message.deinit(); + if (index < pending.messages.buffer.len) { + try pending.append(&message); + try std.testing.expect(message.peer_info == null); + } else { + try std.testing.expectError(error.TooManyEarlyIceCandidates, pending.append(&message)); + try std.testing.expect(message.payload == .ice); + } + } +} + +test "controller state maps to XInput masks used by GeForce NOW" { + const source = c.GO_CONTROLLER_BUTTON_A | c.GO_CONTROLLER_BUTTON_DPAD_LEFT | + c.GO_CONTROLLER_BUTTON_GUIDE; + try std.testing.expectEqual( + input_protocol.Button.a | input_protocol.Button.dpad_left | input_protocol.Button.guide, + geforceButtons(source), + ); + try std.testing.expectEqual(@as(u8, 255), scaleTrigger(65535)); +} + +test "controller activity ignores neutral samples" { + try std.testing.expect(!controllerStateIsActive(std.mem.zeroes(c.GoControllerState))); + + var state = std.mem.zeroes(c.GoControllerState); + state.right_x = 1; + try std.testing.expect(controllerStateIsActive(state)); +} + +test "TURN credentials are percent encoded into the libdatachannel URL" { + const result = try addIceCredentials( + std.testing.allocator, + "turn:example.com:3478?transport=udp", + "user@example", + "p:a ss", + ); + defer std.testing.allocator.free(result); + try std.testing.expectEqualStrings( + "turn:user%40example:p%3Aa%20ss@example.com:3478?transport=udp", + result, + ); +} + +test "GeForce NOW media track roles come from msid" { + try std.testing.expectEqualStrings( + "audio", + mediaStreamTrackId("m=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=msid:second_stream_id audio\r\n").?, + ); + try std.testing.expectEqualStrings( + "mic", + mediaStreamTrackId("m=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=msid:third_stream_id mic\r\n").?, + ); + try std.testing.expectEqualStrings( + "audio", + mediaStreamTrackId("m=audio 0 RTP/AVP\r\na=msid:audio\r\n").?, + ); + try std.testing.expect(mediaStreamTrackId("m=audio 0 RTP/AVP\r\n") == null); +} + +test "ICE URL construction releases partial allocations" { + try std.testing.checkAllAllocationFailures(std.testing.allocator, struct { + fn run(allocator: std.mem.Allocator) !void { + var stun = "stun:example.invalid:3478".*; + var turn = "turn:example.invalid:3478".*; + var username = "user".*; + var credential = "test".*; + var urls = [_][]u8{ &stun, &turn }; + const servers = [_]cloudmatch.IceServer{.{ + .urls = &urls, + .username = &username, + .credential = &credential, + }}; + const result = try buildIceUrls(allocator, &servers); + defer freeIceUrls(allocator, result); + } + }.run, .{}); +} diff --git a/src/session/webrtc_session.zig b/src/session/webrtc_session.zig index 08235c9..b9bbaf5 100644 --- a/src/session/webrtc_session.zig +++ b/src/session/webrtc_session.zig @@ -1,6 +1,7 @@ const std = @import("std"); const json_reader = @import("json_reader"); const json_writer = @import("json_writer"); +const uuid = @import("uuid"); const message_protocol = @import("message_protocol.zig"); const c = @cImport({ @@ -31,7 +32,7 @@ const Session = struct { audio_track: c_int = -1, stream_width: c_uint, stream_height: c_uint, - install_id: [37]u8 = [_]u8{0} ** 37, + install_id: [uuid.string_length + 1]u8 = [_]u8{0} ** (uuid.string_length + 1), connected: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), gathering_complete: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), handshake_complete: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), @@ -69,26 +70,6 @@ fn messageData(data: [*c]const u8, size: c_int) ?[]const u8 { return data[0..length]; } -fn generateInstallId(output: *[37]u8) void { - var bytes: [16]u8 = undefined; - std.crypto.random.bytes(&bytes); - bytes[6] = (bytes[6] & 0x0f) | 0x40; - bytes[8] = (bytes[8] & 0x3f) | 0x80; - _ = std.fmt.bufPrintZ( - output, - "{x:0>2}{x:0>2}{x:0>2}{x:0>2}-{x:0>2}{x:0>2}-{x:0>2}{x:0>2}-" ++ - "{x:0>2}{x:0>2}-{x:0>2}{x:0>2}{x:0>2}{x:0>2}{x:0>2}{x:0>2}", - .{ - bytes[0], bytes[1], bytes[2], bytes[3], - bytes[4], bytes[5], bytes[6], bytes[7], - bytes[8], bytes[9], bytes[10], bytes[11], - bytes[12], bytes[13], bytes[14], bytes[15], - }, - ) catch { - @memcpy(output, "00000000-0000-4000-8000-000000000000\x00"); - }; -} - fn onDescription(_: c_int, sdp: [*c]const u8, _: [*c]const u8, _: ?*anyopaque) callconv(.c) void { if (cString(sdp)) |value| debug("Local SDP ready ({d} bytes)\n", .{value.len}); } @@ -332,7 +313,7 @@ pub export fn go_webrtc_session_create( .stream_width = stream_width, .stream_height = stream_height, }; - generateInstallId(&session.install_id); + uuid.generate(&session.install_id); return session; } diff --git a/src/ui/artwork_decoder.c b/src/ui/artwork_decoder.c index 2d40365..345044d 100644 --- a/src/ui/artwork_decoder.c +++ b/src/ui/artwork_decoder.c @@ -1,33 +1,33 @@ #include "artwork_decoder.h" -#include #include #include #include #include #include +#include #include #include static void normalize_jpeg_pixel_format(AVFrame* frame) { switch ((enum AVPixelFormat)frame->format) { - case AV_PIX_FMT_YUVJ420P: - frame->format = AV_PIX_FMT_YUV420P; - break; - case AV_PIX_FMT_YUVJ422P: - frame->format = AV_PIX_FMT_YUV422P; - break; - case AV_PIX_FMT_YUVJ444P: - frame->format = AV_PIX_FMT_YUV444P; - break; - case AV_PIX_FMT_YUVJ440P: - frame->format = AV_PIX_FMT_YUV440P; - break; - case AV_PIX_FMT_YUVJ411P: - frame->format = AV_PIX_FMT_YUV411P; - break; - default: - return; + case AV_PIX_FMT_YUVJ420P: + frame->format = AV_PIX_FMT_YUV420P; + break; + case AV_PIX_FMT_YUVJ422P: + frame->format = AV_PIX_FMT_YUV422P; + break; + case AV_PIX_FMT_YUVJ444P: + frame->format = AV_PIX_FMT_YUV444P; + break; + case AV_PIX_FMT_YUVJ440P: + frame->format = AV_PIX_FMT_YUV440P; + break; + case AV_PIX_FMT_YUVJ411P: + frame->format = AV_PIX_FMT_YUV411P; + break; + default: + return; } frame->color_range = AVCOL_RANGE_JPEG; } @@ -39,14 +39,14 @@ void go_artwork_image_destroy(GoArtworkImage* image) { memset(image, 0, sizeof(*image)); } -int go_artwork_decode_jpeg(const uint8_t* data, size_t length, int max_width, int max_height, - GoArtworkImage* output) { - if (!data || length == 0 || length > INT_MAX || max_width <= 0 || max_height <= 0 || - !output) +static int decode_image(const uint8_t* data, size_t length, int max_width, int max_height, + enum AVCodecID codec_id, enum AVPixelFormat pixel_format, + int bytes_per_pixel, GoArtworkImage* output) { + if (!data || length == 0 || length > INT_MAX || max_width <= 0 || max_height <= 0 || !output) return -1; memset(output, 0, sizeof(*output)); - const AVCodec* codec = avcodec_find_decoder(AV_CODEC_ID_MJPEG); + const AVCodec* codec = avcodec_find_decoder(codec_id); if (!codec) return -1; AVCodecContext* context = codec ? avcodec_alloc_context3(codec) : NULL; @@ -68,20 +68,20 @@ int go_artwork_decode_jpeg(const uint8_t* data, size_t length, int max_width, in goto cleanup; normalize_jpeg_pixel_format(frame); if (frame->width <= 0 || frame->height <= 0 || frame->width > max_width || - frame->height > max_height || frame->width > INT_MAX / 3) + frame->height > max_height || frame->width > INT_MAX / bytes_per_pixel) goto cleanup; - size_t stride = (size_t)frame->width * 3; + size_t stride = (size_t)frame->width * bytes_per_pixel; if ((size_t)frame->height > SIZE_MAX / stride || stride > INT_MAX) goto cleanup; output->pixels = malloc(stride * (size_t)frame->height); if (!output->pixels) goto cleanup; - rgb->format = AV_PIX_FMT_RGB24; + rgb->format = pixel_format; rgb->width = frame->width; rgb->height = frame->height; rgb->color_range = AVCOL_RANGE_JPEG; - if (av_image_fill_arrays(rgb->data, rgb->linesize, output->pixels, AV_PIX_FMT_RGB24, - frame->width, frame->height, 1) < 0) + if (av_image_fill_arrays(rgb->data, rgb->linesize, output->pixels, pixel_format, frame->width, + frame->height, 1) < 0) goto cleanup; scaler = sws_alloc_context(); if (!scaler) @@ -104,3 +104,20 @@ int go_artwork_decode_jpeg(const uint8_t* data, size_t length, int max_width, in avcodec_free_context(&context); return result; } + +int go_artwork_decode_jpeg(const uint8_t* data, size_t length, int max_width, int max_height, + GoArtworkImage* output) { + return decode_image(data, length, max_width, max_height, AV_CODEC_ID_MJPEG, AV_PIX_FMT_RGB24, 3, + output); +} + +int go_artwork_decode_rgba(const uint8_t* data, size_t length, int max_width, int max_height, + GoArtworkImage* output) { + static const uint8_t png_signature[] = {137, 80, 78, 71, 13, 10, 26, 10}; + if (!data || length < 8) + return -1; + enum AVCodecID codec = memcmp(data, png_signature, sizeof(png_signature)) == 0 + ? AV_CODEC_ID_PNG + : AV_CODEC_ID_MJPEG; + return decode_image(data, length, max_width, max_height, codec, AV_PIX_FMT_RGBA, 4, output); +} diff --git a/src/ui/artwork_decoder.h b/src/ui/artwork_decoder.h index 04f395f..124ea87 100644 --- a/src/ui/artwork_decoder.h +++ b/src/ui/artwork_decoder.h @@ -13,6 +13,8 @@ typedef struct { int go_artwork_decode_jpeg(const uint8_t* data, size_t length, int max_width, int max_height, GoArtworkImage* output); +int go_artwork_decode_rgba(const uint8_t* data, size_t length, int max_width, int max_height, + GoArtworkImage* output); void go_artwork_image_destroy(GoArtworkImage* image); #endif diff --git a/src/ui/handheld_ui.h b/src/ui/handheld_ui.h index 8f53382..160f9ab 100644 --- a/src/ui/handheld_ui.h +++ b/src/ui/handheld_ui.h @@ -16,8 +16,15 @@ typedef int (*GoUiStopRequested)(void* context); enum { GO_HANDHELD_UI_PICK_CANCELLED = -1, GO_HANDHELD_UI_PICK_SIGN_OUT = -2, + GO_HANDHELD_UI_PICK_CHANGE_PROVIDER = -3, }; +typedef enum { + GO_HANDHELD_UI_PROVIDER_CANCELLED = -1, + GO_HANDHELD_UI_PROVIDER_XBOX = 0, + GO_HANDHELD_UI_PROVIDER_GEFORCE_NOW = 1, +} GoHandheldUiProvider; + typedef enum { GO_HANDHELD_UI_ACTION_NONE = 0, GO_HANDHELD_UI_ACTION_BACK, @@ -33,15 +40,27 @@ void go_handheld_ui_draw_loading(GoHandheldUi* ui, const char* heading, const ch GoHandheldUiAction action); void go_handheld_ui_draw_device_code(GoHandheldUi* ui, const char* user_code, const char* status, unsigned int seconds_remaining); +void go_handheld_ui_draw_device_code_for(GoHandheldUi* ui, const char* service, const char* address, + const char* user_code, const char* status, + unsigned int seconds_remaining); +int go_handheld_ui_pick_provider(GoHandheldUi* ui); +void go_handheld_ui_set_provider(GoHandheldUi* ui, GoHandheldUiProvider provider); int go_handheld_ui_wait(GoHandheldUi* ui, Uint32 milliseconds); int go_handheld_ui_cancel_requested(GoHandheldUi* ui); +int go_handheld_ui_ad_action(GoHandheldUi* ui); +void go_handheld_ui_draw_ad_hint(GoHandheldUi* ui, int modal); +int go_handheld_ui_quit_requested(const GoHandheldUi* ui); int go_handheld_ui_sign_in_action(GoHandheldUi* ui); int go_handheld_ui_wait_for_retry(GoHandheldUi* ui, const char* heading, const char* detail); +void go_handheld_ui_show_error(GoHandheldUi* ui, const char* heading, const char* detail); int go_handheld_ui_pick_title(GoHandheldUi* ui, const GoCatalogTitle* titles, int count, const char* requested); int go_handheld_ui_cancelled(const GoHandheldUi* ui); unsigned int go_handheld_ui_stream_width(const GoHandheldUi* ui); unsigned int go_handheld_ui_stream_height(const GoHandheldUi* ui); +void go_handheld_ui_draw_stream_controls(GoHandheldUi* ui, int mouse_mode, float pointer_x, + float pointer_y, int source_width, int source_height, + int show_hint); #ifdef __cplusplus } diff --git a/src/ui/handheld_ui.zig b/src/ui/handheld_ui.zig index 1348151..39fb6c0 100644 --- a/src/ui/handheld_ui.zig +++ b/src/ui/handheld_ui.zig @@ -5,9 +5,12 @@ const keyboard = @import("keyboard.zig"); const library = @import("library_view.zig"); const navigation = @import("navigation_repeat.zig"); const persistent = @import("persistent_settings.zig"); +const provider_picker = @import("provider_picker.zig"); +const provider_badge = @import("provider_badge.zig"); const font = @import("pixel_font.zig"); const settings_view = @import("settings_view.zig"); const stream_dimensions = @import("stream_dimensions.zig"); +const stream_controls = @import("stream_controls.zig"); const style = @import("view_style.zig"); const c = @cImport({ @@ -26,11 +29,18 @@ const Ui = struct { stop_context: ?*anyopaque, settings: persistent.Store, artwork: artwork.Loader = .{}, + provider: provider_badge.Provider = .xbox, cancelled: bool = false, + quit_requested: bool = false, stream_width: u32, stream_height: u32, }; +pub export fn go_handheld_ui_draw_stream_controls(ui: ?*Ui, mouse_mode: c_int, x: f32, y: f32, width: c_int, height: c_int, show_hint: c_int) void { + const value = ui orelse return; + stream_controls.draw(value.renderer, value.settings.face_buttons, mouse_mode != 0, x, y, width, height, show_hint != 0); +} + const ArtworkSelection = struct { title_index: ?usize = null, requested_index: ?usize = null, @@ -73,9 +83,14 @@ fn cancelRequested(ui: *Ui) bool { while (c.SDL_PollEvent(&event) != 0) { c.go_controller_input_handle_event(ui.controller, &event); if (event.type == c.SDL_QUIT or - (event.type == c.SDL_KEYDOWN and event.key.keysym.sym == c.SDLK_ESCAPE) or - (event.type == c.SDL_CONTROLLERBUTTONDOWN and activeControllerEvent(ui, &event) and - semanticButton(ui, event.cbutton.button) == c.SDL_CONTROLLER_BUTTON_B)) + (event.type == c.SDL_KEYDOWN and event.key.keysym.sym == c.SDLK_ESCAPE)) + { + if (event.type == c.SDL_QUIT) ui.quit_requested = true; + ui.cancelled = true; + return true; + } + if (event.type == c.SDL_CONTROLLERBUTTONDOWN and activeControllerEvent(ui, &event) and + semanticButton(ui, event.cbutton.button) == c.SDL_CONTROLLER_BUTTON_B) { ui.cancelled = true; return true; @@ -128,6 +143,7 @@ fn signInAction(ui: *Ui) c_int { var event: c.SDL_Event = undefined; while (c.SDL_PollEvent(&event) != 0) { c.go_controller_input_handle_event(ui.controller, &event); + if (event.type == c.SDL_QUIT) ui.quit_requested = true; if (event.type == c.SDL_QUIT or (event.type == c.SDL_KEYDOWN and event.key.keysym.sym == c.SDLK_ESCAPE) or (event.type == c.SDL_CONTROLLERBUTTONDOWN and activeControllerEvent(ui, &event) and @@ -136,8 +152,9 @@ fn signInAction(ui: *Ui) c_int { ui.cancelled = true; return -1; } - if (event.type == c.SDL_CONTROLLERBUTTONDOWN and activeControllerEvent(ui, &event) and - semanticButton(ui, event.cbutton.button) == c.SDL_CONTROLLER_BUTTON_A) return 1; + if ((event.type == c.SDL_KEYDOWN and event.key.keysym.sym == c.SDLK_RETURN) or + (event.type == c.SDL_CONTROLLERBUTTONDOWN and activeControllerEvent(ui, &event) and + semanticButton(ui, event.cbutton.button) == c.SDL_CONTROLLER_BUTTON_A)) return 1; } if (shouldStop(ui)) { ui.cancelled = true; @@ -218,7 +235,7 @@ fn drawKeyboard( for (keyboard.rows, 0..) |keys, row| { const row_width = keyboardRowWidth(keys); var x = @divTrunc(style.display_width - row_width, 2); - const y: c_int = @intCast(174 + row * 52); + const y: c_int = @intCast(158 + row * 46); for (keys, 0..) |key, column| { const width = @as(c_int, key.width_units) * keyboard.unit_width; drawKey(ui.renderer, x, y, width, key, row == selection.row and column == selection.column); @@ -462,6 +479,7 @@ fn pickTitle(ui: *Ui, titles: []const library.Title, requested: []const u8) c_in ui.renderer, ui.controller, &ui.settings, + ui.provider, ui.stop_requested, ui.stop_context, ); @@ -470,6 +488,7 @@ fn pickTitle(ui: *Ui, titles: []const library.Title, requested: []const u8) c_in return c.GO_HANDHELD_UI_PICK_CANCELLED; } if (result == .sign_out) return c.GO_HANDHELD_UI_PICK_SIGN_OUT; + if (result == .switch_provider) return c.GO_HANDHELD_UI_PICK_CHANGE_PROVIDER; view.rebuild(&ui.settings, view.selectedTitleIndex()); dirty = true; }, @@ -526,7 +545,7 @@ fn pickTitle(ui: *Ui, titles: []const library.Title, requested: []const u8) c_in dirty = true; } if (dirty) { - library.draw(ui.renderer, &view, &ui.settings, artwork_texture); + library.draw(ui.renderer, &view, &ui.settings, artwork_texture, ui.provider); dirty = false; } c.SDL_Delay(16); @@ -582,9 +601,20 @@ pub export fn go_handheld_ui_draw_device_code( user_code: [*c]const u8, status: [*c]const u8, seconds_remaining: c_uint, +) void { + go_handheld_ui_draw_device_code_for(ui, "XBOX SIGN IN", "MICROSOFT.COM/LINK", user_code, status, seconds_remaining); +} + +pub export fn go_handheld_ui_draw_device_code_for( + ui: ?*Ui, + service: [*c]const u8, + address: [*c]const u8, + user_code: [*c]const u8, + status: [*c]const u8, + seconds_remaining: c_uint, ) void { const handle = ui orelse return; - if (user_code == null or status == null) return; + if (service == null or address == null or user_code == null or status == null) return; style.setColor(handle.renderer, style.background()); _ = c.SDL_RenderClear(handle.renderer); style.setColor(handle.renderer, style.panel()); @@ -596,13 +626,14 @@ pub export fn go_handheld_ui_draw_device_code( _ = c.SDL_RenderFillRect(handle.renderer, &footer); style.drawMark(handle.renderer); font.text(handle.renderer, 78, 12, 4, "GREENOVERCAST", style.bright()); - font.text(handle.renderer, 80, 46, 2, "XBOX SIGN IN", style.accent()); + font.text(handle.renderer, 80, 46, 2, service, style.accent()); const heading = "SIGN IN ON ANOTHER DEVICE"; font.text(handle.renderer, @divTrunc(style.display_width - font.textWidth(heading, 3), 2), 96, 3, heading, style.bright()); const instruction = "OPEN THIS ADDRESS ON YOUR PHONE"; font.text(handle.renderer, @divTrunc(style.display_width - font.textWidth(instruction, 2), 2), 148, 2, instruction, style.muted()); - const address = "MICROSOFT.COM/LINK"; - font.text(handle.renderer, @divTrunc(style.display_width - font.textWidth(address, 3), 2), 180, 3, address, style.accent()); + const address_scale: c_int = if (font.textWidth(address, 3) <= style.display_width - 40) 3 else 2; + const address_x = @max(20, @divTrunc(style.display_width - font.textWidth(address, address_scale), 2)); + font.textEllipsized(handle.renderer, address_x, 180, address_scale, address, style.display_width - 40, style.accent()); const code_label = "ENTER THIS CODE"; font.text(handle.renderer, @divTrunc(style.display_width - font.textWidth(code_label, 2), 2), 244, 2, code_label, style.muted()); font.text(handle.renderer, @divTrunc(style.display_width - font.textWidth(user_code, 5), 2), 282, 5, user_code, style.bright()); @@ -618,6 +649,26 @@ pub export fn go_handheld_ui_draw_device_code( c.SDL_RenderPresent(handle.renderer); } +pub export fn go_handheld_ui_pick_provider(ui: ?*Ui) c_int { + const handle = ui orelse return @intFromEnum(provider_picker.Result.cancelled); + handle.cancelled = false; + return @intFromEnum(provider_picker.run( + handle.renderer, + handle.controller, + handle.settings.face_buttons, + handle.stop_requested, + handle.stop_context, + )); +} + +pub export fn go_handheld_ui_set_provider(ui: ?*Ui, provider: c.GoHandheldUiProvider) void { + const handle = ui orelse return; + handle.provider = switch (provider) { + c.GO_HANDHELD_UI_PROVIDER_GEFORCE_NOW => .geforce_now, + else => .xbox, + }; +} + pub export fn go_handheld_ui_wait(ui: ?*Ui, milliseconds: c.Uint32) c_int { const handle = ui orelse return -1; const started = c.SDL_GetTicks(); @@ -632,6 +683,49 @@ pub export fn go_handheld_ui_cancel_requested(ui: ?*Ui) c_int { return @intFromBool(cancelRequested(ui orelse return 1)); } +pub export fn go_handheld_ui_ad_action(ui: ?*Ui) c_int { + const handle = ui orelse return -1; + if (shouldStop(handle)) return -1; + var result: c_int = 0; + var event: c.SDL_Event = undefined; + while (c.SDL_PollEvent(&event) != 0) { + c.go_controller_input_handle_event(handle.controller, &event); + if (event.type == c.SDL_QUIT) { + handle.quit_requested = true; + return -1; + } + if (event.type == c.SDL_KEYDOWN) { + if (event.key.keysym.sym == c.SDLK_ESCAPE) return 2; + if (event.key.keysym.sym == c.SDLK_RETURN) result = 1; + if (event.key.keysym.sym == c.SDLK_LEFT) result = 3; + if (event.key.keysym.sym == c.SDLK_RIGHT) result = 4; + } + if (event.type != c.SDL_CONTROLLERBUTTONDOWN or !activeControllerEvent(handle, &event)) continue; + switch (semanticButton(handle, event.cbutton.button)) { + c.SDL_CONTROLLER_BUTTON_B => return 2, + c.SDL_CONTROLLER_BUTTON_START => result = 1, + c.SDL_CONTROLLER_BUTTON_DPAD_LEFT => result = 3, + c.SDL_CONTROLLER_BUTTON_DPAD_RIGHT => result = 4, + else => {}, + } + } + return result; +} + +pub export fn go_handheld_ui_draw_ad_hint(ui: ?*Ui, modal: c_int) void { + const handle = ui orelse return; + const prompts = [_]controls.Prompt{if (modal != 0) + controls.Prompt.one(controls.face(handle.settings.face_buttons, .b), "BACK TO AD") + else + controls.Prompt.one(.start, "AD INFO")}; + controls.drawCenteredRow(handle.renderer, 439, &prompts, style.bright()); +} + +pub export fn go_handheld_ui_quit_requested(ui: ?*const Ui) c_int { + const handle = ui orelse return 1; + return @intFromBool(handle.quit_requested or shouldStop(handle)); +} + pub export fn go_handheld_ui_sign_in_action(ui: ?*Ui) c_int { return signInAction(ui orelse return -1); } @@ -646,6 +740,29 @@ pub export fn go_handheld_ui_wait_for_retry(ui: ?*Ui, heading: [*c]const u8, det } } +pub export fn go_handheld_ui_show_error(ui: ?*Ui, heading: [*c]const u8, detail: [*c]const u8) void { + const handle = ui orelse return; + drawLoading(handle, heading, detail, c.GO_HANDHELD_UI_ACTION_BACK); + while (true) { + var event: c.SDL_Event = undefined; + while (c.SDL_PollEvent(&event) != 0) { + c.go_controller_input_handle_event(handle.controller, &event); + if (event.type == c.SDL_QUIT) { + handle.quit_requested = true; + return; + } + if (event.type == c.SDL_KEYDOWN and event.key.keysym.sym == c.SDLK_ESCAPE) return; + if (event.type == c.SDL_CONTROLLERBUTTONDOWN and activeControllerEvent(handle, &event) and + semanticButton(handle, event.cbutton.button) == c.SDL_CONTROLLER_BUTTON_B) return; + } + if (shouldStop(handle) or c.go_controller_input_exit_held(handle.controller, 1000) != 0) { + handle.quit_requested = true; + return; + } + c.SDL_Delay(16); + } +} + pub export fn go_handheld_ui_pick_title( ui: ?*Ui, titles: [*c]const c.GoCatalogTitle, diff --git a/src/ui/keyboard.zig b/src/ui/keyboard.zig index aede765..a4c0e77 100644 --- a/src/ui/keyboard.zig +++ b/src/ui/keyboard.zig @@ -47,7 +47,6 @@ const row_letters_3 = [_]Key{ .{ .label = "B", .action = .{ .character = 'B' } }, .{ .label = "N", .action = .{ .character = 'N' } }, .{ .label = "M", .action = .{ .character = 'M' } }, - .{ .label = "SPACE", .action = .space, .width_units = 3 }, }; const row_numbers = [_]Key{ @@ -63,11 +62,16 @@ const row_numbers = [_]Key{ .{ .label = "0", .action = .{ .character = '0' } }, }; +const row_space = [_]Key{ + .{ .label = "SPACE", .action = .space, .width_units = 5 }, +}; + pub const rows = [_][]const Key{ + &row_numbers, &row_letters_1, &row_letters_2, &row_letters_3, - &row_numbers, + &row_space, }; pub const Selection = struct { @@ -148,9 +152,9 @@ pub fn clear(query: []u8) void { test "space key inserts a space" { var query = [_]u8{0} ** 16; @memcpy(query[0..6], "HOLLOW"); - var selection = Selection{ .row = 2, .column = 7 }; + var selection = Selection{ .row = 4, .column = 0 }; activate(selection, &query); - selection = .{ .row = 2, .column = 5 }; + selection = .{ .row = 3, .column = 5 }; activate(selection, &query); try std.testing.expectEqualStrings("HOLLOW N", std.mem.sliceTo(&query, 0)); } @@ -160,17 +164,17 @@ test "selection wraps and stays within the next row" { selection.moveHorizontal(-1); try std.testing.expectEqual(@as(usize, 9), selection.column); selection.moveVertical(1); - try std.testing.expectEqual(@as(usize, 8), selection.column); + try std.testing.expectEqual(@as(usize, 9), selection.column); selection.moveVertical(1); - try std.testing.expectEqual(@as(usize, 7), selection.column); + try std.testing.expectEqual(@as(usize, 8), selection.column); } test "vertical movement follows the visual center of the space key" { - var selection = Selection{ .row = 2, .column = 7 }; - selection.moveVertical(1); - try std.testing.expectEqual(@as(usize, 3), selection.row); - try std.testing.expectEqual(@as(usize, 8), selection.column); + var selection = Selection{ .row = 4, .column = 0 }; selection.moveVertical(-1); - try std.testing.expectEqual(@as(usize, 2), selection.row); - try std.testing.expectEqual(@as(usize, 7), selection.column); + try std.testing.expectEqual(@as(usize, 3), selection.row); + try std.testing.expectEqual(@as(usize, 3), selection.column); + selection.moveVertical(1); + try std.testing.expectEqual(@as(usize, 4), selection.row); + try std.testing.expectEqual(@as(usize, 0), selection.column); } diff --git a/src/ui/library_view.zig b/src/ui/library_view.zig index a01ecf8..a1be432 100644 --- a/src/ui/library_view.zig +++ b/src/ui/library_view.zig @@ -2,6 +2,7 @@ const std = @import("std"); const controls = @import("control_icons.zig"); const search = @import("catalog_search"); const font = @import("pixel_font.zig"); +const provider_badge = @import("provider_badge.zig"); const settings = @import("persistent_settings.zig"); const style = @import("view_style.zig"); @@ -136,20 +137,20 @@ pub fn draw( view: *const View, store: *const settings.Store, artwork_pointer: ?*anyopaque, + provider: provider_badge.Provider, ) void { const renderer: *c.SDL_Renderer = @ptrCast(@alignCast(renderer_pointer)); const artwork: ?*c.SDL_Texture = if (artwork_pointer) |value| @ptrCast(@alignCast(value)) else null; style.setColor(renderer, style.background()); _ = c.SDL_RenderClear(renderer); style.setColor(renderer, style.panel()); - var header = c.SDL_Rect{ .x = 0, .y = 0, .w = style.display_width, .h = 78 }; + var header = c.SDL_Rect{ .x = 0, .y = 0, .w = style.display_width, .h = 62 }; var footer = c.SDL_Rect{ .x = 0, .y = 424, .w = style.display_width, .h = 56 }; _ = c.SDL_RenderFillRect(renderer, &header); _ = c.SDL_RenderFillRect(renderer, &footer); style.drawMark(renderer); - font.text(renderer, 78, 12, 4, "GREENOVERCAST", style.bright()); - drawTabs(renderer, view.collection); + provider_badge.draw(renderer, provider); if (view.count == 0) { const empty = if (view.collection == .favorites) "NO FAVORITES YET" else "NO MATCHING GAMES"; font.text(renderer, 28, 206, 3, empty, style.muted()); @@ -177,7 +178,7 @@ pub fn draw( const index = start + row; if (index >= view.count) break; const title = &view.titles[view.indices[index]]; - const y: c_int = @intCast(86 + row * 36); + const y: c_int = @intCast(70 + row * 38); if (index == view.selected) { style.setColor(renderer, style.selection()); var selection = c.SDL_Rect{ .x = 14, .y = y, .w = list_right - 14, .h = 32 }; @@ -223,16 +224,16 @@ pub fn draw( fn drawTabs(renderer: *c.SDL_Renderer, active: Collection) void { const labels = [_]struct { Collection, [*:0]const u8, c_int }{ - .{ .all, "ALL", 210 }, - .{ .favorites, "FAVORITES", 300 }, + .{ .all, "ALL", 84 }, + .{ .favorites, "FAVORITES", 162 }, }; for (labels) |entry| { if (entry[0] == active) { style.setColor(renderer, style.selection()); - var rect = c.SDL_Rect{ .x = entry[2] - 10, .y = 48, .w = font.textWidth(entry[1], 2) + 20, .h = 24 }; + var rect = c.SDL_Rect{ .x = entry[2] - 10, .y = 16, .w = font.textWidth(entry[1], 2) + 20, .h = 28 }; _ = c.SDL_RenderFillRect(renderer, &rect); } - font.text(renderer, entry[2], 52, 2, entry[1], if (entry[0] == active) style.bright() else style.muted()); + font.text(renderer, entry[2], 22, 2, entry[1], if (entry[0] == active) style.bright() else style.muted()); } } diff --git a/src/ui/provider_badge.zig b/src/ui/provider_badge.zig new file mode 100644 index 0000000..33a1b39 --- /dev/null +++ b/src/ui/provider_badge.zig @@ -0,0 +1,55 @@ +const font = @import("pixel_font.zig"); +const style = @import("view_style.zig"); + +const c = @cImport({ + @cInclude("SDL2/SDL.h"); +}); + +pub const Provider = enum { + xbox, + geforce_now, +}; + +// Bootstrap Icons, rasterized at 24x24; source revision in vendor/manifest.lock. +const xbox_mark = [24]u24{ + 0x007e00, 0x01ff80, 0x003c00, 0x000000, 0x1c0038, 0x3f007c, + 0x7f00fe, 0x7f00fe, 0x7e007e, 0xfe003f, 0xfc183f, 0xf83c1f, + 0xf87e1f, 0xf0ff0f, 0xf1ff8f, 0x63ffc6, 0x67ffe6, 0x67ffe2, + 0x0ffff0, 0x0ffff0, 0x0ffff0, 0x07ffe0, 0x03ffc0, 0x007e00, +}; + +const nvidia_mark = [24]u24{ + 0x000000, 0x000000, 0x000000, 0x007fff, 0x007fff, 0x007fff, + 0x0187ff, 0x0e71ff, 0x187cff, 0x73867f, 0xe6473f, 0xe4667f, + 0x667cef, 0x3379c7, 0x198787, 0x0c7e0f, 0x07707f, 0x0187ff, + 0x007fff, 0x007fff, 0x007fff, 0x000000, 0x000000, 0x000000, +}; + +pub fn draw(renderer_pointer: *anyopaque, provider: Provider) void { + const renderer: *c.SDL_Renderer = @ptrCast(@alignCast(renderer_pointer)); + const label: [*:0]const u8 = if (provider == .xbox) "XBOX" else "GFN"; + const label_width = font.textWidth(label, 2); + const width = 32 + label_width; + const x = style.display_width - width - 16; + + drawMark(renderer, provider, x, 15); + font.text(renderer, x + 32, 20, 2, label, style.bright()); +} + +fn drawMark(renderer: *c.SDL_Renderer, provider: Provider, x: c_int, y: c_int) void { + const rows = if (provider == .xbox) &xbox_mark else &nvidia_mark; + style.setColor(renderer, if (provider == .xbox) style.bright() else .{ .r = 118, .g = 185, .b = 0, .a = 255 }); + for (rows, 0..) |row, dy| { + var column: u5 = 0; + while (column < 24) { + if (row & (@as(u24, 1) << (23 - column)) == 0) { + column += 1; + continue; + } + const start = column; + while (column < 24 and row & (@as(u24, 1) << (23 - column)) != 0) : (column += 1) {} + var rect = c.SDL_Rect{ .x = x + start, .y = y + @as(c_int, @intCast(dy)), .w = column - start, .h = 1 }; + _ = c.SDL_RenderFillRect(renderer, &rect); + } + } +} diff --git a/src/ui/provider_picker.zig b/src/ui/provider_picker.zig new file mode 100644 index 0000000..4306c4b --- /dev/null +++ b/src/ui/provider_picker.zig @@ -0,0 +1,143 @@ +const controls = @import("control_icons.zig"); +const font = @import("pixel_font.zig"); +const settings = @import("persistent_settings.zig"); +const style = @import("view_style.zig"); + +const c = @cImport({ + @cInclude("SDL2/SDL.h"); + @cInclude("controller.h"); +}); + +pub const Result = enum(c_int) { + cancelled = -1, + xbox = 0, + geforce_now = 1, +}; + +const StopRequested = ?*const fn (?*anyopaque) callconv(.c) c_int; + +pub fn run( + renderer_pointer: *anyopaque, + controller_pointer: *anyopaque, + face_buttons: settings.FaceButtonMode, + stop_requested: StopRequested, + stop_context: ?*anyopaque, +) Result { + const renderer: *c.SDL_Renderer = @ptrCast(@alignCast(renderer_pointer)); + const controller: *c.GoControllerInput = @ptrCast(@alignCast(controller_pointer)); + var selection = Result.xbox; + var dirty = true; + var input_armed = false; + const settle_started = c.SDL_GetTicks(); + while (true) { + var event: c.SDL_Event = undefined; + while (c.SDL_PollEvent(&event) != 0) { + c.go_controller_input_handle_event(controller, &event); + if (event.type == c.SDL_QUIT) return .cancelled; + if (!input_armed) continue; + if (event.type == c.SDL_KEYDOWN) switch (event.key.keysym.sym) { + c.SDLK_ESCAPE => return .cancelled, + c.SDLK_UP, c.SDLK_DOWN => selection = toggle(selection), + c.SDLK_RETURN => return selection, + else => {}, + }; + if (event.type == c.SDL_KEYDOWN) dirty = true; + if (event.type == c.SDL_CONTROLLERBUTTONDOWN and + c.go_controller_input_event_is_active(controller, &event) != 0) + { + const button = c.go_controller_input_map_button(controller, event.cbutton.button); + switch (button) { + c.SDL_CONTROLLER_BUTTON_B => return .cancelled, + c.SDL_CONTROLLER_BUTTON_A => return selection, + c.SDL_CONTROLLER_BUTTON_DPAD_UP, c.SDL_CONTROLLER_BUTTON_DPAD_DOWN => { + selection = toggle(selection); + dirty = true; + }, + else => {}, + } + } + } + if (!input_armed and c.SDL_GetTicks() -% settle_started >= 500 and + inputNeutral(controller)) input_armed = true; + if (shouldStop(stop_requested, stop_context) or + c.go_controller_input_exit_held(controller, 1000) != 0) return .cancelled; + if (dirty) { + draw(renderer, face_buttons, selection); + dirty = false; + } + c.SDL_Delay(16); + } +} + +fn inputNeutral(controller: *c.GoControllerInput) bool { + const buttons = [_]c.SDL_GameControllerButton{ + c.SDL_CONTROLLER_BUTTON_A, + c.SDL_CONTROLLER_BUTTON_B, + c.SDL_CONTROLLER_BUTTON_DPAD_UP, + c.SDL_CONTROLLER_BUTTON_DPAD_DOWN, + }; + for (buttons) |button| { + if (c.go_controller_input_button_pressed(controller, button) != 0) return false; + } + const keys = c.SDL_GetKeyboardState(null); + if (keys[c.SDL_SCANCODE_RETURN] != 0 or + keys[c.SDL_SCANCODE_ESCAPE] != 0 or + keys[c.SDL_SCANCODE_UP] != 0 or + keys[c.SDL_SCANCODE_DOWN] != 0) return false; + return true; +} + +fn toggle(current: Result) Result { + return if (current == .xbox) .geforce_now else .xbox; +} + +fn draw( + renderer: *c.SDL_Renderer, + face_buttons: settings.FaceButtonMode, + selection: Result, +) void { + style.setColor(renderer, style.background()); + _ = c.SDL_RenderClear(renderer); + style.setColor(renderer, style.panel()); + var header = c.SDL_Rect{ .x = 0, .y = 0, .w = style.display_width, .h = 74 }; + var footer = c.SDL_Rect{ .x = 0, .y = 424, .w = style.display_width, .h = 56 }; + _ = c.SDL_RenderFillRect(renderer, &header); + _ = c.SDL_RenderFillRect(renderer, &footer); + style.drawMark(renderer); + font.text(renderer, 78, 12, 4, "GREENOVERCAST", style.bright()); + font.text(renderer, 78, 50, 2, "CHOOSE A STREAMING SERVICE", style.accent()); + + drawChoice(renderer, 118, "XBOX CLOUD GAMING", "GAME PASS LIBRARY", selection == .xbox); + drawChoice(renderer, 242, "GEFORCE NOW", "PC GAME STREAMING", selection == .geforce_now); + + const prompts = [_]controls.Prompt{ + controls.Prompt.one(controls.face(face_buttons, .a), "SELECT"), + controls.Prompt.one(.dpad, "MOVE"), + controls.Prompt.one(controls.face(face_buttons, .b), "EXIT"), + }; + controls.drawCenteredRow(renderer, 439, &prompts, style.bright()); + c.SDL_RenderPresent(renderer); +} + +fn drawChoice( + renderer: *c.SDL_Renderer, + y: c_int, + title: [*:0]const u8, + detail: [*:0]const u8, + selected: bool, +) void { + style.setColor(renderer, if (selected) style.selection() else style.panel()); + var panel = c.SDL_Rect{ .x = 54, .y = y, .w = 532, .h = 96 }; + _ = c.SDL_RenderFillRect(renderer, &panel); + if (selected) { + style.setColor(renderer, style.accent()); + var bar = c.SDL_Rect{ .x = 54, .y = y, .w = 6, .h = 96 }; + _ = c.SDL_RenderFillRect(renderer, &bar); + } + font.text(renderer, 84, y + 18, 3, title, if (selected) style.bright() else style.muted()); + font.text(renderer, 84, y + 58, 2, detail, if (selected) style.accent() else style.muted()); +} + +fn shouldStop(callback: StopRequested, context: ?*anyopaque) bool { + return if (callback) |stop| stop(context) != 0 else false; +} diff --git a/src/ui/settings_view.zig b/src/ui/settings_view.zig index c669f67..74eb580 100644 --- a/src/ui/settings_view.zig +++ b/src/ui/settings_view.zig @@ -2,6 +2,7 @@ const std = @import("std"); const controls = @import("control_icons.zig"); const navigation = @import("navigation_repeat.zig"); const font = @import("pixel_font.zig"); +const provider_badge = @import("provider_badge.zig"); const settings = @import("persistent_settings.zig"); const style = @import("view_style.zig"); @@ -12,6 +13,7 @@ const c = @cImport({ pub const Result = enum { back, + switch_provider, sign_out, cancelled, }; @@ -21,6 +23,13 @@ const StopRequested = ?*const fn (?*anyopaque) callconv(.c) c_int; const Row = enum { face_buttons, artwork, + service, + sign_out, +}; + +const Action = enum { + none, + switch_provider, sign_out, }; @@ -28,6 +37,7 @@ pub fn run( renderer_pointer: *anyopaque, controller_pointer: *anyopaque, store: *settings.Store, + provider: provider_badge.Provider, stop_requested: StopRequested, stop_context: ?*anyopaque, ) Result { @@ -46,9 +56,10 @@ pub fn run( c.SDLK_ESCAPE => return .back, c.SDLK_UP => selected = previousRow(selected), c.SDLK_DOWN => selected = nextRow(selected), - c.SDLK_RETURN => if (activate(selected, controller, store)) { - const confirmation = confirmSignOut(renderer, controller, store.face_buttons, stop_requested, stop_context); - if (confirmation != .back) return confirmation; + c.SDLK_RETURN => { + const action = activate(selected, controller, store); + if (resolveAction(action, renderer, controller, store.face_buttons, stop_requested, stop_context)) |result| + return result; dirty = true; }, else => {}, @@ -60,9 +71,10 @@ pub fn run( const button = c.go_controller_input_map_button(controller, event.cbutton.button); switch (button) { c.SDL_CONTROLLER_BUTTON_B => return .back, - c.SDL_CONTROLLER_BUTTON_A => if (activate(selected, controller, store)) { - const confirmation = confirmSignOut(renderer, controller, store.face_buttons, stop_requested, stop_context); - if (confirmation != .back) return confirmation; + c.SDL_CONTROLLER_BUTTON_A => { + const action = activate(selected, controller, store); + if (resolveAction(action, renderer, controller, store.face_buttons, stop_requested, stop_context)) |result| + return result; dirty = true; }, c.SDL_CONTROLLER_BUTTON_DPAD_UP => { @@ -74,7 +86,8 @@ pub fn run( repeat.begin(.down, c.SDL_GetTicks()); }, c.SDL_CONTROLLER_BUTTON_DPAD_LEFT, c.SDL_CONTROLLER_BUTTON_DPAD_RIGHT => { - if (selected != .sign_out) _ = activate(selected, controller, store); + if (selected == .face_buttons or selected == .artwork) + _ = activate(selected, controller, store); }, else => {}, } @@ -96,7 +109,7 @@ pub fn run( } } if (dirty) { - draw(renderer, store, selected); + draw(renderer, store, provider, selected); dirty = false; } c.SDL_Delay(16); @@ -107,7 +120,7 @@ fn activate( row: Row, controller: *c.GoControllerInput, store: *settings.Store, -) bool { +) Action { switch (row) { .face_buttons => { store.face_buttons = if (store.face_buttons == .system) .swapped else .system; @@ -117,24 +130,51 @@ fn activate( ); }, .artwork => store.artwork_enabled = !store.artwork_enabled, - .sign_out => return true, + .service => return .switch_provider, + .sign_out => return .sign_out, } store.save() catch std.debug.print("Settings could not be saved\n", .{}); - return false; + return .none; +} + +fn resolveAction( + action: Action, + renderer: *c.SDL_Renderer, + controller: *c.GoControllerInput, + face_buttons: settings.FaceButtonMode, + stop_requested: StopRequested, + stop_context: ?*anyopaque, +) ?Result { + return switch (action) { + .none => null, + .switch_provider => .switch_provider, + .sign_out => result: { + const confirmation = confirmSignOut( + renderer, + controller, + face_buttons, + stop_requested, + stop_context, + ); + break :result if (confirmation == .back) null else confirmation; + }, + }; } fn previousRow(row: Row) Row { return switch (row) { .face_buttons => .sign_out, .artwork => .face_buttons, - .sign_out => .artwork, + .service => .artwork, + .sign_out => .service, }; } fn nextRow(row: Row) Row { return switch (row) { .face_buttons => .artwork, - .artwork => .sign_out, + .artwork => .service, + .service => .sign_out, .sign_out => .face_buttons, }; } @@ -152,6 +192,7 @@ fn heldDirection(controller: *c.GoControllerInput, latch: *navigation.AxisLatch) fn draw( renderer: *c.SDL_Renderer, store: *const settings.Store, + provider: provider_badge.Provider, selected: Row, ) void { style.setColor(renderer, style.background()); @@ -163,9 +204,10 @@ fn draw( _ = c.SDL_RenderFillRect(renderer, &footer); font.text(renderer, 18, 14, 4, "SETTINGS", style.bright()); - drawRow(renderer, 92, "FACE BUTTONS", if (store.face_buttons == .system) "SYSTEM" else "SWAPPED", selected == .face_buttons); - drawRow(renderer, 148, "GAME ARTWORK", if (store.artwork_enabled) "ON" else "OFF", selected == .artwork); - drawRow(renderer, 204, "ACCOUNT", "SIGN OUT", selected == .sign_out); + drawRow(renderer, 82, "FACE BUTTONS", if (store.face_buttons == .system) "SYSTEM" else "SWAPPED", selected == .face_buttons); + drawRow(renderer, 130, "GAME ARTWORK", if (store.artwork_enabled) "ON" else "OFF", selected == .artwork); + drawRow(renderer, 178, "STREAMING SERVICE", if (provider == .xbox) "XBOX" else "GEFORCE NOW", selected == .service); + drawRow(renderer, 226, "ACCOUNT", "SIGN OUT", selected == .sign_out); drawMappingExplanation(renderer, store.face_buttons); font.text(renderer, 18, 340, 2, "USE SWAPPED ONLY IF BUTTONS ARE REVERSED", style.muted()); @@ -222,7 +264,7 @@ fn confirmSignOut( style.setColor(renderer, style.background()); _ = c.SDL_RenderClear(renderer); font.text(renderer, 164, 174, 4, "SIGN OUT?", style.bright()); - font.text(renderer, 110, 242, 2, "YOU WILL NEED TO LINK XBOX AGAIN", style.muted()); + font.text(renderer, 128, 242, 2, "YOU WILL NEED TO SIGN IN AGAIN", style.muted()); const prompts = [_]controls.Prompt{ controls.Prompt.one(controls.face(face_buttons, .a), "YES"), controls.Prompt.one(controls.face(face_buttons, .b), "NO"), diff --git a/src/ui/stream_controls.zig b/src/ui/stream_controls.zig new file mode 100644 index 0000000..54a912a --- /dev/null +++ b/src/ui/stream_controls.zig @@ -0,0 +1,53 @@ +const controls = @import("control_icons.zig"); +const settings = @import("persistent_settings.zig"); +const style = @import("view_style.zig"); +const c = @cImport({ + @cInclude("SDL2/SDL.h"); +}); + +pub fn draw(renderer_pointer: *anyopaque, face_buttons: settings.FaceButtonMode, mouse_mode: bool, x: f32, y: f32, source_width: c_int, source_height: c_int, show_hint: bool) void { + const renderer: *c.SDL_Renderer = @ptrCast(@alignCast(renderer_pointer)); + var width: c_int = 0; + var height: c_int = 0; + if (c.SDL_GetRendererOutputSize(renderer, &width, &height) != 0 or width <= 0 or height <= 0) return; + var old_x: f32 = 1; + var old_y: f32 = 1; + c.SDL_RenderGetScale(renderer, &old_x, &old_y); + defer _ = c.SDL_RenderSetScale(renderer, old_x, old_y); + _ = c.SDL_RenderSetScale(renderer, @as(f32, @floatFromInt(width)) / 640, @as(f32, @floatFromInt(height)) / 480); + if (mouse_mode or show_hint) { + var band = c.SDL_Rect{ .x = 0, .y = 0, .w = 640, .h = 28 }; + style.setColor(renderer, style.panel()); + _ = c.SDL_RenderFillRect(renderer, &band); + controls.drawCenteredRow(renderer, 4, &.{controls.Prompt.two(.select, controls.face(face_buttons, .y), if (mouse_mode) "GAMEPAD MODE" else "MOUSE MODE")}, style.bright()); + } + if (!mouse_mode) return; + var band = c.SDL_Rect{ .x = 0, .y = 452, .w = 640, .h = 28 }; + style.setColor(renderer, style.panel()); + _ = c.SDL_RenderFillRect(renderer, &band); + controls.drawCenteredRow(renderer, 456, &.{ + controls.Prompt.one(.left_stick, "MOVE"), + controls.Prompt.one(controls.face(face_buttons, .a), "CLICK"), + controls.Prompt.one(controls.face(face_buttons, .b), "RIGHT"), + controls.Prompt.two(.left_bumper, .right_bumper, "SCROLL"), + }, style.bright()); + if (source_width <= 0 or source_height <= 0) return; + var w: f32 = 640; + var h: f32 = 480; + if (@as(i64, width) * source_height > @as(i64, height) * source_width) + w = 640 * (@as(f32, @floatFromInt(height)) * @as(f32, @floatFromInt(source_width))) / (@as(f32, @floatFromInt(width)) * @as(f32, @floatFromInt(source_height))) + else + h = 480 * (@as(f32, @floatFromInt(width)) * @as(f32, @floatFromInt(source_height))) / (@as(f32, @floatFromInt(height)) * @as(f32, @floatFromInt(source_width))); + const px: c_int = @intFromFloat((640 - w) / 2 + x * (w - 1)); + const py: c_int = @intFromFloat((480 - h) / 2 + y * (h - 1)); + style.setColor(renderer, .{ .r = 0, .g = 0, .b = 0, .a = 255 }); + for (0..14) |row| { + var line = c.SDL_Rect{ .x = px, .y = py + @as(c_int, @intCast(row)), .w = @intCast(@min(row + 1, 9)), .h = 1 }; + _ = c.SDL_RenderFillRect(renderer, &line); + } + style.setColor(renderer, style.bright()); + for (2..12) |row| { + var line = c.SDL_Rect{ .x = px + 1, .y = py + @as(c_int, @intCast(row)), .w = @intCast(@min(row - 1, 6)), .h = 1 }; + _ = c.SDL_RenderFillRect(renderer, &line); + } +} diff --git a/src/util/uuid.zig b/src/util/uuid.zig new file mode 100644 index 0000000..4cf462f --- /dev/null +++ b/src/util/uuid.zig @@ -0,0 +1,47 @@ +const std = @import("std"); + +pub const string_length = 36; + +pub fn generate(output: *[string_length + 1]u8) void { + var bytes: [16]u8 = undefined; + std.crypto.random.bytes(&bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + write(output, bytes); +} + +pub fn valid(value: []const u8) bool { + if (value.len != string_length) return false; + for (value, 0..) |byte, index| { + if (index == 8 or index == 13 or index == 18 or index == 23) { + if (byte != '-') return false; + } else if (!std.ascii.isHex(byte)) return false; + } + return value[14] == '4' and (value[19] == '8' or value[19] == '9' or + std.ascii.toLower(value[19]) == 'a' or std.ascii.toLower(value[19]) == 'b'); +} + +fn write(output: *[string_length + 1]u8, bytes: [16]u8) void { + _ = std.fmt.bufPrintZ( + output, + "{x:0>2}{x:0>2}{x:0>2}{x:0>2}-{x:0>2}{x:0>2}-{x:0>2}{x:0>2}-" ++ + "{x:0>2}{x:0>2}-{x:0>2}{x:0>2}{x:0>2}{x:0>2}{x:0>2}{x:0>2}", + .{ + bytes[0], bytes[1], bytes[2], bytes[3], + bytes[4], bytes[5], bytes[6], bytes[7], + bytes[8], bytes[9], bytes[10], bytes[11], + bytes[12], bytes[13], bytes[14], bytes[15], + }, + ) catch unreachable; +} + +test "generates a valid version four UUID" { + var output: [string_length + 1]u8 = undefined; + generate(&output); + try std.testing.expect(valid(std.mem.sliceTo(&output, 0))); +} + +test "rejects malformed UUIDs" { + try std.testing.expect(!valid("12345678-1234-4abc-8def")); + try std.testing.expect(!valid("12345678-1234-1abc-8def-123456789abc")); +} diff --git a/tests/aarch64_runtime_test.sh b/tests/aarch64_runtime_test.sh new file mode 100755 index 0000000..09bf80a --- /dev/null +++ b/tests/aarch64_runtime_test.sh @@ -0,0 +1,116 @@ +#!/bin/sh +set -eu +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +ZIG="$ROOT/.tools/zig-0.14.1/zig" +CHECK="$ROOT/tools/check-aarch64-runtime.sh" +TEST_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/greenovercast-atomics-test.XXXXXX") +trap 'rm -rf "$TEST_ROOT"' EXIT HUP INT TERM +reject() { + expected=$1 + shift + if "$@" >"$TEST_ROOT/rejected.log" 2>&1; then + echo "unexpected success: $*" >&2 + exit 1 + fi + if ! grep -F "$expected" "$TEST_ROOT/rejected.log" >/dev/null; then + cat "$TEST_ROOT/rejected.log" >&2 + echo "missing diagnostic: $expected" >&2 + exit 1 + fi +} +shared() { + "$ZIG" build-lib -target "${3:-aarch64-linux-gnu.2.38}" -dynamic \ + -fno-compiler-rt -fallow-shlib-undefined "$1" -femit-bin="$2" +} +fixture="$TEST_ROOT/project" +mkdir -p "$fixture/tools" "$fixture/.tools/zig-0.14.1" +cp "$ROOT/tools/build-dependencies.sh" "$fixture/tools/" +ln -s "$ZIG" "$fixture/.tools/zig-0.14.1/zig" +sh "$fixture/tools/build-dependencies.sh" --toolchain-only +cat >"$TEST_ROOT/atomic.c" <<'C' +unsigned long long atomic_add(unsigned long long *value) { + return __atomic_fetch_add(value, 1ULL, __ATOMIC_ACQ_REL); +} +C +"$ZIG" cc -target aarch64-linux-gnu.2.38 -mcpu=baseline \ + -O2 -fPIC -moutline-atomics -c "$TEST_ROOT/atomic.c" -o "$TEST_ROOT/outlined.o" +shared "$TEST_ROOT/outlined.o" "$TEST_ROOT/outlined.so" +reject '__aarch64_ldadd8_acq_rel' sh "$CHECK" "$TEST_ROOT/outlined.so" +echo 'PASS: outlined ARM64 C artifact rejected with the reported symbol' +for driver in cc c++; do + language=c + [ "$driver" != "c++" ] || language=c++ + "$fixture/.tools/cross/aarch64-linux-gnu/$driver" -O2 -fPIC \ + -moutline-atomics -x "$language" -c "$TEST_ROOT/atomic.c" -o "$TEST_ROOT/inline.o" + shared "$TEST_ROOT/inline.o" "$TEST_ROOT/inline.so" + sh "$CHECK" "$TEST_ROOT/inline.so" + echo "PASS: $driver wrapper prevents caller flags from re-enabling outline atomics" +done +"$ZIG" build-obj -target aarch64-linux-gnu.2.38 -mcpu=baseline-outline_atomics \ + -O ReleaseSafe -fPIC -cflags -mno-outline-atomics -- "$TEST_ROOT/atomic.c" \ + -femit-bin="$TEST_ROOT/build-obj.o" +shared "$TEST_ROOT/build-obj.o" "$TEST_ROOT/build-obj.so" +sh "$CHECK" "$TEST_ROOT/build-obj.so" +echo 'PASS: Zig C-source flags prevent Clang from re-enabling outline atomics' +cat >"$TEST_ROOT/atomic.zig" <<'ZIG' +export fn atomic_add(value: *u64) u64 { + return @atomicRmw(u64, value, .Add, 1, .acq_rel); +} +ZIG +for policy in +outline_atomics -outline_atomics; do + "$ZIG" build-obj -target aarch64-linux-gnu.2.38 -mcpu="baseline$policy" \ + -O ReleaseSafe -fPIC "$TEST_ROOT/atomic.zig" -femit-bin="$TEST_ROOT/zig.o" + shared "$TEST_ROOT/zig.o" "$TEST_ROOT/zig.so" + case "$policy" in + +*) reject '__aarch64_ldadd8_acq_rel' sh "$CHECK" "$TEST_ROOT/zig.so" ;; + -*) sh "$CHECK" "$TEST_ROOT/zig.so" ;; + esac +done +echo 'PASS: Zig target feature reproduces and removes the same ARM64 helper import' +cat >"$TEST_ROOT/defined.c" <<'C' +unsigned long long __aarch64_ldadd8_acq_rel(unsigned long long add, unsigned long long *value) { + return __atomic_fetch_add(value, add, __ATOMIC_ACQ_REL); +} +C +"$fixture/.tools/cross/aarch64-linux-gnu/cc" -O2 -fPIC \ + -c "$TEST_ROOT/defined.c" -o "$TEST_ROOT/defined.o" +shared "$TEST_ROOT/defined.o" "$TEST_ROOT/defined.so" +sh "$CHECK" "$TEST_ROOT/defined.so" +echo 'PASS: a defined helper is not mistaken for an unresolved import' +cp "$TEST_ROOT/inline.so" "$TEST_ROOT/-inline.so" +( + cd "$TEST_ROOT" + sh "$CHECK" -inline.so +) +reject '__aarch64_ldadd8_acq_rel' sh "$CHECK" "$TEST_ROOT/inline.so" "$TEST_ROOT/outlined.so" +reject 'not a linked executable' sh "$CHECK" "$TEST_ROOT/outlined.o" +"$ZIG" cc -target x86_64-linux-gnu.2.38 -O2 -fPIC \ + -c "$TEST_ROOT/atomic.c" -o "$TEST_ROOT/foreign.o" +shared "$TEST_ROOT/foreign.o" "$TEST_ROOT/foreign.so" x86_64-linux-gnu.2.38 +reject 'not an AArch64 ELF' sh "$CHECK" "$TEST_ROOT/foreign.so" +reject 'usage:' sh "$CHECK" +reject 'missing.so' sh "$CHECK" "$TEST_ROOT/missing.so" +printf 'not an ELF\n' >"$TEST_ROOT/not-elf" +reject 'not-elf' sh "$CHECK" "$TEST_ROOT/not-elf" +if READELF=false sh "$CHECK" "$TEST_ROOT/inline.so" >/dev/null 2>&1; then + echo 'reader failure was accepted' >&2 + exit 1 +fi +echo 'PASS: sidecar imports, wrong architecture/type, invalid files and reader failures are rejected' +# Exercise the standalone package entry point, without invoking PortMaster. +cp "$ROOT/tools/package-portmaster.sh" "$CHECK" "$fixture/tools/" +mkdir -p "$fixture/zig-out/bin" "$fixture/zig-out/lib" "$fixture/zig-out/rockchip" +for path in bin/webrtc_stream lib/libgreenovercast-cedar.so \ + rockchip/libgreenovercast-mpp.so rockchip/librockchip_mpp.so.1 \ + rockchip/greenovercast-mpp-probe.aarch64 bin/libavcodec.so.63 \ + bin/libavutil.so.61 bin/libswscale.so.10 bin/libavformat.so.63 bin/libswresample.so.7; do + cp "$TEST_ROOT/inline.so" "$fixture/zig-out/$path" +done +chmod +x "$fixture/zig-out/bin/webrtc_stream" +PORTMASTER_NEW='' reject 'set PORTMASTER_NEW' sh "$fixture/tools/package-portmaster.sh" +cp "$TEST_ROOT/outlined.so" "$fixture/zig-out/bin/webrtc_stream" +PORTMASTER_NEW='' reject '__aarch64_ldadd8_acq_rel' sh "$fixture/tools/package-portmaster.sh" +cp "$TEST_ROOT/inline.so" "$fixture/zig-out/bin/webrtc_stream" +cp "$TEST_ROOT/outlined.so" "$fixture/zig-out/bin/libavcodec.so.63" +PORTMASTER_NEW='' reject '__aarch64_ldadd8_acq_rel' sh "$fixture/tools/package-portmaster.sh" +echo 'PASS: standalone packaging rejects both a broken executable and a broken bundled library' diff --git a/tests/cedar_list_test.c b/tests/cedar_list_test.c new file mode 100644 index 0000000..834a5e8 --- /dev/null +++ b/tests/cedar_list_test.c @@ -0,0 +1,37 @@ +#include + +struct Entry { + CdxListNodeT node; + int value; +}; + +struct PaddedEntry { + int value; + CdxListNodeT node; +}; + +int main(void) { + CdxListT list; + CdxListInit(&list); + struct Entry* current; + int total = 0; + CdxListForEachEntry(current, &list, node) { + total += current->value; + } + if (total != 0 || !CdxListEmpty(&list)) + return 1; + + struct Entry entry = {.value = 7}; + CdxListAdd(&entry.node, &list); + CdxListForEachEntry(current, &list, node) { + total += current->value; + } + if (total != 7 || CdxListEntry(&entry.node, struct Entry, node) != &entry) + return 1; + CdxListDel(&entry.node); + if (!CdxListEmpty(&list)) + return 1; + + struct PaddedEntry padded = {.value = 3}; + return CdxListEntry(&padded.node, struct PaddedEntry, node) != &padded; +} diff --git a/tests/dependency_toolchain_test.sh b/tests/dependency_toolchain_test.sh new file mode 100644 index 0000000..675aac1 --- /dev/null +++ b/tests/dependency_toolchain_test.sh @@ -0,0 +1,130 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +TEST_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/greenovercast-toolchain-test.XXXXXX") +trap 'rm -rf "$TEST_ROOT"' EXIT HUP INT TERM + +fixture="$TEST_ROOT/project" +prefix="$fixture/.tools/deps/aarch64-linux-gnu" +host_prefix="$TEST_ROOT/host" +mkdir -p "$fixture/tools" "$fixture/.tools/zig-0.14.1" \ + "$prefix/lib/pkgconfig" "$host_prefix/lib/pkgconfig" +cp "$ROOT/tools/build-dependencies.sh" "$fixture/tools/" +printf '#!/bin/sh\nexit 1\n' >"$fixture/.tools/zig-0.14.1/zig" +chmod +x "$fixture/.tools/zig-0.14.1/zig" + +cat >"$prefix/lib/pkgconfig/target-only.pc" <"$host_prefix/lib/pkgconfig/host-only.pc" <"$fixture/CMakeLists.txt" <<'EOF' +cmake_minimum_required(VERSION 3.24) +project(TargetPkgConfig NONE) +find_package(PkgConfig REQUIRED) +pkg_check_modules(TARGET_ONLY REQUIRED target-only) +pkg_check_modules(HOST_ONLY QUIET host-only) +if(HOST_ONLY_FOUND) + message(FATAL_ERROR "Cross toolchain accepted a host pkg-config package") +endif() +if(NOT TARGET_ONLY_LIBRARY_DIRS STREQUAL "${TARGET_PREFIX}/lib") + message(FATAL_ERROR "Cross toolchain changed the target library prefix") +endif() +if(NOT TARGET_ONLY_INCLUDE_DIRS STREQUAL "${TARGET_PREFIX}/include") + message(FATAL_ERROR "Cross toolchain changed the target include prefix") +endif() +EOF + +if ! cmake -S "$fixture" -B "$fixture/build" \ + -DCMAKE_TOOLCHAIN_FILE="$fixture/.tools/cross/aarch64-linux-gnu/toolchain.cmake" \ + -DCMAKE_PREFIX_PATH="$host_prefix" -DTARGET_PREFIX="$prefix" \ + >"$TEST_ROOT/configure.log" 2>&1; then + cat "$TEST_ROOT/configure.log" >&2 + exit 1 +fi +echo "Dependency toolchain isolation passed" + +# Stop before downloads, but exercise the real cache invalidation path. +mock_bin="$TEST_ROOT/mock-bin" +mkdir -p "$mock_bin" "$fixture/vendor/libdatachannel" "$fixture/vendor/mpp" +: >"$fixture/vendor/libdatachannel/CMakeLists.txt" +: >"$fixture/vendor/mpp/CMakeLists.txt" +cat >"$mock_bin/git" <<'EOF_GIT' +#!/bin/sh +case "$2" in + */libdatachannel) echo c6696d157b5612df2a741d9a03b192b47ab6cefb ;; + */mpp) echo c08762ebfadeb4e986d2fed993bc7a54862d3ebe ;; + *) exit 1 ;; +esac +EOF_GIT +cat >"$mock_bin/curl" <<'EOF_CURL' +#!/bin/sh +echo 'fixture: downloads intentionally disabled' >&2 +exit 1 +EOF_CURL +chmod +x "$mock_bin/git" "$mock_bin/curl" +run_until_download() { + if PATH="$mock_bin:$PATH" sh "$fixture/tools/build-dependencies.sh" \ + >"$TEST_ROOT/cache.log" 2>&1; then + echo 'dependency fixture unexpectedly succeeded' >&2 + exit 1 + fi + if ! grep -F 'fixture: downloads intentionally disabled' "$TEST_ROOT/cache.log" >/dev/null; then + cat "$TEST_ROOT/cache.log" >&2 + exit 1 + fi +} +for directory in "$prefix" \ + "$fixture/.tools/build/dependencies-aarch64" \ + "$fixture/.tools/build/libdatachannel-aarch64-release" \ + "$fixture/.tools/build/mpp-aarch64-release"; do + mkdir -p "$directory" + : >"$directory/stale-output" +done +run_until_download +for directory in "$prefix" \ + "$fixture/.tools/build/dependencies-aarch64" \ + "$fixture/.tools/build/libdatachannel-aarch64-release" \ + "$fixture/.tools/build/mpp-aarch64-release"; do + [ ! -e "$directory/stale-output" ] || { + echo "stale dependency survived: $directory" >&2 + exit 1 + } +done +: >"$prefix/fresh-output" +run_until_download +[ -f "$prefix/fresh-output" ] || { + echo 'unchanged toolchain unnecessarily discarded the cache' >&2 + exit 1 +} +printf 'old toolchain\n' >"$fixture/.tools/build/dependencies-aarch64/.toolchain-signature" +sh "$fixture/tools/build-dependencies.sh" --toolchain-only +[ -f "$prefix/fresh-output" ] || { + echo '--toolchain-only discarded dependency outputs' >&2 + exit 1 +} +run_until_download +[ ! -e "$prefix/fresh-output" ] || { + echo 'changed toolchain reused stale installed libraries' >&2 + exit 1 +} +echo 'Dependency cache invalidation and unchanged-toolchain reuse passed' diff --git a/tests/device/README.md b/tests/device/README.md new file mode 100644 index 0000000..d45bf58 --- /dev/null +++ b/tests/device/README.md @@ -0,0 +1,40 @@ +# Ad playback tests + +Build with `tools/zig.sh build ad-player-test`. The default test uses the native +player with mock ad servers and tracking endpoints. It makes no network requests. + +Generate fixtures on a host with FFmpeg: + +```sh +ffmpeg -f lavfi -i testsrc2=size=640x360:rate=30 \ + -f lavfi -i sine=frequency=440:sample_rate=48000 -t 5 \ + -c:v libx264 -profile:v high -bf 2 -g 60 -pix_fmt yuv420p \ + -c:a aac -ac 2 -movflags +faststart ad-test.mp4 +ffmpeg -f lavfi -i color=blue:size=30x30 -frames:v 1 ad-icon.png +ffmpeg -f lavfi -i testsrc2=size=1920x1080 -frames:v 1 ad-panel.png +ffmpeg -i ad-test.mp4 -t 3 -c copy ad-short.mp4 +ffmpeg -i ad-test.mp4 -filter_complex '[0:a]atrim=duration=4[a]' \ + -map 0:v -map '[a]' -c:v copy -c:a aac ad-video-tail.mp4 +``` + +Copy the test executable, fixtures and release libraries into one directory on +the target. Launch it through the device frontend, with no other app running: + +```sh +LD_LIBRARY_PATH=. ./greenovercast-ad-test.aarch64 \ + ad-test.mp4 ad-icon.png ad-short.mp4 ad-video-tail.mp4 ad-panel.png +``` + +It checks paced MP4/AAC playback, multi-ad pods, wrapper and inline tracking, AdChoices +pause/resume, empty inventory, invalid and truncated media, video extending +beyond audio, wrapper loops, and cancellation. A successful +synthetic test does not prove that an ad provider served a real advertisement. + +For a live VAST URL, including a [Google sample ad](https://developers.google.com/interactive-media-ads/docs/sdks/html5/client-side/tags): + +```sh +LD_LIBRARY_PATH=. ./greenovercast-ad-test.aarch64 --live-ad "$VAST_URL" +``` + +This sends real media and tracking requests. NVIDIA session reports are recorded +locally; no NVIDIA account is attached to the test. diff --git a/tests/device/ad_playback_test.zig b/tests/device/ad_playback_test.zig new file mode 100644 index 0000000..a03967b --- /dev/null +++ b/tests/device/ad_playback_test.zig @@ -0,0 +1,323 @@ +const std = @import("std"); +const ads = @import("gfn_ads"); +const c = @cImport({ + @cInclude("SDL2/SDL.h"); +}); +const allocator = std.heap.c_allocator; + +const inline_manifest = + "https://example.invalid/impression" ++ + "https://example.invalid/error/[ERRORCODE]?at=[ADPLAYHEAD]" ++ + "00:00:05" ++ + "https://example.invalid/media" ++ + "https://example.invalid/start" ++ + "https://example.invalid/quarter" ++ + "https://example.invalid/half" ++ + "https://example.invalid/three-quarter" ++ + "https://example.invalid/complete" ++ + "https://example.invalid/pause" ++ + "https://example.invalid/resume" ++ + "" ++ + "https://example.invalid/icon" ++ + "https://example.invalid/icon-view" ++ + "https://example.invalid/icon-click" ++ + "" ++ + "https://example.invalid/icon" ++ + ""; +const wrapper_manifest = + "https://example.invalid/wrapper-view" ++ + "https://example.invalid/wrapper-error/[ERRORCODE]" ++ + "https://example.invalid/inline"; +const media_url = "https://example.invalid/media"; +const media_offset = std.mem.indexOf(u8, inline_manifest, media_url).?; +const pod_manifest = "" ++ + inline_manifest[10..media_offset] ++ media_url ++ "-2" ++ inline_manifest[media_offset + media_url.len .. inline_manifest.len - 12] ++ + "" ++ + inline_manifest[10..media_offset] ++ media_url ++ "-1" ++ inline_manifest[media_offset + media_url.len .. inline_manifest.len - 12] ++ ""; + +const Mode = enum { error_report_failure, drain_cancel, wrapper_invalid_error_tracker, wrapper_loop_error_tracker, play, pause, large_fallback, slow_pause, audio_running, macros, tracker_failure, cancel, broken, error_tracker_failure, cancel_report_failure, empty, empty_report_failure, truncated, video_tail, pod, pod_truncated, pod_download_failure, wrapper_empty, wrapper_loop, wrapper_invalid }; +const Recorded = struct { update: ads.protocol.Update, time_ms: u64 }; + +fn prependOversizedError(storage: std.mem.Allocator, manifest: []const u8) ![]u8 { + var oversized: [8192]u8 = @splat('x'); + const prefix = "https://example.invalid/"; + @memcpy(oversized[0..prefix.len], prefix); + const macro = "[ADPLAYHEAD]"; + @memcpy(oversized[oversized.len - macro.len ..], macro); + const error_at = std.mem.indexOf(u8, manifest, "").?; + return std.fmt.allocPrint(storage, "{s}{s}{s}", .{ manifest[0..error_at], oversized, manifest[error_at..] }); +} + +const Context = struct { + media: []const u8, + image: []const u8, + fallback_image: []const u8 = "", + short_media: []const u8, + mode: Mode, + start: u64, + timeout_ms: u64 = 20000, + paused: bool = false, + resumed: bool = false, + playback_started_at: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), + pause_accepted_at: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), + media_reads: usize = 0, + media_order: [2]u8 = .{ 0, 0 }, + error_beacon_started: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + report_rejections: usize = 0, + mutex: std.Thread.Mutex = .{}, + updates: std.ArrayList(Recorded), + beacons: std.ArrayList([]u8), + + fn read(pointer: *anyopaque, storage: std.mem.Allocator, address: []const u8, _: usize, abort: *std.atomic.Value(bool)) ![]u8 { + const self: *Context = @ptrCast(@alignCast(pointer)); + if (abort.load(.acquire)) return error.Cancelled; + if (std.mem.indexOf(u8, address, "/tag?") != null) { + if (self.mode == .wrapper_invalid_error_tracker or self.mode == .wrapper_loop_error_tracker) return prependOversizedError(storage, wrapper_manifest); + return storage.dupe(u8, if (self.mode == .empty or self.mode == .empty_report_failure) "" else if (self.mode == .pod or self.mode == .pod_truncated or self.mode == .pod_download_failure) pod_manifest else wrapper_manifest); + } + if (std.mem.endsWith(u8, address, "/inline")) { + if (self.mode == .macros) return std.mem.replaceOwned(u8, storage, inline_manifest, "/impression", "/impression?asset=[ASSETURI]&custom=[VENDOR_TOKEN]"); + if (self.mode == .large_fallback) return std.mem.replaceOwned(u8, storage, inline_manifest, "https://example.invalid/icon", "https://example.invalid/panel"); + if (self.mode == .error_tracker_failure) return prependOversizedError(storage, inline_manifest); + return storage.dupe(u8, if (self.mode == .wrapper_empty) "" else if (self.mode == .wrapper_loop or self.mode == .wrapper_loop_error_tracker) wrapper_manifest else if (self.mode == .wrapper_invalid or self.mode == .wrapper_invalid_error_tracker) "" else inline_manifest); + } + if (std.mem.endsWith(u8, address, "/media") or std.mem.endsWith(u8, address, "/media-1") or std.mem.endsWith(u8, address, "/media-2")) { + if (self.media_reads < self.media_order.len) self.media_order[self.media_reads] = if (std.mem.endsWith(u8, address, "-1")) 1 else if (std.mem.endsWith(u8, address, "-2")) 2 else 0; + self.media_reads += 1; + if (self.mode == .slow_pause) c.SDL_Delay(1600); + if (self.mode == .pod_download_failure and self.media_reads == 2) return error.AdDownloadFailed; + return storage.dupe(u8, if (self.mode == .broken or self.mode == .error_tracker_failure or self.mode == .error_report_failure or self.mode == .cancel_report_failure or self.mode == .drain_cancel) "not an MP4" else if (self.mode == .pod_truncated and self.media_reads == 2) self.short_media else self.media); + } + if (std.mem.endsWith(u8, address, "/icon")) return storage.dupe(u8, self.image); + if (std.mem.endsWith(u8, address, "/panel")) return storage.dupe(u8, self.fallback_image); + if (self.mode == .error_report_failure or self.mode == .drain_cancel) self.error_beacon_started.store(true, .release); + if (self.mode == .drain_cancel) c.SDL_Delay(100); + self.mutex.lock(); + defer self.mutex.unlock(); + const owned = try storage.dupe(u8, address); + self.beacons.append(owned) catch |err| { + storage.free(owned); + return err; + }; + if (self.mode == .tracker_failure) return error.AdDownloadFailed; + return storage.alloc(u8, 0); + } + + fn report(pointer: *anyopaque, update: ads.protocol.Update) !void { + const self: *Context = @ptrCast(@alignCast(pointer)); + self.mutex.lock(); + defer self.mutex.unlock(); + try self.updates.append(.{ .update = update, .time_ms = c.SDL_GetTicks64() - self.start }); + if (update.action == .start) self.playback_started_at.store(c.SDL_GetTicks64(), .release); + if (update.action == .pause) self.pause_accepted_at.store(c.SDL_GetTicks64(), .release); + if (self.mode == .error_report_failure and update.action == .event) { + const until = c.SDL_GetTicks64() + 2000; + while (!self.error_beacon_started.load(.acquire) and c.SDL_GetTicks64() < until) c.SDL_Delay(1); + self.report_rejections += 1; + return error.AdReportRejected; + } + if ((self.mode == .cancel_report_failure or self.mode == .empty_report_failure) and update.action == .cancel) return error.AdReportRejected; + } + + fn count(self: *Context, suffix: []const u8) usize { + var result: usize = 0; + for (self.beacons.items) |address| if (std.mem.endsWith(u8, address, suffix)) { + result += 1; + }; + return result; + } +}; + +export fn go_handheld_ui_cancel_requested(pointer: ?*anyopaque) c_int { + const context: *Context = @ptrCast(@alignCast(pointer.?)); + return @intFromBool(context.mode == .drain_cancel and context.error_beacon_started.load(.acquire)); +} +export fn go_handheld_ui_draw_loading(_: ?*anyopaque, _: [*:0]const u8, _: [*:0]const u8, _: c_int) void {} +export fn go_handheld_ui_draw_ad_hint(_: ?*anyopaque, _: c_int) void {} +export fn go_handheld_ui_ad_action(pointer: ?*anyopaque) c_int { + const context: *Context = @ptrCast(@alignCast(pointer.?)); + const elapsed = c.SDL_GetTicks64() - context.start; + if (elapsed > context.timeout_ms or (context.mode == .cancel and elapsed > 1000)) return -1; + if (context.mode == .pause or context.mode == .large_fallback or context.mode == .slow_pause) { + const started = context.playback_started_at.load(.acquire); + const accepted = context.pause_accepted_at.load(.acquire); + if (started != 0 and c.SDL_GetTicks64() - started > 500 and !context.paused) { + context.paused = true; + return 1; + } + if (accepted != 0 and c.SDL_GetTicks64() - accepted > 700 and !context.resumed) { + context.resumed = true; + return 2; + } + } + return 0; +} + +fn require(value: bool) !void { + if (!value) return error.AssertionFailed; +} + +fn readFixture(path: []const u8, limit: usize) ![]u8 { + const file = try std.fs.cwd().openFile(path, .{}); + defer file.close(); + return file.reader().readAllAlloc(allocator, limit); +} + +fn run(renderer: *c.SDL_Renderer, device: c.SDL_AudioDeviceID, media: []const u8, short: []const u8, image: []const u8, fallback_image: []const u8, mode: Mode) !void { + c.SDL_PauseAudioDevice(device, @intFromBool(mode != .audio_running)); + const previous_audio_status = c.SDL_GetAudioDeviceStatus(device); + var context = Context{ .media = media, .short_media = short, .image = image, .fallback_image = fallback_image, .mode = mode, .start = c.SDL_GetTicks64(), .updates = std.ArrayList(Recorded).init(allocator), .beacons = std.ArrayList([]u8).init(allocator) }; + defer { + for (context.beacons.items) |address| allocator.free(address); + context.beacons.deinit(); + context.updates.deinit(); + } + var worker = ads.network.Worker{ .allocator = allocator, .reporter = .{ .context = &context, .send = Context.report }, .source = .{ .context = &context, .read = Context.read } }; + try worker.start(); + defer worker.deinit(); + var playback = ads.Playback{ .renderer = @ptrCast(renderer), .device = device, .ui = @ptrCast(&context), .provider = 1, .app_id = "1", .opportunity = .{ .id = "test-pod", .url = "https://example.invalid/tag", .state = 1, .duration_ms = 5000 } }; + const failure: ?anyerror = result: { + playback.execute(&worker) catch |err| break :result err; + break :result null; + }; + worker.deinit(); + try require(c.SDL_GetAudioDeviceStatus(device) == previous_audio_status); + const success = mode == .play or mode == .pause or mode == .large_fallback or mode == .slow_pause or mode == .audio_running or mode == .macros or mode == .tracker_failure or mode == .video_tail or mode == .pod; + const no_fill = mode == .empty or mode == .wrapper_empty; + try require(if (success or no_fill) failure == null else failure != null); + if (mode == .truncated) try require(failure.? == error.AdMediaTruncated); + if (mode == .broken or mode == .error_tracker_failure or mode == .error_report_failure or mode == .cancel_report_failure) try require(failure.? == error.InvalidMp4); + if (mode == .error_report_failure) try require(context.report_rejections == 1); + if (mode == .drain_cancel) try require(failure.? == error.Cancelled); + if (mode == .empty_report_failure) try require(failure.? == error.AdReportRejected); + if (mode == .error_tracker_failure) try require(context.count("/error/405?at=00%3A00%3A00.000") == 1); + if (mode == .pod or mode == .pod_truncated or mode == .pod_download_failure) try require(std.mem.eql(u8, &context.media_order, &.{ 1, 2 })); + if (mode == .pod_truncated) { + var first_end: ?u64 = null; + for (context.updates.items) |record| { + if (record.update.action == .event and std.mem.eql(u8, record.update.event.?, "COMPLETE")) { + first_end = record.update.watched_ms; + break; + } + } + try require(failure.? == error.AdMediaTruncated and first_end != null); + const local = playback.watched_ms - first_end.?; + try require(local >= 3098 and local <= 3100); + var buffer: [64]u8 = undefined; + const suffix = try std.fmt.bufPrint(&buffer, "/error/405?at=00%3A00%3A03.{d:0>3}", .{local % 1000}); + try require(context.count(suffix) == 1); + } + if (mode == .pod_download_failure) { + try require(failure.? == error.AdDownloadFailed and playback.watched_ms >= 4998 and playback.watched_ms <= 5000); + try require(context.count("/error/401?at=00%3A00%3A00.000") == 1); + } + if (mode == .wrapper_loop or mode == .wrapper_loop_error_tracker) try require(failure.? == error.VastWrapperLimit); + if (mode == .wrapper_invalid or mode == .wrapper_invalid_error_tracker) { + try require(failure.? == error.InvalidXml); + try require(context.count("/wrapper-error/100") == 1); + } + var starts: usize = 0; + var finishes: usize = 0; + var cancels: usize = 0; + var milestones: usize = 0; + for (context.updates.items) |record| switch (record.update.action) { + .start => { + starts += 1; + try require(record.update.watched_ms == 0); + }, + .finish => { + finishes += 1; + const duration: u64 = if (mode == .pod) 10000 else 5000; + try require(record.time_ms >= duration - 100 and record.update.watched_ms >= duration - 100 and record.update.watched_ms <= duration + 100); + if (mode == .pause or mode == .large_fallback or mode == .slow_pause) try require(record.update.paused_ms >= 700 and record.time_ms >= 5700); + }, + .cancel => { + cancels += 1; + if (mode == .drain_cancel) try require(std.mem.eql(u8, record.update.reason.?, "USER_CANCELLED")); + if (mode == .empty) try require(record.update.watched_ms == 0 and + std.mem.eql(u8, record.update.reason.?, "VAST_EMPTY_RESPONSE")); + if (mode == .wrapper_empty) try require(record.update.watched_ms == 0 and + std.mem.eql(u8, record.update.reason.?, "VAST_NO_ADS_AFTER_WRAPPER")); + }, + .event => { + if (std.mem.eql(u8, record.update.event.?, "FIRST_QUARTILE") or + std.mem.eql(u8, record.update.event.?, "MIDPOINT") or + std.mem.eql(u8, record.update.event.?, "THIRD_QUARTILE")) milestones += 1; + }, + else => {}, + }; + try require(finishes == @intFromBool(success) and cancels == @intFromBool(!success)); + if (success) { + const ads_played: usize = if (mode == .pod) 2 else 1; + try require(starts == 1 and milestones == 3 * ads_played); + for ([_][]const u8{ "/impression", "/start", "/quarter", "/half", "/three-quarter", "/complete", "/icon-view" }) |suffix| { + const expected = if (mode == .macros and std.mem.eql(u8, suffix, "/impression")) "/impression?asset=-1&custom=[VENDOR_TOKEN]" else suffix; + try require(context.count(expected) == ads_played); + } + try require(context.count("/wrapper-view") == @intFromBool(mode != .pod)); + if (mode == .pause or mode == .large_fallback or mode == .slow_pause) for ([_][]const u8{ "/pause", "/resume", "/icon-click" }) |suffix| try require(context.count(suffix) == 1); + } else try require(context.count("/complete") == @intFromBool(mode == .pod_truncated or mode == .pod_download_failure)); + std.debug.print("PASS ad playback: {s}, watched={d}ms paused={d}ms reports={d} beacons={d}\n", .{ @tagName(mode), playback.watched_ms, playback.paused_ms, context.updates.items.len, context.beacons.items.len }); +} + +pub fn main() !void { + const args = try std.process.argsAlloc(allocator); + defer std.process.argsFree(allocator, args); + const live = args.len == 3 and std.mem.eql(u8, args[1], "--live-ad"); + if (!live and args.len != 6) return error.ExpectedMp4PngShortVideoTailAndFallbackFixtures; + if (c.SDL_Init(c.SDL_INIT_VIDEO | c.SDL_INIT_AUDIO) != 0) return error.SdlUnavailable; + defer c.SDL_Quit(); + const window = c.SDL_CreateWindow("Ad playback test", 0, 0, 640, 480, c.SDL_WINDOW_FULLSCREEN) orelse return error.WindowUnavailable; + defer c.SDL_DestroyWindow(window); + const renderer = c.SDL_CreateRenderer(window, -1, c.SDL_RENDERER_ACCELERATED) orelse + c.SDL_CreateRenderer(window, -1, 0) orelse return error.RendererUnavailable; + defer c.SDL_DestroyRenderer(renderer); + _ = c.SDL_RenderSetLogicalSize(renderer, 640, 480); + var spec = std.mem.zeroes(c.SDL_AudioSpec); + spec.freq = 48000; + spec.channels = 2; + spec.format = c.AUDIO_S16SYS; + spec.samples = 960; + const device = c.SDL_OpenAudioDevice(null, 0, &spec, null, 0); + if (device == 0) return error.AudioUnavailable; + defer c.SDL_CloseAudioDevice(device); + if (live) return runLive(renderer, device, args[2]); + const media = try readFixture(args[1], 96 * 1024 * 1024); + defer allocator.free(media); + const image = try readFixture(args[2], 4 * 1024 * 1024); + defer allocator.free(image); + const short = try readFixture(args[3], 96 * 1024 * 1024); + defer allocator.free(short); + const tail = try readFixture(args[4], 96 * 1024 * 1024); + defer allocator.free(tail); + const fallback_image = try readFixture(args[5], 4 * 1024 * 1024); + defer allocator.free(fallback_image); + for (std.enums.values(Mode)) |mode| + try run(renderer, device, if (mode == .truncated) short else if (mode == .video_tail) tail else media, short, image, fallback_image, mode); +} + +fn runLive(renderer: *c.SDL_Renderer, device: c.SDL_AudioDeviceID, address: []const u8) !void { + var context = Context{ .media = "", .short_media = "", .image = "", .mode = .pause, .start = c.SDL_GetTicks64(), .timeout_ms = 180000, .updates = std.ArrayList(Recorded).init(allocator), .beacons = std.ArrayList([]u8).init(allocator) }; + defer context.updates.deinit(); + defer context.beacons.deinit(); + var worker = ads.network.Worker{ .allocator = allocator, .reporter = .{ .context = &context, .send = Context.report } }; + try worker.start(); + defer worker.deinit(); + var playback = ads.Playback{ .renderer = @ptrCast(renderer), .device = device, .ui = @ptrCast(&context), .provider = 1, .app_id = "1", .opportunity = .{ .id = "live-component-test", .url = address, .state = 1, .duration_ms = 0 } }; + std.debug.print("Live ad component test: real network/media/tracking; no NVIDIA account attached\n", .{}); + try playback.execute(&worker); + const until = c.SDL_GetTicks64() + 30000; + while (!try worker.idle() and c.SDL_GetTicks64() < until) c.SDL_Delay(16); + try require(try worker.idle()); + try require(worker.beacon_failures == 0 and playback.watched_ms > 0); + var starts: usize = 0; + var finishes: usize = 0; + for (context.updates.items) |record| { + if (record.update.action == .start) starts += 1; + if (record.update.action == .finish) finishes += 1; + try require(record.update.action != .cancel); + } + try require(starts == 1 and finishes == 1); + if (context.pause_accepted_at.load(.acquire) != 0) try require(context.resumed and playback.paused_ms >= 700); + std.debug.print("PASS live ad: watched={d}ms paused={d}ms reports={d}, no failed tracking requests\n", .{ playback.watched_ms, playback.paused_ms, context.updates.items.len }); +} diff --git a/tests/gfn_http_fake.zig b/tests/gfn_http_fake.zig new file mode 100644 index 0000000..f3d4cd8 --- /dev/null +++ b/tests/gfn_http_fake.zig @@ -0,0 +1,107 @@ +const std = @import("std"); + +const Response = extern struct { + data: ?[*]u8, + len: usize, + status: c_long, +}; + +pub var requests: usize = 0; +pub var polls: usize = 0; +pub var cancelled = false; +pub var cancel_on_poll: usize = 1; +pub var response: ?*Response = null; + +pub fn reset() void { + requests = 0; + polls = 0; + cancelled = false; + cancel_on_poll = 1; + response = null; +} + +var no_content = Response{ .data = null, .len = 0, .status = 204 }; +var json_response: Response = undefined; + +pub fn replyJson(data: []const u8) void { + json_response = .{ .data = @constCast(data.ptr), .len = data.len, .status = 200 }; + response = &json_response; +} + +pub fn replyNoContent() void { + response = &no_content; +} + +pub fn authClient(comptime Client: type) Client { + return .{ + .allocator = std.testing.allocator, + .ui = @ptrFromInt(1), + .oauth_client_id = @constCast("test-client"), + .protocol_client_id = @constCast("test-protocol"), + .credential_path = @constCast("unused"), + .key_path = @constCast("unused"), + .device_id_path = @constCast("unused"), + .device_id = [_]u8{0} ** 37, + .tokens = .{ + .allocator = std.testing.allocator, + .access_token = @constCast("header.eyJzdWIiOiJ0ZXN0LXVzZXIifQ.signature"), + .refresh_token = null, + .id_token = null, + .client_token = null, + .expires_at = 0, + }, + }; +} + +export fn go_http_request_bounded_cancelable( + _: [*:0]const u8, + _: [*:0]const u8, + _: ?[*:0]const u8, + _: ?[*]const [*:0]const u8, + _: c_int, + _: usize, + cancel: ?*const fn (?*anyopaque) callconv(.c) c_int, + context: ?*anyopaque, +) ?*Response { + requests += 1; + if (cancel) |check| { + if (check(context) != 0) return null; + } + return response; +} + +export fn go_http_response_destroy(_: ?*Response) void {} + +export fn go_http_response_succeeded(value: ?*const Response) c_int { + const result = value orelse return 0; + return @intFromBool(result.status >= 200 and result.status < 300); +} + +export fn go_http_request_bounded( + _: [*:0]const u8, + _: [*:0]const u8, + _: ?[*:0]const u8, + _: ?[*]const [*:0]const u8, + _: c_int, + _: usize, +) ?*Response { + requests += 1; + return response; +} + +export fn go_handheld_ui_cancel_requested(_: ?*anyopaque) c_int { + polls += 1; + if (cancelled or polls < cancel_on_poll) return 0; + cancelled = true; + return 1; +} + +export fn go_handheld_ui_cancelled(_: ?*anyopaque) c_int { + return @intFromBool(cancelled); +} + +export fn go_handheld_ui_wait(_: ?*anyopaque, _: u32) c_int { + return 0; +} + +export fn go_handheld_ui_draw_loading(_: ?*anyopaque, _: [*:0]const u8, _: [*:0]const u8, _: c_int) void {} diff --git a/tests/http_client_test.zig b/tests/http_client_test.zig new file mode 100644 index 0000000..fd046b0 --- /dev/null +++ b/tests/http_client_test.zig @@ -0,0 +1,70 @@ +const std = @import("std"); +const c = @cImport({ + @cInclude("http_client.h"); + @cInclude("curl/curl.h"); +}); + +const Receiver = struct { + server: *std.net.Server, + method: ?std.http.Method = null, + body: [256]u8 = undefined, + body_length: usize = 0, + failure: ?anyerror = null, + + fn run(self: *Receiver) void { + self.receive() catch |err| { + self.failure = err; + }; + } + + fn receive(self: *Receiver) !void { + var descriptors = [_]std.posix.pollfd{.{ + .fd = self.server.stream.handle, + .events = std.posix.POLL.IN, + .revents = 0, + }}; + if (try std.posix.poll(&descriptors, 5000) == 0) return error.AcceptTimedOut; + const connection = try self.server.accept(); + defer connection.stream.close(); + const timeout = std.posix.timeval{ .sec = 5, .usec = 0 }; + try std.posix.setsockopt(connection.stream.handle, std.posix.SOL.SOCKET, std.posix.SO.RCVTIMEO, std.mem.asBytes(&timeout)); + var buffer: [4096]u8 = undefined; + var http = std.http.Server.init(connection, &buffer); + var request = try http.receiveHead(); + self.method = request.head.method; + const reader = try request.reader(); + self.body_length = try reader.readAll(&self.body); + try request.respond("ok", .{ .keep_alive = false }); + } +}; + +fn checkRequest(method: [:0]const u8, body: ?[:0]const u8, expected: std.http.Method) !void { + const loopback = try std.net.Address.parseIp4("127.0.0.1", 0); + var server = try loopback.listen(.{}); + defer server.deinit(); + const address = try std.fmt.allocPrintZ(std.testing.allocator, "http://127.0.0.1:{d}/session", .{server.listen_address.getPort()}); + defer std.testing.allocator.free(address); + var receiver = Receiver{ .server = &server }; + const thread = try std.Thread.spawn(.{}, Receiver.run, .{&receiver}); + const response = c.go_http_request_bounded(method.ptr, address.ptr, if (body) |value| value.ptr else null, null, 0, 256); + thread.join(); + defer c.go_http_response_destroy(response); + if (receiver.failure) |err| return err; + try std.testing.expect(response != null); + try std.testing.expectEqual(@as(c_long, 200), response.*.status); + try std.testing.expectEqual(expected, receiver.method.?); + try std.testing.expectEqualStrings(body orelse "", receiver.body[0..receiver.body_length]); + try std.testing.expectEqualStrings("ok", response.*.data[0..response.*.len]); +} + +test "HTTP request methods preserve their bodies on the wire" { + try std.testing.expect(c.curl_global_init(c.CURL_GLOBAL_DEFAULT) == c.CURLE_OK); + defer c.curl_global_cleanup(); + try checkRequest("PUT", "{\"action\":6,\"adUpdates\":[]}", .PUT); + try checkRequest("PATCH", "{\"value\":true}", .PATCH); + try checkRequest("POST", "{\"appId\":1}", .POST); + try checkRequest("POST", null, .POST); + try checkRequest("GET", null, .GET); + try checkRequest("GET", "query body", .GET); + try checkRequest("DELETE", null, .DELETE); +} diff --git a/tools/build-dependencies.sh b/tools/build-dependencies.sh index 92b4b98..c1790f4 100755 --- a/tools/build-dependencies.sh +++ b/tools/build-dependencies.sh @@ -19,6 +19,7 @@ CXX_WRAPPER="$CROSS/c++" AR_WRAPPER="$CROSS/ar" RANLIB_WRAPPER="$CROSS/ranlib" TOOLCHAIN="$CROSS/toolchain.cmake" +CLANG_CONFIG="$CROSS/clang.cfg" LIBDATACHANNEL_SOURCE="$ROOT/vendor/libdatachannel" LIBDATACHANNEL_BUILD="$TOOLS/build/libdatachannel-aarch64-release" LIBDATACHANNEL_COMMIT=c6696d157b5612df2a741d9a03b192b47ab6cefb @@ -32,7 +33,7 @@ MPP_COMMIT=c08762ebfadeb4e986d2fed993bc7a54862d3ebe exit 1 } -for command_name in cmake curl make patch perl python3 tar; do +for command_name in cmake curl make patch perl pkg-config python3 tar; do command -v "$command_name" >/dev/null 2>&1 || { echo "missing build tool: $command_name" >&2 exit 1 @@ -41,17 +42,24 @@ done mkdir -p "$DOWNLOADS" "$SOURCES" "$BUILDS" "$PREFIX" "$CROSS" "$TOOLS/cache/zig" +# pkg-config's host search paths are independent of CMake's find root. +export PKG_CONFIG_LIBDIR="$PREFIX/lib/pkgconfig:$PREFIX/share/pkgconfig" +export PKG_CONFIG_PATH= +export PKG_CONFIG_SYSROOT_DIR= + write_wrapper() { wrapper=$1 driver=$2 { printf '%s\n' '#!/bin/sh' - printf 'exec "%s" %s -target aarch64-linux-gnu.2.38 -ffile-prefix-map="%s"=. "$@"\n' \ - "$ZIG" "$driver" "$ROOT" + printf 'exec "%s" %s -target aarch64-linux-gnu.2.38 -ffile-prefix-map="%s"=. "$@" -mno-outline-atomics --config="%s"\n' \ + "$ZIG" "$driver" "$ROOT" "$CLANG_CONFIG" } >"$wrapper" chmod +x "$wrapper" } +# Zig 0.14.1 consumes -m flags before Clang applies its Linux defaults. +printf '%s\n' '-mno-outline-atomics' >"$CLANG_CONFIG" write_wrapper "$CC_WRAPPER" cc write_wrapper "$CXX_WRAPPER" c++ { @@ -77,6 +85,10 @@ chmod +x "$AR_WRAPPER" "$RANLIB_WRAPPER" printf '%s\n' 'set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)' printf '%s\n' 'set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)' printf '%s\n' 'set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)' + printf 'set(ENV{PKG_CONFIG_LIBDIR} "%s")\n' "$PKG_CONFIG_LIBDIR" + printf '%s\n' 'set(ENV{PKG_CONFIG_PATH} "")' + printf '%s\n' 'set(ENV{PKG_CONFIG_SYSROOT_DIR} "")' + printf '%s\n' 'set(PKG_CONFIG_USE_CMAKE_PREFIX_PATH FALSE)' } >"$TOOLCHAIN" if [ "${1:-}" = "--toolchain-only" ]; then @@ -103,6 +115,19 @@ actual_mpp_commit=$(git -C "$MPP_SOURCE" rev-parse HEAD) exit 1 } +# Compiler flags are not part of the per-library completion stamps. Invalidate +# both build trees and installed libraries when the generated toolchain changes. +toolchain_signature=$(cksum "$CC_WRAPPER" "$CXX_WRAPPER" "$TOOLCHAIN" "$CLANG_CONFIG") +cached_signature= +if [ -f "$BUILDS/.toolchain-signature" ]; then + cached_signature=$(cat "$BUILDS/.toolchain-signature") +fi +if [ "$cached_signature" != "$toolchain_signature" ]; then + rm -rf "$BUILDS" "$LIBDATACHANNEL_BUILD" "$MPP_BUILD" "$PREFIX" + mkdir -p "$BUILDS" "$PREFIX" + printf '%s\n' "$toolchain_signature" >"$BUILDS/.toolchain-signature" +fi + hash_file() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1 @@ -178,6 +203,12 @@ fetch_source libdrm-2.4.128.tar.xz libdrm-2.4.128 \ fetch_source SDL2-2.28.5.tar.gz SDL2-2.28.5 \ https://github.com/libsdl-org/SDL/releases/download/release-2.28.5/SDL2-2.28.5.tar.gz \ 332cb37d0be20cb9541739c61f79bae5a477427d79ae85e352089afdaf6666e4 +fetch_source zlib-1.3.2.tar.gz zlib-1.3.2 \ + https://zlib.net/zlib-1.3.2.tar.gz \ + bb329a0a2cd0274d05519d61c667c062e06990d72e125ee2dfa8de64f0119d16 +fetch_source expat-2.8.5.tar.xz expat-2.8.5 \ + https://github.com/libexpat/libexpat/releases/download/R_2_8_5/expat-2.8.5.tar.xz \ + 1e727b8933ec51a77a9a9d9afcf8e688bce45d907c13e36ab7393fe36e703182 export ZIG_GLOBAL_CACHE_DIR="$TOOLS/cache/zig" export SOURCE_DATE_EPOCH=0 @@ -221,8 +252,8 @@ if [ ! -f "$BUILDS/.opus-1.6.1-static" ]; then : >"$BUILDS/.opus-1.6.1-static" fi -if [ ! -f "$BUILDS/.curl-8.20.0-static-http" ]; then - curl_build="$BUILDS/curl-8.20.0-static-http" +if [ ! -f "$BUILDS/.curl-8.20.0-static-http-ws" ]; then + curl_build="$BUILDS/curl-8.20.0-static-http-ws" cmake -S "$SOURCES/curl-8.20.0" -B "$curl_build" \ -DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" \ -DCMAKE_INSTALL_PREFIX="$PREFIX" \ @@ -232,7 +263,22 @@ if [ ! -f "$BUILDS/.curl-8.20.0-static-http" ]; then -DBUILD_SHARED_LIBS=OFF \ -DBUILD_STATIC_LIBS=ON \ -DBUILD_TESTING=OFF \ - -DHTTP_ONLY=ON \ + -DHTTP_ONLY=OFF \ + -DCURL_DISABLE_WEBSOCKETS=OFF \ + -DCURL_DISABLE_DICT=ON \ + -DCURL_DISABLE_FILE=ON \ + -DCURL_DISABLE_FTP=ON \ + -DCURL_DISABLE_GOPHER=ON \ + -DCURL_DISABLE_IMAP=ON \ + -DCURL_DISABLE_IPFS=ON \ + -DCURL_DISABLE_LDAP=ON \ + -DCURL_DISABLE_LDAPS=ON \ + -DCURL_DISABLE_MQTT=ON \ + -DCURL_DISABLE_POP3=ON \ + -DCURL_DISABLE_RTSP=ON \ + -DCURL_DISABLE_SMTP=ON \ + -DCURL_DISABLE_TELNET=ON \ + -DCURL_DISABLE_TFTP=ON \ -DCURL_USE_OPENSSL=ON \ -DOPENSSL_ROOT_DIR="$PREFIX" \ -DOPENSSL_USE_STATIC_LIBS=ON \ @@ -252,12 +298,12 @@ if [ ! -f "$BUILDS/.curl-8.20.0-static-http" ]; then -DCURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt cmake --build "$curl_build" --parallel cmake --install "$curl_build" - : >"$BUILDS/.curl-8.20.0-static-http" + : >"$BUILDS/.curl-8.20.0-static-http-ws" fi -if [ ! -f "$BUILDS/.sdl2-2.28.5-link" ]; then +if [ ! -f "$BUILDS/.sdl2-2.28.5-link-v2" ]; then sdl_build="$BUILDS/SDL2-2.28.5-link" - cmake -S "$SOURCES/SDL2-2.28.5" -B "$sdl_build" \ + cmake --fresh -S "$SOURCES/SDL2-2.28.5" -B "$sdl_build" \ -DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" \ -DCMAKE_INSTALL_PREFIX="$PREFIX" \ -DCMAKE_INSTALL_LIBDIR=lib \ @@ -283,7 +329,7 @@ if [ ! -f "$BUILDS/.sdl2-2.28.5-link" ]; then -DSDL_FCITX=OFF cmake --build "$sdl_build" --parallel cmake --install "$sdl_build" - : >"$BUILDS/.sdl2-2.28.5-link" + : >"$BUILDS/.sdl2-2.28.5-link-v2" fi if [ ! -f "$BUILDS/.libudev-zero-1.0.3-static" ]; then @@ -305,22 +351,49 @@ if [ ! -f "$BUILDS/.libdrm-2.4.128-v4l2-headers" ]; then : >"$BUILDS/.libdrm-2.4.128-v4l2-headers" fi -if [ ! -f "$BUILDS/.ffmpeg-9.0-h264-mjpeg-v4l2-request-shared" ]; then - ffmpeg_build="$BUILDS/ffmpeg-9.0-h264-mjpeg-v4l2-request-shared" +if [ ! -f "$BUILDS/.zlib-1.3.2-static" ]; then + zlib_build="$BUILDS/zlib-1.3.2-static" + cmake -S "$SOURCES/zlib-1.3.2" -B "$zlib_build" \ + -DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" -DCMAKE_INSTALL_PREFIX="$PREFIX" \ + -DCMAKE_POSITION_INDEPENDENT_CODE=ON -DCMAKE_BUILD_TYPE=Release \ + -DZLIB_BUILD_SHARED=OFF -DZLIB_BUILD_STATIC=ON -DZLIB_BUILD_TESTING=OFF + cmake --build "$zlib_build" --parallel + cmake --install "$zlib_build" + : >"$BUILDS/.zlib-1.3.2-static" +fi + +if [ ! -f "$BUILDS/.expat-2.8.5-static" ]; then + expat_build="$BUILDS/expat-2.8.5-static" + cmake -S "$SOURCES/expat-2.8.5" -B "$expat_build" \ + -DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" -DCMAKE_INSTALL_PREFIX="$PREFIX" \ + -DCMAKE_INSTALL_LIBDIR=lib -DCMAKE_POSITION_INDEPENDENT_CODE=ON \ + -DCMAKE_BUILD_TYPE=Release -DEXPAT_SHARED_LIBS=OFF -DEXPAT_DTD=OFF \ + -DEXPAT_BUILD_TOOLS=OFF -DEXPAT_BUILD_EXAMPLES=OFF -DEXPAT_BUILD_TESTS=OFF + cmake --build "$expat_build" --parallel + cmake --install "$expat_build" + : >"$BUILDS/.expat-2.8.5-static" +fi + +if [ ! -f "$BUILDS/.ffmpeg-9.0-mp4-vast-v4l2-request-shared" ]; then + ffmpeg_build="$BUILDS/ffmpeg-9.0-mp4-vast-v4l2-request-shared" ffmpeg_source="$BUILDS/ffmpeg-9.0" ffmpeg_request_patch="$DOWNLOADS/ffmpeg-9.0-v4l2-request.patch" ffmpeg_portable_patch="$ROOT/vendor/patches/ffmpeg-9.0-v4l2-request-portable.patch" - rm -rf "$ffmpeg_build" "$ffmpeg_source" - mkdir -p "$ffmpeg_build" + ffmpeg_patch_dir="$BUILDS/ffmpeg-9.0-patches" + rm -rf "$ffmpeg_build" "$ffmpeg_source" "$ffmpeg_patch_dir" + mkdir -p "$ffmpeg_build" "$ffmpeg_patch_dir" tar -xf "$DOWNLOADS/ffmpeg-9.0.tar.xz" -C "$BUILDS" - patch -d "$ffmpeg_source" -p1 <"$ffmpeg_request_patch" + # Later mailbox patches depend on edits made by earlier patches. + git mailsplit -o"$ffmpeg_patch_dir" "$ffmpeg_request_patch" + for request_patch in "$ffmpeg_patch_dir"/*; do + patch -d "$ffmpeg_source" -p1 <"$request_patch" + done patch -d "$ffmpeg_source" -p1 <"$ffmpeg_portable_patch" find "$ffmpeg_source" -name '*.orig' -delete nm_tool=$(command -v llvm-nm || command -v nm) ( cd "$ffmpeg_build" env -u CPPFLAGS -u CFLAGS -u CXXFLAGS -u LDFLAGS \ - PKG_CONFIG_PATH="$PREFIX/lib/pkgconfig" \ "$ffmpeg_source/configure" \ --prefix="$PREFIX" \ --arch=aarch64 \ @@ -338,10 +411,17 @@ if [ ! -f "$BUILDS/.ffmpeg-9.0-h264-mjpeg-v4l2-request-shared" ]; then --disable-all \ --enable-avcodec \ --enable-avutil \ + --enable-avformat \ + --enable-swresample \ --enable-swscale \ --enable-decoder=h264 \ --enable-decoder=mjpeg \ + --enable-decoder=aac \ + --enable-decoder=png \ --enable-parser=h264 \ + --enable-parser=aac \ + --enable-demuxer=mov \ + --enable-zlib \ --enable-v4l2-request \ --enable-libudev \ --disable-libdrm \ @@ -353,12 +433,12 @@ if [ ! -f "$BUILDS/.ffmpeg-9.0-h264-mjpeg-v4l2-request-shared" ]; then --disable-doc \ --disable-debug \ --disable-stripping \ - --extra-cflags="-O3 -I$PREFIX/include/libdrm -ffile-prefix-map=$ROOT=." \ + --extra-cflags="-O3 -I$PREFIX/include -I$PREFIX/include/libdrm -ffile-prefix-map=$ROOT=." \ --extra-ldflags="-L$PREFIX/lib" make -j4 make install-libs install-headers ) - : >"$BUILDS/.ffmpeg-9.0-h264-mjpeg-v4l2-request-shared" + : >"$BUILDS/.ffmpeg-9.0-mp4-vast-v4l2-request-shared" fi if [ ! -f "$LIBDATACHANNEL_BUILD/.greenovercast-$LIBDATACHANNEL_COMMIT" ]; then diff --git a/tools/check-aarch64-runtime.sh b/tools/check-aarch64-runtime.sh new file mode 100755 index 0000000..ae8e64a --- /dev/null +++ b/tools/check-aarch64-runtime.sh @@ -0,0 +1,48 @@ +#!/bin/sh +set -eu +export LC_ALL=C +if [ "$#" -eq 0 ]; then + echo "usage: $0 ELF_FILE..." >&2 + exit 1 +fi +reader=${READELF:-} +if [ -z "$reader" ]; then + for candidate in llvm-readelf readelf greadelf; do + if command -v "$candidate" >/dev/null 2>&1; then + reader=$candidate + break + fi + done +fi +[ -n "$reader" ] || { + echo "missing llvm-readelf or GNU readelf; install LLVM or binutils" >&2 + exit 1 +} +status=0 +for artifact; do + case "$artifact" in + /*) ;; + *) artifact="./$artifact" ;; + esac + if ! symbols=$("$reader" -h -W --dyn-syms "$artifact" 2>&1); then + printf '%s\n' "$symbols" >&2 + status=1 + continue + fi + if ! printf '%s\n' "$symbols" | grep -Eq 'Machine:[[:space:]]+AArch64[[:space:]]*$'; then + printf '%s: not an AArch64 ELF artifact\n' "$artifact" >&2 + status=1 + continue + fi + if ! printf '%s\n' "$symbols" | grep -Eq 'Type:[[:space:]]+(DYN|EXEC)[[:space:]]'; then + printf '%s: not a linked executable or shared library\n' "$artifact" >&2 + status=1 + continue + fi + missing=$(printf '%s\n' "$symbols" | awk '$7 == "UND" && $8 ~ /^__aarch64_/ { print $8 }' | sort -u) + if [ -n "$missing" ]; then + printf '%s: unresolved AArch64 runtime helpers:\n%s\n' "$artifact" "$missing" >&2 + status=1 + fi +done +exit "$status" diff --git a/tools/package-portmaster.sh b/tools/package-portmaster.sh index 6adc739..a956f18 100755 --- a/tools/package-portmaster.sh +++ b/tools/package-portmaster.sh @@ -7,13 +7,15 @@ OUTPUT=${1:-$ROOT/zig-out/greenovercast.zip} PORTMASTER_NEW=${PORTMASTER_NEW:-${2:-}} SOURCE="$ROOT/packaging/portmaster/greenovercast" BINARY="$ROOT/zig-out/bin/webrtc_stream" -CEDAR="$ROOT/zig-out/bin/libgreenovercast-cedar.so" +CEDAR="$ROOT/zig-out/lib/libgreenovercast-cedar.so" MPP_PLUGIN="$ROOT/zig-out/rockchip/libgreenovercast-mpp.so" MPP_RUNTIME="$ROOT/zig-out/rockchip/librockchip_mpp.so.1" MPP_PROBE="$ROOT/zig-out/rockchip/greenovercast-mpp-probe.aarch64" AVCODEC="$ROOT/zig-out/bin/libavcodec.so.63" AVUTIL="$ROOT/zig-out/bin/libavutil.so.61" SWSCALE="$ROOT/zig-out/bin/libswscale.so.10" +AVFORMAT="$ROOT/zig-out/bin/libavformat.so.63" +SWRESAMPLE="$ROOT/zig-out/bin/libswresample.so.7" case "$OUTPUT" in /*) ;; @@ -34,12 +36,16 @@ for mpp_file in "$MPP_PLUGIN" "$MPP_RUNTIME" "$MPP_PROBE"; do exit 1 } done -for private_library in "$AVCODEC" "$AVUTIL" "$SWSCALE"; do +for private_library in "$AVCODEC" "$AVUTIL" "$SWSCALE" "$AVFORMAT" "$SWRESAMPLE"; do [ -f "$private_library" ] || { echo "missing private library: $private_library" >&2 exit 1 } done +sh "$ROOT/tools/check-aarch64-runtime.sh" \ + "$BINARY" "$CEDAR" "$MPP_PLUGIN" "$MPP_RUNTIME" "$MPP_PROBE" \ + "$AVCODEC" "$AVUTIL" "$SWSCALE" "$AVFORMAT" "$SWRESAMPLE" + [ -n "$PORTMASTER_NEW" ] || { echo "set PORTMASTER_NEW to a current PortMaster-New checkout" >&2 exit 1 @@ -75,6 +81,8 @@ cp "$ROOT/vendor/mpp/LICENSES/MIT" \ cp "$AVCODEC" "$port/greenovercast/libavcodec.so.63" cp "$AVUTIL" "$port/greenovercast/libavutil.so.61" cp "$SWSCALE" "$port/greenovercast/libswscale.so.10" +cp "$AVFORMAT" "$port/greenovercast/libavformat.so.63" +cp "$SWRESAMPLE" "$port/greenovercast/libswresample.so.7" find "$port" -type f -exec chmod 644 {} + : >"$checker/.github_check" @@ -118,6 +126,8 @@ required = { "greenovercast/libavcodec.so.63", "greenovercast/libavutil.so.61", "greenovercast/libswscale.so.10", + "greenovercast/libavformat.so.63", + "greenovercast/libswresample.so.7", } expected_hashes = { "greenovercast/rocknix/h700/greenovercast_h700_overlay.ko": diff --git a/vendor/cedarx/base/include/CdxTypes.h b/vendor/cedarx/base/include/CdxTypes.h index 99c5b59..e3302ae 100755 --- a/vendor/cedarx/base/include/CdxTypes.h +++ b/vendor/cedarx/base/include/CdxTypes.h @@ -18,6 +18,8 @@ #ifndef CDX_TYPES_H #define CDX_TYPES_H +#include + #ifdef NULL #undef NULL #endif @@ -63,7 +65,7 @@ typedef cdx_int32 cdx_err; #define CDX_TRUE 1 #define CDX_FALSE 0 -#define CdxOffsetof(TYPE, MEMBER) ((size_t) &((TYPE *)0)->MEMBER) +#define CdxOffsetof(TYPE, MEMBER) offsetof(TYPE, MEMBER) #define CdxContainerOf(ptr, type, member) ({ \ const typeof(((type *)0)->member) *__mptr = (ptr); \ diff --git a/vendor/manifest.lock b/vendor/manifest.lock index f4d4d36..e2dd6cf 100644 --- a/vendor/manifest.lock +++ b/vendor/manifest.lock @@ -42,7 +42,15 @@ commit = "c08762ebfadeb4e986d2fed993bc7a54862d3ebe" license = "Apache-2.0 and MIT" linkage = "dynamic optional decoder plugin; firmware MPP preferred when compatible" build = "git submodule headers and private userspace library; tools/build-dependencies.sh and build.zig" -status = "live cloud playback proven on RK3566 SpruceOS 4.2.0 and RK3326 AmberELEC prerelease-20250515; RK3326 dArkOS 4.4 offline 720p decode and frontend startup also proven" +status = "live cloud playback proven on RK3566 SpruceOS 4.2.0/4.3.3 and RK3326 AmberELEC prerelease-20250515; GFN gameplay tested on RK3326 dArkOS 02012026, with uneven render cadence" + +[asset.provider_icons] +source = "https://github.com/twbs/icons" +commit = "6945b7006285d444cc17ff2e22c7691719229526" +license = "MIT" +nvidia_svg_sha256 = "3e1027070f7db94347d631ebf6299cc01a3e813610af743506f5acc1257aef81" +xbox_svg_sha256 = "e73c5df7997cfec0f6deed44c8a160bb649af865789cafd688db9bedaaf42e1a" +build = "icons/nvidia.svg and icons/xbox.svg rasterized to 24x24 alpha masks in src/ui/provider_badge.zig" [dep.libdatachannel] version = "v0.24.3" @@ -65,7 +73,7 @@ version = "8.20.0" source = "https://curl.se/download/curl-8.20.0.tar.xz" sha256 = "63fe2dc148ba0ceae89922ef838f7e5c946272c2e78b7c59fab4b79d3ce2b896" license = "curl (MIT-style)" -linkage = "static; HTTP and HTTPS only" +linkage = "static; HTTP, HTTPS, and WebSocket" status = "source-built by tools/build-dependencies.sh" [dep.ffmpeg] @@ -73,10 +81,27 @@ version = "9.0" source = "https://ffmpeg.org/releases/ffmpeg-9.0.tar.xz" sha256 = "7f607a00dd0d28a729d5a4811205812eef01cf6ef6155025febb6f36a9062d52" license = "LGPLv2.1-or-later" -linkage = "dynamic private libavcodec, libavutil, and libswscale" -build = "H.264 and MJPEG decoders, H.264 parser, and H.264 V4L2 request hwaccel; no programs, network support, GPL, or nonfree components" +linkage = "dynamic private libavcodec, libavformat, libavutil, libswresample, and libswscale" +build = "H.264, MJPEG, PNG, and AAC decoders; MP4 demuxer; H.264 parser and V4L2 request hwaccel; no programs, network support, GPL, or nonfree components" status = "source-built by tools/build-dependencies.sh and shipped with the port" +[dep.expat] +version = "2.8.5" +source = "https://github.com/libexpat/libexpat/releases/download/R_2_8_5/expat-2.8.5.tar.xz" +sha256 = "1e727b8933ec51a77a9a9d9afcf8e688bce45d907c13e36ab7393fe36e703182" +license = "MIT" +linkage = "static" +build = "XML parser with DTD support disabled" +status = "source-built by tools/build-dependencies.sh" + +[dep.zlib] +version = "1.3.2" +source = "https://zlib.net/zlib-1.3.2.tar.gz" +sha256 = "bb329a0a2cd0274d05519d61c667c062e06990d72e125ee2dfa8de64f0119d16" +license = "Zlib" +linkage = "static into FFmpeg" +status = "source-built by tools/build-dependencies.sh" + [dep.ffmpeg_v4l2_request] source = "https://raw.githubusercontent.com/ROCKNIX/distribution/e9e6b8531df13bc9058ca1771dab5f0c4fd5e98e/packages/multimedia/ffmpeg/patches/v4l2-request/0001-v4l2-request.patch" commit = "e9e6b8531df13bc9058ca1771dab5f0c4fd5e98e"