diff --git a/.github/workflows/build-windows-plugin.yml b/.github/workflows/build-windows-plugin.yml new file mode 100644 index 0000000..51aed67 --- /dev/null +++ b/.github/workflows/build-windows-plugin.yml @@ -0,0 +1,46 @@ +name: Build Windows Plugin + +on: + pull_request: + paths: + - "SDK/Plugins/Windows/**/*.cpp" + - "SDK/Plugins/Windows/**/*.h" + - "SDK/Plugins/Windows/CMakeLists.txt" + - "SDK/Plugins/CMakeLists.txt" + - ".github/workflows/build-windows-plugin.yml" + push: + branches: [main] + paths: + - "SDK/Plugins/Windows/**/*.cpp" + - "SDK/Plugins/Windows/**/*.h" + - "SDK/Plugins/Windows/CMakeLists.txt" + - "SDK/Plugins/CMakeLists.txt" + - ".github/workflows/build-windows-plugin.yml" + +jobs: + build: + name: Build PrivyWebView.dll + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install WebView2 SDK + run: | + nuget install Microsoft.Web.WebView2 -OutputDirectory ${{ runner.temp }}/webview2 + + - name: Configure CMake + run: | + $wv2 = Get-ChildItem "${{ runner.temp }}/webview2/Microsoft.Web.WebView2.*" | Select-Object -First 1 + $wv2Root = "$($wv2.FullName)/build/native" + cmake -S SDK/Plugins -B SDK/Plugins/build -G "Visual Studio 17 2022" -A x64 -D WEBVIEW2_ROOT="$wv2Root" + + - name: Build + run: cmake --build SDK/Plugins/build --config Release + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: PrivyWebView-windows-x64 + path: SDK/Plugins/Windows/x86_64/Release/PrivyWebView.dll + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 9de2588..162b1d1 100644 --- a/.gitignore +++ b/.gitignore @@ -93,5 +93,16 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +# Build artifact folders for C Windows plugin +/SDK/Plugins/Windows/**/bin/ +/SDK/Plugins/Windows/**/obj/ +/SDK/Plugins/Windows/**/Debug/ +/SDK/Plugins/Windows/**/Release/*.lib* +/SDK/Plugins/Windows/**/Release/*.exp* +/SDK/Plugins/Build* + +# Temporary build/test folders +SampleApp/Test*/ + # Environment secrets .env \ No newline at end of file diff --git a/SDK/Plugins/CMakeLists.txt b/SDK/Plugins/CMakeLists.txt new file mode 100644 index 0000000..d03dac4 --- /dev/null +++ b/SDK/Plugins/CMakeLists.txt @@ -0,0 +1,6 @@ +cmake_minimum_required(VERSION 3.16) +project(PrivyWebViewPlugins LANGUAGES C CXX) + +if(WIN32 OR CMAKE_SYSTEM_NAME STREQUAL "Windows") + add_subdirectory(Windows) +endif() diff --git a/SDK/Plugins/CMakeLists.txt.meta b/SDK/Plugins/CMakeLists.txt.meta new file mode 100644 index 0000000..68161c3 --- /dev/null +++ b/SDK/Plugins/CMakeLists.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 28ad8fe971df0584684b7f789da0eb59 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/SDK/Plugins/Windows.meta b/SDK/Plugins/Windows.meta new file mode 100644 index 0000000..84cae23 --- /dev/null +++ b/SDK/Plugins/Windows.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 63fcc736b8f873d40addc87509bb2bb9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/SDK/Plugins/Windows/CMakeLists.txt b/SDK/Plugins/Windows/CMakeLists.txt new file mode 100644 index 0000000..2e1f5bd --- /dev/null +++ b/SDK/Plugins/Windows/CMakeLists.txt @@ -0,0 +1,77 @@ +cmake_minimum_required(VERSION 3.16) +project(PrivyWebViewWindows LANGUAGES CXX) + +set(PLUGIN_OUTPUT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/x86_64") + +add_library(PrivyWebView SHARED PrivyWebView.cpp) + +# Ensure output is placed in the Unity plugin folder +set_target_properties(PrivyWebView PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${PLUGIN_OUTPUT_DIR}" + LIBRARY_OUTPUT_DIRECTORY "${PLUGIN_OUTPUT_DIR}" + ARCHIVE_OUTPUT_DIRECTORY "${PLUGIN_OUTPUT_DIR}" +) + +# WebView2 SDK support +# User can specify WEBVIEW2_ROOT to point to the WebView2 SDK root (contains include/ and lib/) +if(NOT WEBVIEW2_ROOT) + # ProgramFiles(x86) contains parentheses and cannot be referenced directly in CMake variables. + # Use cmd to expand it instead. + execute_process(COMMAND cmd /c "echo %ProgramFiles(x86)%" OUTPUT_VARIABLE _progfiles_x86 OUTPUT_STRIP_TRAILING_WHITESPACE) + execute_process(COMMAND cmd /c "echo %ProgramFiles%" OUTPUT_VARIABLE _progfiles OUTPUT_STRIP_TRAILING_WHITESPACE) + + if(_progfiles_x86) + set(_webview2_candidate "${_progfiles_x86}/Microsoft WebView2 SDK") + elseif(_progfiles) + set(_webview2_candidate "${_progfiles}/Microsoft WebView2 SDK") + endif() + + if(_webview2_candidate) + set(WEBVIEW2_ROOT "${_webview2_candidate}") + endif() +endif() + +# Primary (recommended) include path +find_path(WEBVIEW2_INCLUDE_DIR WebView2.h + PATHS + "${WEBVIEW2_ROOT}/include" + "${WEBVIEW2_ROOT}/*/include" + NO_DEFAULT_PATH +) + +# Primary lib path +find_library(WEBVIEW2_LIB WebView2LoaderStatic + PATHS + "${WEBVIEW2_ROOT}/x64" + "${WEBVIEW2_ROOT}/x86" + "${WEBVIEW2_ROOT}/*/x64" + "${WEBVIEW2_ROOT}/*/x86" + NO_DEFAULT_PATH +) + +# Fallback: if we still don't find it, check the expected folder layout directly. +if(NOT WEBVIEW2_INCLUDE_DIR) + if(EXISTS "${WEBVIEW2_ROOT}/include/WebView2.h") + set(WEBVIEW2_INCLUDE_DIR "${WEBVIEW2_ROOT}/include") + endif() +endif() + +if(NOT WEBVIEW2_LIB) + if(EXISTS "${WEBVIEW2_ROOT}/x64/WebView2LoaderStatic.lib") + set(WEBVIEW2_LIB "${WEBVIEW2_ROOT}/x64/WebView2LoaderStatic.lib") + elseif(EXISTS "${WEBVIEW2_ROOT}/x86/WebView2LoaderStatic.lib") + set(WEBVIEW2_LIB "${WEBVIEW2_ROOT}/x86/WebView2LoaderStatic.lib") + endif() +endif() + +message(STATUS "WebView2 SDK root candidate: ${WEBVIEW2_ROOT}") +message(STATUS "Searching for WebView2.h under: ${WEBVIEW2_ROOT}/include") + +if(WEBVIEW2_INCLUDE_DIR AND WEBVIEW2_LIB) + message(STATUS "Found WebView2 include: ${WEBVIEW2_INCLUDE_DIR}") + message(STATUS "Found WebView2 lib: ${WEBVIEW2_LIB}") + target_include_directories(PrivyWebView PRIVATE "${WEBVIEW2_INCLUDE_DIR}") + target_link_libraries(PrivyWebView PRIVATE "${WEBVIEW2_LIB}") +else() + message(FATAL_ERROR "WebView2 SDK not found. Please install WebView2 SDK (https://developer.microsoft.com/microsoft-edge/webview2/) and ensure WebView2.h and WebView2.lib are available. You can also set WEBVIEW2_ROOT to the SDK root folder.") +endif() diff --git a/SDK/Plugins/Windows/CMakeLists.txt.meta b/SDK/Plugins/Windows/CMakeLists.txt.meta new file mode 100644 index 0000000..36aef08 --- /dev/null +++ b/SDK/Plugins/Windows/CMakeLists.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f0a4866a36c9b974ba277dc86b8bcc0d +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/SDK/Plugins/Windows/PrivyWebView.cpp b/SDK/Plugins/Windows/PrivyWebView.cpp new file mode 100644 index 0000000..74d9811 --- /dev/null +++ b/SDK/Plugins/Windows/PrivyWebView.cpp @@ -0,0 +1,562 @@ +// Native plugin for Windows providing two isolated WebView2 instances: +// +// 1. **Wallet WebView** – Hidden, persistent. Hosts the Privy embedded-wallet +// iframe and handles JSON postMessage communication with Unity. +// +// 2. **OAuth WebView** – Visible, transient. Shown only during OAuth login +// flows to display provider consent screens. Intercepts the redirect +// containing `privy_oauth_code` and forwards it to Unity. +// +// Each webview gets its own ICoreWebView2Environment with a **separate user-data +// directory**, ensuring full browser-level isolation of cookies, localStorage, +// and session state between OAuth and wallet contexts. +// +// Build requirements: +// - WebView2 SDK (Microsoft Edge WebView2) +// - Visual Studio (MSVC) toolchain +// + +#include +#include // SHGetFolderPathW +#include +#include +#include + +// WebView2 headers (part of Microsoft Edge WebView2 SDK) +#include "WebView2.h" + +// WRL smart pointers for COM lifetime management +#include + +// --------------------------------------------------------------------------- +// Callback types shared with the C# managed layer. +// --------------------------------------------------------------------------- +using MessageCallback = void(__cdecl*)(const char*); +using StatusCallback = void(__cdecl*)(const char*); + +// --------------------------------------------------------------------------- +// Encoding helpers +// --------------------------------------------------------------------------- +static std::wstring Utf8ToUtf16(const std::string& utf8) +{ + if (utf8.empty()) + return {}; + + int size = MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), -1, nullptr, 0); + if (size <= 0) + return {}; + + std::vector buffer(size); + MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), -1, buffer.data(), size); + + if (!buffer.empty() && buffer.back() == L'\0') + buffer.pop_back(); + + return std::wstring(buffer.begin(), buffer.end()); +} + +static std::string Utf16ToUtf8(const std::wstring& utf16) +{ + if (utf16.empty()) + return {}; + + int size = WideCharToMultiByte(CP_UTF8, 0, utf16.c_str(), -1, nullptr, 0, nullptr, nullptr); + if (size <= 0) + return {}; + + std::vector buffer(size); + WideCharToMultiByte(CP_UTF8, 0, utf16.c_str(), -1, buffer.data(), size, nullptr, nullptr); + + if (!buffer.empty() && buffer.back() == '\0') + buffer.pop_back(); + + return std::string(buffer.begin(), buffer.end()); +} + +// Return /PrivyWebView/ as a wide string. +// Each webview instance uses a different subfolder to achieve browser-level isolation. +static std::wstring GetUserDataFolder(const wchar_t* subfolder) +{ + wchar_t appDataPath[MAX_PATH] = {}; + if (SUCCEEDED(SHGetFolderPathW(nullptr, CSIDL_LOCAL_APPDATA, nullptr, 0, appDataPath))) + { + std::wstring path(appDataPath); + path += L"\\PrivyWebView\\"; + path += subfolder; + return path; + } + // Fallback: relative to current directory + std::wstring fallback = L".\\PrivyWebView\\"; + fallback += subfolder; + return fallback; +} + +// =================================================================== +// WALLET WEBVIEW – hidden, persistent, iframe communication +// =================================================================== + +static MessageCallback g_walletMessageCb = nullptr; +static StatusCallback g_walletLoadedCb = nullptr; +static StatusCallback g_walletErrorCb = nullptr; + +static Microsoft::WRL::ComPtr g_walletEnv; +static Microsoft::WRL::ComPtr g_walletController; +static Microsoft::WRL::ComPtr g_walletWebView; + +static HWND g_walletHWnd = nullptr; +static const wchar_t kWalletWindowClass[] = L"PrivyWalletWebViewClass"; + +static std::wstring g_walletPendingUrl; +static std::wstring g_walletPendingJs; + +static LRESULT CALLBACK WalletWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) +{ + // The wallet window should never be visible to the user – ignore close. + if (message == WM_CLOSE) { + ShowWindow(hWnd, SW_HIDE); + return 0; + } + return DefWindowProcW(hWnd, message, wParam, lParam); +} + +static HWND EnsureWalletWindow() +{ + if (g_walletHWnd && !IsWindow(g_walletHWnd)) + g_walletHWnd = nullptr; + + if (g_walletHWnd) + return g_walletHWnd; + + WNDCLASSEXW wcex = {}; + wcex.cbSize = sizeof(wcex); + wcex.style = CS_HREDRAW | CS_VREDRAW; + wcex.lpfnWndProc = WalletWndProc; + wcex.hInstance = GetModuleHandleW(nullptr); + wcex.hCursor = LoadCursorW(nullptr, (LPCWSTR)IDC_ARROW); + wcex.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1); + wcex.lpszClassName = kWalletWindowClass; + + if (!RegisterClassExW(&wcex) && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) + return nullptr; + + g_walletHWnd = CreateWindowExW( + 0, kWalletWindowClass, L"PrivyWallet", + WS_OVERLAPPEDWINDOW, + CW_USEDEFAULT, CW_USEDEFAULT, 1024, 768, + nullptr, nullptr, wcex.hInstance, nullptr); + + if (g_walletHWnd) { + ShowWindow(g_walletHWnd, SW_HIDE); + UpdateWindow(g_walletHWnd); + } + return g_walletHWnd; +} + +extern "C" __declspec(dllexport) void __cdecl PrivyWebView_Wallet_Initialize( + MessageCallback onMessage, StatusCallback onLoaded, StatusCallback onError) +{ + g_walletMessageCb = onMessage; + g_walletLoadedCb = onLoaded; + g_walletErrorCb = onError; + + EnsureWalletWindow(); + + std::wstring userDataDir = GetUserDataFolder(L"Wallet"); + + HRESULT hr = CreateCoreWebView2EnvironmentWithOptions( + nullptr, userDataDir.c_str(), nullptr, + Microsoft::WRL::Callback( + [](HRESULT result, ICoreWebView2Environment* env) -> HRESULT { + if (FAILED(result)) { + if (g_walletErrorCb) { + char buf[128]; + sprintf_s(buf, "Wallet WebView2 env failed (0x%08lX)", result); + g_walletErrorCb(buf); + } + return result; + } + + g_walletEnv = env; + env->CreateCoreWebView2Controller( + g_walletHWnd, + Microsoft::WRL::Callback( + [](HRESULT result, ICoreWebView2Controller* controller) -> HRESULT { + if (FAILED(result)) { + if (g_walletErrorCb) { + char buf[128]; + sprintf_s(buf, "Wallet controller failed (0x%08lX)", result); + g_walletErrorCb(buf); + } + return result; + } + + g_walletController = controller; + controller->get_CoreWebView2(&g_walletWebView); + + // Harden WebView: disable DevTools, context menus, and browser accelerator keys. + ICoreWebView2Settings* settings = nullptr; + g_walletWebView->get_Settings(&settings); + if (settings) { + settings->put_AreDevToolsEnabled(FALSE); + settings->put_AreDefaultContextMenusEnabled(FALSE); + settings->put_AreBrowserAcceleratorKeysEnabled(FALSE); + settings->Release(); + } + + controller->put_IsVisible(TRUE); + RECT bounds = {0, 0, 1024, 768}; + controller->put_Bounds(bounds); + + // Forward postMessage from the embedded wallet iframe to Unity. + g_walletWebView->add_WebMessageReceived( + Microsoft::WRL::Callback( + [](ICoreWebView2*, ICoreWebView2WebMessageReceivedEventArgs* args) -> HRESULT { + PWSTR msg = nullptr; + if (SUCCEEDED(args->TryGetWebMessageAsString(&msg)) && msg) { + std::string utf8 = Utf16ToUtf8(std::wstring(msg)); + if (g_walletMessageCb) + g_walletMessageCb(utf8.c_str()); + CoTaskMemFree(msg); + } + return S_OK; + }).Get(), + nullptr); + + // Prevent new-window popups from the wallet iframe. + g_walletWebView->add_NewWindowRequested( + Microsoft::WRL::Callback( + [](ICoreWebView2* sender, ICoreWebView2NewWindowRequestedEventArgs* args) -> HRESULT { + LPWSTR uri = nullptr; + if (SUCCEEDED(args->get_Uri(&uri)) && uri) { + if (sender) + sender->Navigate(uri); + args->put_Handled(TRUE); + CoTaskMemFree(uri); + } + return S_OK; + }).Get(), + nullptr); + + // Notify the managed side when a page finishes loading so it can + // inject the UnityProxy and start the ready-ping at the right time. + g_walletWebView->add_NavigationCompleted( + Microsoft::WRL::Callback( + [](ICoreWebView2* sender, ICoreWebView2NavigationCompletedEventArgs*) -> HRESULT { + PWSTR source = nullptr; + if (SUCCEEDED(sender->get_Source(&source)) && source) { + std::string utf8 = Utf16ToUtf8(std::wstring(source)); + if (g_walletLoadedCb) + g_walletLoadedCb(utf8.c_str()); + CoTaskMemFree(source); + } + return S_OK; + }).Get(), + nullptr); + + if (!g_walletPendingUrl.empty()) { + g_walletWebView->Navigate(g_walletPendingUrl.c_str()); + g_walletPendingUrl.clear(); + } + if (!g_walletPendingJs.empty()) { + g_walletWebView->ExecuteScript(g_walletPendingJs.c_str(), nullptr); + g_walletPendingJs.clear(); + } + + return S_OK; + }).Get()); + return S_OK; + }).Get()); +} + +extern "C" __declspec(dllexport) void __cdecl PrivyWebView_Wallet_LoadUrl(const char* url) +{ + std::wstring wurl = Utf8ToUtf16(url); + if (wurl.empty()) return; + + EnsureWalletWindow(); + + if (!g_walletWebView) { + g_walletPendingUrl = std::move(wurl); + return; + } + g_walletWebView->Navigate(wurl.c_str()); +} + +extern "C" __declspec(dllexport) void __cdecl PrivyWebView_Wallet_EvaluateJS(const char* js) +{ + std::wstring jsW = Utf8ToUtf16(js); + if (jsW.empty()) return; + + if (!g_walletWebView) { + g_walletPendingJs = std::move(jsW); + return; + } + g_walletWebView->ExecuteScript(jsW.c_str(), nullptr); +} + +extern "C" __declspec(dllexport) void __cdecl PrivyWebView_Wallet_Destroy() +{ + g_walletController = nullptr; + g_walletWebView = nullptr; + g_walletEnv = nullptr; +} + +// =================================================================== +// OAUTH WEBVIEW – visible, transient, redirect interception +// =================================================================== + +static MessageCallback g_oauthMessageCb = nullptr; +static StatusCallback g_oauthLoadedCb = nullptr; +static StatusCallback g_oauthErrorCb = nullptr; + +static Microsoft::WRL::ComPtr g_oauthEnv; +static Microsoft::WRL::ComPtr g_oauthController; +static Microsoft::WRL::ComPtr g_oauthWebView; + +static HWND g_oauthHWnd = nullptr; +static const wchar_t kOAuthWindowClass[] = L"PrivyOAuthWebViewClass"; + +static std::wstring g_oauthPendingUrl; +static std::wstring g_oauthRedirectUri; + +static LRESULT CALLBACK OAuthWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) +{ + if (message == WM_CLOSE) { + if (g_oauthErrorCb) + g_oauthErrorCb("WEBVIEW_WINDOW_CLOSED"); + ShowWindow(hWnd, SW_HIDE); + return 0; + } + if (message == WM_SIZE && g_oauthController) { + RECT bounds; + GetClientRect(hWnd, &bounds); + g_oauthController->put_Bounds(bounds); + } + return DefWindowProcW(hWnd, message, wParam, lParam); +} + +static HWND EnsureOAuthWindow() +{ + if (g_oauthHWnd && !IsWindow(g_oauthHWnd)) + g_oauthHWnd = nullptr; + + if (g_oauthHWnd) + return g_oauthHWnd; + + WNDCLASSEXW wcex = {}; + wcex.cbSize = sizeof(wcex); + wcex.style = CS_HREDRAW | CS_VREDRAW; + wcex.lpfnWndProc = OAuthWndProc; + wcex.hInstance = GetModuleHandleW(nullptr); + wcex.hCursor = LoadCursorW(nullptr, (LPCWSTR)IDC_ARROW); + wcex.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1); + wcex.lpszClassName = kOAuthWindowClass; + + if (!RegisterClassExW(&wcex) && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) + return nullptr; + + g_oauthHWnd = CreateWindowExW( + 0, kOAuthWindowClass, L"Privy Login", + WS_OVERLAPPEDWINDOW, + CW_USEDEFAULT, CW_USEDEFAULT, 1024, 768, + nullptr, nullptr, wcex.hInstance, nullptr); + + if (g_oauthHWnd) { + ShowWindow(g_oauthHWnd, SW_HIDE); + UpdateWindow(g_oauthHWnd); + } + return g_oauthHWnd; +} + +static void ShowOAuthWindow() +{ + EnsureOAuthWindow(); + if (g_oauthHWnd && IsWindow(g_oauthHWnd)) { + ShowWindow(g_oauthHWnd, SW_SHOW); + SetForegroundWindow(g_oauthHWnd); + SetWindowPos(g_oauthHWnd, HWND_TOPMOST, 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW); + } +} + +static void HideOAuthWindow() +{ + if (g_oauthHWnd && IsWindow(g_oauthHWnd)) + ShowWindow(g_oauthHWnd, SW_HIDE); +} + +// Checks whether a URL matches the configured redirect URI and contains +// the OAuth completion marker. +// If found: hides the OAuth window and sends the URL to the managed callback. +static bool InterceptOAuthRedirect(const std::wstring& url) +{ + if (!g_oauthRedirectUri.empty() && + url.find(g_oauthRedirectUri) == 0 && + url.find(L"privy_oauth_code=") != std::wstring::npos) { + HideOAuthWindow(); + std::string utf8 = Utf16ToUtf8(url); + if (g_oauthMessageCb) + g_oauthMessageCb(utf8.c_str()); + return true; + } + return false; +} + +extern "C" __declspec(dllexport) void __cdecl PrivyWebView_OAuth_SetRedirectUrl(const char* url) +{ + if (url == nullptr || url[0] == '\0') { + g_oauthRedirectUri.clear(); + return; + } + g_oauthRedirectUri = Utf8ToUtf16(url); +} + +extern "C" __declspec(dllexport) void __cdecl PrivyWebView_OAuth_Initialize( + MessageCallback onMessage, StatusCallback onLoaded, StatusCallback onError) +{ + g_oauthMessageCb = onMessage; + g_oauthLoadedCb = onLoaded; + g_oauthErrorCb = onError; + + EnsureOAuthWindow(); + + std::wstring userDataDir = GetUserDataFolder(L"OAuth"); + + HRESULT hr = CreateCoreWebView2EnvironmentWithOptions( + nullptr, userDataDir.c_str(), nullptr, + Microsoft::WRL::Callback( + [](HRESULT result, ICoreWebView2Environment* env) -> HRESULT { + if (FAILED(result)) { + if (g_oauthErrorCb) { + char buf[128]; + sprintf_s(buf, "OAuth WebView2 env failed (0x%08lX)", result); + g_oauthErrorCb(buf); + } + return result; + } + + g_oauthEnv = env; + env->CreateCoreWebView2Controller( + g_oauthHWnd, + Microsoft::WRL::Callback( + [](HRESULT result, ICoreWebView2Controller* controller) -> HRESULT { + if (FAILED(result)) { + if (g_oauthErrorCb) { + char buf[128]; + sprintf_s(buf, "OAuth controller failed (0x%08lX)", result); + g_oauthErrorCb(buf); + } + return result; + } + + g_oauthController = controller; + controller->get_CoreWebView2(&g_oauthWebView); + + // Harden WebView: disable DevTools, context menus, and browser accelerator keys. + ICoreWebView2Settings* settings = nullptr; + g_oauthWebView->get_Settings(&settings); + if (settings) { + settings->put_AreDevToolsEnabled(FALSE); + settings->put_AreDefaultContextMenusEnabled(FALSE); + settings->put_AreBrowserAcceleratorKeysEnabled(FALSE); + settings->Release(); + } + + controller->put_IsVisible(TRUE); + + RECT bounds; + GetClientRect(g_oauthHWnd, &bounds); + controller->put_Bounds(bounds); + + // Top-level navigation: check for OAuth redirect. + g_oauthWebView->add_NavigationStarting( + Microsoft::WRL::Callback( + [](ICoreWebView2*, ICoreWebView2NavigationStartingEventArgs* args) -> HRESULT { + LPWSTR uri = nullptr; + if (SUCCEEDED(args->get_Uri(&uri)) && uri) { + std::wstring u(uri); + if (InterceptOAuthRedirect(u)) + args->put_Cancel(TRUE); + CoTaskMemFree(uri); + } + return S_OK; + }).Get(), + nullptr); + + // Iframe navigation: silent-auth flows redirect inside an iframe. + g_oauthWebView->add_FrameNavigationStarting( + Microsoft::WRL::Callback( + [](ICoreWebView2*, ICoreWebView2NavigationStartingEventArgs* args) -> HRESULT { + LPWSTR uri = nullptr; + if (SUCCEEDED(args->get_Uri(&uri)) && uri) { + std::wstring u(uri); + if (InterceptOAuthRedirect(u)) + args->put_Cancel(TRUE); + CoTaskMemFree(uri); + } + return S_OK; + }).Get(), + nullptr); + + // New-window requests: keep inside the OAuth webview. + g_oauthWebView->add_NewWindowRequested( + Microsoft::WRL::Callback( + [](ICoreWebView2* sender, ICoreWebView2NewWindowRequestedEventArgs* args) -> HRESULT { + LPWSTR uri = nullptr; + if (SUCCEEDED(args->get_Uri(&uri)) && uri) { + std::wstring u(uri); + if (InterceptOAuthRedirect(u)) { + args->put_Handled(TRUE); + } else if (sender) { + sender->Navigate(u.c_str()); + args->put_Handled(TRUE); + } + CoTaskMemFree(uri); + } + return S_OK; + }).Get(), + nullptr); + + if (g_oauthLoadedCb) g_oauthLoadedCb(""); + + if (!g_oauthPendingUrl.empty()) { + g_oauthWebView->Navigate(g_oauthPendingUrl.c_str()); + g_oauthPendingUrl.clear(); + } + + return S_OK; + }).Get()); + return S_OK; + }).Get()); +} + +extern "C" __declspec(dllexport) void __cdecl PrivyWebView_OAuth_LoadUrl(const char* url) +{ + std::wstring wurl = Utf8ToUtf16(url); + if (wurl.empty()) return; + + EnsureOAuthWindow(); + + if (!g_oauthWebView) { + g_oauthPendingUrl = std::move(wurl); + return; + } + g_oauthWebView->Navigate(wurl.c_str()); +} + +extern "C" __declspec(dllexport) void __cdecl PrivyWebView_OAuth_ShowWindow() +{ + ShowOAuthWindow(); +} + +extern "C" __declspec(dllexport) void __cdecl PrivyWebView_OAuth_HideWindow() +{ + HideOAuthWindow(); +} + +extern "C" __declspec(dllexport) void __cdecl PrivyWebView_OAuth_Destroy() +{ + g_oauthController = nullptr; + g_oauthWebView = nullptr; + g_oauthEnv = nullptr; +} diff --git a/SDK/Plugins/Windows/PrivyWebView.cpp.meta b/SDK/Plugins/Windows/PrivyWebView.cpp.meta new file mode 100644 index 0000000..8caba01 --- /dev/null +++ b/SDK/Plugins/Windows/PrivyWebView.cpp.meta @@ -0,0 +1,69 @@ +fileFormatVersion: 2 +guid: 0817cf7983fbba342bd981b01f71713f +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Editor: 0 + Exclude Linux64: 1 + Exclude OSXUniversal: 0 + Exclude WebGL: 1 + Exclude Win: 0 + Exclude Win64: 0 + - first: + Any: + second: + enabled: 1 + settings: {} + - first: + Editor: Editor + second: + enabled: 1 + settings: + CPU: AnyCPU + DefaultValueInitialized: true + OS: Windows + - first: + Standalone: Linux64 + second: + enabled: 0 + settings: + CPU: None + - first: + Standalone: OSXUniversal + second: + enabled: 1 + settings: + CPU: None + - first: + Standalone: Win + second: + enabled: 1 + settings: + CPU: x86 + - first: + Standalone: Win64 + second: + enabled: 1 + settings: + CPU: x86_64 + - first: + WebGL: WebGL + second: + enabled: 0 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/SDK/Plugins/Windows/x86_64.meta b/SDK/Plugins/Windows/x86_64.meta new file mode 100644 index 0000000..ec9ccc7 --- /dev/null +++ b/SDK/Plugins/Windows/x86_64.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 930874b12f7cd6247a9d041b8d926050 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/SDK/Plugins/Windows/x86_64/Release.meta b/SDK/Plugins/Windows/x86_64/Release.meta new file mode 100644 index 0000000..0c9d7ed --- /dev/null +++ b/SDK/Plugins/Windows/x86_64/Release.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: eedb1b1e093e263459b3cccfe6bbb32c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/SDK/Plugins/Windows/x86_64/Release/PrivyWebView.dll b/SDK/Plugins/Windows/x86_64/Release/PrivyWebView.dll new file mode 100644 index 0000000..5c9857f Binary files /dev/null and b/SDK/Plugins/Windows/x86_64/Release/PrivyWebView.dll differ diff --git a/SDK/Plugins/Windows/x86_64/Release/PrivyWebView.dll.meta b/SDK/Plugins/Windows/x86_64/Release/PrivyWebView.dll.meta new file mode 100644 index 0000000..3448bfc --- /dev/null +++ b/SDK/Plugins/Windows/x86_64/Release/PrivyWebView.dll.meta @@ -0,0 +1,69 @@ +fileFormatVersion: 2 +guid: 8f0d69b5a7dc0c74d8eaa0368ce768a9 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Editor: 0 + Exclude Linux64: 0 + Exclude OSXUniversal: 0 + Exclude WebGL: 1 + Exclude Win: 0 + Exclude Win64: 0 + - first: + Any: + second: + enabled: 1 + settings: {} + - first: + Editor: Editor + second: + enabled: 1 + settings: + CPU: AnyCPU + DefaultValueInitialized: true + OS: Windows + - first: + Standalone: Linux64 + second: + enabled: 1 + settings: + CPU: AnyCPU + - first: + Standalone: OSXUniversal + second: + enabled: 1 + settings: + CPU: None + - first: + Standalone: Win + second: + enabled: 1 + settings: + CPU: x86 + - first: + Standalone: Win64 + second: + enabled: 1 + settings: + CPU: x86_64 + - first: + WebGL: WebGL + second: + enabled: 0 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/SDK/Runtime/Auth/AuthDelegator.cs b/SDK/Runtime/Auth/AuthDelegator.cs index ed570c8..6ad3ddc 100644 --- a/SDK/Runtime/Auth/AuthDelegator.cs +++ b/SDK/Runtime/Auth/AuthDelegator.cs @@ -271,11 +271,36 @@ public async Task RestoreSession() Token jwt = Token.Parse(persistedSession.AccessToken); InternalAuthSession newSession = persistedSession; + bool shouldRefresh = persistedSession?.RefreshToken != null; - if (jwt != null && jwt.IsExpired(Constants.DEFAULT_EXPIRATION_PADDING_IN_SECONDS)) + if (jwt == null || jwt.IsExpired(Constants.DEFAULT_EXPIRATION_PADDING_IN_SECONDS)) { - newSession = await _authRepository.RefreshSession(persistedSession.AccessToken, - persistedSession.RefreshToken); //could be null if request fails + // Expired or invalid token must refresh to validate session and get correct user data. + if (shouldRefresh) + { + newSession = await _authRepository.RefreshSession(persistedSession.AccessToken, + persistedSession.RefreshToken); + } + else + { + // No refresh token available: log out and force re-auth. + Logout(); + return; + } + } + else if (shouldRefresh) + { + // Token valid, but refresh for authoritative user mapping and any account changes. + try + { + newSession = await _authRepository.RefreshSession(persistedSession.AccessToken, + persistedSession.RefreshToken); + } + catch + { + // If refresh fails, keep persisted session to continue working offline. + newSession = persistedSession; + } } SetInternalAuthSession(newSession, persistedSession); diff --git a/SDK/Runtime/Auth/OAuth/LoginWithOAuth.cs b/SDK/Runtime/Auth/OAuth/LoginWithOAuth.cs index 49114f7..253f00a 100644 --- a/SDK/Runtime/Auth/OAuth/LoginWithOAuth.cs +++ b/SDK/Runtime/Auth/OAuth/LoginWithOAuth.cs @@ -22,6 +22,12 @@ public async Task LoginWithProvider(OAuthProvider provider, string re var result = await PromptOAuthCredentials(provider, redirectUri, codeChallenge, stateCode); + if (result.OAuthState != stateCode) + { + throw new System.Security.SecurityException( + "OAuth state mismatch — possible CSRF attack."); + } + return await _authDelegator.AuthenticateOAuthFlow( result.OAuthCode, codeVerifier, @@ -41,7 +47,8 @@ private async Task PromptOAuthCredentials(OAuthProvider provide if (IsNativeAppleFlow(provider)) return await new NativeAppleSignInFlow().PerformFlow(stateCode); - return await _oAuthFlow.PerformOAuthFlow(oauthUrl, redirectUri); + // Use the transformed redirect URI (e.g., localhost callback) for both init and the WebView flow. + return await _oAuthFlow.PerformOAuthFlow(oauthUrl, transformedRedirectUri); } private static bool IsNativeAppleFlow(OAuthProvider provider) diff --git a/SDK/Runtime/Auth/OAuth/OAuthFlows/IOAuthFlow.cs b/SDK/Runtime/Auth/OAuth/OAuthFlows/IOAuthFlow.cs index 699247b..007c033 100644 --- a/SDK/Runtime/Auth/OAuth/OAuthFlows/IOAuthFlow.cs +++ b/SDK/Runtime/Auth/OAuth/OAuthFlows/IOAuthFlow.cs @@ -18,6 +18,9 @@ internal static IOAuthFlow GetPlatformOAuthFlow() return new OAuthIOSWebAuthenticationFlow(); case RuntimePlatform.WebGLPlayer: return new OAuthWebGLPopupFlow(); + case RuntimePlatform.WindowsPlayer: + case RuntimePlatform.WindowsEditor: + return new OAuthWindowsWebViewFlow(); case RuntimePlatform.OSXEditor: return new OAuthInEditorFlow(); default: diff --git a/SDK/Runtime/Auth/OAuth/OAuthFlows/OAuthWindowsWebViewFlow.cs b/SDK/Runtime/Auth/OAuth/OAuthFlows/OAuthWindowsWebViewFlow.cs new file mode 100644 index 0000000..1f7fab6 --- /dev/null +++ b/SDK/Runtime/Auth/OAuth/OAuthFlows/OAuthWindowsWebViewFlow.cs @@ -0,0 +1,20 @@ +using Privy.Wallets; +using System; +using System.Threading.Tasks; + +namespace Privy.Auth.OAuth +{ + internal class OAuthWindowsWebViewFlow : IOAuthFlow + { + public async Task PerformOAuthFlow(string oAuthUrl, string redirectUri) + { + // The Windows embedded WebView handler intercepts the redirect URL and completes the flow. + // We pass the redirect URI so the handler can detect OAuth callback hits. + // We still pass the redirect URI through to server side via the init call. + + // Timeout set to 5 minutes to match other flows. + var result = await WindowsWebViewHandler.RunOAuthFlow(oAuthUrl, redirectUri, TimeSpan.FromMinutes(5)); + return result; + } + } +} diff --git a/SDK/Runtime/Auth/OAuth/OAuthFlows/OAuthWindowsWebViewFlow.cs.meta b/SDK/Runtime/Auth/OAuth/OAuthFlows/OAuthWindowsWebViewFlow.cs.meta new file mode 100644 index 0000000..2f3b96f --- /dev/null +++ b/SDK/Runtime/Auth/OAuth/OAuthFlows/OAuthWindowsWebViewFlow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 01c5dc23fd0389441aa747674b76fc56 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/SDK/Runtime/EmbeddedWallet/IWebviewHandler.cs b/SDK/Runtime/EmbeddedWallet/IWebviewHandler.cs index 5b118e5..91e2aad 100644 --- a/SDK/Runtime/EmbeddedWallet/IWebviewHandler.cs +++ b/SDK/Runtime/EmbeddedWallet/IWebviewHandler.cs @@ -23,6 +23,8 @@ internal static IWebViewHandler GetPlatformWebViewHandler(WebViewManager webView return new BrowserDomIframeHandler(webViewManager); #elif UNITY_IOS || UNITY_ANDROID || UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX return new WebViewHandler(webViewManager); +#elif UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN + return new WindowsWebViewHandler(webViewManager); #else return new WebViewHandlerForUnsupportedPlatform(); #endif diff --git a/SDK/Runtime/EmbeddedWallet/WindowsWebViewHandler.cs b/SDK/Runtime/EmbeddedWallet/WindowsWebViewHandler.cs new file mode 100644 index 0000000..8d8e2ed --- /dev/null +++ b/SDK/Runtime/EmbeddedWallet/WindowsWebViewHandler.cs @@ -0,0 +1,265 @@ +#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN +using System; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Privy.Auth.OAuth; +using Privy.Utils; + +namespace Privy.Wallets +{ + internal class WindowsWebViewHandler : IWebViewHandler + { + private readonly WebViewManager _webViewManager; + + private static TaskCompletionSource _oauthTcs; + private static readonly object _oauthLock = new object(); + private static string _oauthRedirectUri; + + internal static bool IsOAuthFlowActive => _oauthTcs != null; + + // Delegates for native callbacks + private delegate void NativeMessageCallback([MarshalAs(UnmanagedType.LPStr)] string message); + private delegate void NativeStatusCallback([MarshalAs(UnmanagedType.LPStr)] string message); + + // Keep delegates alive to avoid GC + private readonly NativeMessageCallback _onWalletMessageReceived; + private readonly NativeMessageCallback _onOAuthMessageReceived; + private readonly NativeStatusCallback _onPageLoaded; + private readonly NativeStatusCallback _onError; + + internal WindowsWebViewHandler(WebViewManager webViewManager = null) + { + _webViewManager = webViewManager; + + _onWalletMessageReceived = OnWalletMessageReceived; + _onOAuthMessageReceived = OnOAuthMessageReceived; + _onPageLoaded = OnPageLoaded; + _onError = OnError; + + // Initialize native WebView2 plugin (two isolated webviews) + PrivyLogger.Debug("Initializing Windows WebView handler"); + PrivyWebView_Wallet_Initialize(_onWalletMessageReceived, _onPageLoaded, _onError); + PrivyWebView_OAuth_Initialize(_onOAuthMessageReceived, _onPageLoaded, _onError); + } + + internal async static Task RunOAuthFlow(string url, string redirectUri, TimeSpan timeout) + { + // Create the TaskCompletionSource in a local variable so we can safely reference it + // even if _oauthTcs is cleared by the completion path. + var tcs = new TaskCompletionSource(); + + lock (_oauthLock) + { + if (_oauthTcs != null) + { + throw new InvalidOperationException("An OAuth flow is already in progress."); + } + + _oauthTcs = tcs; + _oauthRedirectUri = redirectUri; + } + + // Configure redirect URI for native callback interception. + PrivyWebView_OAuth_SetRedirectUrl(redirectUri); + + // Show window only for OAuth flows. + PrivyWebView_OAuth_ShowWindow(); + // Load the URL into the OAuth WebView (will be queued if WebView isn't ready) + PrivyWebView_OAuth_LoadUrl(url); + + // Timeout safety + var cancellation = Task.Delay(timeout); + + var completedTask = await Task.WhenAny(tcs.Task, cancellation); + + // Only clear the shared reference if it still points at our tcs. + lock (_oauthLock) + { + if (_oauthTcs == tcs) + { + _oauthTcs = null; + } + } + + if (completedTask == cancellation) + { + tcs.TrySetException(new TimeoutException("OAuth flow timed out.")); + } + + return await tcs.Task; + } + + public void LoadUrl(string url) + { + PrivyWebView_Wallet_LoadUrl(url); + } + + public void SendMessage(string message) + { + // Dispatch a message into the WebView using same mechanism as other handlers. + string jsDispatchEvent = $@" + window.dispatchEvent(new MessageEvent('message', {{ data: {message} }})); + "; + PrivyWebView_Wallet_EvaluateJS(jsDispatchEvent); + } + + private void OnPageLoaded(string url) + { + PrivyLogger.Debug($"Loaded URL: {url}"); + + // Only attempt to ping the embedded wallet when we are on the embedded wallet host page. + // Once the flow navigates to external sites (Google login, etc.), those pages will not respond to our ping. + if (!url.Contains("/embedded-wallets")) + { + PrivyLogger.Debug("Page load is not the embedded wallet page; skipping ready ping."); + return; + } + + // Inject JS proxy to enable embedded wallet communication. + var js = @" + window.PRIVY_UNITY = true; + + window.UnityProxy = { + postMessage: function(message) { + window.chrome.webview.postMessage(message); + } + }; + "; + + PrivyWebView_Wallet_EvaluateJS(js); + _ =_webViewManager.PingReadyUntilSuccessful(); + } + + private void OnWalletMessageReceived(string message) + { + _webViewManager?.OnMessageReceived(message); + } + + private void OnOAuthMessageReceived(string message) + { + if (!IsOAuthFlowActive) + { + PrivyLogger.Debug($"OAuth message received while no flow active, ignoring: {message}"); + return; + } + + // Match configured redirect URI + if (!string.IsNullOrEmpty(_oauthRedirectUri) && + message.StartsWith(_oauthRedirectUri, StringComparison.OrdinalIgnoreCase)) + { + PrivyLogger.Debug($"OAuth redirect intercepted: {message}"); + + try + { + var uri = new Uri(message); + var result = OAuthResultData.ParseFromUri(uri); + + if (string.IsNullOrEmpty(result?.OAuthCode)) + { + PrivyLogger.Warning($"OAuth redirect received without authorization code: {uri.Query}"); + lock (_oauthLock) + { + _oauthTcs?.TrySetException( + new PrivyAuthenticationException( + "OAuth redirect received without authorization code. The user may already be logged in with a session that could not be restored.", + AuthenticationError.OAuthVerificationFailed)); + _oauthTcs = null; + _oauthRedirectUri = null; + } + return; + } + + lock (_oauthLock) + { + _oauthTcs?.TrySetResult(result); + _oauthTcs = null; + _oauthRedirectUri = null; + } + return; + } + catch (Exception ex) + { + PrivyLogger.Error($"OAuth redirect parse failed: {ex}"); + lock (_oauthLock) + { + _oauthTcs?.TrySetException(ex); + _oauthTcs = null; + _oauthRedirectUri = null; + } + return; + } + } + + // If this is a URL from the OAuth webflow and does not contain code, ignore it to prevent JSON parsing crashes. + if (Uri.IsWellFormedUriString(message, UriKind.Absolute)) + { + PrivyLogger.Debug($"OAuth flow URL ignored in message handler: {message}"); + return; + } + + PrivyLogger.Debug($"Non-URL OAuth message received, ignoring: {message}"); + } + + private void OnError(string message) + { + if (message == "WEBVIEW_WINDOW_CLOSED") + { + lock (_oauthLock) + { + if (_oauthTcs != null) + { + _oauthTcs.TrySetCanceled(); + _oauthTcs = null; + _oauthRedirectUri = null; + } + } + + PrivyLogger.Info("Windows WebView OAuth flow canceled by user closing window."); + return; + } + + PrivyLogger.Error($"Windows WebView error: {message}"); + } + + #region Native Plugin Imports + + // Wallet WebView (hidden, persistent — hosts embedded wallet iframe) + [DllImport("PrivyWebView", CallingConvention = CallingConvention.Cdecl)] + private static extern void PrivyWebView_Wallet_Initialize(NativeMessageCallback onMessage, + NativeStatusCallback onLoaded, + NativeStatusCallback onError); + + [DllImport("PrivyWebView", CallingConvention = CallingConvention.Cdecl)] + private static extern void PrivyWebView_Wallet_LoadUrl([MarshalAs(UnmanagedType.LPStr)] string url); + + [DllImport("PrivyWebView", CallingConvention = CallingConvention.Cdecl)] + private static extern void PrivyWebView_Wallet_EvaluateJS([MarshalAs(UnmanagedType.LPStr)] string js); + + [DllImport("PrivyWebView", CallingConvention = CallingConvention.Cdecl)] + private static extern void PrivyWebView_Wallet_Destroy(); + + // OAuth WebView (visible, transient — OAuth consent screens) + [DllImport("PrivyWebView", CallingConvention = CallingConvention.Cdecl)] + private static extern void PrivyWebView_OAuth_Initialize(NativeMessageCallback onMessage, + NativeStatusCallback onLoaded, + NativeStatusCallback onError); + + [DllImport("PrivyWebView", CallingConvention = CallingConvention.Cdecl)] + private static extern void PrivyWebView_OAuth_SetRedirectUrl([MarshalAs(UnmanagedType.LPStr)] string url); + + [DllImport("PrivyWebView", CallingConvention = CallingConvention.Cdecl)] + private static extern void PrivyWebView_OAuth_LoadUrl([MarshalAs(UnmanagedType.LPStr)] string url); + + [DllImport("PrivyWebView", CallingConvention = CallingConvention.Cdecl)] + private static extern void PrivyWebView_OAuth_ShowWindow(); + + [DllImport("PrivyWebView", CallingConvention = CallingConvention.Cdecl)] + private static extern void PrivyWebView_OAuth_HideWindow(); + + [DllImport("PrivyWebView", CallingConvention = CallingConvention.Cdecl)] + private static extern void PrivyWebView_OAuth_Destroy(); + + #endregion + } +} +#endif diff --git a/SDK/Runtime/EmbeddedWallet/WindowsWebViewHandler.cs.meta b/SDK/Runtime/EmbeddedWallet/WindowsWebViewHandler.cs.meta new file mode 100644 index 0000000..e2d2027 --- /dev/null +++ b/SDK/Runtime/EmbeddedWallet/WindowsWebViewHandler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: aaea4e0d1ba58804a94b4f457ce11fee +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/SDK/Runtime/Networking/HttpRequestHandler.cs b/SDK/Runtime/Networking/HttpRequestHandler.cs index f8ae51b..d782872 100644 --- a/SDK/Runtime/Networking/HttpRequestHandler.cs +++ b/SDK/Runtime/Networking/HttpRequestHandler.cs @@ -29,7 +29,11 @@ IClientAnalyticsIdRepository clientAnalyticsIdRepository _clientId = privyConfig.ClientId; _baseUrl = $"{PrivyEnvironment.BASE_URL}/api/v1"; _clientAnalyticsIdRepository = clientAnalyticsIdRepository; - _appIdentifier = Application.identifier; + // Unity's Application.identifier can be empty for some standalone builds (or not set in Player Settings). + // The backend expects a native app identifier header for mobile/desktop clients, so we provide a fallback. + _appIdentifier = !string.IsNullOrEmpty(Application.identifier) + ? Application.identifier + : Application.productName; PrivyLogger.Debug($"App identifier is {_appId}"); PrivyLogger.Debug($"Unity app identifier is {_appIdentifier}"); @@ -65,8 +69,8 @@ public async Task SendRequestAsync(string path, string jsonData, string clientAnalyticsId = _clientAnalyticsIdRepository.LoadClientId(); request.SetRequestHeader(Constants.PRIVY_CLIENT_ANALYTICS_ID_HEADER, clientAnalyticsId); - // Need to add native app bundle ID here - if (_appIdentifier != null) + // Need to add native app bundle ID here (only for native platforms, not WebGL) + if (Application.platform != RuntimePlatform.WebGLPlayer && _appIdentifier != null) { request.SetRequestHeader(Constants.PRIVY_NATIVE_APP_IDENTIFIER, _appIdentifier); } diff --git a/SampleApp/Assets/Scripts/AuthScreenController.cs b/SampleApp/Assets/Scripts/AuthScreenController.cs index 9eccd2b..871c701 100644 --- a/SampleApp/Assets/Scripts/AuthScreenController.cs +++ b/SampleApp/Assets/Scripts/AuthScreenController.cs @@ -74,7 +74,13 @@ private void Awake() unlinkSmsButton.onClick.AddListener(OnUnlinkSmsButtonClick); if (updateSmsPhoneButton != null) updateSmsPhoneButton.onClick.AddListener(OnUpdateSmsPhoneButtonClick); + } + private void Start() + { + // InitialScreenController.Start() owns the startup auth check and calls + // ShowAuthorizedScreen() if already authenticated. Subscribing here for + // subsequent auth-state changes (login, logout) is all that's needed. PrivyManager.Instance.AuthStateChanged += OnAuthStateChange; } diff --git a/SampleApp/Assets/Scripts/InitialScreenController.cs b/SampleApp/Assets/Scripts/InitialScreenController.cs index d246aeb..a4bfe42 100644 --- a/SampleApp/Assets/Scripts/InitialScreenController.cs +++ b/SampleApp/Assets/Scripts/InitialScreenController.cs @@ -1,5 +1,6 @@ using System; using Privy; +using System.Threading.Tasks; using Privy.Auth; using Privy.Auth.Models; using Privy.Config; @@ -19,10 +20,10 @@ public class InitialScreenController : MonoBehaviour public Button loginWithOAuthAppleButton; public EnvConfig envConfig; - private readonly string _redirectUri = Application.platform == RuntimePlatform.WebGLPlayer ? + private readonly string _redirectUri = Application.platform == RuntimePlatform.WebGLPlayer ? (new Uri(Application.absoluteURL).GetLeftPart(UriPartial.Authority) + "/unity_callback.html") : - "unitydl://"; // Must set each platforms deeplink scheme to this - + "unitydl://"; // Use local callback for non-web builds ios/andriod + private void Awake() { EnvFileReader.Config = envConfig; @@ -68,21 +69,37 @@ private void OnLoginWithSmsButtonClick() private async void OnLoginWithOAuthGoogleButtonClick() { - await PrivyManager.Instance.OAuth.LoginWithProvider(OAuthProvider.Google, _redirectUri); + await LoginWithOAuthProvider(OAuthProvider.Google); } - + private async void OnLoginWithOAuthDiscordButtonClick() { - await PrivyManager.Instance.OAuth.LoginWithProvider(OAuthProvider.Discord, _redirectUri); + await LoginWithOAuthProvider(OAuthProvider.Discord); } private async void OnLoginWithOAuthTwitterButtonClick() { - await PrivyManager.Instance.OAuth.LoginWithProvider(OAuthProvider.Twitter, _redirectUri); + await LoginWithOAuthProvider(OAuthProvider.Twitter); } private async void OnLoginWithOAuthAppleButtonClick() { - await PrivyManager.Instance.OAuth.LoginWithProvider(OAuthProvider.Apple, _redirectUri); + await LoginWithOAuthProvider(OAuthProvider.Apple); + } + + private async Task LoginWithOAuthProvider(OAuthProvider provider) + { + try + { + var state = await PrivyManager.Instance.OAuth.LoginWithProvider(provider, _redirectUri); + if (state == AuthState.Authenticated) + { + UIManager.Instance.ShowAuthorizedScreen(); + } + } + catch (Exception ex) + { + Debug.LogError($"OAuth login failed for {provider}: {ex.Message}"); + } } } diff --git a/SampleApp/Assets/Scripts/UIManager.cs b/SampleApp/Assets/Scripts/UIManager.cs index 7738e4a..2974f7e 100644 --- a/SampleApp/Assets/Scripts/UIManager.cs +++ b/SampleApp/Assets/Scripts/UIManager.cs @@ -225,15 +225,10 @@ public void ShowWalletUI() public void ShowAuthorizedScreen() { - StateMachine.ClearHistory(); - - // If already on the authorized screen, just refresh the data display if (StateMachine.CurrentScreen == UIScreenId.Authorized) - { - authScreenController.OnAuthorizedScreenShown(); return; - } + StateMachine.ClearHistory(); NavigateTo(UIScreenId.Authorized); } } diff --git a/docs/windows_support.md b/docs/windows_support.md new file mode 100644 index 0000000..51af9a6 --- /dev/null +++ b/docs/windows_support.md @@ -0,0 +1,173 @@ +# Windows Support Guide (Embedded Wallet) + +> This document describes the Windows implementation details for Privy SDK Embedded Wallet in Unity. +> It is intended as a maintenance and future development reference. + +## 1. Scope + +- Windows desktop support under `UNITY_STANDALONE_WIN` and `UNITY_EDITOR_WIN`. +- Embedded wallet transport pipeline across `WebViewManager` and `WindowsWebViewHandler`. +- OAuth flow handling with native WebView2 plugin. +- Dual isolated WebView2 instances for security separation between OAuth and wallet contexts. + +## 2. Key files + +- `SDK/Runtime/EmbeddedWallet/WebViewManager.cs` + - central request/response manager + - correlation by ID map + - ready state + timeout behavior + - wallet operation wrappers (CreateEthereumWallet / CreateSolanaWallet / ConnectWallet / RecoverWallet / Request / SignWithUserSigner) + +- `SDK/Runtime/EmbeddedWallet/IWebviewHandler.cs` + - interface for platform-specific handlers + +- `SDK/Runtime/EmbeddedWallet/WindowsWebViewHandler.cs` + - Windows-specific implementation + - manages both the persistent Wallet WebView and the transient OAuth WebView + - native plugin calls via `DllImport("PrivyWebView")` with explicit `EntryPoint` bindings + - OAuth relay, redirect interception, and `TaskCompletionSource` completion + - content injection via `PrivyWebView_Wallet_EvaluateJS` and message callback plumbing + +- `SDK/Plugins/Windows/PrivyWebView.cpp` + - native C++ plugin built into `PrivyWebView.dll` + - implements two isolated WebView2 instances with separate user-data directories + - exposes `PrivyWebView_Wallet_*` and `PrivyWebView_OAuth_*` API families + - retains legacy `PrivyWebView_*` wrappers for backward compatibility + +- `SDK/Runtime/EmbeddedWallet/WebViewHandler.cs` + - non-Windows handler (mobile/mac) for reference and cross-platform consistency + +## 3. Architecture + +### 3.1 Dual-WebView security model + +The Windows implementation runs **two completely isolated WebView2 instances** to prevent OAuth provider cookies, localStorage, and session state from leaking into the embedded wallet context (and vice versa). + +| | Wallet WebView | OAuth WebView | +|---|---|---| +| **Purpose** | Hosts the Privy embedded-wallet iframe; handles JSON postMessage communication | Displays provider consent screens (Google, Discord, etc.) during login | +| **Visibility** | Always hidden — never shown to the user | Shown as a foreground window during auth; hidden on completion | +| **Lifetime** | Persistent — created once for the SDK lifetime | Transient — lazy-initialized on first OAuth call, reused across flows | +| **User-data directory** | `%LocalAppData%\PrivyWebView\Wallet` | `%LocalAppData%\PrivyWebView\OAuth` | +| **Browser isolation** | Own cookies, localStorage, session cache | Own cookies, localStorage, session cache | +| **Navigation hooks** | None (no URL interception) | `NavigationStarting` + `FrameNavigationStarting` + `NewWindowRequested` — all check for `privy_oauth_code=` | +| **Message mechanism** | `add_WebMessageReceived` → JSON forwarded to Unity | URL redirect interception only — no `postMessage` | + +Separate user-data directories are the critical isolation boundary. The Edge/Chromium process hosting each WebView2 instance stores cookies and Web Storage independently, so a compromised or malicious OAuth provider page cannot read wallet key material from localStorage, and wallet operations cannot observe OAuth session cookies. + +### 3.2 Native plugin API (`PrivyWebView.dll`) + +Two API families, one per webview: + +**Wallet WebView** (persistent, hidden): +``` +PrivyWebView_Wallet_Initialize(onMessage, onLoaded, onError) +PrivyWebView_Wallet_LoadUrl(url) +PrivyWebView_Wallet_EvaluateJS(js) +PrivyWebView_Wallet_Destroy() +``` + +**OAuth WebView** (transient, visible during auth): +``` +PrivyWebView_OAuth_Initialize(onMessage, onLoaded, onError) +PrivyWebView_OAuth_SetRedirectUrl(redirectUri) +PrivyWebView_OAuth_LoadUrl(url) +PrivyWebView_OAuth_ShowWindow() +PrivyWebView_OAuth_HideWindow() +PrivyWebView_OAuth_Destroy() +``` + +### 3.3 WebViewManager + +- Performs JSON message transport to/from embedded wallet JS. +- Tracks outstanding requests in `_requestResponseMap` keyed by request ID. +- Uses `TaskCompletionSource` for asynchronous response completion. +- Handles ping-ready handshake via `PingReadyUntilSuccessful`. +- Supports cancellation via `_disposeCts`. + +### 3.4 WindowsWebViewHandler + +- Construction initializes the **Wallet WebView** immediately via `PrivyWebView_Wallet_Initialize`. +- `RunOAuthFlow()` (static) lazy-initializes the **OAuth WebView** on first call, then reuses it across subsequent auth flows. +- Wallet callbacks (`OnWalletMessageReceived`, `OnWalletPageLoaded`, `OnWalletError`): + - `OnWalletPageLoaded` fires via `NavigationCompleted` when a page finishes loading. It guards on the URL containing `/embedded-wallets` before injecting the JS proxy and starting the ping loop. An empty URL (init signal from the controller) is ignored. + - `OnWalletMessageReceived` forwards all messages directly to `WebViewManager` — no OAuth URL sniffing needed. +- OAuth callbacks (`OnOAuthMessageReceived`, `OnOAuthLoaded`, `OnOAuthError`) are static and handle only redirect interception and `TaskCompletionSource` completion. +- On page load, injects a JS proxy into the wallet webview: + - `window.PRIVY_UNITY = true` + - `window.UnityProxy.postMessage(...)` forwards to `window.chrome.webview.postMessage(...)` +- Tracks OAuth via static `_oauthTcs`, `_oauthLock`, `_oauthRedirectUri`. + +## 4. Build requirements (Windows) + +- Unity player settings for Standalone Windows + Editor Windows. + - Standalone Windows uses `Product Name` from Unity Project Settings (does not use `Company Name`) for generated output folder and overlay. + - iOS/Android typically use `Company Name` as publisher bundle id prefix, but this is not used for Windows build targets in this SDK path. +- Include native plugin library `PrivyWebView.dll` in `Plugins` with x64/x86 configurations. +- Ensure `WebView2` runtime is installed on host machine. +- Fallback path: `WebViewHandlerForUnsupportedPlatform` for missing plugin or unsupported platform. + +### 4.1 Native Plugin Build (Windows) + +The native plugin is built from `SDK\Plugins` and provides the `PrivyWebView` API used by `WindowsWebViewHandler`. + +The build runs automatically in CI via the `build-windows-plugin.yml` GitHub Actions workflow, which: +- Installs the WebView2 SDK via NuGet +- Configures and builds with CMake using Visual Studio 2022 +- Uploads `PrivyWebView.dll` as a downloadable build artifact + +## 5. Runtime behavior + +### Startup + +1. `WebViewManager` constructed with `PrivyConfig`; calls `_webViewHandler.LoadUrl(embedded-wallets URL)`. +2. `WindowsWebViewHandler` calls `PrivyWebView_Wallet_Initialize`, creating the persistent Wallet WebView with its own isolated user-data directory. +3. `PrivyWebView_Wallet_LoadUrl` navigates to the embedded-wallet host page. +4. When the page finishes loading, `NavigationCompleted` fires → `OnWalletPageLoaded(url)` → JS proxy injected → `PingReadyUntilSuccessful()` starts. +5. Once the iframe responds, `_readyTcs` is resolved and the wallet is ready for operations. + +### OAuth login flow + +1. `OAuthWindowsWebViewFlow.PerformOAuthFlow()` calls `WindowsWebViewHandler.RunOAuthFlow(url, redirectUri, timeout)`. +2. On first call, the OAuth WebView is lazy-initialized via `PrivyWebView_OAuth_Initialize` with its own isolated user-data directory. +3. `PrivyWebView_OAuth_SetRedirectUrl` configures the expected callback prefix. +4. `PrivyWebView_OAuth_ShowWindow` makes the login window visible and foreground. +5. `PrivyWebView_OAuth_LoadUrl` navigates to the provider authorization URL. +6. `NavigationStarting`, `FrameNavigationStarting`, and `NewWindowRequested` handlers all check every URL for `privy_oauth_code=`. When found: + - The OAuth window is hidden. + - The full redirect URL is forwarded to `OnOAuthMessageReceived`. + - `OAuthResultData` (`privy_oauth_code`, `privy_oauth_state`) is parsed and the `TaskCompletionSource` is resolved. +7. `RunOAuthFlow` returns the result to the caller. The Wallet WebView is unaffected throughout. + +### Timeouts + +- Default wallet call: 30 seconds +- Create/recover wallet flows: 60 seconds +- `PingReady` per-attempt timeout: 150 ms +- OAuth flow: 5 minutes + +### Disposal + +- Cancel all pending tasks and `_disposeCts`, clear request map, cancel `_readyTcs`. + +## 6. Troubleshooting + +- Log `No matching task found for ID`: response arrived after timeout/dispose or malformed ID. +- `OAuth flow is already in progress`: previous `RunOAuthFlow` not finished. +- `WEBVIEW_WINDOW_CLOSED`: user cancelled OAuth; handler cancels `_oauthTcs`. +- `JSON/URI decode errors`: `WebViewManager` decodes URL-encoded payloads from native layer when needed. +- If the wallet ping loop runs every frame: verify `NavigationCompleted` is firing. The `onLoaded` callback must deliver a non-empty URL for `OnWalletPageLoaded` to proceed. An empty URL (controller init signal) is silently ignored. +- OAuth window not appearing: confirm `PrivyWebView_OAuth_Initialize` succeeded — check for `OAuth WebView2 env failed` or `OAuth controller failed` error logs. + +## 7. Suggested future improvements + +- Add explicit WebView2 version detection and user-facing error handling. +- Document writer-level configuration for ping interval and maximum retry. +- Add end-to-end tests for `WebViewManager` RPC timeout and cancellation behavior. +- Consider destroying and recreating the OAuth WebView after each flow to free memory and clear any residual provider session state. +- Add a `PrivyWebView_OAuth_ClearData()` export to selectively clear OAuth cookies between flows without full destruction. + +## 8. Metadata + +- Tags: `windows`, `embedded-wallet`, `webview2`, `oauth`, `unity`, `security`, `isolation` +- Status: current