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 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]