From 8c5f664422593bf60c4e25498a5b63c064c435a4 Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Tue, 28 Jul 2026 13:20:11 +0300 Subject: [PATCH 01/13] Add a Windows head to the facade and the MAUI sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third platform, and the one that is not a binding: Vonage's OpenTok.Client is already managed .NET. What it lacks is a WinUI 3 renderer, which OpenTok.Net.Win supplies; this wires both into the facade so the same OpenTokSession/Publisher/Subscriber code runs on Windows unchanged. Platforms/Windows implements every partial hook. Member names and types were taken by reflecting over OpenTokNetStandard.dll rather than guessed — its XML docs omit the event-args members entirely. Some of the shared API has no Windows equivalent: camera position, torch and zoom (desktop webcams have none), the end-to-end encryption secret, Pause/Resume, and all of OpenTokAudioSession (no CallKit analogue). All documented no-ops rather than throws — a facade whose common API throws on one platform is not a facade, and it would throw on the platform least likely to be tested first. OpenTokWindowsContext is new machinery with no counterpart on the other two heads. Windows is the only one whose SDK has an explicit Context, all three object types must share one, and it is built with an OpenTokDispatcher so events land on the UI thread — otherwise the first handler that touches XAML throws RPC_E_WRONG_THREAD. Reference counted, because a publisher can outlive the session it was published to. The -windows target frameworks are appended only when building on Windows, since no runner can build all three heads: ios needs Xcode, windows needs the Windows SDK reference packs. That leaves a macOS-packed package silently without Windows assets, so build/AddWindowsAssets.sh packs them separately and merges with the existing merge-packages.py, and CI gains an add-windows-assets job that asserts the merge actually landed rather than trusting exit 0. The MAUI sample gains a Windows head. Its permission call moves behind a CapturePermissions shim, matching the existing CaptureLifetime one: Windows desktop has no runtime prompt at all, and MAUI's Permissions API gives a version-dependent answer there that must not be read as a refusal. MainPage.xaml.cs still contains no platform branching. iOS and Android verified locally: facade, MAUI package and sample all build Release. Co-Authored-By: Claude Opus 5 --- .github/workflows/build.yml | 102 ++++++++++++- Directory.Build.props | 26 ++++ README.md | 39 ++++- build/AddWindowsAssets.sh | 118 +++++++++++++++ .../OpenTok.Sample.Maui/CapturePermissions.cs | 33 ++++ samples/OpenTok.Sample.Maui/MainPage.xaml.cs | 11 +- .../OpenTok.Sample.Maui.csproj | 26 ++++ .../Android/CapturePermissions.Android.cs | 18 +++ .../Platforms/Windows/App.xaml | 6 + .../Platforms/Windows/App.xaml.cs | 11 ++ .../Windows/CaptureLifetime.Windows.cs | 21 +++ .../Windows/CapturePermissions.Windows.cs | 22 +++ .../Platforms/Windows/app.manifest | 15 ++ .../Platforms/iOS/CapturePermissions.iOS.cs | 13 ++ .../OpenTokVideoViewHandler.Windows.cs | 136 +++++++++++++++++ src/OpenTok.Net.props | 31 ++++ src/OpenTok.Net/OpenTok.Net.csproj | 13 ++ .../Windows/IOpenTokVideoSource.Windows.cs | 41 +++++ .../Windows/OpenTokAudioSession.Windows.cs | 45 ++++++ .../Windows/OpenTokPublisher.Windows.cs | 114 ++++++++++++++ .../Windows/OpenTokSession.Windows.cs | 141 ++++++++++++++++++ .../Windows/OpenTokSubscriber.Windows.cs | 81 ++++++++++ .../Windows/OpenTokWindowsContext.cs | 94 ++++++++++++ 23 files changed, 1141 insertions(+), 16 deletions(-) create mode 100755 build/AddWindowsAssets.sh create mode 100644 samples/OpenTok.Sample.Maui/CapturePermissions.cs create mode 100644 samples/OpenTok.Sample.Maui/Platforms/Android/CapturePermissions.Android.cs create mode 100644 samples/OpenTok.Sample.Maui/Platforms/Windows/App.xaml create mode 100644 samples/OpenTok.Sample.Maui/Platforms/Windows/App.xaml.cs create mode 100644 samples/OpenTok.Sample.Maui/Platforms/Windows/CaptureLifetime.Windows.cs create mode 100644 samples/OpenTok.Sample.Maui/Platforms/Windows/CapturePermissions.Windows.cs create mode 100644 samples/OpenTok.Sample.Maui/Platforms/Windows/app.manifest create mode 100644 samples/OpenTok.Sample.Maui/Platforms/iOS/CapturePermissions.iOS.cs create mode 100644 src/OpenTok.Net.Maui/Platforms/Windows/OpenTokVideoViewHandler.Windows.cs create mode 100644 src/OpenTok.Net/Platforms/Windows/IOpenTokVideoSource.Windows.cs create mode 100644 src/OpenTok.Net/Platforms/Windows/OpenTokAudioSession.Windows.cs create mode 100644 src/OpenTok.Net/Platforms/Windows/OpenTokPublisher.Windows.cs create mode 100644 src/OpenTok.Net/Platforms/Windows/OpenTokSession.Windows.cs create mode 100644 src/OpenTok.Net/Platforms/Windows/OpenTokSubscriber.Windows.cs create mode 100644 src/OpenTok.Net/Platforms/Windows/OpenTokWindowsContext.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8f75bff..c42ec2a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -271,6 +271,72 @@ 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 + + - name: Pack the Windows heads and merge them in + shell: bash + run: ./build/AddWindowsAssets.sh "${{ inputs.version }}" + + # Asserted rather than assumed: the script merges whatever the pack produced, and a pack that + # quietly emitted no Windows target framework — because the workload was missing, or because + # the IsOSPlatform condition in OpenTok.Net.props stopped matching — would still exit 0 and + # upload the unchanged package. + - name: Check the Windows assets are actually in there + shell: bash + run: | + missing=0 + for id in OpenTok.Net OpenTok.Net.Maui; do + pkg="$(ls artifacts/${id}.*.nupkg | grep -v symbols | head -1)" + if unzip -l "${pkg}" | grep -q 'lib/net9.0-windows'; then + echo " ok ${id}" + else + echo "::error::${pkg} has no lib/net9.0-windows*/ assets after the merge" + missing=1 + fi + done + exit "${missing}" + + - 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 +420,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 +443,15 @@ 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 + extra-args: -p:RuntimeIdentifier=win-x64 -p:SelfContained=false steps: - uses: actions/checkout@v4 @@ -404,10 +482,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 @@ -449,6 +529,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 +540,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/Directory.Build.props b/Directory.Build.props index 945db06..7b021d6 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -62,6 +62,32 @@ 15.0 24 + + 10.0.19041.0 + 10.0.17763.0 + + + 2.34.1.1 + + + 2.34.1 + s.bokatuk s.bokatuk Copyright © s.bokatuk diff --git a/README.md b/README.md index d5f55b8..c33512a 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) 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..c81524a --- /dev/null +++ b/build/AddWindowsAssets.sh @@ -0,0 +1,118 @@ +#!/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) + +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" +MERGED_DIR="$OUTPUT/.merged" + +SDK10_DIR="$(mktemp -d)" +trap 'rm -rf "$SDK10_DIR" "$WIN1_DIR" "$WIN2_DIR" "$MERGED_DIR"' EXIT +cat > "$SDK10_DIR/global.json" <&2 + exit 1 + fi + + rm -rf "$WIN1_DIR" "$WIN2_DIR" "$MERGED_DIR" + + echo "==> packing $package windows heads ($PASS1_BAND band)" + dotnet pack "$project" \ + -c Release \ + -p:OpenTokSdkBand="$PASS1_BAND" \ + $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" \ + $VERSION_ARG \ + -o "$WIN2_DIR") + + # 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. + echo "==> merging windows target frameworks into $package" + python3 "$ROOT/build/merge-packages.py" "$OUTPUT" "$WIN1_DIR" "$MERGED_DIR" + python3 "$ROOT/build/merge-packages.py" "$MERGED_DIR" "$WIN2_DIR" "$OUTPUT" +done + +rm -rf "$WIN1_DIR" "$WIN2_DIR" "$MERGED_DIR" + +echo "==> windows assets added to $OUTPUT" 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..17206f7 100644 --- a/samples/OpenTok.Sample.Maui/OpenTok.Sample.Maui.csproj +++ b/samples/OpenTok.Sample.Maui/OpenTok.Sample.Maui.csproj @@ -23,6 +23,16 @@ net9.0-android35.0;net9.0-ios18.0 net10.0-android36.0;net10.0-ios26.0 + + $(TargetFrameworks);net9.0-windows$(OpenTokWindowsSdkVersion) + $(TargetFrameworks);net10.0-windows$(OpenTokWindowsSdkVersion) + Exe OpenTok.Sample.Maui true @@ -49,6 +59,22 @@ $(OpenTokSupportedAndroidVersion) + + $(OpenTokSupportedWindowsVersion) + $(OpenTokSupportedWindowsVersion) + + + win-x64 + false + None + + 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..f16a475 --- /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/Platforms/Windows/OpenTokVideoViewHandler.Windows.cs b/src/OpenTok.Net.Maui/Platforms/Windows/OpenTokVideoViewHandler.Windows.cs new file mode 100644 index 0000000..0f0eec3 --- /dev/null +++ b/src/OpenTok.Net.Maui/Platforms/Windows/OpenTokVideoViewHandler.Windows.cs @@ -0,0 +1,136 @@ +using Microsoft.Maui.Handlers; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; + +namespace OpenTok.Net.Maui; + +/// +/// Hosts an OpenTok video source in a WinUI 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 Panel CreatePlatformView() => + new Grid + { + Background = new SolidColorBrush(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(Panel platformView) + { + base.ConnectHandler(platformView); + + VirtualView.SourceChanged += OnSourceChanged; + Attach(VirtualView.Source); + } + + /// + protected override void DisconnectHandler(Panel 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(FrameworkElement? 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 Panel previous) + { + previous.Children.Remove(nativeView); + } + + nativeView.HorizontalAlignment = HorizontalAlignment.Stretch; + nativeView.VerticalAlignment = VerticalAlignment.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..5acb36a 100644 --- a/src/OpenTok.Net.props +++ b/src/OpenTok.Net.props @@ -29,6 +29,21 @@ net8.0-android34.0;net9.0-android35.0;net8.0-ios18.0;net9.0-ios18.0 net10.0-android36.0;net10.0-ios26.0 + + $(TargetFrameworks);net8.0-windows$(OpenTokWindowsSdkVersion);net9.0-windows$(OpenTokWindowsSdkVersion) + $(TargetFrameworks);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..e6d40b6 100644 --- a/src/OpenTok.Net/OpenTok.Net.csproj +++ b/src/OpenTok.Net/OpenTok.Net.csproj @@ -33,4 +33,17 @@ + + + + + + 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..57176e8 --- /dev/null +++ b/src/OpenTok.Net/Platforms/Windows/OpenTokSubscriber.Windows.cs @@ -0,0 +1,81 @@ +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; + + /// + 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; + + 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; + } + } +} From cd343cad6e558dc953fb32c392cae63d2202c3b0 Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Tue, 28 Jul 2026 13:25:48 +0300 Subject: [PATCH 02/13] Correct the 2.34.1.3 release notes, and publish the package that has Windows in it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notes were written when 2.34.1.3 was only the Android pin fix, and were never revised once the Windows head landed in the same version. They now cover both, plus what has no Windows equivalent and why those are no-ops rather than throws, and the release ordering: OpenTok.Net.Win 2.34.1.1 has to be on nuget.org too, not just OpenTok.Net.Android 2.34.1.4. release.yml was publishing the wrong artifact. The pack job runs on macOS and cannot build a -windows target framework at all, so its output has no Windows assets whatsoever; add-windows-assets packs those and uploads the merged result separately. Publishing nuget-packages rather than nuget-packages-with-windows would have shipped a package that restores cleanly and then has nothing for a Windows head — silently, since NuGet only reports a missing target framework when something asks for it. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 8 +++- docs/release-notes/2.34.1.3.md | 82 +++++++++++++++++++++++++++++----- 2 files changed, 79 insertions(+), 11 deletions(-) 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/docs/release-notes/2.34.1.3.md b/docs/release-notes/2.34.1.3.md index 3a04663..a2576c6 100644 --- a/docs/release-notes/2.34.1.3.md +++ b/docs/release-notes/2.34.1.3.md @@ -1,6 +1,53 @@ # OpenTok.Net 2.34.1.3 -A pin bump. No API change, no behaviour change — the façade's own source is identical to 2.34.1.2. +Adds Windows as a third platform, and repoints the Android pin at a version that exists. + +## Windows + +`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) 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. ## Repoints Android at a version that exists @@ -14,17 +61,32 @@ exact, by design, so a consumer cannot resolve a platform package it was never b pointed at nothing. `dotnet add package OpenTok.Net` could not restore. `OpenTok.Net.Android` 2.34.1.4 is the re-cut of that release, at 167 MB. Its binding content is -unchanged; everything that moved was in the transformers package, which this façade does not -reference. +unchanged. + +`OpenTokIosPackageVersion` is unchanged at 2.34.1.2, which is current. + +## Release ordering + +**Both platform packages must be on nuget.org before this is published:** + +* `OpenTok.Net.Android` 2.34.1.4 +* `OpenTok.Net.Win` 2.34.1.1 + +That ordering always applied — the pins are exact and bumped by hand — but the previous release is +what happens when a platform package does not arrive. + +## Packaging note -**`OpenTok.Net.Android` 2.34.1.4 must be on nuget.org before this is published.** That ordering -always applied to this repository — the pin is exact and bumped by hand — but it is worth stating -here, because the previous release is the one that proved what happens when the platform package -does not arrive. +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. -`OpenTokIosPackageVersion` is unchanged at 2.34.1.2, which is current: the iOS train published -cleanly, and `OpenTok.Net.iOS` 2.34.1.2 and `OpenTok.Net.Transformers.iOS` 2.34.1.2 are both live. +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/`. ## If you are on 2.34.1.2 -Upgrade. There is nothing to change in your code — 2.34.1.2 simply cannot restore. +Upgrade. Nothing to change in your code — 2.34.1.2 cannot restore at all. From f529978980cd01220b15a0a46d095085f4254009 Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Tue, 28 Jul 2026 13:44:47 +0300 Subject: [PATCH 03/13] Move the Windows head to 2.34.1.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2.34.1.3 releases from the fix branch without Windows, so this branch is the next version rather than the same one. The facade revision goes to 4 and OpenTok.Net.Win is pinned at 2.34.1.4 — starting that package at .1 would have had a 2.34.1.4 facade wrapping OpenTok.Net.Android 2.34.1.4 alongside OpenTok.Net.Win 2.34.1.1, which is legal and unreadable. docs/release-notes/2.34.1.3.md goes back to describing only the Android pin fix, which is what that release now is; the Windows content moves to 2.34.1.4.md along with the release ordering that OpenTok.Net.Win must reach nuget.org first. Co-Authored-By: Claude Opus 5 --- Directory.Build.props | 4 +- README.md | 2 +- docs/release-notes/2.34.1.3.md | 82 +++++----------------------------- docs/release-notes/2.34.1.4.md | 72 +++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 75 deletions(-) create mode 100644 docs/release-notes/2.34.1.4.md diff --git a/Directory.Build.props b/Directory.Build.props index 7b021d6..80a8c71 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) - 2.34.1.1 + 2.34.1.4 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 - $(TargetFrameworks);net9.0-windows$(OpenTokWindowsSdkVersion) - $(TargetFrameworks);net10.0-windows$(OpenTokWindowsSdkVersion) + net9.0-windows$(OpenTokWindowsSdkVersion) + net10.0-windows$(OpenTokWindowsSdkVersion) Exe OpenTok.Sample.Maui diff --git a/src/OpenTok.Net.props b/src/OpenTok.Net.props index 5acb36a..01369ef 100644 --- a/src/OpenTok.Net.props +++ b/src/OpenTok.Net.props @@ -26,23 +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 - $(TargetFrameworks);net8.0-windows$(OpenTokWindowsSdkVersion);net9.0-windows$(OpenTokWindowsSdkVersion) - $(TargetFrameworks);net10.0-windows$(OpenTokWindowsSdkVersion) + net8.0-windows$(OpenTokWindowsSdkVersion);net9.0-windows$(OpenTokWindowsSdkVersion) + net10.0-windows$(OpenTokWindowsSdkVersion) + 2.3.1 + + diff --git a/src/OpenTok.Net/Platforms/Windows/OpenTokSubscriber.Windows.cs b/src/OpenTok.Net/Platforms/Windows/OpenTokSubscriber.Windows.cs index 57176e8..48663c2 100644 --- a/src/OpenTok.Net/Platforms/Windows/OpenTokSubscriber.Windows.cs +++ b/src/OpenTok.Net/Platforms/Windows/OpenTokSubscriber.Windows.cs @@ -18,6 +18,18 @@ public sealed partial class OpenTokSubscriber : IOpenTokVideoSource 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; @@ -59,8 +71,13 @@ private partial void CreateNative(OpenTokStream stream) private partial void SetSubscribeToCaptionsNative(bool value) => _subscriber!.SubscribeToCaptions = value; - private partial void SetCaptionsTranslationLanguageNative(string? 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; From cd7466b9afb39625a2edc0fd46bc2faac4921c15 Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Tue, 28 Jul 2026 15:47:46 +0300 Subject: [PATCH 06/13] Document pinning a OpenTok.Net.Win beta while a Windows change is in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pin is exact, so the Windows jobs here cannot run until the version named exists on nuget.org — restore fails with NU1102 and the Windows leg goes red for a reason unrelated to the change under review. OpenTok.Net.Win now publishes a beta from every pull request, so the answer is to point OpenTokWinPackageVersion at that beta until the real release. Said next to the property rather than only in the release notes, because that is where someone hits the problem. Co-Authored-By: Claude Opus 5 --- Directory.Build.props | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Directory.Build.props b/Directory.Build.props index 65e4440..006cd49 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -85,6 +85,18 @@ The Windows head's own package. Exact, like the other two — see the comment on those, and note the extra reason here: OpenTok.Net.Win carries the renderer that reads VideoFrame planes directly, so a mismatched pair is not a version-skew inconvenience but a memory-safety one. + + Because the pin is exact, the Windows jobs here cannot run until this version exists on + nuget.org — restore fails with NU1102 and the whole Windows leg goes red for a reason that has + nothing to do with the change under review. While a Windows change is still in flight, point + this at the beta that repository's own pull request publishes: + + 2.34.1.4-beta.7.3 + + OpenTok.Net.Win's pr.yml pushes one per pull-request build and prints the exact version in the + job summary. Move it back to the stable version before tagging a release here — the release + notes for 2.34.1.4 spell out that ordering, and OpenTok.Net 2.34.1.2 is what happens when it + is not followed. --> 2.34.1.4 From c4d290b0b609c242535581c2baf93e52f8191af8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Jul 2026 06:55:44 +0000 Subject: [PATCH 07/13] Merge Windows assets per package instead of across all of artifacts --- build/AddWindowsAssets.sh | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/build/AddWindowsAssets.sh b/build/AddWindowsAssets.sh index 44adc97..d4afd7a 100755 --- a/build/AddWindowsAssets.sh +++ b/build/AddWindowsAssets.sh @@ -74,10 +74,11 @@ 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" "$MERGED_DIR"' EXIT +trap 'rm -rf "$SDK10_DIR" "$WIN1_DIR" "$WIN2_DIR" "$PRIMARY_DIR" "$MERGED_DIR"' EXIT cat > "$SDK10_DIR/global.json" < packing $package windows heads ($PASS1_BAND band)" dotnet pack "$project" \ @@ -108,13 +109,29 @@ for package in $PACKAGES; do $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" + 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" + 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. - echo "==> merging windows target frameworks into $package" - python3 "$ROOT/build/merge-packages.py" "$OUTPUT" "$WIN1_DIR" "$MERGED_DIR" + python3 "$ROOT/build/merge-packages.py" "$PRIMARY_DIR" "$WIN1_DIR" "$MERGED_DIR" python3 "$ROOT/build/merge-packages.py" "$MERGED_DIR" "$WIN2_DIR" "$OUTPUT" done -rm -rf "$WIN1_DIR" "$WIN2_DIR" "$MERGED_DIR" +rm -rf "$WIN1_DIR" "$WIN2_DIR" "$PRIMARY_DIR" "$MERGED_DIR" echo "==> windows assets added to $OUTPUT" From 0d442689c0adec65930d5c99c4be8bf86f113b02 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:02:38 +0000 Subject: [PATCH 08/13] Keep OpenTok.Client's native payload out of the Windows resource indexes --- src/OpenTok.Net.Maui/OpenTok.Net.Maui.csproj | 48 ++++++++++++++++++++ src/OpenTok.Net/OpenTok.Net.csproj | 26 ++++++++++- 2 files changed, 72 insertions(+), 2 deletions(-) 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/OpenTok.Net.csproj b/src/OpenTok.Net/OpenTok.Net.csproj index 9444020..37f447e 100644 --- a/src/OpenTok.Net/OpenTok.Net.csproj +++ b/src/OpenTok.Net/OpenTok.Net.csproj @@ -42,8 +42,30 @@ own reference list rather than arriving transitively. --> - - + + + + + + <_OpenTokMissingPriPayloadFiles Include="@(_AllChildProjectItemsWithTargetPath)" + Condition=" !Exists('%(FullPath)') " /> + <_AllChildProjectItemsWithTargetPath Remove="@(_OpenTokMissingPriPayloadFiles)" /> + + + + +