```
chore(bridge): 删除桥接包的提交记录和Git属性配置 删除了 .codely-bridge-commit 文件中存储的提交哈希值 移除了 .gitattributes 文件中的行结束符配置规则 ```
This commit is contained in:
1 parent
d26d323912
commit
13d0c80335
392 files changed
+1814
-1895
No files matched your search
+69
@@ -0,0 +1,69 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(NativeBrowser CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
|
||||
# Statically link the MSVC runtime so users' VC++ Redistributable version
|
||||
# does not affect us. An outdated MSVCP140.dll on the host (e.g. VS 2017's
|
||||
# 14.11.x) ships an older _Mtx_internal_imp_t layout than the one this DLL
|
||||
# was compiled against, and std::mutex::lock SIGSEGVs on first call.
|
||||
# Requires CMP0091 NEW (default since CMake 3.15).
|
||||
if(MSVC)
|
||||
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
endif()
|
||||
# ── WebView2 SDK ──────────────────────────────────────────────────────────────
|
||||
set(WEBVIEW2_DIR "${CMAKE_SOURCE_DIR}/vendor/webview2")
|
||||
|
||||
if(NOT EXISTS "${WEBVIEW2_DIR}/include/WebView2.h")
|
||||
message(FATAL_ERROR "WebView2 SDK not found at ${WEBVIEW2_DIR}. "
|
||||
"Run: nuget install Microsoft.Web.WebView2 -OutputDirectory vendor/nuget "
|
||||
"and copy include/ and lib/x64/ to vendor/webview2/")
|
||||
endif()
|
||||
|
||||
# ── 目标 DLL ─────────────────────────────────────────────────────────────────
|
||||
add_library(NativeBrowser SHARED
|
||||
src/NativeBrowser.cpp
|
||||
src/BrowserInstance.cpp
|
||||
src/STAThread.cpp
|
||||
src/WndProcHook.cpp
|
||||
)
|
||||
|
||||
target_include_directories(NativeBrowser PRIVATE
|
||||
include/
|
||||
${WEBVIEW2_DIR}/include/
|
||||
)
|
||||
|
||||
target_compile_definitions(NativeBrowser PRIVATE
|
||||
NATIVEBROWSER_EXPORTS
|
||||
UNICODE
|
||||
_UNICODE
|
||||
WIN32_LEAN_AND_MEAN
|
||||
NOMINMAX
|
||||
)
|
||||
|
||||
target_compile_options(NativeBrowser PRIVATE
|
||||
/W3
|
||||
/WX- # 不把警告当错误(方便迭代)
|
||||
/MP # 并行编译
|
||||
)
|
||||
|
||||
target_link_libraries(NativeBrowser PRIVATE
|
||||
${WEBVIEW2_DIR}/lib/x64/WebView2LoaderStatic.lib
|
||||
shlwapi.lib # WebView2Loader 依赖
|
||||
version.lib # WebView2Loader 依赖
|
||||
comctl32.lib # SetWindowSubclass
|
||||
ole32.lib # CoInitializeEx
|
||||
oleaut32.lib
|
||||
)
|
||||
|
||||
# ── 构建后复制到 Plugins/Win/ 目录
|
||||
set(PLUGIN_OUTPUT_DIR "${CMAKE_SOURCE_DIR}/..")
|
||||
add_custom_command(TARGET NativeBrowser POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory "${PLUGIN_OUTPUT_DIR}"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"$<TARGET_FILE:NativeBrowser>"
|
||||
"${PLUGIN_OUTPUT_DIR}/NativeBrowser.dll"
|
||||
COMMENT "Copying NativeBrowser.dll → Plugins/Win/"
|
||||
)
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2939fbf3c29694a4dad2a67467b71df4
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Build NativeBrowser.dll and copy to Plugins/Win/
|
||||
|
||||
.PARAMETER Config
|
||||
Build configuration: Release (default) or Debug
|
||||
|
||||
.PARAMETER Clean
|
||||
Remove build directory before configuring
|
||||
|
||||
.EXAMPLE
|
||||
.\build.ps1
|
||||
.\build.ps1 -Config Debug
|
||||
.\build.ps1 -Clean
|
||||
#>
|
||||
param(
|
||||
[ValidateSet("Release", "Debug")]
|
||||
[string]$Config = "Release",
|
||||
|
||||
[switch]$Clean
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ScriptDir = $PSScriptRoot
|
||||
$BuildDir = Join-Path $ScriptDir "build"
|
||||
|
||||
# ── Tool detection ─────────────────────────────────────────────────────────────
|
||||
|
||||
function Find-CMake {
|
||||
$cmake = Get-Command cmake -ErrorAction SilentlyContinue
|
||||
if ($cmake) { return $cmake.Source }
|
||||
|
||||
$candidates = @(
|
||||
"$env:ProgramFiles\CMake\bin\cmake.exe",
|
||||
"${env:ProgramFiles(x86)}\CMake\bin\cmake.exe"
|
||||
)
|
||||
foreach ($c in $candidates) {
|
||||
if (Test-Path $c) { return $c }
|
||||
}
|
||||
throw "cmake not found. Install CMake 3.20+ and add it to PATH.`nhttps://cmake.org/download/"
|
||||
}
|
||||
|
||||
function Find-VSGenerator {
|
||||
$generators = @(
|
||||
@{ Name = "Visual Studio 17 2022"; MinVer = "17" },
|
||||
@{ Name = "Visual Studio 16 2019"; MinVer = "16" }
|
||||
)
|
||||
|
||||
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
|
||||
if (Test-Path $vswhere) {
|
||||
foreach ($g in $generators) {
|
||||
$found = & $vswhere -version "[$($g.MinVer),)" -products * `
|
||||
-requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 `
|
||||
-property installationPath 2>$null
|
||||
if ($found) { return $g.Name }
|
||||
}
|
||||
}
|
||||
|
||||
return "Visual Studio 17 2022"
|
||||
}
|
||||
|
||||
# ── Main ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=======================================" -ForegroundColor Cyan
|
||||
Write-Host " NativeBrowser Build [$Config]" -ForegroundColor Cyan
|
||||
Write-Host "=======================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# 1. Check CMake
|
||||
Write-Host "[1/5] Checking CMake..." -ForegroundColor Yellow
|
||||
$cmake = Find-CMake
|
||||
$cmakeVer = (& $cmake --version 2>&1 | Select-Object -First 1) -replace "cmake version ", ""
|
||||
Write-Host " $cmake (v$cmakeVer)"
|
||||
|
||||
# 2. Check WebView2 SDK
|
||||
Write-Host "[2/5] Checking WebView2 SDK..." -ForegroundColor Yellow
|
||||
$webview2Header = Join-Path $ScriptDir "vendor\webview2\include\WebView2.h"
|
||||
if (-not (Test-Path $webview2Header)) {
|
||||
Write-Host ""
|
||||
Write-Host "ERROR: WebView2 SDK not found at:" -ForegroundColor Red
|
||||
Write-Host " $webview2Header" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Host "Run the following to fetch it from NuGet cache:" -ForegroundColor Yellow
|
||||
Write-Host ' $src = (Get-ChildItem "$env:USERPROFILE\.nuget\packages\microsoft.web.webview2" | Sort-Object Name -Desc | Select -First 1).FullName'
|
||||
Write-Host ' Copy-Item "$src\build\native\include\WebView2.h" "vendor\webview2\include\" -Force'
|
||||
Write-Host ' Copy-Item "$src\build\native\x64\WebView2LoaderStatic.lib" "vendor\webview2\lib\x64\" -Force'
|
||||
exit 1
|
||||
}
|
||||
Write-Host " OK ($webview2Header)"
|
||||
|
||||
# 3. Clean (optional)
|
||||
if ($Clean -and (Test-Path $BuildDir)) {
|
||||
Write-Host "[3/5] Cleaning build directory..." -ForegroundColor Yellow
|
||||
Remove-Item $BuildDir -Recurse -Force
|
||||
Write-Host " Removed: $BuildDir"
|
||||
} else {
|
||||
Write-Host "[3/5] Clean skipped (use -Clean to force)" -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# 4. CMake configure
|
||||
$generator = Find-VSGenerator
|
||||
Write-Host "[4/5] CMake configure [Generator: $generator]..." -ForegroundColor Yellow
|
||||
|
||||
& $cmake -B $BuildDir -G $generator -A x64 -S $ScriptDir
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "ERROR: CMake configure failed (exit $LASTEXITCODE)" -ForegroundColor Red
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
# 5. Build
|
||||
Write-Host ""
|
||||
Write-Host "[5/5] Building [$Config]..." -ForegroundColor Yellow
|
||||
|
||||
& $cmake --build $BuildDir --config $Config --parallel
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "ERROR: Build failed (exit $LASTEXITCODE)" -ForegroundColor Red
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
# Verify output
|
||||
$outputDll = [IO.Path]::GetFullPath((Join-Path $ScriptDir "..\NativeBrowser.dll"))
|
||||
|
||||
Write-Host ""
|
||||
if (Test-Path $outputDll) {
|
||||
$sizeKB = [math]::Round((Get-Item $outputDll).Length / 1KB, 1)
|
||||
Write-Host "Build succeeded!" -ForegroundColor Green
|
||||
Write-Host " Output : $outputDll"
|
||||
Write-Host " Size : $sizeKB KB"
|
||||
} else {
|
||||
Write-Host "ERROR: Build finished but output DLL not found: $outputDll" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 014f48bf90c089e45851051fdeb3d0a4
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: abf11ba4a72d5e64cb5d590ab381802a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
#pragma once
|
||||
#include <windows.h>
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef NATIVEBROWSER_EXPORTS
|
||||
# define NB_API extern "C" __declspec(dllexport)
|
||||
#else
|
||||
# define NB_API extern "C" __declspec(dllimport)
|
||||
#endif
|
||||
|
||||
// ── 回调函数类型 ──────────────────────────────────────────────────────────────
|
||||
// 所有回调均从 STA 线程通过 PostMessage 投递到 Unity 主线程执行。
|
||||
typedef void (__stdcall* NB_NavigationCallback) (const wchar_t* url, void* ctx);
|
||||
typedef void (__stdcall* NB_LoadCompletedCallback)(bool success, void* ctx);
|
||||
typedef void (__stdcall* NB_TitleChangedCallback) (const wchar_t* title, void* ctx);
|
||||
typedef void (__stdcall* NB_MessageCallback) (const wchar_t* json, void* ctx);
|
||||
typedef void (__stdcall* NB_DestroyedCallback) (void* ctx);
|
||||
|
||||
#pragma pack(push, 8)
|
||||
struct NB_Callbacks {
|
||||
NB_NavigationCallback onNavigationStarting; // 可为 null
|
||||
NB_LoadCompletedCallback onLoadCompleted; // 可为 null
|
||||
NB_TitleChangedCallback onTitleChanged; // 可为 null
|
||||
NB_MessageCallback onWebMessageReceived; // 可为 null
|
||||
NB_DestroyedCallback onParentDestroyed; // 父 HWND 销毁时通知 C#
|
||||
void* ctx; // 透传给所有回调
|
||||
};
|
||||
#pragma pack(pop)
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────────────────
|
||||
// NB_Create:启动独立 STA 线程,异步初始化 WebView2,完成后自动导航 initialUrl。
|
||||
// 返回不透明句柄;失败(参数无效、Runtime 未安装等)返回 null。
|
||||
NB_API void* NB_Create(
|
||||
HWND parentHwnd,
|
||||
const wchar_t* userDataFolder,
|
||||
const wchar_t* initialUrl,
|
||||
const NB_Callbacks* callbacks // 可为 null
|
||||
);
|
||||
|
||||
NB_API void NB_Destroy(void* handle);
|
||||
|
||||
// NB_ShutdownAll:同步关闭所有活跃 BrowserInstance(总耗时最长 timeoutMs)。
|
||||
// 在调用线程上 pump 消息以驱动 WebView2 跨 apartment marshaling,避免父 HWND
|
||||
// 所属线程被阻塞导致 Close 死锁。仅 PrepareShutdown + FinalizeShutdown,不释放
|
||||
// 内存(进程即将退出,OS 回收即可,避免与 NB_Destroy 后台线程的 delete 竞争)。
|
||||
// 必须在 Unity Editor 退出时(EditorApplication.quitting)调用一次。
|
||||
NB_API void NB_ShutdownAll(uint32_t timeoutMs);
|
||||
|
||||
// NB_SetCallbacks:替换托管回调函数指针。
|
||||
// Unity Domain Reload 后旧指针失效,必须在 OnEnable 中调用此函数注册新委托。
|
||||
NB_API void NB_SetCallbacks(void* handle, const NB_Callbacks* callbacks);
|
||||
|
||||
// NB_IsHandleValid:判断 handle 是否仍指向一个活跃的 BrowserInstance。
|
||||
// 不解引用指针,安全用于 Domain Reload 后的句柄验证。
|
||||
NB_API bool NB_IsHandleValid(void* handle);
|
||||
|
||||
// ── 导航(线程安全,内部转发到 STA 线程)──────────────────────────────────────
|
||||
NB_API void NB_Navigate (void* handle, const wchar_t* url);
|
||||
NB_API void NB_GoBack (void* handle);
|
||||
NB_API void NB_GoForward (void* handle);
|
||||
NB_API void NB_Reload (void* handle);
|
||||
NB_API void NB_Stop (void* handle);
|
||||
|
||||
// ── 布局(线程安全)──────────────────────────────────────────────────────────
|
||||
// NB_Create 内部已 hook 父 HWND 的 WM_SIZE,通常无需手动调用 NB_Resize。
|
||||
NB_API void NB_Resize (void* handle, int x, int y, int w, int h);
|
||||
NB_API void NB_SetVisible (void* handle, bool visible);
|
||||
NB_API void NB_SetTopInset (void* handle, int topPx);
|
||||
|
||||
// ── 脚本 ─────────────────────────────────────────────────────────────────────
|
||||
NB_API void NB_ExecuteScript (void* handle, const wchar_t* js);
|
||||
NB_API void NB_PostWebMessage (void* handle, const wchar_t* json);
|
||||
|
||||
// ── Focus ──
|
||||
// NB_MoveFocus: move keyboard focus into the WebView2 content (equivalent to
|
||||
// ICoreWebView2Controller::MoveFocus with the PROGRAMMATIC reason). When the
|
||||
// WebView2 is embedded as a child of Unity's GUIView, the parent keeps Win32
|
||||
// focus after navigation, so HTML autofocus / element.focus() neither show a
|
||||
// caret nor receive keystrokes; the host must call this to hand focus to the
|
||||
// content. Thread-safe; forwarded to the STA thread internally.
|
||||
NB_API void NB_MoveFocus (void* handle);
|
||||
|
||||
// ── 调试 ─────────────────────────────────────────────────────────────────────
|
||||
NB_API void NB_OpenDevTools(void* handle);
|
||||
|
||||
// ── Runtime 检测(同步,可在任意线程调用)───────────────────────────────────
|
||||
NB_API bool NB_IsRuntimeAvailable();
|
||||
NB_API const wchar_t* NB_GetRuntimeVersion(); // 返回内部静态缓冲区
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 952eec9c9781d7c478a1057764a499ea
|
||||
PluginImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 3
|
||||
iconMap: {}
|
||||
executionOrder: {}
|
||||
defineConstraints: []
|
||||
isPreloaded: 0
|
||||
isOverridable: 1
|
||||
isExplicitlyReferenced: 0
|
||||
validateReferences: 1
|
||||
platformData:
|
||||
Any:
|
||||
enabled: 0
|
||||
settings: {}
|
||||
Editor:
|
||||
enabled: 0
|
||||
settings:
|
||||
DefaultValueInitialized: true
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ecfd8da23365a1346a0bef7733afff7b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+400
@@ -0,0 +1,400 @@
|
||||
#include "BrowserInstance.h"
|
||||
#include <wrl/event.h> // Microsoft::WRL::Callback
|
||||
#include <shlwapi.h>
|
||||
|
||||
// ── 静态成员定义 ──────────────────────────────────────────────────────────────
|
||||
std::mutex BrowserInstance::s_mapMutex;
|
||||
std::unordered_map<HWND, BrowserInstance*> BrowserInstance::s_hwndMap;
|
||||
|
||||
std::unordered_set<BrowserInstance*> BrowserInstance::s_liveInstances;
|
||||
std::mutex BrowserInstance::s_liveSetMutex;
|
||||
|
||||
// ── WinEvent 回调(WINEVENT_INCONTEXT → 在 Unity 主线程同步调用)────────────
|
||||
void CALLBACK BrowserInstance::WinEventProc(
|
||||
HWINEVENTHOOK /*hook*/, DWORD /*event*/,
|
||||
HWND hwnd, LONG objId, LONG childId,
|
||||
DWORD /*idEventThread*/, DWORD /*dwmsEventTime*/)
|
||||
{
|
||||
if (objId != 0 /*OBJID_WINDOW*/ || childId != 0 /*CHILDID_SELF*/) return;
|
||||
|
||||
BrowserInstance* inst = nullptr;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(s_mapMutex);
|
||||
auto it = s_hwndMap.find(hwnd);
|
||||
if (it == s_hwndMap.end()) return;
|
||||
inst = it->second;
|
||||
}
|
||||
if (!inst || inst->m_shutdown.load()) return;
|
||||
|
||||
RECT rc = {};
|
||||
if (!GetClientRect(hwnd, &rc)) return;
|
||||
inst->PostResizeBounds(rc.right, rc.bottom);
|
||||
}
|
||||
|
||||
// ── Constructor / Destructor ──────────────────────────────────────────────────
|
||||
|
||||
BrowserInstance::BrowserInstance(HWND parentHwnd,
|
||||
std::wstring userDataFolder,
|
||||
std::wstring initialUrl,
|
||||
NB_Callbacks callbacks)
|
||||
: m_parentHwnd(parentHwnd)
|
||||
, m_userDataFolder(std::move(userDataFolder))
|
||||
, m_initialUrl(std::move(initialUrl))
|
||||
, m_callbacks(callbacks)
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(s_liveSetMutex);
|
||||
s_liveInstances.insert(this);
|
||||
}
|
||||
|
||||
m_staThread = std::make_unique<STAThread>(L"NativeBrowser-STA");
|
||||
|
||||
m_wndProcHook = std::make_unique<WndProcHook>(
|
||||
parentHwnd,
|
||||
[this](int w, int h) { PostResizeBounds(w, h); },
|
||||
[this] { FireParentDestroyed(); }
|
||||
);
|
||||
|
||||
m_staThread->Post([this] { InitOnSTAThread(); });
|
||||
}
|
||||
|
||||
BrowserInstance::~BrowserInstance()
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(s_liveSetMutex);
|
||||
s_liveInstances.erase(this);
|
||||
}
|
||||
|
||||
// 析构时确保两阶段都完成(正常情况下 NB_Destroy 已经完成了 Phase 1;
|
||||
// 若析构由 NB_Destroy 的后台线程 delete 触发,Phase 2 也已完成,均幂等)。
|
||||
PrepareShutdown();
|
||||
FinalizeShutdown();
|
||||
}
|
||||
|
||||
// ── 两阶段关闭 ────────────────────────────────────────────────────────────────
|
||||
|
||||
bool BrowserInstance::PrepareShutdown()
|
||||
{
|
||||
// m_shutdown 保证幂等:只有第一次调用执行实际逻辑
|
||||
if (m_shutdown.exchange(true)) return false;
|
||||
|
||||
// 1. 从全局映射删除 → WinEvent/WM_SIZE 回调立即找不到此实例(线程安全)
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(s_mapMutex);
|
||||
s_hwndMap.erase(m_parentHwnd);
|
||||
}
|
||||
|
||||
// 2. 卸载 WndProcHook(调用 RemoveWindowSubclass,必须在 Unity 主线程)
|
||||
m_wndProcHook.reset();
|
||||
|
||||
// WinEvent 的 UnhookWinEvent 需在 STA 线程(与 SetWinEventHook 同线程),
|
||||
// 留给 FinalizeShutdown 通过 STA Post 完成。
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void BrowserInstance::PostFinalizeShutdown()
|
||||
{
|
||||
// m_finalized 保证幂等
|
||||
if (m_finalized.exchange(true)) return;
|
||||
|
||||
// 确保 PrepareShutdown 已完成(若直接调用 FinalizeShutdown 而未先 Prepare)
|
||||
PrepareShutdown();
|
||||
|
||||
// 捕获 WinEvent hook 供 STA 线程卸载(原子交换,避免与 RegisterWinEventHook 竞争)
|
||||
HWINEVENTHOOK hook = m_winEventHook.exchange(nullptr);
|
||||
|
||||
// 向 STA 线程投递:卸载 WinEvent hook + 关闭 WebView2 COM 对象
|
||||
m_staThread->Post([this, hook] {
|
||||
if (hook) UnhookWinEvent(hook); // 与 SetWinEventHook 同线程(STA)
|
||||
|
||||
if (m_webview)
|
||||
{
|
||||
m_webview->remove_NavigationStarting(m_navigationToken);
|
||||
m_webview->remove_DocumentTitleChanged(m_titleToken);
|
||||
m_webview->remove_WebMessageReceived(m_messageToken);
|
||||
m_webview->remove_NavigationCompleted(m_loadToken);
|
||||
}
|
||||
if (m_controller) m_controller->Close();
|
||||
m_webview.Reset();
|
||||
m_controller.Reset();
|
||||
m_env.Reset();
|
||||
});
|
||||
|
||||
// 投递 WM_QUIT,让 STA 线程在处理完上述 close 工作后退出。
|
||||
// 不在此函数内等待——由调用方负责(NB_ShutdownAll 用 pumped wait 避免死锁)。
|
||||
m_staThread->RequestQuit();
|
||||
}
|
||||
|
||||
HANDLE BrowserInstance::GetStaThreadHandle() const
|
||||
{
|
||||
return m_staThread ? m_staThread->ThreadHandle() : nullptr;
|
||||
}
|
||||
|
||||
void BrowserInstance::FinalizeShutdown(DWORD timeoutMs)
|
||||
{
|
||||
PostFinalizeShutdown(); // 幂等
|
||||
if (m_staThread)
|
||||
m_staThread->Shutdown(timeoutMs); // RequestQuit 已发;这里只是 WaitForSingleObject
|
||||
}
|
||||
|
||||
// ── 跨 Domain Reload 恢复 ─────────────────────────────────────────────────────
|
||||
|
||||
bool BrowserInstance::IsLive(void* handle)
|
||||
{
|
||||
if (!handle) return false;
|
||||
std::lock_guard<std::mutex> lk(s_liveSetMutex);
|
||||
return s_liveInstances.count(static_cast<BrowserInstance*>(handle)) > 0;
|
||||
}
|
||||
|
||||
void BrowserInstance::SetCallbacks(const NB_Callbacks& callbacks)
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_callbacksMutex);
|
||||
m_callbacks = callbacks;
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
void BrowserInstance::Navigate(const wchar_t* url)
|
||||
{
|
||||
std::wstring u(url ? url : L"");
|
||||
m_staThread->Post([this, u = std::move(u)] {
|
||||
if (m_webview) m_webview->Navigate(u.c_str());
|
||||
});
|
||||
}
|
||||
|
||||
void BrowserInstance::GoBack() { m_staThread->Post([this] { if (m_webview) m_webview->GoBack(); }); }
|
||||
void BrowserInstance::GoForward() { m_staThread->Post([this] { if (m_webview) m_webview->GoForward(); }); }
|
||||
void BrowserInstance::Reload() { m_staThread->Post([this] { if (m_webview) m_webview->Reload(); }); }
|
||||
void BrowserInstance::Stop() { m_staThread->Post([this] { if (m_webview) m_webview->Stop(); }); }
|
||||
|
||||
void BrowserInstance::Resize(int x, int y, int w, int h)
|
||||
{
|
||||
m_staThread->Post([this, x, y, w, h] {
|
||||
if (m_controller) m_controller->put_Bounds({ x, y, x + w, y + h });
|
||||
});
|
||||
}
|
||||
|
||||
void BrowserInstance::SetVisible(bool v)
|
||||
{
|
||||
m_staThread->Post([this, v] {
|
||||
if (m_controller) m_controller->put_IsVisible(v ? TRUE : FALSE);
|
||||
});
|
||||
}
|
||||
|
||||
void BrowserInstance::ExecuteScript(const wchar_t* js)
|
||||
{
|
||||
std::wstring s(js ? js : L"");
|
||||
m_staThread->Post([this, s = std::move(s)] {
|
||||
if (m_webview) m_webview->ExecuteScript(s.c_str(), nullptr);
|
||||
});
|
||||
}
|
||||
|
||||
void BrowserInstance::PostWebMessage(const wchar_t* json)
|
||||
{
|
||||
std::wstring s(json ? json : L"");
|
||||
m_staThread->Post([this, s = std::move(s)] {
|
||||
if (m_webview) m_webview->PostWebMessageAsJson(s.c_str());
|
||||
});
|
||||
}
|
||||
|
||||
void BrowserInstance::OpenDevTools()
|
||||
{
|
||||
m_staThread->Post([this] {
|
||||
if (m_webview) m_webview->OpenDevToolsWindow();
|
||||
});
|
||||
}
|
||||
|
||||
void BrowserInstance::MoveFocus()
|
||||
{
|
||||
// 必须在拥有 controller 的 STA 线程上调用。PROGRAMMATIC 让 WebView2 取得 Win32 焦点,
|
||||
// 内容文档随之获得系统焦点,挂起的 autofocus / 已聚焦元素的光标得以显示并接收键盘输入。
|
||||
m_staThread->Post([this] {
|
||||
if (m_controller && !m_shutdown.load())
|
||||
m_controller->MoveFocus(COREWEBVIEW2_MOVE_FOCUS_REASON_PROGRAMMATIC);
|
||||
});
|
||||
}
|
||||
|
||||
void BrowserInstance::SetTopInset(int px)
|
||||
{
|
||||
m_topInset.store(px < 0 ? 0 : px);
|
||||
m_staThread->Post([this] { SyncBounds(); });
|
||||
}
|
||||
|
||||
// ── Resize 合并投递(主线程调用)─────────────────────────────────────────────
|
||||
void BrowserInstance::PostResizeBounds(int w, int h)
|
||||
{
|
||||
m_pendingW.store((LONG)w);
|
||||
m_pendingH.store((LONG)h);
|
||||
|
||||
bool expected = false;
|
||||
if (m_resizePending.compare_exchange_strong(expected, true))
|
||||
{
|
||||
m_staThread->Post([this] {
|
||||
m_resizePending.store(false);
|
||||
if (!m_controller || m_shutdown.load()) return;
|
||||
LONG w = m_pendingW.load();
|
||||
LONG h = m_pendingH.load();
|
||||
int inset = m_topInset.load();
|
||||
m_controller->put_Bounds({ 0, inset, (int)w, (int)h });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── WinEvent Hook ─────────────────────────────────────────────────────────────
|
||||
|
||||
void BrowserInstance::RegisterWinEventHook()
|
||||
{
|
||||
HMODULE hMod = nullptr;
|
||||
GetModuleHandleExW(
|
||||
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
||||
reinterpret_cast<LPCWSTR>(&BrowserInstance::WinEventProc),
|
||||
&hMod);
|
||||
|
||||
DWORD parentThreadId = GetWindowThreadProcessId(m_parentHwnd, nullptr);
|
||||
m_winEventHook = SetWinEventHook(
|
||||
EVENT_OBJECT_LOCATIONCHANGE, EVENT_OBJECT_LOCATIONCHANGE,
|
||||
hMod,
|
||||
WinEventProc,
|
||||
0,
|
||||
parentThreadId,
|
||||
WINEVENT_INCONTEXT
|
||||
);
|
||||
}
|
||||
|
||||
// ── WebView2 Initialization (runs on STA thread) ──────────────────────────────
|
||||
|
||||
void BrowserInstance::InitOnSTAThread()
|
||||
{
|
||||
using namespace Microsoft::WRL;
|
||||
|
||||
HRESULT hr = CreateCoreWebView2EnvironmentWithOptions(
|
||||
nullptr,
|
||||
m_userDataFolder.c_str(),
|
||||
nullptr,
|
||||
Callback<ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler>(
|
||||
[this](HRESULT result, ICoreWebView2Environment* env) -> HRESULT
|
||||
{
|
||||
if (FAILED(result) || !env) { FireLoadCompleted(false); return result; }
|
||||
m_env = env;
|
||||
|
||||
return env->CreateCoreWebView2Controller(
|
||||
m_parentHwnd,
|
||||
Callback<ICoreWebView2CreateCoreWebView2ControllerCompletedHandler>(
|
||||
[this](HRESULT result, ICoreWebView2Controller* controller) -> HRESULT
|
||||
{
|
||||
if (FAILED(result) || !controller) { FireLoadCompleted(false); return result; }
|
||||
m_controller = controller;
|
||||
m_controller->get_CoreWebView2(&m_webview);
|
||||
|
||||
SyncBounds();
|
||||
|
||||
ComPtr<ICoreWebView2Settings> settings;
|
||||
if (SUCCEEDED(m_webview->get_Settings(&settings)))
|
||||
{
|
||||
settings->put_IsStatusBarEnabled(FALSE);
|
||||
settings->put_AreDevToolsEnabled(TRUE);
|
||||
}
|
||||
|
||||
m_webview->add_NavigationStarting(
|
||||
Callback<ICoreWebView2NavigationStartingEventHandler>(
|
||||
[this](ICoreWebView2*, ICoreWebView2NavigationStartingEventArgs* args) -> HRESULT
|
||||
{
|
||||
LPWSTR uri = nullptr;
|
||||
args->get_Uri(&uri);
|
||||
if (uri) { FireNavigationStarting(uri); CoTaskMemFree(uri); }
|
||||
return S_OK;
|
||||
}).Get(), &m_navigationToken);
|
||||
|
||||
m_webview->add_DocumentTitleChanged(
|
||||
Callback<ICoreWebView2DocumentTitleChangedEventHandler>(
|
||||
[this](ICoreWebView2* wv, IUnknown*) -> HRESULT
|
||||
{
|
||||
LPWSTR title = nullptr;
|
||||
wv->get_DocumentTitle(&title);
|
||||
if (title) { FireTitleChanged(title); CoTaskMemFree(title); }
|
||||
return S_OK;
|
||||
}).Get(), &m_titleToken);
|
||||
|
||||
m_webview->add_WebMessageReceived(
|
||||
Callback<ICoreWebView2WebMessageReceivedEventHandler>(
|
||||
[this](ICoreWebView2*, ICoreWebView2WebMessageReceivedEventArgs* args) -> HRESULT
|
||||
{
|
||||
LPWSTR json = nullptr;
|
||||
args->get_WebMessageAsJson(&json);
|
||||
if (json) { FireWebMessageReceived(json); CoTaskMemFree(json); }
|
||||
return S_OK;
|
||||
}).Get(), &m_messageToken);
|
||||
|
||||
m_webview->add_NavigationCompleted(
|
||||
Callback<ICoreWebView2NavigationCompletedEventHandler>(
|
||||
[this](ICoreWebView2*, ICoreWebView2NavigationCompletedEventArgs* args) -> HRESULT
|
||||
{
|
||||
BOOL success = FALSE;
|
||||
args->get_IsSuccess(&success);
|
||||
FireLoadCompleted(success == TRUE);
|
||||
return S_OK;
|
||||
}).Get(), &m_loadToken);
|
||||
|
||||
m_ready = true;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(s_mapMutex);
|
||||
s_hwndMap[m_parentHwnd] = this;
|
||||
}
|
||||
RegisterWinEventHook();
|
||||
|
||||
m_webview->Navigate(m_initialUrl.c_str());
|
||||
return S_OK;
|
||||
}).Get());
|
||||
}).Get());
|
||||
|
||||
if (FAILED(hr))
|
||||
FireLoadCompleted(false);
|
||||
}
|
||||
|
||||
void BrowserInstance::SyncBounds()
|
||||
{
|
||||
if (!m_controller || !m_parentHwnd) return;
|
||||
RECT rc = {};
|
||||
GetClientRect(m_parentHwnd, &rc);
|
||||
int inset = m_topInset.load();
|
||||
m_controller->put_Bounds({ 0, inset, rc.right, rc.bottom });
|
||||
}
|
||||
|
||||
// ── Callbacks ─────────────────────────────────────────────────────────────────
|
||||
|
||||
void BrowserInstance::FireNavigationStarting(const std::wstring& url)
|
||||
{
|
||||
NB_NavigationCallback cb; void* ctx;
|
||||
{ std::lock_guard<std::mutex> lk(m_callbacksMutex); cb = m_callbacks.onNavigationStarting; ctx = m_callbacks.ctx; }
|
||||
if (cb) cb(url.c_str(), ctx);
|
||||
}
|
||||
|
||||
void BrowserInstance::FireLoadCompleted(bool success)
|
||||
{
|
||||
NB_LoadCompletedCallback cb; void* ctx;
|
||||
{ std::lock_guard<std::mutex> lk(m_callbacksMutex); cb = m_callbacks.onLoadCompleted; ctx = m_callbacks.ctx; }
|
||||
if (cb) cb(success, ctx);
|
||||
}
|
||||
|
||||
void BrowserInstance::FireTitleChanged(const std::wstring& title)
|
||||
{
|
||||
NB_TitleChangedCallback cb; void* ctx;
|
||||
{ std::lock_guard<std::mutex> lk(m_callbacksMutex); cb = m_callbacks.onTitleChanged; ctx = m_callbacks.ctx; }
|
||||
if (cb) cb(title.c_str(), ctx);
|
||||
}
|
||||
|
||||
void BrowserInstance::FireWebMessageReceived(const std::wstring& json)
|
||||
{
|
||||
NB_MessageCallback cb; void* ctx;
|
||||
{ std::lock_guard<std::mutex> lk(m_callbacksMutex); cb = m_callbacks.onWebMessageReceived; ctx = m_callbacks.ctx; }
|
||||
if (cb) cb(json.c_str(), ctx);
|
||||
}
|
||||
|
||||
void BrowserInstance::FireParentDestroyed()
|
||||
{
|
||||
NB_DestroyedCallback cb; void* ctx;
|
||||
{ std::lock_guard<std::mutex> lk(m_callbacksMutex); cb = m_callbacks.onParentDestroyed; ctx = m_callbacks.ctx; }
|
||||
if (cb) cb(ctx);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2fc66ef2416d9064f9d3a0e22f94fc97
|
||||
PluginImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 3
|
||||
iconMap: {}
|
||||
executionOrder: {}
|
||||
defineConstraints: []
|
||||
isPreloaded: 0
|
||||
isOverridable: 1
|
||||
isExplicitlyReferenced: 0
|
||||
validateReferences: 1
|
||||
platformData:
|
||||
Any:
|
||||
enabled: 0
|
||||
settings: {}
|
||||
Editor:
|
||||
enabled: 0
|
||||
settings:
|
||||
DefaultValueInitialized: true
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
#pragma once
|
||||
#include <windows.h>
|
||||
#include <wrl/client.h>
|
||||
#include <WebView2.h>
|
||||
#include <string>
|
||||
#include <memory>
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include "../include/NativeBrowser.h"
|
||||
#include "STAThread.h"
|
||||
#include "WndProcHook.h"
|
||||
|
||||
|
||||
class BrowserInstance {
|
||||
public:
|
||||
BrowserInstance(HWND parentHwnd,
|
||||
std::wstring userDataFolder,
|
||||
std::wstring initialUrl,
|
||||
NB_Callbacks callbacks);
|
||||
~BrowserInstance();
|
||||
|
||||
// ── 跨 Domain Reload 恢复支持 ─────────────────────────────────────────
|
||||
// 用于验证一个 void* 是否仍指向活跃的 BrowserInstance,避免野指针崩溃
|
||||
static bool IsLive(void* handle);
|
||||
// 替换托管回调函数指针(Domain Reload 后旧指针已失效,必须在 OnEnable 中调用)
|
||||
void SetCallbacks(const NB_Callbacks& callbacks);
|
||||
|
||||
// 活跃实例注册表(供 NativeBrowser.cpp 的 NB_IsHandleValid 使用)
|
||||
static std::unordered_set<BrowserInstance*> s_liveInstances;
|
||||
static std::mutex s_liveSetMutex;
|
||||
|
||||
BrowserInstance(const BrowserInstance&) = delete;
|
||||
BrowserInstance& operator=(const BrowserInstance&) = delete;
|
||||
|
||||
void Navigate (const wchar_t* url);
|
||||
void GoBack ();
|
||||
void GoForward ();
|
||||
void Reload ();
|
||||
void Stop ();
|
||||
void Resize (int x, int y, int w, int h);
|
||||
void SetVisible (bool v);
|
||||
void SetTopInset (int px);
|
||||
void ExecuteScript (const wchar_t* js);
|
||||
void PostWebMessage (const wchar_t* json);
|
||||
void OpenDevTools ();
|
||||
void MoveFocus ();
|
||||
|
||||
bool PrepareShutdown();
|
||||
void FinalizeShutdown(DWORD timeoutMs = 5000);
|
||||
|
||||
// 拆分版 FinalizeShutdown:仅投递 close 工作和 WM_QUIT 到 STA 线程,不等待。
|
||||
// 调用方需通过 GetStaThreadHandle() 自行等待——通常用 MsgWaitForMultipleObjects
|
||||
// pump 消息,避免 WebView2 的 cross-apartment marshaling 在父 HWND 所属线程上死锁。
|
||||
void PostFinalizeShutdown();
|
||||
HANDLE GetStaThreadHandle() const;
|
||||
|
||||
void PostResizeBounds(int w, int h);
|
||||
|
||||
private:
|
||||
void InitOnSTAThread();
|
||||
void SyncBounds();
|
||||
void RegisterWinEventHook();
|
||||
|
||||
static void CALLBACK WinEventProc(HWINEVENTHOOK hook, DWORD event,
|
||||
HWND hwnd, LONG objId, LONG childId,
|
||||
DWORD idEventThread, DWORD dwmsEventTime);
|
||||
|
||||
static std::mutex s_mapMutex;
|
||||
static std::unordered_map<HWND, BrowserInstance*> s_hwndMap;
|
||||
|
||||
void FireNavigationStarting (const std::wstring& url);
|
||||
void FireLoadCompleted (bool success);
|
||||
void FireTitleChanged (const std::wstring& title);
|
||||
void FireWebMessageReceived (const std::wstring& json);
|
||||
void FireParentDestroyed ();
|
||||
|
||||
HWND m_parentHwnd;
|
||||
std::wstring m_userDataFolder;
|
||||
std::wstring m_initialUrl;
|
||||
NB_Callbacks m_callbacks;
|
||||
|
||||
std::unique_ptr<STAThread> m_staThread;
|
||||
std::unique_ptr<WndProcHook> m_wndProcHook;
|
||||
|
||||
Microsoft::WRL::ComPtr<ICoreWebView2Environment> m_env;
|
||||
Microsoft::WRL::ComPtr<ICoreWebView2Controller> m_controller;
|
||||
Microsoft::WRL::ComPtr<ICoreWebView2> m_webview;
|
||||
|
||||
EventRegistrationToken m_navigationToken = {};
|
||||
EventRegistrationToken m_titleToken = {};
|
||||
EventRegistrationToken m_messageToken = {};
|
||||
EventRegistrationToken m_loadToken = {};
|
||||
|
||||
std::atomic<HWINEVENTHOOK> m_winEventHook { nullptr };
|
||||
|
||||
std::atomic<LONG> m_pendingW { 0 };
|
||||
std::atomic<LONG> m_pendingH { 0 };
|
||||
std::atomic<bool> m_resizePending { false };
|
||||
|
||||
std::atomic<bool> m_ready { false };
|
||||
std::atomic<bool> m_shutdown { false };
|
||||
std::atomic<bool> m_finalized { false };
|
||||
std::atomic<int> m_topInset { 0 };
|
||||
|
||||
// 保护 m_callbacks 的读写,SetCallbacks 在 Unity 主线程调用,
|
||||
// Fire* 系列在 STA 线程调用,需要互斥
|
||||
mutable std::mutex m_callbacksMutex;
|
||||
};
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4b99dbf7103b9ae43a458a59e5b176b9
|
||||
PluginImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 3
|
||||
iconMap: {}
|
||||
executionOrder: {}
|
||||
defineConstraints: []
|
||||
isPreloaded: 0
|
||||
isOverridable: 1
|
||||
isExplicitlyReferenced: 0
|
||||
validateReferences: 1
|
||||
platformData:
|
||||
Any:
|
||||
enabled: 0
|
||||
settings: {}
|
||||
Editor:
|
||||
enabled: 0
|
||||
settings:
|
||||
DefaultValueInitialized: true
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
#include "../include/NativeBrowser.h"
|
||||
#include "BrowserInstance.h"
|
||||
#include <WebView2.h>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────────────
|
||||
|
||||
NB_API void* NB_Create(HWND parentHwnd, const wchar_t* userDataFolder,
|
||||
const wchar_t* initialUrl, const NB_Callbacks* callbacks)
|
||||
{
|
||||
if (!parentHwnd || !IsWindow(parentHwnd)) return nullptr;
|
||||
|
||||
try
|
||||
{
|
||||
auto* inst = new BrowserInstance(
|
||||
parentHwnd,
|
||||
userDataFolder ? userDataFolder : L"",
|
||||
initialUrl ? initialUrl : L"about:blank",
|
||||
callbacks ? *callbacks : NB_Callbacks{}
|
||||
);
|
||||
return inst;
|
||||
}
|
||||
catch (...) { return nullptr; }
|
||||
}
|
||||
|
||||
NB_API void NB_Destroy(void* handle)
|
||||
{
|
||||
if (!handle) return;
|
||||
auto* inst = static_cast<BrowserInstance*>(handle);
|
||||
|
||||
inst->PrepareShutdown();
|
||||
std::thread([inst] {
|
||||
inst->FinalizeShutdown();
|
||||
delete inst;
|
||||
}).detach();
|
||||
}
|
||||
|
||||
NB_API void NB_ShutdownAll(uint32_t timeoutMs)
|
||||
{
|
||||
// 复制一份活跃实例列表,避免在等待期间与 NB_Destroy 的后台 delete 线程竞争。
|
||||
std::vector<BrowserInstance*> insts;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(BrowserInstance::s_liveSetMutex);
|
||||
insts.assign(BrowserInstance::s_liveInstances.begin(),
|
||||
BrowserInstance::s_liveInstances.end());
|
||||
}
|
||||
if (insts.empty()) return;
|
||||
|
||||
// Phase 1: PrepareShutdown 必须在调用线程(父 HWND 所属线程)上执行,
|
||||
// 因为 ~WndProcHook 调用 RemoveWindowSubclass 要求窗口所属线程。
|
||||
for (auto* inst : insts)
|
||||
try { inst->PrepareShutdown(); } catch (...) {}
|
||||
|
||||
// Phase 2: 并行投递 close + WM_QUIT 到所有 STA 线程,收集线程句柄等待。
|
||||
std::vector<HANDLE> threads;
|
||||
threads.reserve(insts.size());
|
||||
for (auto* inst : insts)
|
||||
{
|
||||
try
|
||||
{
|
||||
inst->PostFinalizeShutdown();
|
||||
if (HANDLE h = inst->GetStaThreadHandle()) threads.push_back(h);
|
||||
}
|
||||
catch (...) {}
|
||||
}
|
||||
|
||||
// Phase 3: 在调用线程 pump 消息等待 STA 线程退出。
|
||||
// 不能 WaitForSingleObject:ICoreWebView2Controller::Close 会跨 apartment 把
|
||||
// 工作 marshal 回父 HWND 所属线程;若调用线程阻塞不 pump 消息,STA 线程将
|
||||
// 永久卡在 Close 内部,msedgewebview2.exe 不会被通知退出,并且后续父 HWND
|
||||
// 销毁时无法整理归 STA 所有的子窗口 → 主线程挂起。
|
||||
const DWORD start = GetTickCount();
|
||||
while (!threads.empty())
|
||||
{
|
||||
const DWORD elapsed = GetTickCount() - start;
|
||||
if (elapsed >= timeoutMs) break;
|
||||
|
||||
DWORD result = MsgWaitForMultipleObjectsEx(
|
||||
(DWORD)threads.size(), threads.data(),
|
||||
timeoutMs - elapsed,
|
||||
QS_ALLINPUT, MWMO_INPUTAVAILABLE);
|
||||
|
||||
if (result == WAIT_TIMEOUT || result == WAIT_FAILED) break;
|
||||
|
||||
if (result < WAIT_OBJECT_0 + threads.size())
|
||||
{
|
||||
threads.erase(threads.begin() + (result - WAIT_OBJECT_0));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 输入队列有消息:pump 以让 cross-apartment marshaling 完成。
|
||||
MSG msg;
|
||||
while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE))
|
||||
{
|
||||
if (msg.message == WM_QUIT)
|
||||
{
|
||||
// 调用方的主消息循环需要这条;重新投递并立即返回。
|
||||
PostQuitMessage((int)msg.wParam);
|
||||
return;
|
||||
}
|
||||
TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NB_API void NB_SetCallbacks(void* handle, const NB_Callbacks* callbacks)
|
||||
{
|
||||
if (!handle || !callbacks) return;
|
||||
if (!BrowserInstance::IsLive(handle)) return;
|
||||
static_cast<BrowserInstance*>(handle)->SetCallbacks(*callbacks);
|
||||
}
|
||||
|
||||
NB_API bool NB_IsHandleValid(void* handle)
|
||||
{
|
||||
return BrowserInstance::IsLive(handle);
|
||||
}
|
||||
|
||||
// ── 导航 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
NB_API void NB_Navigate (void* h, const wchar_t* url) { if (h) static_cast<BrowserInstance*>(h)->Navigate(url); }
|
||||
NB_API void NB_GoBack (void* h) { if (h) static_cast<BrowserInstance*>(h)->GoBack(); }
|
||||
NB_API void NB_GoForward (void* h) { if (h) static_cast<BrowserInstance*>(h)->GoForward(); }
|
||||
NB_API void NB_Reload (void* h) { if (h) static_cast<BrowserInstance*>(h)->Reload(); }
|
||||
NB_API void NB_Stop (void* h) { if (h) static_cast<BrowserInstance*>(h)->Stop(); }
|
||||
|
||||
// ── 布局 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
NB_API void NB_Resize (void* h, int x, int y, int w, int h2) { if (h) static_cast<BrowserInstance*>(h)->Resize(x, y, w, h2); }
|
||||
NB_API void NB_SetVisible (void* h, bool v) { if (h) static_cast<BrowserInstance*>(h)->SetVisible(v); }
|
||||
NB_API void NB_SetTopInset(void* h, int px) { if (h) static_cast<BrowserInstance*>(h)->SetTopInset(px); }
|
||||
|
||||
// ── 脚本 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
NB_API void NB_ExecuteScript (void* h, const wchar_t* js) { if (h) static_cast<BrowserInstance*>(h)->ExecuteScript(js); }
|
||||
NB_API void NB_PostWebMessage (void* h, const wchar_t* json) { if (h) static_cast<BrowserInstance*>(h)->PostWebMessage(json); }
|
||||
|
||||
// ── 焦点 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
NB_API void NB_MoveFocus(void* h) { if (h) static_cast<BrowserInstance*>(h)->MoveFocus(); }
|
||||
|
||||
// ── 调试 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
NB_API void NB_OpenDevTools(void* h) { if (h) static_cast<BrowserInstance*>(h)->OpenDevTools(); }
|
||||
|
||||
// ── Runtime 检测 ──────────────────────────────────────────────────────────
|
||||
|
||||
NB_API bool NB_IsRuntimeAvailable()
|
||||
{
|
||||
LPWSTR version = nullptr;
|
||||
HRESULT hr = GetAvailableCoreWebView2BrowserVersionString(nullptr, &version);
|
||||
if (version) CoTaskMemFree(version);
|
||||
return SUCCEEDED(hr);
|
||||
}
|
||||
|
||||
NB_API const wchar_t* NB_GetRuntimeVersion()
|
||||
{
|
||||
static wchar_t s_buf[128] = {};
|
||||
LPWSTR version = nullptr;
|
||||
if (SUCCEEDED(GetAvailableCoreWebView2BrowserVersionString(nullptr, &version)) && version)
|
||||
{
|
||||
wcsncpy_s(s_buf, version, _TRUNCATE);
|
||||
CoTaskMemFree(version);
|
||||
}
|
||||
return s_buf;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ffaeb9254924fd540b5d715b37113e41
|
||||
PluginImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 3
|
||||
iconMap: {}
|
||||
executionOrder: {}
|
||||
defineConstraints: []
|
||||
isPreloaded: 0
|
||||
isOverridable: 1
|
||||
isExplicitlyReferenced: 0
|
||||
validateReferences: 1
|
||||
platformData:
|
||||
Any:
|
||||
enabled: 0
|
||||
settings: {}
|
||||
Editor:
|
||||
enabled: 0
|
||||
settings:
|
||||
DefaultValueInitialized: true
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
#include "STAThread.h"
|
||||
#include <objbase.h>
|
||||
#include <stdexcept>
|
||||
|
||||
static constexpr wchar_t kWndClass[] = L"NativeBrowserSTAPump";
|
||||
|
||||
static LRESULT CALLBACK PumpWndProc(HWND hwnd, UINT msg, WPARAM w, LPARAM l)
|
||||
{
|
||||
return DefWindowProcW(hwnd, msg, w, l);
|
||||
}
|
||||
|
||||
// ── 构造/析构 ──────────────────────────────────────────────────────────────
|
||||
|
||||
STAThread::STAThread(std::wstring name)
|
||||
: m_name(std::move(name))
|
||||
{
|
||||
m_readyEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr);
|
||||
if (!m_readyEvent)
|
||||
throw std::runtime_error("STAThread: CreateEvent failed");
|
||||
|
||||
m_thread = CreateThread(nullptr, 0, ThreadProc, this, 0, nullptr);
|
||||
if (!m_thread)
|
||||
{
|
||||
CloseHandle(m_readyEvent);
|
||||
throw std::runtime_error("STAThread: CreateThread failed");
|
||||
}
|
||||
|
||||
// 等待线程就绪(m_pumpHwnd 已创建,CoInitialize 已完成)
|
||||
WaitForSingleObject(m_readyEvent, INFINITE);
|
||||
CloseHandle(m_readyEvent);
|
||||
m_readyEvent = nullptr;
|
||||
}
|
||||
|
||||
STAThread::~STAThread()
|
||||
{
|
||||
Shutdown(5000);
|
||||
if (m_thread)
|
||||
{
|
||||
CloseHandle(m_thread);
|
||||
m_thread = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 公开 API ──────────────────────────────────────────────────────────────
|
||||
|
||||
void STAThread::Post(std::function<void()> work)
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_queueMutex);
|
||||
m_queue.push(std::move(work));
|
||||
}
|
||||
if (m_pumpHwnd)
|
||||
PostMessageW(m_pumpHwnd, WM_EXECUTE, 0, 0);
|
||||
}
|
||||
|
||||
void STAThread::RequestQuit()
|
||||
{
|
||||
// 幂等:仅第一次调用真正发送 WM_QUIT
|
||||
if (m_shutdown.exchange(true)) return;
|
||||
if (m_pumpHwnd)
|
||||
PostMessageW(m_pumpHwnd, WM_QUIT, 0, 0);
|
||||
}
|
||||
|
||||
void STAThread::Shutdown(DWORD timeoutMs)
|
||||
{
|
||||
RequestQuit();
|
||||
if (m_thread)
|
||||
WaitForSingleObject(m_thread, timeoutMs);
|
||||
}
|
||||
|
||||
// ── 线程实现 ──────────────────────────────────────────────────────────────
|
||||
|
||||
DWORD WINAPI STAThread::ThreadProc(LPVOID param)
|
||||
{
|
||||
reinterpret_cast<STAThread*>(param)->Run();
|
||||
return 0;
|
||||
}
|
||||
|
||||
void STAThread::Run()
|
||||
{
|
||||
// STA COM 初始化(必须在 GetMessage 循环前完成)
|
||||
HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
if (FAILED(hr) && hr != S_FALSE)
|
||||
{
|
||||
SetEvent(m_readyEvent);
|
||||
return;
|
||||
}
|
||||
|
||||
// 注册 message-only 窗口类(忽略"已注册"错误,重用已有注册即可)
|
||||
HINSTANCE inst = GetModuleHandleW(nullptr);
|
||||
WNDCLASSEXW wc = {};
|
||||
wc.cbSize = sizeof(wc);
|
||||
wc.lpfnWndProc = PumpWndProc;
|
||||
wc.hInstance = inst;
|
||||
wc.lpszClassName = kWndClass;
|
||||
RegisterClassExW(&wc); // 忽略返回值;若已注册直接复用
|
||||
|
||||
// 创建 message-only 窗口(HWND_MESSAGE = (HWND)-3)
|
||||
m_pumpHwnd = CreateWindowExW(
|
||||
0, kWndClass, L"", 0,
|
||||
0, 0, 0, 0,
|
||||
HWND_MESSAGE, nullptr, inst, nullptr);
|
||||
|
||||
// 通知构造函数:线程已就绪
|
||||
SetEvent(m_readyEvent);
|
||||
|
||||
if (!m_pumpHwnd)
|
||||
{
|
||||
CoUninitialize();
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理构造函数返回前就入队的工作项
|
||||
DrainQueue();
|
||||
|
||||
// Win32 消息泵——同时服务用户工作队列和 WebView2 内部 COM 回调
|
||||
MSG msg;
|
||||
while (GetMessageW(&msg, nullptr, 0, 0) > 0)
|
||||
{
|
||||
if (msg.message == WM_EXECUTE)
|
||||
{
|
||||
DrainQueue();
|
||||
continue; // 线程消息无需 Translate/Dispatch
|
||||
}
|
||||
TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
|
||||
// 最后清理一次队列(处理 Shutdown 前投递的工作)
|
||||
DrainQueue();
|
||||
|
||||
DestroyWindow(m_pumpHwnd);
|
||||
m_pumpHwnd = nullptr;
|
||||
|
||||
UnregisterClassW(kWndClass, inst);
|
||||
CoUninitialize();
|
||||
}
|
||||
|
||||
void STAThread::DrainQueue()
|
||||
{
|
||||
for (;;)
|
||||
{
|
||||
std::function<void()> work;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_queueMutex);
|
||||
if (m_queue.empty()) break;
|
||||
work = std::move(m_queue.front());
|
||||
m_queue.pop();
|
||||
}
|
||||
// 在 STA 线程上执行,异常不向上传播(避免崩溃消息泵)
|
||||
try { work(); }
|
||||
catch (...) {}
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2ac276fea4b65e041940e59b8ee03a84
|
||||
PluginImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 3
|
||||
iconMap: {}
|
||||
executionOrder: {}
|
||||
defineConstraints: []
|
||||
isPreloaded: 0
|
||||
isOverridable: 1
|
||||
isExplicitlyReferenced: 0
|
||||
validateReferences: 1
|
||||
platformData:
|
||||
Any:
|
||||
enabled: 0
|
||||
settings: {}
|
||||
Editor:
|
||||
enabled: 0
|
||||
settings:
|
||||
DefaultValueInitialized: true
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
#include <windows.h>
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
#include <queue>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
/// STA 线程 + Win32 消息泵 + 工作队列。
|
||||
/// 所有 WebView2 COM 对象必须在同一 STA 线程上创建和访问。
|
||||
class STAThread {
|
||||
public:
|
||||
static constexpr UINT WM_EXECUTE = WM_APP + 1;
|
||||
|
||||
/// 启动 STA 线程,阻塞直到线程就绪(message-only 窗口已创建)。
|
||||
explicit STAThread(std::wstring name = L"WebView2-STA");
|
||||
~STAThread();
|
||||
|
||||
STAThread(const STAThread&) = delete;
|
||||
STAThread& operator=(const STAThread&) = delete;
|
||||
|
||||
/// 在 STA 线程异步执行 work(线程安全,可从任意线程调用)。
|
||||
void Post(std::function<void()> work);
|
||||
|
||||
/// 仅向 STA 线程投递 WM_QUIT,不等待退出(线程安全,幂等)。
|
||||
/// 调用方负责后续等待 ThreadHandle()。
|
||||
void RequestQuit();
|
||||
|
||||
/// 停止 STA 线程(发送 WM_QUIT)并等待退出(最多 timeoutMs ms)。
|
||||
/// 等待使用 WaitForSingleObject——若调用方需要在等待期间 pump 消息,请使用 RequestQuit + ThreadHandle 自行 MsgWaitForMultipleObjects。
|
||||
void Shutdown(DWORD timeoutMs = 5000);
|
||||
|
||||
HWND PumpHWND() const { return m_pumpHwnd; }
|
||||
HANDLE ThreadHandle() const { return m_thread; }
|
||||
|
||||
private:
|
||||
static DWORD WINAPI ThreadProc(LPVOID param);
|
||||
void Run();
|
||||
void DrainQueue();
|
||||
|
||||
std::wstring m_name;
|
||||
HANDLE m_thread = nullptr;
|
||||
HANDLE m_readyEvent = nullptr; // 线程就绪信号
|
||||
HWND m_pumpHwnd = nullptr; // message-only 窗口
|
||||
std::mutex m_queueMutex;
|
||||
std::queue<std::function<void()>> m_queue;
|
||||
std::atomic<bool> m_shutdown { false };
|
||||
};
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e265ceebc7185524f80432008c1508e8
|
||||
PluginImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 3
|
||||
iconMap: {}
|
||||
executionOrder: {}
|
||||
defineConstraints: []
|
||||
isPreloaded: 0
|
||||
isOverridable: 1
|
||||
isExplicitlyReferenced: 0
|
||||
validateReferences: 1
|
||||
platformData:
|
||||
Any:
|
||||
enabled: 0
|
||||
settings: {}
|
||||
Editor:
|
||||
enabled: 0
|
||||
settings:
|
||||
DefaultValueInitialized: true
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
#include "WndProcHook.h"
|
||||
|
||||
WndProcHook::WndProcHook(HWND hwnd,
|
||||
std::function<void(int w, int h)> onResize,
|
||||
std::function<void()> onDestroy)
|
||||
: m_hwnd(hwnd)
|
||||
, m_onResize(std::move(onResize))
|
||||
, m_onDestroy(std::move(onDestroy))
|
||||
{
|
||||
if (!hwnd || !IsWindow(hwnd)) return;
|
||||
|
||||
// data 指向 this,SubclassProc 通过 data 拿回实例指针
|
||||
m_installed = SetWindowSubclass(hwnd, SubclassProc, kSubclassId,
|
||||
reinterpret_cast<DWORD_PTR>(this));
|
||||
}
|
||||
|
||||
WndProcHook::~WndProcHook()
|
||||
{
|
||||
if (m_installed && m_hwnd && IsWindow(m_hwnd))
|
||||
RemoveWindowSubclass(m_hwnd, SubclassProc, kSubclassId);
|
||||
}
|
||||
|
||||
LRESULT CALLBACK WndProcHook::SubclassProc(
|
||||
HWND hwnd, UINT msg, WPARAM w, LPARAM l,
|
||||
UINT_PTR subclassId, DWORD_PTR data)
|
||||
{
|
||||
auto* self = reinterpret_cast<WndProcHook*>(data);
|
||||
|
||||
switch (msg)
|
||||
{
|
||||
case WM_SIZE:
|
||||
{
|
||||
int width = LOWORD(l);
|
||||
int height = HIWORD(l);
|
||||
if (self && self->m_onResize && (width > 0 || height > 0))
|
||||
self->m_onResize(width, height);
|
||||
break;
|
||||
}
|
||||
case WM_DESTROY:
|
||||
// 先通知上层,再自动卸载 hook(窗口即将销毁,不需要再保留)
|
||||
if (self)
|
||||
{
|
||||
self->m_installed = false; // 防止析构函数重复 Remove
|
||||
if (self->m_onDestroy)
|
||||
self->m_onDestroy();
|
||||
}
|
||||
RemoveWindowSubclass(hwnd, SubclassProc, subclassId);
|
||||
break;
|
||||
}
|
||||
|
||||
return DefSubclassProc(hwnd, msg, w, l);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e747acbe12284b945801da61a4cf8a02
|
||||
PluginImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 3
|
||||
iconMap: {}
|
||||
executionOrder: {}
|
||||
defineConstraints: []
|
||||
isPreloaded: 0
|
||||
isOverridable: 1
|
||||
isExplicitlyReferenced: 0
|
||||
validateReferences: 1
|
||||
platformData:
|
||||
Any:
|
||||
enabled: 0
|
||||
settings: {}
|
||||
Editor:
|
||||
enabled: 0
|
||||
settings:
|
||||
DefaultValueInitialized: true
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
#include <windows.h>
|
||||
#include <commctrl.h>
|
||||
#include <functional>
|
||||
|
||||
/// 使用 SetWindowSubclass 对父 HWND 进行子类化,
|
||||
/// 拦截 WM_SIZE(同步 WebView2 大小)和 WM_DESTROY(通知 C# 重建)。
|
||||
/// SetWindowSubclass 比 SetWindowLongPtr 更安全,支持多层 hook 共存。
|
||||
class WndProcHook {
|
||||
public:
|
||||
WndProcHook(HWND hwnd,
|
||||
std::function<void(int w, int h)> onResize,
|
||||
std::function<void()> onDestroy);
|
||||
~WndProcHook(); // 自动 Unhook
|
||||
|
||||
WndProcHook(const WndProcHook&) = delete;
|
||||
WndProcHook& operator=(const WndProcHook&) = delete;
|
||||
|
||||
private:
|
||||
static LRESULT CALLBACK SubclassProc(
|
||||
HWND hwnd, UINT msg, WPARAM w, LPARAM l,
|
||||
UINT_PTR subclassId, DWORD_PTR data);
|
||||
|
||||
HWND m_hwnd;
|
||||
bool m_installed = false;
|
||||
std::function<void(int w, int h)> m_onResize;
|
||||
std::function<void()> m_onDestroy;
|
||||
|
||||
static constexpr UINT_PTR kSubclassId = 0xC0DE1234;
|
||||
};
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ebcae0884dd4a174ba4583a1f6fb25bd
|
||||
PluginImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 3
|
||||
iconMap: {}
|
||||
executionOrder: {}
|
||||
defineConstraints: []
|
||||
isPreloaded: 0
|
||||
isOverridable: 1
|
||||
isExplicitlyReferenced: 0
|
||||
validateReferences: 1
|
||||
platformData:
|
||||
Any:
|
||||
enabled: 0
|
||||
settings: {}
|
||||
Editor:
|
||||
enabled: 0
|
||||
settings:
|
||||
DefaultValueInitialized: true
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b049108f6bef8e043a09a31eda129d5a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Generated
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7f4f14217db9afd4db6275362c570019
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c50d484c6dc510340bdeb39b5d8d9fd8
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+48014
File diff suppressed because it is too large.
Load diff
+23
@@ -0,0 +1,23 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 62f77e9053e91ce458f4ae5435066105
|
||||
PluginImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 3
|
||||
iconMap: {}
|
||||
executionOrder: {}
|
||||
defineConstraints: []
|
||||
isPreloaded: 0
|
||||
isOverridable: 1
|
||||
isExplicitlyReferenced: 0
|
||||
validateReferences: 1
|
||||
platformData:
|
||||
Any:
|
||||
enabled: 0
|
||||
settings: {}
|
||||
Editor:
|
||||
enabled: 0
|
||||
settings:
|
||||
DefaultValueInitialized: true
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: db85966d76b8a9343ab2a40ba1dd12ab
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0acaf66b3e60e5b46b80de339531f445
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3df12db069448324ba9f45ea9785f546
|
||||
PluginImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 3
|
||||
iconMap: {}
|
||||
executionOrder: {}
|
||||
defineConstraints: []
|
||||
isPreloaded: 0
|
||||
isOverridable: 1
|
||||
isExplicitlyReferenced: 0
|
||||
validateReferences: 1
|
||||
platformData:
|
||||
Any:
|
||||
enabled: 0
|
||||
settings: {}
|
||||
Editor:
|
||||
enabled: 0
|
||||
settings:
|
||||
DefaultValueInitialized: true
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in new issue
Block a user