diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7136926..e5ab909 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 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..4a64437 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,39 @@ 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. + ## 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`. + +Host tests also need SDL2 development files: install +`libsdl2-dev pkg-config` on Debian/Ubuntu or `sdl2 pkg-config` with Homebrew. ```sh tools/bootstrap.sh diff --git a/THIRDPARTY.md b/THIRDPARTY.md index e4ba393..d285464 100644 --- a/THIRDPARTY.md +++ b/THIRDPARTY.md @@ -19,5 +19,8 @@ release. Full license texts for the bundled libraries ship alongside the port in | [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..debebc4 100644 --- a/build.zig +++ b/build.zig @@ -11,7 +11,6 @@ const aarch64_linux_query: std.Target.Query = .{ const c_test_flags = &.{ "-std=c11", "-Wall", "-Wextra", "-Werror" }; const project_include_paths = [_][]const u8{ - ".tools/deps/aarch64-linux-gnu/include", "vendor/libdatachannel/include", "src/media/audio", "src/media/video", @@ -51,6 +50,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 +81,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 +123,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 }); @@ -146,6 +148,28 @@ fn addStaticArchive(module: *std.Build.Module, b: *std.Build, path: []const u8) 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 }); + 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,6 +190,31 @@ 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, + "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(.{ @@ -456,32 +505,97 @@ 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 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/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 video_decoder_object = addHostZigObject(b, "video-decoder", "src/media/video/video_decoder.zig"); @@ -536,6 +650,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/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/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..c67ac8d 100644 --- a/src/app/release.zig +++ b/src/app/release.zig @@ -1,5 +1,9 @@ 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 c = @cImport({ @cInclude("SDL2/SDL.h"); @@ -21,11 +25,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 +79,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 +104,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 +171,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 +277,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 +304,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 +344,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 +369,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 +431,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 +457,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 +467,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 +491,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 +520,15 @@ pub const Release = struct { } pub fn waitReady(self: *Release) Result { + if (self.provider == .geforce_now) { + 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 +539,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 +549,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 +560,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 +604,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 +654,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 +667,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 +683,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 +753,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 +815,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 +847,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/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..166d2d8 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; @@ -135,7 +144,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 +157,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/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..44cceae --- /dev/null +++ b/src/provider/geforce_now/cloudmatch_protocol.zig @@ -0,0 +1,819 @@ +const std = @import("std"); +const endpoint = @import("endpoint.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, + + 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 { + 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, + }; +} + +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 "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..6fad6bf --- /dev/null +++ b/src/provider/geforce_now/session_client.zig @@ -0,0 +1,732 @@ +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 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, + + 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.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; + uuid.generate(&self.client_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.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); + } + + 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.clientId()), + 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.clientId()), + 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)); + 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); +} 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/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/handheld_ui.h b/src/ui/handheld_ui.h index 8f53382..d1f49c5 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,25 @@ 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_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..9e38a0e 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,11 @@ 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_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 +702,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/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..546f282 --- /dev/null +++ b/tests/dependency_toolchain_test.sh @@ -0,0 +1,64 @@ +#!/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" 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/tools/build-dependencies.sh b/tools/build-dependencies.sh index 92b4b98..d9c4ccd 100755 --- a/tools/build-dependencies.sh +++ b/tools/build-dependencies.sh @@ -32,7 +32,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,6 +41,11 @@ 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 @@ -77,6 +82,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 @@ -221,8 +230,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 +241,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 +276,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 +307,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 @@ -320,7 +344,6 @@ if [ ! -f "$BUILDS/.ffmpeg-9.0-h264-mjpeg-v4l2-request-shared" ]; then ( 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 \ diff --git a/tools/package-portmaster.sh b/tools/package-portmaster.sh index 6adc739..611f528 100755 --- a/tools/package-portmaster.sh +++ b/tools/package-portmaster.sh @@ -7,7 +7,7 @@ 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" 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..37137ed 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]