diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8f75bff..4d054fd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -271,6 +271,61 @@ jobs: if-no-files-found: ignore retention-days: 7 + # Adds the -windows target frameworks to the packages the macOS pack job produced. + # + # OpenTok.Net has three platform heads and no runner can build all of them: the ios head needs + # Xcode, the windows head needs the Windows SDK reference packs and WinUI. So + # src/OpenTok.Net.props appends the -windows target frameworks only when building on Windows, + # which makes the macOS-packed package correct, complete for iOS and Android, and silently devoid + # of Windows assets. + # + # This closes that the same way the net9/net10 band split is closed — pack again where it can be + # packed, then merge with build/merge-packages.py. Uploaded under a second artifact name rather + # than replacing the first, because an artifact cannot be overwritten and because the two are + # genuinely different: the release job publishes this one, while the iOS and Android verification + # jobs are unaffected by whether it succeeded. + add-windows-assets: + name: add Windows assets to the packages + timeout-minutes: 30 + needs: pack + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 9.0.x + 10.0.x + + - name: Install the MAUI workload + run: dotnet workload install maui-windows + + - name: Download packages + uses: actions/download-artifact@v4 + with: + name: nuget-packages + path: artifacts + + # The script asserts the result itself: require_windows_frameworks reads the lib// folders + # out of every merged package and fails the step if any of them carries no Windows assets, so + # a pack that quietly emitted no -windows target framework cannot reach the upload below. + # Repeating that here as a shell one-liner was worse than nothing: with `-o pipefail`, which + # this shell sets, `unzip -l pkg | grep -q ...` reports failure whenever grep matches early + # enough to exit before unzip has finished writing, because unzip then dies of SIGPIPE and its + # 141 becomes the pipeline's status. That is a coin toss on the package's entry order, and it + # came up tails on a package that did have its Windows assets. + - name: Pack the Windows heads and merge them in + shell: bash + run: ./build/AddWindowsAssets.sh "${{ inputs.version }}" + + - name: Upload packages + uses: actions/upload-artifact@v4 + with: + name: nuget-packages-with-windows + path: artifacts + sample: name: build sample app timeout-minutes: 45 @@ -354,7 +409,10 @@ jobs: verify-release: name: Release build (${{ matrix.platform }}) timeout-minutes: 45 - needs: pack + # add-windows-assets as well as pack: only the windows leg needs it, but a matrix has one set + # of dependencies. The cost is that the iOS and Android legs wait for a Windows pack they do not + # use; the alternative is a second nearly identical job. + needs: [pack, add-windows-assets] if: ${{ inputs.verify-release }} runs-on: ${{ matrix.runs-on }} strategy: @@ -374,6 +432,19 @@ jobs: target-framework: net9.0-android35.0 workload: maui-android extra-args: -p:RuntimeIdentifier=android-arm64 + # x64, not the runner's native architecture: OpenTok.Client's native payload has no arm64 + # build, and OpenTok.Net.Win's own targets file fails the build rather than let that + # surface at runtime. Also the only leg that needs the Windows assets to have been merged + # into the package — see the add-windows-assets job. + - platform: windows + runs-on: windows-latest + target-framework: net9.0-windows10.0.19041.0 + workload: maui-windows + # OpenTokWindowsOnly, so the sample declares the Windows head alone. Without it the + # declared list still names an ios head this runner cannot build in principle, which + # breaks MauiVersion resolution for the head it can — the NU1015 trap the sample's own + # .csproj documents. + extra-args: -p:OpenTokWindowsOnly=true -p:RuntimeIdentifier=win-x64 -p:SelfContained=false steps: - uses: actions/checkout@v4 @@ -404,10 +475,12 @@ jobs: with: packages: 'platforms;android-35' + # The Windows leg needs the package that has Windows assets in it, which is a different + # artifact — see the add-windows-assets job for why there are two. - name: Download packages uses: actions/download-artifact@v4 with: - name: nuget-packages + name: ${{ matrix.platform == 'windows' && 'nuget-packages-with-windows' || 'nuget-packages' }} path: artifacts # net9, not net10 as the Debug sample job uses: this needs no scratch global.json, and the @@ -429,7 +502,12 @@ jobs: # # Unlike the Debug sample job, this cannot be fixed by installing both workloads: that job # runs entirely on macOS, where both exist. This one deliberately builds Android on Linux. + # shell: bash on this step and the next, because the windows leg would otherwise get pwsh — + # which reads the line continuations below as an operator and fails to parse the command + # before ever running it. The two legs on macOS and Linux already had bash by default, which + # is why this only ever surfaced here. - name: Build the sample in Release + shell: bash run: | dotnet build samples/OpenTok.Sample.Maui/OpenTok.Sample.Maui.csproj \ --configuration Release \ @@ -441,6 +519,7 @@ jobs: # .app is assembled, and on Android r8 and the packaging step run before the APK is. A failure # in any of them never gets far enough to produce one. - name: Check the app was produced + shell: bash run: | case "${{ matrix.platform }}" in ios) @@ -449,6 +528,9 @@ jobs: android) produced="$(find samples/OpenTok.Sample.Maui/bin/Release -name '*.apk' -print -quit)" ;; + windows) + produced="$(find samples/OpenTok.Sample.Maui/bin/Release -name 'OpenTok.Sample.Maui.exe' -print -quit)" + ;; esac if [ -z "${produced}" ]; then @@ -457,3 +539,18 @@ jobs: fi echo "produced ${produced}" + + # The renderer is useless if Vonage's native payload does not travel with it, and unlike the + # other two platforms nothing about the build fails when it does not — opentok.dll is copied + # by a plain Content glob in OpenTok.Client's own targets, so a packaging change drops it + # silently and the app dies on the first call into the SDK. + - name: Check the native payload reached the output + if: matrix.platform == 'windows' + shell: bash + run: | + found="$(find samples/OpenTok.Sample.Maui/bin/Release -name 'opentok.dll' -print -quit)" + if [ -z "${found}" ]; then + echo "::error::opentok.dll is missing from the Windows build output" + exit 1 + fi + echo "ok ${found}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b0a0535..ba10a6a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,10 +103,16 @@ jobs: with: dotnet-version: 9.0.x + # nuget-packages-with-windows, not nuget-packages. The pack job runs on macOS and cannot build + # a -windows target framework at all, so its output is complete for iOS and Android and has no + # Windows assets whatsoever; build.yml's add-windows-assets job packs those separately and + # merges them in. Publishing the wrong one of the two would ship a package that restores fine + # and then has nothing for a Windows head — silently, since NuGet reports a missing target + # framework only when something asks for it. - name: Download packages uses: actions/download-artifact@v4 with: - name: nuget-packages + name: nuget-packages-with-windows path: artifacts # nuget.org is published to first: a GitHub release linking to packages that failed to upload diff --git a/Directory.Build.props b/Directory.Build.props index 945db06..006cd49 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -22,7 +22,7 @@ makes the package invisible to `dotnet add package`. --> 2.34.1 - 3 + 4 $(OpenTokVersion).$(OpenTokBindingRevision) + 10.0.19041.0 + 10.0.17763.0 + + + 2.3.1 + + + 2.34.1.4 + + + 2.34.1 + s.bokatuk s.bokatuk Copyright © s.bokatuk diff --git a/README.md b/README.md index d5f55b8..9cd8cae 100755 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ [![OpenTok SDK 2.34.1](https://img.shields.io/badge/OpenTok%20SDK-2.34.1-099DFD)](https://developer.vonage.com/en/video/client-sdk/overview) [![Licence: MIT](https://img.shields.io/badge/licence-MIT-green)](LICENSE) -One `Session`/`Publisher`/`Subscriber` API over Vonage's (formerly TokBox's) native OpenTok iOS and -Android SDKs, so a .NET or .NET MAUI app writes its calling code once. +One `Session`/`Publisher`/`Subscriber` API over Vonage's (formerly TokBox's) OpenTok iOS, Android +and Windows SDKs, so a .NET or .NET MAUI app writes its calling code once. ```bash dotnet add package OpenTok.Net.Maui @@ -27,8 +27,8 @@ session.StreamReceived += (_, e) => session.Connect(token); ``` -No `#if IOS`, no `#if ANDROID`, no delegate subclass, no Java listener, no `JavaCast`, and no -hand-written video-view handler. `samples/OpenTok.Sample.Maui` is that code as a running app — +No `#if IOS`, no `#if ANDROID`, no `#if WINDOWS`, no delegate subclass, no Java listener, no +`JavaCast`, and no hand-written video-view handler. `samples/OpenTok.Sample.Maui` is that code as a running app — compare it with the per-platform samples in the two binding repositories, which are the same flow written twice. @@ -38,10 +38,35 @@ written twice. | Package | Depends on | Use it when | | --- | --- | --- | -| `OpenTok.Net` | [`OpenTok.Net.iOS`](https://github.com/sbokatuk/OpenTok.Net.iOS) / [`OpenTok.Net.Android`](https://github.com/sbokatuk/OpenTok.Net.Android), per target framework | Always — `OpenTokSession`, `OpenTokPublisher`, `OpenTokSubscriber`. | +| `OpenTok.Net` | [`OpenTok.Net.iOS`](https://github.com/sbokatuk/OpenTok.Net.iOS) / [`OpenTok.Net.Android`](https://github.com/sbokatuk/OpenTok.Net.Android) / [`OpenTok.Net.Win`](https://github.com/sbokatuk/OpenTok.Net.Win), per target framework | Always — `OpenTokSession`, `OpenTokPublisher`, `OpenTokSubscriber`. | | `OpenTok.Net.Maui` | `OpenTok.Net` + `Microsoft.Maui.Controls` | You are building MAUI and want `OpenTokVideoView` rather than writing a handler. | -Both carry `net8.0`, `net9.0` and `net10.0` for iOS and Android — six target frameworks each. +Both carry `net8.0`, `net9.0` and `net10.0` for iOS, Android and Windows — nine target frameworks +each. + +### Windows + +Windows arrives differently from the other two. Vonage's `OpenTok.Client` is already managed .NET, +so there is no binding to generate — but it ships video renderers only for WPF and Windows Forms, +and none at all on the `netstandard2.0` asset a modern .NET app resolves. .NET MAUI on Windows is +WinUI 3, so without help a MAUI app can connect, publish and subscribe and have nowhere to put the +picture. [`OpenTok.Net.Win`](https://github.com/sbokatuk/OpenTok.Net.Win) 2.34.1.4 supplies the WinUI +renderer; this façade uses it, and you do not reference it directly. + +Two things about Windows that the other platforms do not ask of you: + +* **x64 only.** `OpenTok.Client`'s native payload has no arm64 build. `OpenTok.Net.Win` fails the + build with **OTW0001** rather than letting that become a `BadImageFormatException` after launch. + Windows on ARM runs the x64 build under emulation. +* **Create the first OpenTok object on the UI thread.** The Windows SDK has an explicit context + object with no iOS or Android equivalent, and the façade binds it to that thread's dispatcher + queue so events arrive somewhere they can touch the UI. It is what `OpenTokSession` already asks + for; on Windows it is enforced rather than advised. + +Some of the shared API has no Windows equivalent and is a documented no-op there rather than an +exception — camera position, torch and zoom (desktop webcams have none), the end-to-end encryption +secret, `Pause()`/`Resume()`, and all of `OpenTokAudioSession` (Windows has no CallKit analogue). A +façade whose common API throws on one platform would not be a façade. For background blur, background replacement or noise suppression, add the platform transformers package too — `OpenTok.Net.Transformers.iOS` and/or `OpenTok.Net.Transformers.Android`. Neither is @@ -76,7 +101,7 @@ per-platform code. | Sample | Platforms | Shows | | --- | --- | --- | -| `OpenTok.Sample.Maui` | both | Everything in the table above, one page, no per-platform code | +| `OpenTok.Sample.Maui` | iOS, Android, Windows | Everything in the table above, one page, no per-platform code | | `OpenTok.Sample.CallKit.iOS` | iOS | `CXProvider`, answering from the system call UI, audio handed to CallKit | | `OpenTok.Sample.Telecom.Android` | Android | `ConnectionService` + `PhoneAccount`, and a camera/microphone foreground service | diff --git a/build/AddWindowsAssets.sh b/build/AddWindowsAssets.sh new file mode 100755 index 0000000..f17a91e --- /dev/null +++ b/build/AddWindowsAssets.sh @@ -0,0 +1,187 @@ +#!/bin/sh + +set -e + +# Adds the Windows target frameworks to packages that were already packed on macOS. +# +# Usage (on Windows, from a checkout with artifacts/ already populated): +# ./build/AddWindowsAssets.sh # version from Directory.Build.props +# ./build/AddWindowsAssets.sh 2.34.1.3-beta.1 # explicit package version, matching the pack +# +# Why this is a second script rather than part of BuildNugets.sh: +# +# OpenTok.Net has three platform heads and no machine can build all of them. The ios head needs +# Xcode, so packing happens on macOS; the windows head needs the Windows SDK reference packs and +# WinUI, which exist only on Windows. src/OpenTok.Net.props therefore appends the -windows target +# frameworks only when the build is running on Windows — so a package produced by BuildNugets.sh on +# a Mac is correct, complete for iOS and Android, and silently has no Windows assets at all. +# +# This closes that gap the same way the net9/net10 band split is closed: pack again on the platform +# that can, then merge with build/merge-packages.py, which copies across any lib// the primary +# package does not already have and adds the matching nuspec dependency group. +# +# Run it *after* BuildNugets.sh, against the same artifacts/ directory and the same version. The +# result overwrites the packages in place, so running it twice is harmless — the second run finds +# the Windows assets already present and copies nothing. +# +# Both SDK bands are packed for the same reason as BuildNugets.sh: net8 and net9 windows heads come +# from the .NET 9 band and net10 from the .NET 10 band. Unlike ios and android there is no workload +# to install — a -windows target framework needs only the Windows SDK reference pack, which every +# band carries. + +cd "$(dirname "$0")" + +VERSION="$1" +ROOT="$(cd .. && pwd)" +OUTPUT="$ROOT/artifacts" + +PASS1_BAND="net9" +PASS2_BAND="net10" +PASS2_SDK="10.0.100" + +case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*|Windows_NT) ;; + *) + echo "error: this must run on Windows — a -windows target framework cannot be built" >&2 + echo " anywhere else. Run build/BuildNugets.sh on macOS first, then this here." >&2 + exit 1 + ;; +esac + +PACKAGES=$(grep -v '^#' packages.tsv | grep -v '^[[:space:]]*$' | cut -f1) + +# The lib// folders a package carries for Windows, one per line, or nothing at all. +# +# Read from the zip rather than from the merge's own report: merge-packages.py prints the target +# frameworks it *added*, and a package can advertise a target framework in its nuspec while +# carrying no assets for it — which restores, and then fails to compile at whoever picked it. +windows_frameworks() { + python3 - "$1" <<'PY' +import sys +import zipfile + +with zipfile.ZipFile(sys.argv[1]) as package: + frameworks = { + name.split("/")[1] + for name in package.namelist() + if name.startswith("lib/") and "-windows" in name.split("/")[1] and name.count("/") > 1 + } + +print("\n".join(sorted(frameworks))) +PY +} + +# Fails unless the package carries Windows assets, naming it and listing what it does carry. +require_windows_frameworks() { + frameworks=$(windows_frameworks "$1" | tr '\n' ' ') + + if [ -z "$(echo "$frameworks" | tr -d ' ')" ]; then + echo "error: $(basename "$1") carries no lib// assets for Windows." >&2 + exit 1 + fi + + echo " $(basename "$1"): $frameworks" +} + +if [ -z "$PACKAGES" ]; then + echo "error: no packages found in build/packages.tsv" >&2 + exit 1 +fi + +VERSION_ARG="" +if [ -n "$VERSION" ]; then + case "$VERSION" in + *[!A-Za-z0-9.+_-]*) + echo "error: invalid version '$VERSION'" >&2 + exit 1 + ;; + esac + VERSION_ARG="-p:Version=$VERSION" +fi + +if [ ! -d "$OUTPUT" ] || [ -z "$(ls "$OUTPUT"/*.nupkg 2>/dev/null)" ]; then + echo "error: no packages in $OUTPUT. Run build/BuildNugets.sh on macOS first and bring its" >&2 + echo " artifacts/ here — this script adds to existing packages, it does not create them." >&2 + exit 1 +fi + +WIN1_DIR="$OUTPUT/.win9-pass" +WIN2_DIR="$OUTPUT/.win10-pass" +PRIMARY_DIR="$OUTPUT/.primary" +MERGED_DIR="$OUTPUT/.merged" + +SDK10_DIR="$(mktemp -d)" +trap 'rm -rf "$SDK10_DIR" "$WIN1_DIR" "$WIN2_DIR" "$PRIMARY_DIR" "$MERGED_DIR"' EXIT +cat > "$SDK10_DIR/global.json" <&2 + exit 1 + fi + + rm -rf "$WIN1_DIR" "$WIN2_DIR" "$PRIMARY_DIR" "$MERGED_DIR" + + echo "==> packing $package windows heads ($PASS1_BAND band)" + dotnet pack "$project" \ + -c Release \ + -p:OpenTokSdkBand="$PASS1_BAND" \ + -p:OpenTokWindowsOnly=true \ + $VERSION_ARG \ + -o "$WIN1_DIR" + + echo "==> packing $package windows heads ($PASS2_BAND band)" + (cd "$SDK10_DIR" && dotnet pack "$project" \ + -c Release \ + -p:OpenTokSdkBand="$PASS2_BAND" \ + -p:OpenTokWindowsOnly=true \ + $VERSION_ARG \ + -o "$WIN2_DIR") + + # merge-packages.py merges every package it finds in the primary directory, so the primary + # here cannot be artifacts/ itself: that holds all the packages, while the Windows passes hold + # only the one just packed, and every other id would be reported as having no counterpart. + # Stage just this package's files — the pass directory names them, so no version is needed. + echo "==> merging windows target frameworks into $package" + mkdir -p "$PRIMARY_DIR" + nupkg="" + for asset in "$WIN1_DIR"/*.nupkg "$WIN1_DIR"/*.snupkg; do + [ -f "$asset" ] || continue + name=$(basename "$asset") + if [ ! -f "$OUTPUT/$name" ]; then + echo "error: $name is not in $OUTPUT — run build/BuildNugets.sh on macOS first and" >&2 + echo " bring its artifacts/ here, at the same version." >&2 + exit 1 + fi + cp "$OUTPUT/$name" "$PRIMARY_DIR/$name" + case "$name" in *.nupkg) nupkg="$name" ;; esac + done + + # Merged in two steps because merge-packages.py takes one additional directory at a time, and + # into a scratch directory because it will not read and write the same place. + python3 "$ROOT/build/merge-packages.py" "$PRIMARY_DIR" "$WIN1_DIR" "$MERGED_DIR" + python3 "$ROOT/build/merge-packages.py" "$MERGED_DIR" "$WIN2_DIR" "$OUTPUT" + + # Straight after the merge rather than only at the end, because the next package is packed + # against this one out of artifacts/ — so a package that came out of the merge without its + # Windows assets would otherwise be discovered later, having already been built against. + require_windows_frameworks "$OUTPUT/$nupkg" + MERGED_PACKAGES="$MERGED_PACKAGES $nupkg" +done + +rm -rf "$WIN1_DIR" "$WIN2_DIR" "$PRIMARY_DIR" "$MERGED_DIR" + +# And again over the finished directory. Everything above packs and merges one package while the +# rest sit in the same directory, so "each package was right when it was merged" is not the same +# claim as "every package is still right now". +echo "==> windows assets in $OUTPUT" +for nupkg in $MERGED_PACKAGES; do + require_windows_frameworks "$OUTPUT/$nupkg" +done diff --git a/docs/release-notes/2.34.1.4.md b/docs/release-notes/2.34.1.4.md new file mode 100644 index 0000000..4a08f6e --- /dev/null +++ b/docs/release-notes/2.34.1.4.md @@ -0,0 +1,72 @@ +# OpenTok.Net 2.34.1.4 + +Adds Windows as a third platform. No change to the iOS or Android heads, and no change to the +platform pins beyond adding one — 2.34.1.3 already repointed Android. + +`OpenTokSession`, `OpenTokPublisher` and `OpenTokSubscriber` now run on Windows, over Vonage's +`OpenTok.Client`. The same code that already runs on iOS and Android runs there unchanged; +`samples/OpenTok.Sample.Maui` gained a Windows head and no per-platform code. + +Both packages now carry `net8.0`, `net9.0` and `net10.0` for iOS, Android **and** Windows — nine +target frameworks each, up from six. + +## Why this needed a new package + +Vonage's Windows SDK is not a binding problem: `OpenTok.Client` is already managed .NET. It is a +*rendering* problem. It ships video renderers for WPF and Windows Forms only, and its +`netstandard2.0` asset — the one a modern .NET app resolves — carries none at all. .NET MAUI on +Windows is WinUI 3, so without help an app could connect, publish and subscribe and have nowhere to +put the picture. + +[`OpenTok.Net.Win`](https://github.com/sbokatuk/OpenTok.Net.Win) 2.34.1.4 supplies the WinUI +renderer. `OpenTok.Net` depends on it for Windows target frameworks; you do not reference it +yourself. + +## Two things Windows asks that the other platforms do not + +**x64 only.** `OpenTok.Client`'s native payload has no arm64 build. `OpenTok.Net.Win` fails the +build with `OTW0001` rather than letting that become a `BadImageFormatException` after launch; +Windows on ARM runs the x64 build under emulation. The MAUI sample sets `win-x64` for this reason. + +**Create the first OpenTok object on the UI thread.** The Windows SDK has an explicit `Context` +object with no iOS or Android equivalent, all three object types must share one, and the façade +builds it bound to that thread's dispatcher queue. Without that the SDK raises events on its own +threads and the first handler that touches XAML throws `RPC_E_WRONG_THREAD` — in exactly the +handlers that need to change the UI. `OpenTokSession` already asked callers to drive one session +from one thread; on Windows it is now enforced rather than advised. + +## What has no Windows equivalent + +Documented no-ops rather than exceptions, because a façade whose shared API throws on one platform +is not a façade — and it would throw on the platform least likely to be tested first: + +| | Why | +| --- | --- | +| `CameraPosition`, `CameraTorch`, `CameraZoomFactor` | Phone concepts. Desktop webcams have no front/back, no torch and no optical zoom, and `OpenTok.Client` exposes none. | +| `SetEncryptionSecret` | No corresponding member on `OpenTok.Session` in 2.34.1. Media is still transport-encrypted. | +| `Pause()` / `Resume()` | Nothing takes the camera away from a background desktop app. Same as iOS; Android is the outlier. | +| `OpenTokAudioSession` | Windows has no CallKit or `android.telecom` analogue — no system call UI to hand the audio session to. | + +Properties still report back what was set, so an app's own state stays consistent. + +## Release ordering + +**`OpenTok.Net.Win` 2.34.1.4 must be on nuget.org before this is published.** The pin is exact, by +design, so a consumer cannot resolve a platform package this façade was never built against — which +means publishing first would produce a package that cannot restore on Windows. 2.34.1.2 is what +happens when that ordering slips. + +`OpenTok.Net.Android` 2.34.1.4 and `OpenTok.Net.iOS` 2.34.1.2 are unchanged from 2.34.1.3. + +## Packaging note + +No runner can build all three platform heads: iOS needs Xcode, Windows needs the Windows SDK +reference packs and WinUI. The `-windows` target frameworks are therefore appended only when the +build is running on Windows, which would leave a macOS-packed package silently without them. +`build/AddWindowsAssets.sh` packs those heads separately and merges them in with the existing +`build/merge-packages.py`; CI runs it in an `add-windows-assets` job that asserts the merged package +really does contain `lib/net9.0-windows*/` rather than trusting the script's exit code, and +`release.yml` publishes that merged artifact rather than the macOS one. + +If you build packages locally on a Mac, they will have no Windows assets until you run that script +on a Windows machine against the same `artifacts/`. diff --git a/samples/OpenTok.Sample.Maui/CapturePermissions.cs b/samples/OpenTok.Sample.Maui/CapturePermissions.cs new file mode 100644 index 0000000..ffa621a --- /dev/null +++ b/samples/OpenTok.Sample.Maui/CapturePermissions.cs @@ -0,0 +1,33 @@ +namespace OpenTok.Sample.Maui; + +/// +/// Asks for camera and microphone access, where asking is a thing the platform does. +/// +/// +/// +/// The second and last place this sample branches, for the same reason as +/// : the platforms differ in whether the operation exists at all, not +/// merely in how it is spelled. +/// +/// +/// iOS and Android gate camera and microphone behind a runtime prompt, and an app that +/// starts publishing without asking gets a black frame and silence. Windows desktop has no +/// runtime prompt — access is a system-wide switch under Settings > Privacy that the user owns +/// and the app cannot raise — so there is nothing to await and nothing to be refused. +/// +/// +/// A shim rather than a #if in MainPage.xaml.cs, which is deliberately free of them: +/// the point that file makes is that the OpenTok API needs no platform branching, and mixing in an +/// unrelated one would blunt it. +/// +/// +public static partial class CapturePermissions +{ + /// + /// Returns whether capture may proceed. Never throws; a refusal is reported as + /// . + /// + public static Task RequestAsync() => RequestPlatformAsync(); + + private static partial Task RequestPlatformAsync(); +} diff --git a/samples/OpenTok.Sample.Maui/MainPage.xaml.cs b/samples/OpenTok.Sample.Maui/MainPage.xaml.cs index 6dee8ec..a05963d 100644 --- a/samples/OpenTok.Sample.Maui/MainPage.xaml.cs +++ b/samples/OpenTok.Sample.Maui/MainPage.xaml.cs @@ -371,13 +371,10 @@ private void OnSubscriberCaption(object? sender, OpenTokCaptionEventArgs e) // ---- teardown --------------------------------------------------------------------------- - private static async Task RequestCapturePermissionsAsync() - { - var camera = await Permissions.RequestAsync(); - var microphone = await Permissions.RequestAsync(); - - return camera == PermissionStatus.Granted && microphone == PermissionStatus.Granted; - } + // Behind a shim rather than calling MAUI's Permissions API directly, because Windows has no + // runtime prompt to make — see CapturePermissions. Still one call from here, and still no + // platform branching in this file. + private static Task RequestCapturePermissionsAsync() => CapturePermissions.RequestAsync(); private void SetConnected(bool connected) { diff --git a/samples/OpenTok.Sample.Maui/OpenTok.Sample.Maui.csproj b/samples/OpenTok.Sample.Maui/OpenTok.Sample.Maui.csproj index cadafda..a19d6b4 100644 --- a/samples/OpenTok.Sample.Maui/OpenTok.Sample.Maui.csproj +++ b/samples/OpenTok.Sample.Maui/OpenTok.Sample.Maui.csproj @@ -20,8 +20,23 @@ --> net9 - net9.0-android35.0;net9.0-ios18.0 - net10.0-android36.0;net10.0-ios26.0 + net9.0-android35.0;net9.0-ios18.0 + net10.0-android36.0;net10.0-ios26.0 + + + net9.0-windows$(OpenTokWindowsSdkVersion) + net10.0-windows$(OpenTokWindowsSdkVersion) Exe OpenTok.Sample.Maui @@ -49,6 +64,22 @@ $(OpenTokSupportedAndroidVersion) + + $(OpenTokSupportedWindowsVersion) + $(OpenTokSupportedWindowsVersion) + + + win-x64 + false + None + + @@ -79,4 +110,30 @@ + + + + <_OpenTokMissingPriPayloadFiles Include="@(_AllChildProjectItemsWithTargetPath)" + Condition=" !Exists('%(FullPath)') " /> + <_AllChildProjectItemsWithTargetPath Remove="@(_OpenTokMissingPriPayloadFiles)" /> + + + + + diff --git a/samples/OpenTok.Sample.Maui/Platforms/Android/CapturePermissions.Android.cs b/samples/OpenTok.Sample.Maui/Platforms/Android/CapturePermissions.Android.cs new file mode 100644 index 0000000..0b89e66 --- /dev/null +++ b/samples/OpenTok.Sample.Maui/Platforms/Android/CapturePermissions.Android.cs @@ -0,0 +1,18 @@ +namespace OpenTok.Sample.Maui; + +/// The Android half: MAUI's own permission prompts, which map to the runtime permissions. +/// +/// Identical to the iOS half rather than shared with it, because the shared file is the one place a +/// Windows implementation has to differ — and two five-line copies read better than a three-way +/// conditional over a thing that is only conditional once. +/// +public static partial class CapturePermissions +{ + private static async partial Task RequestPlatformAsync() + { + var camera = await Permissions.RequestAsync(); + var microphone = await Permissions.RequestAsync(); + + return camera == PermissionStatus.Granted && microphone == PermissionStatus.Granted; + } +} diff --git a/samples/OpenTok.Sample.Maui/Platforms/Windows/App.xaml b/samples/OpenTok.Sample.Maui/Platforms/Windows/App.xaml new file mode 100644 index 0000000..1e9789f --- /dev/null +++ b/samples/OpenTok.Sample.Maui/Platforms/Windows/App.xaml @@ -0,0 +1,6 @@ + + diff --git a/samples/OpenTok.Sample.Maui/Platforms/Windows/App.xaml.cs b/samples/OpenTok.Sample.Maui/Platforms/Windows/App.xaml.cs new file mode 100644 index 0000000..c5ef132 --- /dev/null +++ b/samples/OpenTok.Sample.Maui/Platforms/Windows/App.xaml.cs @@ -0,0 +1,11 @@ +using Microsoft.UI.Xaml; + +namespace OpenTok.Sample.Maui.WinUI; + +/// The Windows entry point. Everything real is in . +public partial class App : MauiWinUIApplication +{ + public App() => InitializeComponent(); + + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/samples/OpenTok.Sample.Maui/Platforms/Windows/CaptureLifetime.Windows.cs b/samples/OpenTok.Sample.Maui/Platforms/Windows/CaptureLifetime.Windows.cs new file mode 100644 index 0000000..76e1c5b --- /dev/null +++ b/samples/OpenTok.Sample.Maui/Platforms/Windows/CaptureLifetime.Windows.cs @@ -0,0 +1,21 @@ +namespace OpenTok.Sample.Maui; + +/// +/// The Windows half of : nothing to do. +/// +/// +/// Windows places no restriction on camera or microphone access for a background desktop app. There +/// is no equivalent of Android 14's foreground-service requirement and no background-mode +/// declaration to make — a minimised window keeps capturing. So both halves are empty, and that is +/// the answer rather than a gap: this is the platform the shim exists to contrast with. +/// +public static partial class CaptureLifetime +{ + private static partial void BeginPlatform() + { + } + + private static partial void EndPlatform() + { + } +} diff --git a/samples/OpenTok.Sample.Maui/Platforms/Windows/CapturePermissions.Windows.cs b/samples/OpenTok.Sample.Maui/Platforms/Windows/CapturePermissions.Windows.cs new file mode 100644 index 0000000..99b77b2 --- /dev/null +++ b/samples/OpenTok.Sample.Maui/Platforms/Windows/CapturePermissions.Windows.cs @@ -0,0 +1,22 @@ +namespace OpenTok.Sample.Maui; + +/// The Windows half: nothing to ask. +/// +/// +/// A Windows desktop app has no runtime camera or microphone prompt. Access is governed by a +/// system-wide switch under Settings > Privacy & security that the user owns; an app cannot +/// raise it and is not told about it. If capture is blocked there, the SDK simply produces no +/// frames. +/// +/// +/// So this returns true rather than calling MAUI's Permissions API. That API's behaviour on +/// an unpackaged Windows app is version-dependent — it either reports Granted immediately or +/// declines to answer — and neither outcome means the user refused anything. Treating a +/// non-committal answer as a refusal would leave the sample unable to publish on the one platform +/// where nothing was ever denied. +/// +/// +public static partial class CapturePermissions +{ + private static partial Task RequestPlatformAsync() => Task.FromResult(true); +} diff --git a/samples/OpenTok.Sample.Maui/Platforms/Windows/app.manifest b/samples/OpenTok.Sample.Maui/Platforms/Windows/app.manifest new file mode 100644 index 0000000..b0bc769 --- /dev/null +++ b/samples/OpenTok.Sample.Maui/Platforms/Windows/app.manifest @@ -0,0 +1,15 @@ + + + + + + + + PerMonitorV2 + + + diff --git a/samples/OpenTok.Sample.Maui/Platforms/iOS/CapturePermissions.iOS.cs b/samples/OpenTok.Sample.Maui/Platforms/iOS/CapturePermissions.iOS.cs new file mode 100644 index 0000000..1bd7704 --- /dev/null +++ b/samples/OpenTok.Sample.Maui/Platforms/iOS/CapturePermissions.iOS.cs @@ -0,0 +1,13 @@ +namespace OpenTok.Sample.Maui; + +/// The iOS half: MAUI's own permission prompts, which map to AVFoundation's. +public static partial class CapturePermissions +{ + private static async partial Task RequestPlatformAsync() + { + var camera = await Permissions.RequestAsync(); + var microphone = await Permissions.RequestAsync(); + + return camera == PermissionStatus.Granted && microphone == PermissionStatus.Granted; + } +} diff --git a/src/OpenTok.Net.Maui/OpenTok.Net.Maui.csproj b/src/OpenTok.Net.Maui/OpenTok.Net.Maui.csproj index c7cfde6..3c16a0a 100644 --- a/src/OpenTok.Net.Maui/OpenTok.Net.Maui.csproj +++ b/src/OpenTok.Net.Maui/OpenTok.Net.Maui.csproj @@ -53,4 +53,52 @@ + + + + + + + + + <_OpenTokMissingPriPayloadFiles Include="@(_AllChildProjectItemsWithTargetPath)" + Condition=" !Exists('%(FullPath)') " /> + <_AllChildProjectItemsWithTargetPath Remove="@(_OpenTokMissingPriPayloadFiles)" /> + + + + + diff --git a/src/OpenTok.Net.Maui/Platforms/Windows/OpenTokVideoViewHandler.Windows.cs b/src/OpenTok.Net.Maui/Platforms/Windows/OpenTokVideoViewHandler.Windows.cs new file mode 100644 index 0000000..7f2e87e --- /dev/null +++ b/src/OpenTok.Net.Maui/Platforms/Windows/OpenTokVideoViewHandler.Windows.cs @@ -0,0 +1,147 @@ +using Microsoft.Maui.Handlers; + +// Aliased rather than imported wholesale, because .NET MAUI and WinUI name the same concepts +// identically and a handler is the one place both are in scope at once: Grid, SolidColorBrush, +// HorizontalAlignment and VerticalAlignment all exist in Microsoft.Maui.* and Microsoft.UI.Xaml.*, +// and plain `using` directives for both make every one of them ambiguous (CS0104). +// +// A `W` prefix for the Windows side, so which framework a type belongs to is visible at the use +// site rather than inferred from the using block. +using WFrameworkElement = Microsoft.UI.Xaml.FrameworkElement; +using WGrid = Microsoft.UI.Xaml.Controls.Grid; +using WHorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment; +using WPanel = Microsoft.UI.Xaml.Controls.Panel; +using WSolidColorBrush = Microsoft.UI.Xaml.Media.SolidColorBrush; +using WVerticalAlignment = Microsoft.UI.Xaml.VerticalAlignment; + +namespace OpenTok.Net.Maui; + +/// +/// Hosts an OpenTok video source in a WinUI Grid container. +/// +/// +/// +/// Same shape as the iOS and Android handlers — a container created up front, the source's own view +/// added to it once available — but the timing underneath differs. On Windows the source's view +/// exists from construction, because the façade creates it rather than waiting for the SDK to +/// produce one. NativeViewAvailable is still raised, immediately, so this handler needs no +/// special case for that. +/// +/// +/// The other difference is what a "native view" can be attached to. A WinUI element has a single +/// parent and will throw if added to a second one, so detaches before attaching +/// rather than after — moving a source between two instances is +/// otherwise an exception rather than a moved tile. +/// +/// +public partial class OpenTokVideoViewHandler : ViewHandler +{ + /// The view has no properties of its own; the mapper exists because a handler needs one. + public static readonly IPropertyMapper VideoMapper = + new PropertyMapper(ViewMapper); + + /// Creates the handler. Registered by UseOpenTok(). + public OpenTokVideoViewHandler() : base(VideoMapper) + { + } + + /// + protected override WPanel CreatePlatformView() => + new WGrid + { + Background = new WSolidColorBrush(Microsoft.UI.Colors.Black), + + // The video element is sized by the grid, and anything overflowing is the letterbox + // case — clipped so a UniformToFill source cannot paint over neighbouring tiles. + Clip = null, + }; + + /// + protected override void ConnectHandler(WPanel platformView) + { + base.ConnectHandler(platformView); + + VirtualView.SourceChanged += OnSourceChanged; + Attach(VirtualView.Source); + } + + /// + protected override void DisconnectHandler(WPanel platformView) + { + VirtualView.SourceChanged -= OnSourceChanged; + Detach(VirtualView.Source); + Clear(); + + base.DisconnectHandler(platformView); + } + + private void OnSourceChanged(object? sender, OpenTokVideoSourceChangedEventArgs e) + { + Detach(e.OldSource); + Clear(); + Attach(e.NewSource); + } + + private void Attach(IOpenTokVideoSource? source) + { + if (source is null) + { + return; + } + + source.NativeViewAvailable += OnNativeViewAvailable; + Show(source.NativeView); + } + + private void Detach(IOpenTokVideoSource? source) + { + if (source is not null) + { + source.NativeViewAvailable -= OnNativeViewAvailable; + } + } + + private void OnNativeViewAvailable(object? sender, EventArgs e) + { + // Marshalled for the same reason as the other two handlers. It is very likely already the + // UI thread here — the façade's Windows head binds the SDK's context to the UI dispatcher + // queue — but this handler cannot verify that from where it stands, and BeginInvoke on the + // thread you are already on is cheap. + var view = (sender as IOpenTokVideoSource)?.NativeView; + MainThread.BeginInvokeOnMainThread(() => Show(view)); + } + + private void Show(WFrameworkElement? nativeView) + { + if (nativeView is null || PlatformView is null) + { + return; + } + + Clear(); + + // Removed from a previous parent first. Unlike UIView.AddSubview, which reparents silently, + // adding a WinUI element that still has a parent throws — and the case that hits it is + // ordinary: a subscriber tile moved between two views during a layout change. + if (nativeView.Parent is WPanel previous) + { + previous.Children.Remove(nativeView); + } + + nativeView.HorizontalAlignment = WHorizontalAlignment.Stretch; + nativeView.VerticalAlignment = WVerticalAlignment.Stretch; + + PlatformView.Children.Add(nativeView); + } + + /// + /// Empties the container without disposing what it held. + /// + /// + /// The façade owns the video view and reuses it — removing it from a parent is detaching, not + /// destroying. Disposing it here would break a source moved between two + /// instances, and would also dispose the renderer the SDK is + /// still holding a reference to. + /// + private void Clear() => PlatformView?.Children.Clear(); +} diff --git a/src/OpenTok.Net.props b/src/OpenTok.Net.props index 1bcdebf..01369ef 100644 --- a/src/OpenTok.Net.props +++ b/src/OpenTok.Net.props @@ -26,8 +26,29 @@ carry fails to restore, and does so only for whoever picks that framework. --> net9 - net8.0-android34.0;net9.0-android35.0;net8.0-ios18.0;net9.0-ios18.0 - net10.0-android36.0;net10.0-ios26.0 + net8.0-android34.0;net9.0-android35.0;net8.0-ios18.0;net9.0-ios18.0 + net10.0-android36.0;net10.0-ios26.0 + + + net8.0-windows$(OpenTokWindowsSdkVersion);net9.0-windows$(OpenTokWindowsSdkVersion) + net10.0-windows$(OpenTokWindowsSdkVersion) + + $(OpenTokSupportedWindowsVersion) + true + + true diff --git a/src/OpenTok.Net/OpenTok.Net.csproj b/src/OpenTok.Net/OpenTok.Net.csproj index b9228e6..37f447e 100644 --- a/src/OpenTok.Net/OpenTok.Net.csproj +++ b/src/OpenTok.Net/OpenTok.Net.csproj @@ -33,4 +33,49 @@ + + + + + + + + + + diff --git a/src/OpenTok.Net/Platforms/Windows/IOpenTokVideoSource.Windows.cs b/src/OpenTok.Net/Platforms/Windows/IOpenTokVideoSource.Windows.cs new file mode 100644 index 0000000..59bc8a3 --- /dev/null +++ b/src/OpenTok.Net/Platforms/Windows/IOpenTokVideoSource.Windows.cs @@ -0,0 +1,41 @@ +using Microsoft.UI.Xaml; + +namespace OpenTok.Net; + +/// +/// Something that has a native video view to show — an (the local +/// camera preview) or an (a remote participant). +/// +/// +/// +/// The one deliberately platform-typed member of this package's API. is a +/// FrameworkElement here, a UIView on iOS and an Android.Views.View on Android, +/// so the declaration lives under Platforms/ and is compiled once per platform. +/// +/// +/// Windows differs from the other two in where the view comes from. On iOS and Android the SDK +/// creates it and the façade hands it over; here the SDK creates nothing — it renders into an +/// IVideoRenderer that the caller supplies. So the view is an OpenTokVideoView from +/// OpenTok.Net.Win, constructed by the façade, and it exists from the moment the +/// publisher or subscriber does rather than appearing later. +/// +/// +public interface IOpenTokVideoSource +{ + /// + /// The view to display, or once disposed. + /// + /// + /// Unlike iOS and Android this is non-null from construction, because on Windows the façade owns + /// the view rather than waiting for the SDK to produce one. is + /// still raised once, immediately, so that a consumer written against the other two platforms + /// works unchanged. + /// + FrameworkElement? NativeView { get; } + + /// + /// Raised when becomes available, for sources that do not have one + /// from the start. + /// + event EventHandler? NativeViewAvailable; +} diff --git a/src/OpenTok.Net/Platforms/Windows/OpenTokAudioSession.Windows.cs b/src/OpenTok.Net/Platforms/Windows/OpenTokAudioSession.Windows.cs new file mode 100644 index 0000000..f3bda87 --- /dev/null +++ b/src/OpenTok.Net/Platforms/Windows/OpenTokAudioSession.Windows.cs @@ -0,0 +1,45 @@ +namespace OpenTok.Net; + +/// +/// The Windows half of — every member a documented no-op. +/// +/// +/// +/// This whole type exists for CallKit on iOS and android.telecom on Android: an OS-level +/// calling framework owns the audio route, and the app has to hand the SDK's audio session over to +/// it and be told when it has been activated. Windows has no such framework. There is no system +/// call UI to integrate with, nothing that takes ownership of the audio device out from under the +/// process, and correspondingly nothing in OpenTok.Client to call. +/// +/// +/// Empty rather than throwing, and this is the deliberate part. Code like this is ordinary in a +/// shared MAUI view model: +/// +/// +/// OpenTokAudioSession.EnableCallingServicesMode(); +/// OpenTokAudioSession.PrepareForCall(); +/// +/// +/// It has to be safe to run everywhere. A Windows head that threw would force the one thing this +/// façade exists to remove — an #if WINDOWS around a call whose absence changes nothing — +/// and would do it at runtime, on the platform least likely to be tested first. +/// +/// +public static partial class OpenTokAudioSession +{ + private static partial void EnableCallingServicesModeNative() + { + } + + private static partial void PrepareForCallNative() + { + } + + private static partial void NotifyActivatedNative() + { + } + + private static partial void NotifyDeactivatedNative() + { + } +} diff --git a/src/OpenTok.Net/Platforms/Windows/OpenTokPublisher.Windows.cs b/src/OpenTok.Net/Platforms/Windows/OpenTokPublisher.Windows.cs new file mode 100644 index 0000000..2cc6897 --- /dev/null +++ b/src/OpenTok.Net/Platforms/Windows/OpenTokPublisher.Windows.cs @@ -0,0 +1,114 @@ +using Microsoft.UI.Xaml; +using OpenTok.Net.Win.Rendering; +using WindowsPublisher = OpenTok.Publisher; + +namespace OpenTok.Net; + +/// +/// The Windows half of , over OpenTok.Publisher. +/// +/// +/// +/// The camera controls are the interesting part of this file, and they are the part that does +/// nothing. , CameraTorch and +/// CameraZoomFactor are phone concepts: OpenTok.Client exposes a VideoCapturer and no +/// notion of a front or back camera, a torch, or optical zoom, because desktop webcams do not have +/// them. They are accepted and ignored here rather than throwing — see each one for why that is the +/// right call for a façade. +/// +/// +/// The view is created here rather than taken from the SDK. On iOS and Android the SDK owns a native +/// view; on Windows it renders into an IVideoRenderer supplied at build time, so the façade +/// constructs an OpenTokVideoView and hands it its own renderer. +/// +/// +public sealed partial class OpenTokPublisher : IOpenTokVideoSource +{ + private WindowsPublisher? _publisher; + private OpenTokVideoView? _view; + private Context? _context; + + /// + public FrameworkElement? NativeView => _view; + + /// + public event EventHandler? NativeViewAvailable; + + internal WindowsPublisher NativePublisher => + _publisher ?? throw new ObjectDisposedException(nameof(OpenTokPublisher)); + + private partial void CreateNative(string? name) + { + _context = OpenTokWindowsContext.Acquire(); + + // UniformToFill: a self-view is nearly always a small tile, where letterboxing wastes most + // of it. Matches what the iOS and Android heads ask their native views for. + _view = new OpenTokVideoView { Stretch = Microsoft.UI.Xaml.Media.Stretch.UniformToFill }; + + _publisher = new WindowsPublisher.Builder(_context) + { + Renderer = _view.Renderer, + Name = name, + }.Build(); + + _publisher.AudioLevel += (_, e) => OnAudioLevel(e.AudioLevel); + + // Raised even though the view was ready before this returns. A consumer written against + // iOS or Android subscribes to this and waits; never raising it would leave that consumer + // waiting forever on the one platform where the view was there all along. + NativeViewAvailable?.Invoke(this, EventArgs.Empty); + } + + private partial void SetPublishAudioNative(bool value) => _publisher!.PublishAudio = value; + + private partial void SetPublishVideoNative(bool value) => _publisher!.PublishVideo = value; + + private partial void SetCameraPositionNative(OpenTokCameraPosition value) + { + // No front/back on a desktop. OpenTok.Client selects a capture device through + // VideoCapturer, which enumerates whatever cameras exist without classifying them — there + // is nothing here to map "front" or "back" onto. + // + // Ignored rather than thrown, deliberately: a shared view model that sets CameraPosition on + // startup is ordinary, and a façade whose common API throws on one platform is not a façade. + // The property still reports back what was set, so the app's own state stays consistent. + _ = value; + } + + private partial void SetCameraTorchNative(bool value) + { + // Webcams have no torch. Same reasoning as CameraPosition above. + _ = value; + } + + private partial void SetCameraZoomFactorNative(float value) + { + // No zoom control in OpenTok.Client's capture API. Same reasoning again. + _ = value; + } + + private partial void SetVideoTransformersNative(OpenTokTransformer[] transformers) => + _publisher!.VideoTransformers = + [.. transformers.Select(t => new VideoTransformer(t.Name, t.Properties))]; + + private partial void SetAudioTransformersNative(OpenTokTransformer[] transformers) => + _publisher!.AudioTransformers = + [.. transformers.Select(t => new AudioTransformer(t.Name, t.Properties))]; + + private partial void DisposeNative() + { + _publisher?.Dispose(); + _publisher = null; + + // After the publisher, not before: the SDK can deliver one last frame into the renderer + // while the publisher is being torn down, and a disposed renderer simply drops it. + _view?.Dispose(); + _view = null; + + if (_context is not null) + { + _context = null; + OpenTokWindowsContext.Release(); + } + } +} diff --git a/src/OpenTok.Net/Platforms/Windows/OpenTokSession.Windows.cs b/src/OpenTok.Net/Platforms/Windows/OpenTokSession.Windows.cs new file mode 100644 index 0000000..4162ffe --- /dev/null +++ b/src/OpenTok.Net/Platforms/Windows/OpenTokSession.Windows.cs @@ -0,0 +1,141 @@ +using WindowsSession = OpenTok.Session; + +namespace OpenTok.Net; + +/// +/// The Windows half of , over OpenTok.Session from Vonage's +/// OpenTok.Client package. +/// +/// +/// +/// The least like the other two heads, because the SDK underneath is not a binding — it is +/// hand-written managed .NET, with .NET events and a builder instead of an Objective-C delegate or +/// a Java listener. That makes this file the short one: there is no delegate object to keep alive +/// against a weak native reference, and no listener class to translate. +/// +/// +/// Errors arrive only as events. Unlike iOS, no call here returns an error to inspect; +/// unlike Android, there is no listener interface to implement. Everything goes through +/// Session.Error, which is forwarded to . +/// +/// +/// Some of the façade has no Windows equivalent. Encryption secrets and pause/resume are +/// documented no-ops below rather than throwing, because the façade's contract is that an app +/// written once runs on all three platforms — a method that throws on one of them is not a façade. +/// +/// +public sealed partial class OpenTokSession +{ + private WindowsSession? _session; + private Context? _context; + + private partial void CreateNative() + { + _context = OpenTokWindowsContext.Acquire(); + + _session = new WindowsSession.Builder(_context, ApiKey, SessionId).Build(); + + _session.Connected += (_, _) => OnConnected(); + _session.Disconnected += (_, _) => OnDisconnected(); + _session.Error += (_, e) => OnFailed(new OpenTokError((int)e.ErrorCode, e.ErrorDescription ?? "unknown error")); + + _session.StreamReceived += (_, e) => OnStreamReceived(Convert(e.Stream)); + _session.StreamDropped += (_, e) => OnStreamDropped(Convert(e.Stream)); + + _session.ConnectionCreated += (_, e) => OnConnectionCreated(Convert(e.Connection)); + _session.ConnectionDropped += (_, e) => OnConnectionDestroyed(Convert(e.Connection)); + + _session.Signal += (_, e) => + OnSignalReceived(e.Type, e.Data, e.Connection is null ? null : Convert(e.Connection)); + + _session.ArchiveStarted += (_, e) => OnArchiveStarted(e.ArchiveId, e.ArchiveName); + _session.ArchiveStopped += (_, e) => OnArchiveStopped(e.ArchiveId); + + // Named for the transition rather than the state, unlike iOS's DidBeginReconnecting / + // DidReconnect — the same two moments under different names. + _session.ReconnectionStart += (_, _) => OnReconnecting(); + _session.ReconnectionSuccess += (_, _) => OnReconnected(); + + _session.MuteForced += (_, e) => OnMuteForced(e.IsActive); + } + + private partial void ConnectNative(string token) => _session!.Connect(token); + + private partial void DisconnectNative() => _session!.Disconnect(); + + private partial void PublishNative(OpenTokPublisher publisher) => + _session!.Publish(publisher.NativePublisher); + + private partial void UnpublishNative(OpenTokPublisher publisher) => + _session!.Unpublish(publisher.NativePublisher); + + private partial void SubscribeNative(OpenTokSubscriber subscriber) => + _session!.Subscribe(subscriber.NativeSubscriber); + + private partial void UnsubscribeNative(OpenTokSubscriber subscriber) => + _session!.Unsubscribe(subscriber.NativeSubscriber); + + // The trailing false is retryAfterReconnect. Left off to match iOS and Android, where a signal + // sent while reconnecting is simply lost rather than queued — the façade cannot offer a + // guarantee that only one of its three platforms can keep. + private partial void SignalNative(string? type, string? data, OpenTokConnection? to) => + _session!.SendSignal(type, data, (Connection?)to?.NativeConnection, false); + + private partial void ForceMuteAllNative(OpenTokStream[] except) => + _session!.ForceMuteAll([.. except.Select(s => (Stream)s.NativeStream)]); + + private partial void DisableForceMuteNative() => _session!.DisableForceMute(); + + private partial void ForceMuteStreamNative(OpenTokStream stream) => + _session!.ForceMuteStream((Stream)stream.NativeStream); + + private partial void ForceDisconnectNative(OpenTokConnection connection) => + _session!.ForceDisconnect((Connection)connection.NativeConnection); + + private partial void SetEncryptionSecretNative(string secret) + { + // No Windows equivalent. End-to-end encryption is exposed on iOS (setEncryptionSecret) and + // Android, but OpenTok.Client 2.34.1 has no corresponding member on Session — checked + // against its own XML documentation, not inferred. + // + // Silent rather than throwing: an app that calls this and runs on all three platforms would + // otherwise crash only on Windows. Failing here would also be misleading, since the media + // itself is still transport-encrypted. + _ = secret; + } + + private partial void PauseNative() + { + // Nothing to do, for the same reason as iOS: Windows has no application lifecycle event + // that stops the camera, so there is nothing to hand back. Android is the outlier. + } + + private partial void ResumeNative() + { + } + + private partial OpenTokCapabilities? GetCapabilitiesNative() => + _session?.Capabilities is { } c + ? new OpenTokCapabilities(c.CanPublish, c.CanSubscribe, c.CanForceMute, c.CanForceDisconnect) + : null; + + private partial string? OwnConnectionIdNative() => _session?.Connection?.Id; + + private partial void DisposeNative() + { + _session?.Dispose(); + _session = null; + + if (_context is not null) + { + _context = null; + OpenTokWindowsContext.Release(); + } + } + + internal static OpenTokStream Convert(Stream stream) => + new(stream.Id, stream.Name, stream.HasAudio, stream.HasVideo, stream); + + internal static OpenTokConnection Convert(Connection connection) => + new(connection.Id, connection.Data, connection); +} diff --git a/src/OpenTok.Net/Platforms/Windows/OpenTokSubscriber.Windows.cs b/src/OpenTok.Net/Platforms/Windows/OpenTokSubscriber.Windows.cs new file mode 100644 index 0000000..48663c2 --- /dev/null +++ b/src/OpenTok.Net/Platforms/Windows/OpenTokSubscriber.Windows.cs @@ -0,0 +1,98 @@ +using Microsoft.UI.Xaml; +using OpenTok.Net.Win.Rendering; +using WindowsSubscriber = OpenTok.Subscriber; + +namespace OpenTok.Net; + +/// +/// The Windows half of , over OpenTok.Subscriber. +/// +/// +/// The view is available immediately here, unlike iOS and Android where the SDK produces one only +/// once video is decoding. On Windows the SDK renders into an IVideoRenderer the caller +/// supplies, so the façade owns the view from the start — see . +/// +public sealed partial class OpenTokSubscriber : IOpenTokVideoSource +{ + private WindowsSubscriber? _subscriber; + private OpenTokVideoView? _view; + private Context? _context; + + // The language that was *asked for*, which on Windows is not the same claim the other two heads + // make. OpenTok.Client's CaptionsTranslationLanguage is set-only: there is no getter. + // + // OpenTokSubscriber's setter reads the value back from the SDK on purpose — "the SDK silently + // declines a language it does not support, and reporting the requested value would be a lie". + // Windows cannot honour that, because there is nothing to read. So this reports the request, and + // if the SDK declined it the property will disagree with reality until Vonage adds a getter. + // + // Better than the alternatives: returning null would break the round-trip on every platform for + // the sake of one, and throwing would break the shared API. + private string? _requestedCaptionsTranslationLanguage; + + /// + public FrameworkElement? NativeView => _view; + + /// + public event EventHandler? NativeViewAvailable; + + internal WindowsSubscriber NativeSubscriber => + _subscriber ?? throw new ObjectDisposedException(nameof(OpenTokSubscriber)); + + private partial void CreateNative(OpenTokStream stream) + { + _context = OpenTokWindowsContext.Acquire(); + + // Uniform, not UniformToFill: a remote participant is the thing being watched, and cropping + // a face out of frame to fill a tile is worse than letterboxing it. + _view = new OpenTokVideoView(); + + _subscriber = new WindowsSubscriber.Builder(_context, (Stream)stream.NativeStream) + { + Renderer = _view.Renderer, + }.Build(); + + _subscriber.Connected += (_, _) => OnConnected(); + _subscriber.StreamDisconnected += (_, _) => OnDisconnectedFromStream(); + _subscriber.Error += (_, e) => + OnFailed(new OpenTokError((int)e.ErrorCode, e.ErrorDescription ?? "unknown error")); + + _subscriber.CaptionText += (_, e) => OnCaption(e.Text, e.IsFinal); + _subscriber.AudioLevel += (_, e) => OnAudioLevel(e.AudioLevel); + + // Raised immediately, for the reason given on IOpenTokVideoSource: a consumer written + // against iOS or Android waits for this before showing the tile. + NativeViewAvailable?.Invoke(this, EventArgs.Empty); + } + + private partial void SetSubscribeToAudioNative(bool value) => _subscriber!.SubscribeToAudio = value; + + private partial void SetSubscribeToVideoNative(bool value) => _subscriber!.SubscribeToVideo = value; + + private partial void SetSubscribeToCaptionsNative(bool value) => _subscriber!.SubscribeToCaptions = value; + + private partial void SetCaptionsTranslationLanguageNative(string? value) + { + _subscriber!.CaptionsTranslationLanguage = value; + _requestedCaptionsTranslationLanguage = value; + } + + private partial string? GetCaptionsTranslationLanguageNative() => _requestedCaptionsTranslationLanguage; + + private partial void SetAudioVolumeNative(double value) => _subscriber!.AudioVolume = value; + + private partial void DisposeNative() + { + _subscriber?.Dispose(); + _subscriber = null; + + _view?.Dispose(); + _view = null; + + if (_context is not null) + { + _context = null; + OpenTokWindowsContext.Release(); + } + } +} diff --git a/src/OpenTok.Net/Platforms/Windows/OpenTokWindowsContext.cs b/src/OpenTok.Net/Platforms/Windows/OpenTokWindowsContext.cs new file mode 100644 index 0000000..9275c44 --- /dev/null +++ b/src/OpenTok.Net/Platforms/Windows/OpenTokWindowsContext.cs @@ -0,0 +1,94 @@ +using Microsoft.UI.Dispatching; +using OpenTok.Net.Win; + +namespace OpenTok.Net; + +/// +/// The one OpenTok.Context every façade object on Windows shares. +/// +/// +/// +/// Windows is the only platform of the three where the SDK has an explicit context object. +/// Session, Publisher and Subscriber are all built from one, they must be built +/// from the same one to interoperate, and it owns the native resources they sit on. iOS and +/// Android have no equivalent — their objects are constructed directly — so nothing in the façade's +/// shared API can carry it and it has to live here. +/// +/// +/// Created with an rather than through Context.Instance, and +/// that is the whole reason this type exists rather than a one-line singleton. The default context +/// raises every event on the SDK's own threads; the façade's contract is that events are raised on +/// the platform's callback thread and a UI app must marshal — but on Windows "must marshal" is not +/// a caution, it is RPC_E_WRONG_THREAD the first time a handler touches XAML. Binding the +/// context to the UI thread's dispatcher queue makes the Windows head behave the way an app +/// written against the iOS and Android heads already expects. +/// +/// +/// Consequently the first façade object must be constructed on a UI thread. That matches what +/// already documents — drive one session from one thread, in a UI app +/// the UI thread — and failing loudly here is far kinder than the alternative, which is video that +/// works until the first event handler runs. +/// +/// +internal static class OpenTokWindowsContext +{ + private static readonly object Gate = new(); + private static Context? _context; + private static int _references; + + /// + /// The shared context, creating it if this is the first façade object. Each call must be paired + /// with a . + /// + /// Called off a UI thread before any context exists. + internal static Context Acquire() + { + lock (Gate) + { + if (_context is null) + { + var dispatcherQueue = DispatcherQueue.GetForCurrentThread() + ?? throw new InvalidOperationException( + "The first OpenTok object on Windows must be created on a UI thread. The " + + "SDK's context is bound to that thread's dispatcher queue so that session, " + + "publisher and subscriber events arrive somewhere they can safely touch the " + + "UI; there is no dispatcher queue on this thread to bind to."); + + _context = new Context(new OpenTokDispatcher(dispatcherQueue)); + } + + _references++; + return _context; + } + } + + /// + /// Drops one reference, disposing the context when the last façade object goes. + /// + /// + /// Reference counted rather than disposed with the session, because a publisher can outlive the + /// session it was published to — republishing an existing publisher into a new session is a + /// perfectly ordinary thing to do, and disposing the context underneath it would take its native + /// resources with it. + /// + internal static void Release() + { + lock (Gate) + { + if (_references == 0) + { + return; + } + + _references--; + + if (_references > 0) + { + return; + } + + _context?.Dispose(); + _context = null; + } + } +}