diff --git a/AspNetCore.sln b/AspNetCore.sln index d6480d77016c..cd7270b1eb14 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1574,7 +1574,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.Browse EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{722E5A66-D84A-4689-AA87-7197FF5D7070}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WasmLinkerTest", "src\Components\WebAssembly\testassets\WasmLinkerTest\WasmLinkerTest.csproj", "{3B375FFC-1E38-453E-A26D-A510CCD3339E}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WasmLinkerTest", "src\Components\WebAssembly\testassets\WasmLinkerTest\WasmLinkerTest.csproj", "{3B375FFC-1E38-453E-A26D-A510CCD3339E}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MapActionSample", "src\Http\Routing\samples\MapActionSample\MapActionSample.csproj", "{8F510BAA-FA6B-4648-8F98-28DF5C69DBB2}" EndProject @@ -1582,11 +1582,39 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{7128 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "HostedBlazorWebassemblyApp", "HostedBlazorWebassemblyApp", "{B4226BE2-DCB7-40C5-93F2-94C9BD6F4394}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HostedBlazorWebassemblyApp.Client", "src\Components\WebAssembly\Samples\HostedBlazorWebassemblyApp\Client\HostedBlazorWebassemblyApp.Client.csproj", "{8F6F73F7-0DDA-4AA3-9887-2FB0141786AC}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HostedBlazorWebassemblyApp.Client", "src\Components\WebAssembly\Samples\HostedBlazorWebassemblyApp\Client\HostedBlazorWebassemblyApp.Client.csproj", "{8F6F73F7-0DDA-4AA3-9887-2FB0141786AC}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HostedBlazorWebassemblyApp.Server", "src\Components\WebAssembly\Samples\HostedBlazorWebassemblyApp\Server\HostedBlazorWebassemblyApp.Server.csproj", "{1A5582DD-06F4-4427-BFDC-B021A84A01BC}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HostedBlazorWebassemblyApp.Server", "src\Components\WebAssembly\Samples\HostedBlazorWebassemblyApp\Server\HostedBlazorWebassemblyApp.Server.csproj", "{1A5582DD-06F4-4427-BFDC-B021A84A01BC}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HostedBlazorWebassemblyApp.Shared", "src\Components\WebAssembly\Samples\HostedBlazorWebassemblyApp\Shared\HostedBlazorWebassemblyApp.Shared.csproj", "{E18EF144-9C2C-4366-B54C-09ACF7692A4F}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HostedBlazorWebassemblyApp.Shared", "src\Components\WebAssembly\Samples\HostedBlazorWebassemblyApp\Shared\HostedBlazorWebassemblyApp.Shared.csproj", "{E18EF144-9C2C-4366-B54C-09ACF7692A4F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "WebView", "WebView", "{C445B129-0A4D-41F5-8347-6534B6B12303}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "WebView", "WebView", "{DF2765A8-3836-4C3F-B199-788F732848A8}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Components.WebView", "src\Components\WebView\WebView\src\Microsoft.AspNetCore.Components.WebView.csproj", "{35B8F997-C8BC-41C8-AA4D-5BD9F8D6069E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Platforms", "Platforms", "{8B196DE0-F585-43D6-BE72-6F15BB4EB5E5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Wpf", "Wpf", "{5241CF68-66A0-4724-9BAA-36DB959A5B11}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Components.WebView.Wpf", "src\Components\WebView\Platforms\Wpf\src\Microsoft.AspNetCore.Components.WebView.Wpf.csproj", "{7858C658-1669-4A73-89FE-9D9FF682E83E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Components.WebView.Test", "src\Components\WebView\WebView\test\Microsoft.AspNetCore.Components.WebView.Test.csproj", "{4276CAB2-80BF-438A-9E01-22BAE78D4930}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{D3B76F4E-A980-45BF-AEA1-EA3175B0B5A1}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BlazorWpfApp", "src\Components\WebView\Samples\BlazorWpfApp\BlazorWpfApp.csproj", "{1E9B6311-4B68-44C9-9571-1DB0C362987D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "WebView2", "WebView2", "{6685185E-C16A-4DAA-B16B-95E8FF65DE82}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Components.WebView.WebView2", "src\Components\WebView\Platforms\WebView2\src\Microsoft.AspNetCore.Components.WebView.WebView2.csproj", "{621FB2DB-2446-46CD-AB7A-189B4CB14A41}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "WinForms", "WinForms", "{D4E9A2C5-0838-42DF-BC80-C829C4C9137E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Components.WebView.WindowsForms", "src\Components\WebView\Platforms\WindowsForms\src\Microsoft.AspNetCore.Components.WebView.WindowsForms.csproj", "{3BA297F8-1CA1-492D-AE64-A60B825D8501}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BlazorWinFormsApp", "src\Components\WebView\Samples\BlazorWinFormsApp\BlazorWinFormsApp.csproj", "{CC740832-D268-47A3-9058-B9054F8397E2}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -7467,6 +7495,18 @@ Global {B739074E-6652-4F5B-B37E-775DC2245FEC}.Release|x64.Build.0 = Release|Any CPU {B739074E-6652-4F5B-B37E-775DC2245FEC}.Release|x86.ActiveCfg = Release|Any CPU {B739074E-6652-4F5B-B37E-775DC2245FEC}.Release|x86.Build.0 = Release|Any CPU + {3B375FFC-1E38-453E-A26D-A510CCD3339E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3B375FFC-1E38-453E-A26D-A510CCD3339E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3B375FFC-1E38-453E-A26D-A510CCD3339E}.Debug|x64.ActiveCfg = Debug|Any CPU + {3B375FFC-1E38-453E-A26D-A510CCD3339E}.Debug|x64.Build.0 = Debug|Any CPU + {3B375FFC-1E38-453E-A26D-A510CCD3339E}.Debug|x86.ActiveCfg = Debug|Any CPU + {3B375FFC-1E38-453E-A26D-A510CCD3339E}.Debug|x86.Build.0 = Debug|Any CPU + {3B375FFC-1E38-453E-A26D-A510CCD3339E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3B375FFC-1E38-453E-A26D-A510CCD3339E}.Release|Any CPU.Build.0 = Release|Any CPU + {3B375FFC-1E38-453E-A26D-A510CCD3339E}.Release|x64.ActiveCfg = Release|Any CPU + {3B375FFC-1E38-453E-A26D-A510CCD3339E}.Release|x64.Build.0 = Release|Any CPU + {3B375FFC-1E38-453E-A26D-A510CCD3339E}.Release|x86.ActiveCfg = Release|Any CPU + {3B375FFC-1E38-453E-A26D-A510CCD3339E}.Release|x86.Build.0 = Release|Any CPU {8F510BAA-FA6B-4648-8F98-28DF5C69DBB2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8F510BAA-FA6B-4648-8F98-28DF5C69DBB2}.Debug|Any CPU.Build.0 = Debug|Any CPU {8F510BAA-FA6B-4648-8F98-28DF5C69DBB2}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -7491,18 +7531,6 @@ Global {8F6F73F7-0DDA-4AA3-9887-2FB0141786AC}.Release|x64.Build.0 = Release|Any CPU {8F6F73F7-0DDA-4AA3-9887-2FB0141786AC}.Release|x86.ActiveCfg = Release|Any CPU {8F6F73F7-0DDA-4AA3-9887-2FB0141786AC}.Release|x86.Build.0 = Release|Any CPU - {3B375FFC-1E38-453E-A26D-A510CCD3339E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3B375FFC-1E38-453E-A26D-A510CCD3339E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3B375FFC-1E38-453E-A26D-A510CCD3339E}.Debug|x64.ActiveCfg = Debug|Any CPU - {3B375FFC-1E38-453E-A26D-A510CCD3339E}.Debug|x64.Build.0 = Debug|Any CPU - {3B375FFC-1E38-453E-A26D-A510CCD3339E}.Debug|x86.ActiveCfg = Debug|Any CPU - {3B375FFC-1E38-453E-A26D-A510CCD3339E}.Debug|x86.Build.0 = Debug|Any CPU - {3B375FFC-1E38-453E-A26D-A510CCD3339E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3B375FFC-1E38-453E-A26D-A510CCD3339E}.Release|Any CPU.Build.0 = Release|Any CPU - {3B375FFC-1E38-453E-A26D-A510CCD3339E}.Release|x64.ActiveCfg = Release|Any CPU - {3B375FFC-1E38-453E-A26D-A510CCD3339E}.Release|x64.Build.0 = Release|Any CPU - {3B375FFC-1E38-453E-A26D-A510CCD3339E}.Release|x86.ActiveCfg = Release|Any CPU - {3B375FFC-1E38-453E-A26D-A510CCD3339E}.Release|x86.Build.0 = Release|Any CPU {1A5582DD-06F4-4427-BFDC-B021A84A01BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1A5582DD-06F4-4427-BFDC-B021A84A01BC}.Debug|Any CPU.Build.0 = Debug|Any CPU {1A5582DD-06F4-4427-BFDC-B021A84A01BC}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -7527,6 +7555,90 @@ Global {E18EF144-9C2C-4366-B54C-09ACF7692A4F}.Release|x64.Build.0 = Release|Any CPU {E18EF144-9C2C-4366-B54C-09ACF7692A4F}.Release|x86.ActiveCfg = Release|Any CPU {E18EF144-9C2C-4366-B54C-09ACF7692A4F}.Release|x86.Build.0 = Release|Any CPU + {35B8F997-C8BC-41C8-AA4D-5BD9F8D6069E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {35B8F997-C8BC-41C8-AA4D-5BD9F8D6069E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {35B8F997-C8BC-41C8-AA4D-5BD9F8D6069E}.Debug|x64.ActiveCfg = Debug|Any CPU + {35B8F997-C8BC-41C8-AA4D-5BD9F8D6069E}.Debug|x64.Build.0 = Debug|Any CPU + {35B8F997-C8BC-41C8-AA4D-5BD9F8D6069E}.Debug|x86.ActiveCfg = Debug|Any CPU + {35B8F997-C8BC-41C8-AA4D-5BD9F8D6069E}.Debug|x86.Build.0 = Debug|Any CPU + {35B8F997-C8BC-41C8-AA4D-5BD9F8D6069E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {35B8F997-C8BC-41C8-AA4D-5BD9F8D6069E}.Release|Any CPU.Build.0 = Release|Any CPU + {35B8F997-C8BC-41C8-AA4D-5BD9F8D6069E}.Release|x64.ActiveCfg = Release|Any CPU + {35B8F997-C8BC-41C8-AA4D-5BD9F8D6069E}.Release|x64.Build.0 = Release|Any CPU + {35B8F997-C8BC-41C8-AA4D-5BD9F8D6069E}.Release|x86.ActiveCfg = Release|Any CPU + {35B8F997-C8BC-41C8-AA4D-5BD9F8D6069E}.Release|x86.Build.0 = Release|Any CPU + {7858C658-1669-4A73-89FE-9D9FF682E83E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7858C658-1669-4A73-89FE-9D9FF682E83E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7858C658-1669-4A73-89FE-9D9FF682E83E}.Debug|x64.ActiveCfg = Debug|Any CPU + {7858C658-1669-4A73-89FE-9D9FF682E83E}.Debug|x64.Build.0 = Debug|Any CPU + {7858C658-1669-4A73-89FE-9D9FF682E83E}.Debug|x86.ActiveCfg = Debug|Any CPU + {7858C658-1669-4A73-89FE-9D9FF682E83E}.Debug|x86.Build.0 = Debug|Any CPU + {7858C658-1669-4A73-89FE-9D9FF682E83E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7858C658-1669-4A73-89FE-9D9FF682E83E}.Release|Any CPU.Build.0 = Release|Any CPU + {7858C658-1669-4A73-89FE-9D9FF682E83E}.Release|x64.ActiveCfg = Release|Any CPU + {7858C658-1669-4A73-89FE-9D9FF682E83E}.Release|x64.Build.0 = Release|Any CPU + {7858C658-1669-4A73-89FE-9D9FF682E83E}.Release|x86.ActiveCfg = Release|Any CPU + {7858C658-1669-4A73-89FE-9D9FF682E83E}.Release|x86.Build.0 = Release|Any CPU + {4276CAB2-80BF-438A-9E01-22BAE78D4930}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4276CAB2-80BF-438A-9E01-22BAE78D4930}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4276CAB2-80BF-438A-9E01-22BAE78D4930}.Debug|x64.ActiveCfg = Debug|Any CPU + {4276CAB2-80BF-438A-9E01-22BAE78D4930}.Debug|x64.Build.0 = Debug|Any CPU + {4276CAB2-80BF-438A-9E01-22BAE78D4930}.Debug|x86.ActiveCfg = Debug|Any CPU + {4276CAB2-80BF-438A-9E01-22BAE78D4930}.Debug|x86.Build.0 = Debug|Any CPU + {4276CAB2-80BF-438A-9E01-22BAE78D4930}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4276CAB2-80BF-438A-9E01-22BAE78D4930}.Release|Any CPU.Build.0 = Release|Any CPU + {4276CAB2-80BF-438A-9E01-22BAE78D4930}.Release|x64.ActiveCfg = Release|Any CPU + {4276CAB2-80BF-438A-9E01-22BAE78D4930}.Release|x64.Build.0 = Release|Any CPU + {4276CAB2-80BF-438A-9E01-22BAE78D4930}.Release|x86.ActiveCfg = Release|Any CPU + {4276CAB2-80BF-438A-9E01-22BAE78D4930}.Release|x86.Build.0 = Release|Any CPU + {1E9B6311-4B68-44C9-9571-1DB0C362987D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1E9B6311-4B68-44C9-9571-1DB0C362987D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1E9B6311-4B68-44C9-9571-1DB0C362987D}.Debug|x64.ActiveCfg = Debug|Any CPU + {1E9B6311-4B68-44C9-9571-1DB0C362987D}.Debug|x64.Build.0 = Debug|Any CPU + {1E9B6311-4B68-44C9-9571-1DB0C362987D}.Debug|x86.ActiveCfg = Debug|Any CPU + {1E9B6311-4B68-44C9-9571-1DB0C362987D}.Debug|x86.Build.0 = Debug|Any CPU + {1E9B6311-4B68-44C9-9571-1DB0C362987D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1E9B6311-4B68-44C9-9571-1DB0C362987D}.Release|Any CPU.Build.0 = Release|Any CPU + {1E9B6311-4B68-44C9-9571-1DB0C362987D}.Release|x64.ActiveCfg = Release|Any CPU + {1E9B6311-4B68-44C9-9571-1DB0C362987D}.Release|x64.Build.0 = Release|Any CPU + {1E9B6311-4B68-44C9-9571-1DB0C362987D}.Release|x86.ActiveCfg = Release|Any CPU + {1E9B6311-4B68-44C9-9571-1DB0C362987D}.Release|x86.Build.0 = Release|Any CPU + {621FB2DB-2446-46CD-AB7A-189B4CB14A41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {621FB2DB-2446-46CD-AB7A-189B4CB14A41}.Debug|Any CPU.Build.0 = Debug|Any CPU + {621FB2DB-2446-46CD-AB7A-189B4CB14A41}.Debug|x64.ActiveCfg = Debug|Any CPU + {621FB2DB-2446-46CD-AB7A-189B4CB14A41}.Debug|x64.Build.0 = Debug|Any CPU + {621FB2DB-2446-46CD-AB7A-189B4CB14A41}.Debug|x86.ActiveCfg = Debug|Any CPU + {621FB2DB-2446-46CD-AB7A-189B4CB14A41}.Debug|x86.Build.0 = Debug|Any CPU + {621FB2DB-2446-46CD-AB7A-189B4CB14A41}.Release|Any CPU.ActiveCfg = Release|Any CPU + {621FB2DB-2446-46CD-AB7A-189B4CB14A41}.Release|Any CPU.Build.0 = Release|Any CPU + {621FB2DB-2446-46CD-AB7A-189B4CB14A41}.Release|x64.ActiveCfg = Release|Any CPU + {621FB2DB-2446-46CD-AB7A-189B4CB14A41}.Release|x64.Build.0 = Release|Any CPU + {621FB2DB-2446-46CD-AB7A-189B4CB14A41}.Release|x86.ActiveCfg = Release|Any CPU + {621FB2DB-2446-46CD-AB7A-189B4CB14A41}.Release|x86.Build.0 = Release|Any CPU + {3BA297F8-1CA1-492D-AE64-A60B825D8501}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3BA297F8-1CA1-492D-AE64-A60B825D8501}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3BA297F8-1CA1-492D-AE64-A60B825D8501}.Debug|x64.ActiveCfg = Debug|Any CPU + {3BA297F8-1CA1-492D-AE64-A60B825D8501}.Debug|x64.Build.0 = Debug|Any CPU + {3BA297F8-1CA1-492D-AE64-A60B825D8501}.Debug|x86.ActiveCfg = Debug|Any CPU + {3BA297F8-1CA1-492D-AE64-A60B825D8501}.Debug|x86.Build.0 = Debug|Any CPU + {3BA297F8-1CA1-492D-AE64-A60B825D8501}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3BA297F8-1CA1-492D-AE64-A60B825D8501}.Release|Any CPU.Build.0 = Release|Any CPU + {3BA297F8-1CA1-492D-AE64-A60B825D8501}.Release|x64.ActiveCfg = Release|Any CPU + {3BA297F8-1CA1-492D-AE64-A60B825D8501}.Release|x64.Build.0 = Release|Any CPU + {3BA297F8-1CA1-492D-AE64-A60B825D8501}.Release|x86.ActiveCfg = Release|Any CPU + {3BA297F8-1CA1-492D-AE64-A60B825D8501}.Release|x86.Build.0 = Release|Any CPU + {CC740832-D268-47A3-9058-B9054F8397E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CC740832-D268-47A3-9058-B9054F8397E2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CC740832-D268-47A3-9058-B9054F8397E2}.Debug|x64.ActiveCfg = Debug|Any CPU + {CC740832-D268-47A3-9058-B9054F8397E2}.Debug|x64.Build.0 = Debug|Any CPU + {CC740832-D268-47A3-9058-B9054F8397E2}.Debug|x86.ActiveCfg = Debug|Any CPU + {CC740832-D268-47A3-9058-B9054F8397E2}.Debug|x86.Build.0 = Debug|Any CPU + {CC740832-D268-47A3-9058-B9054F8397E2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CC740832-D268-47A3-9058-B9054F8397E2}.Release|Any CPU.Build.0 = Release|Any CPU + {CC740832-D268-47A3-9058-B9054F8397E2}.Release|x64.ActiveCfg = Release|Any CPU + {CC740832-D268-47A3-9058-B9054F8397E2}.Release|x64.Build.0 = Release|Any CPU + {CC740832-D268-47A3-9058-B9054F8397E2}.Release|x86.ActiveCfg = Release|Any CPU + {CC740832-D268-47A3-9058-B9054F8397E2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -8304,13 +8416,27 @@ Global {8F33439F-5532-45D6-8A44-20EF9104AA9D} = {5F0044F2-4C66-46A8-BD79-075F001AA034} {B739074E-6652-4F5B-B37E-775DC2245FEC} = {8F33439F-5532-45D6-8A44-20EF9104AA9D} {722E5A66-D84A-4689-AA87-7197FF5D7070} = {54C42F57-5447-4C21-9812-4AF665567566} - {8F510BAA-FA6B-4648-8F98-28DF5C69DBB2} = {722E5A66-D84A-4689-AA87-7197FF5D7070} {3B375FFC-1E38-453E-A26D-A510CCD3339E} = {7D2B0799-A634-42AC-AE77-5D167BA51389} + {8F510BAA-FA6B-4648-8F98-28DF5C69DBB2} = {722E5A66-D84A-4689-AA87-7197FF5D7070} {71287382-95EF-490D-A285-87196E29E88A} = {562D5067-8CD8-4F19-BCBB-873204932C61} {B4226BE2-DCB7-40C5-93F2-94C9BD6F4394} = {71287382-95EF-490D-A285-87196E29E88A} {8F6F73F7-0DDA-4AA3-9887-2FB0141786AC} = {B4226BE2-DCB7-40C5-93F2-94C9BD6F4394} {1A5582DD-06F4-4427-BFDC-B021A84A01BC} = {B4226BE2-DCB7-40C5-93F2-94C9BD6F4394} {E18EF144-9C2C-4366-B54C-09ACF7692A4F} = {B4226BE2-DCB7-40C5-93F2-94C9BD6F4394} + {C445B129-0A4D-41F5-8347-6534B6B12303} = {60D51C98-2CC0-40DF-B338-44154EFEE2FF} + {DF2765A8-3836-4C3F-B199-788F732848A8} = {C445B129-0A4D-41F5-8347-6534B6B12303} + {35B8F997-C8BC-41C8-AA4D-5BD9F8D6069E} = {DF2765A8-3836-4C3F-B199-788F732848A8} + {8B196DE0-F585-43D6-BE72-6F15BB4EB5E5} = {C445B129-0A4D-41F5-8347-6534B6B12303} + {5241CF68-66A0-4724-9BAA-36DB959A5B11} = {8B196DE0-F585-43D6-BE72-6F15BB4EB5E5} + {7858C658-1669-4A73-89FE-9D9FF682E83E} = {5241CF68-66A0-4724-9BAA-36DB959A5B11} + {4276CAB2-80BF-438A-9E01-22BAE78D4930} = {DF2765A8-3836-4C3F-B199-788F732848A8} + {D3B76F4E-A980-45BF-AEA1-EA3175B0B5A1} = {C445B129-0A4D-41F5-8347-6534B6B12303} + {1E9B6311-4B68-44C9-9571-1DB0C362987D} = {D3B76F4E-A980-45BF-AEA1-EA3175B0B5A1} + {6685185E-C16A-4DAA-B16B-95E8FF65DE82} = {8B196DE0-F585-43D6-BE72-6F15BB4EB5E5} + {621FB2DB-2446-46CD-AB7A-189B4CB14A41} = {6685185E-C16A-4DAA-B16B-95E8FF65DE82} + {D4E9A2C5-0838-42DF-BC80-C829C4C9137E} = {8B196DE0-F585-43D6-BE72-6F15BB4EB5E5} + {3BA297F8-1CA1-492D-AE64-A60B825D8501} = {D4E9A2C5-0838-42DF-BC80-C829C4C9137E} + {CC740832-D268-47A3-9058-B9054F8397E2} = {D3B76F4E-A980-45BF-AEA1-EA3175B0B5A1} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F} diff --git a/eng/Build.props b/eng/Build.props index 1ac125ac50c3..3baf566be996 100644 --- a/eng/Build.props +++ b/eng/Build.props @@ -42,6 +42,15 @@ $(RepoRoot)src\Servers\Kestrel\perf\PlatformBenchmarks\**\*.csproj; $(RepoRoot)src\SignalR\perf\benchmarkapps\**\*.csproj; " /> + + + diff --git a/eng/Dependencies.props b/eng/Dependencies.props index f2257f03cb2d..8dd976051839 100644 --- a/eng/Dependencies.props +++ b/eng/Dependencies.props @@ -128,6 +128,7 @@ and are generated based on the last package release. + diff --git a/eng/ProjectReferences.props b/eng/ProjectReferences.props index 541a8559fb60..65eeee75677d 100644 --- a/eng/ProjectReferences.props +++ b/eng/ProjectReferences.props @@ -147,6 +147,10 @@ + + + + diff --git a/eng/Versions.props b/eng/Versions.props index 341350c07304..37301f9ccfcd 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -204,6 +204,7 @@ 3.0.1 3.0.1 11.1.0 + 1.0.705.50 1.4.0 6.8.0 5.9.0-rc.7121 diff --git a/src/Components/ComponentsNoDeps.slnf b/src/Components/ComponentsNoDeps.slnf index b89704c487d0..ebdb7e6aaaf1 100644 --- a/src/Components/ComponentsNoDeps.slnf +++ b/src/Components/ComponentsNoDeps.slnf @@ -37,6 +37,13 @@ "src\\Components\\WebAssembly\\testassets\\Wasm.Authentication.Server\\Wasm.Authentication.Server.csproj", "src\\Components\\WebAssembly\\testassets\\Wasm.Authentication.Shared\\Wasm.Authentication.Shared.csproj", "src\\Components\\WebAssembly\\testassets\\WasmLinkerTest\\WasmLinkerTest.csproj", + "src\\Components\\WebView\\Platforms\\WebView2\\src\\Microsoft.AspNetCore.Components.WebView.WebView2.csproj", + "src\\Components\\WebView\\Platforms\\WindowsForms\\src\\Microsoft.AspNetCore.Components.WebView.WindowsForms.csproj", + "src\\Components\\WebView\\Platforms\\Wpf\\src\\Microsoft.AspNetCore.Components.WebView.Wpf.csproj", + "src\\Components\\WebView\\Samples\\BlazorWinFormsApp\\BlazorWinFormsApp.csproj", + "src\\Components\\WebView\\Samples\\BlazorWpfApp\\BlazorWpfApp.csproj", + "src\\Components\\WebView\\WebView\\src\\Microsoft.AspNetCore.Components.WebView.csproj", + "src\\Components\\WebView\\WebView\\test\\Microsoft.AspNetCore.Components.WebView.Test.csproj", "src\\Components\\Web\\src\\Microsoft.AspNetCore.Components.Web.csproj", "src\\Components\\Web\\test\\Microsoft.AspNetCore.Components.Web.Tests.csproj", "src\\Components\\benchmarkapps\\Wasm.Performance\\ConsoleHost\\Wasm.Performance.ConsoleHost.csproj", diff --git a/src/Components/Ignitor/test/Ignitor.Test.csproj b/src/Components/Ignitor/test/Ignitor.Test.csproj index 99ec3b143b0f..ec0fbc0f8616 100644 --- a/src/Components/Ignitor/test/Ignitor.Test.csproj +++ b/src/Components/Ignitor/test/Ignitor.Test.csproj @@ -1,4 +1,4 @@ - + $(DefaultNetCoreTargetFramework) @@ -12,7 +12,7 @@ - + diff --git a/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj b/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj index b135236a7587..62cc63c20623 100644 --- a/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj +++ b/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj @@ -29,13 +29,7 @@ - + @@ -47,13 +41,14 @@ <_FileProviderTaskAssembly>$(ArtifactsDir)bin\Microsoft.Extensions.FileProviders.Embedded.Manifest.Task\$(Configuration)\netstandard2.0\Microsoft.Extensions.FileProviders.Embedded.Manifest.Task.dll - + + + + @@ -96,15 +91,12 @@ blazor.server.js - ..\..\Web.JS\dist\Debug\$(BlazorServerJSFilename) - ..\..\Web.JS\dist\Release\$(BlazorServerJSFilename) + ..\..\Web.JS\dist\Debug\$(BlazorServerJSFilename) + ..\..\Web.JS\dist\Release\$(BlazorServerJSFilename) - + ..\..\Web.JS\dist\Release\$(BlazorServerJSFilename) @@ -114,8 +106,7 @@ - + diff --git a/src/Components/Shared/src/ArrayBuilder.cs b/src/Components/Shared/src/ArrayBuilder.cs index 206c741538c6..6d578adb76d5 100644 --- a/src/Components/Shared/src/ArrayBuilder.cs +++ b/src/Components/Shared/src/ArrayBuilder.cs @@ -10,6 +10,8 @@ #if IGNITOR namespace Ignitor +#elif BLAZOR_WEBVIEW +namespace Microsoft.AspNetCore.Components.WebView #elif COMPONENTS_SERVER namespace Microsoft.AspNetCore.Components.Server.Circuits #else diff --git a/src/Components/Server/src/Circuits/ArrayBuilderMemoryStream.cs b/src/Components/Shared/src/ArrayBuilderMemoryStream.cs similarity index 97% rename from src/Components/Server/src/Circuits/ArrayBuilderMemoryStream.cs rename to src/Components/Shared/src/ArrayBuilderMemoryStream.cs index 4970ce6caa59..c9122a6a341a 100644 --- a/src/Components/Server/src/Circuits/ArrayBuilderMemoryStream.cs +++ b/src/Components/Shared/src/ArrayBuilderMemoryStream.cs @@ -8,7 +8,11 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Components.RenderTree; +#if BLAZOR_WEBVIEW +namespace Microsoft.AspNetCore.Components.WebView +#else namespace Microsoft.AspNetCore.Components.Server.Circuits +#endif { /// /// Writeable memory stream backed by a an . diff --git a/src/Components/Shared/src/ElementReferenceJsonConverter.cs b/src/Components/Shared/src/ElementReferenceJsonConverter.cs index b0a2cca8769a..ba4d0868bd9c 100644 --- a/src/Components/Shared/src/ElementReferenceJsonConverter.cs +++ b/src/Components/Shared/src/ElementReferenceJsonConverter.cs @@ -4,7 +4,7 @@ using System; using System.Text.Json; using System.Text.Json.Serialization; - +#nullable enable namespace Microsoft.AspNetCore.Components { internal sealed class ElementReferenceJsonConverter : JsonConverter diff --git a/src/Components/Server/src/Circuits/RenderBatchWriter.cs b/src/Components/Shared/src/RenderBatchWriter.cs similarity index 99% rename from src/Components/Server/src/Circuits/RenderBatchWriter.cs rename to src/Components/Shared/src/RenderBatchWriter.cs index 97ff9ac09dab..865059a0d543 100644 --- a/src/Components/Server/src/Circuits/RenderBatchWriter.cs +++ b/src/Components/Shared/src/RenderBatchWriter.cs @@ -11,6 +11,8 @@ #if IGNITOR namespace Ignitor +#elif BLAZOR_WEBVIEW +namespace Microsoft.AspNetCore.Components.WebView #else namespace Microsoft.AspNetCore.Components.Server.Circuits #endif diff --git a/src/Components/Shared/src/WebEventData.cs b/src/Components/Shared/src/WebEventData.cs index 72ca4806e2ee..9fd631cd3fab 100644 --- a/src/Components/Shared/src/WebEventData.cs +++ b/src/Components/Shared/src/WebEventData.cs @@ -6,7 +6,7 @@ using System.Text.Json; using Microsoft.AspNetCore.Components.RenderTree; using static Microsoft.AspNetCore.Internal.LinkerFlags; - +#nullable enable namespace Microsoft.AspNetCore.Components.Web { internal class WebEventData diff --git a/src/Components/Web.JS/.gitignore b/src/Components/Web.JS/.gitignore index b1e47fe5bdee..ab3d00a89cc0 100644 --- a/src/Components/Web.JS/.gitignore +++ b/src/Components/Web.JS/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/Debug/ dist/Release/blazor.webassembly.js +dist/Release/blazor.webview.js diff --git a/src/Components/Web.JS/Microsoft.AspNetCore.Components.Web.JS.npmproj b/src/Components/Web.JS/Microsoft.AspNetCore.Components.Web.JS.npmproj index 54815896084e..404b65d2be7a 100644 --- a/src/Components/Web.JS/Microsoft.AspNetCore.Components.Web.JS.npmproj +++ b/src/Components/Web.JS/Microsoft.AspNetCore.Components.Web.JS.npmproj @@ -15,6 +15,8 @@ + + diff --git a/src/Components/Web.JS/dist/Release/blazor.webview.js b/src/Components/Web.JS/dist/Release/blazor.webview.js new file mode 100644 index 000000000000..99d4677ec215 --- /dev/null +++ b/src/Components/Web.JS/dist/Release/blazor.webview.js @@ -0,0 +1 @@ +(()=>{"use strict";var e,t,n;!function(e){window.DotNet=e;const t=[];class n{constructor(e){this._jsObject=e,this._cachedFunctions=new Map}findFunction(e){const t=this._cachedFunctions.get(e);if(t)return t;let n,r=this._jsObject;if(e.split(".").forEach((t=>{if(!(t in r))throw new Error(`Could not find '${e}' ('${t}' was undefined).`);n=r,r=r[t]})),r instanceof Function)return r=r.bind(n),this._cachedFunctions.set(e,r),r;throw new Error(`The value '${e}' is not a function.`)}getWrappedObject(){return this._jsObject}}const r="__jsObjectId",o={},a={0:new n(window)};a[0]._cachedFunctions.set("import",(e=>("string"==typeof e&&e.startsWith("./")&&(e=document.baseURI+e.substr(2)),import(e))));let s,i=1,c=1,l=null;function u(e){t.push(e)}function d(e){if(e&&"object"==typeof e){a[c]=new n(e);const t={[r]:c};return c++,t}throw new Error(`Cannot create a JSObjectReference from the value '${e}'.`)}function h(e){return e?JSON.parse(e,((e,n)=>t.reduce(((t,n)=>n(e,t)),n))):null}function f(e,t,n,r){const o=m();if(o.invokeDotNetFromJS){const a=JSON.stringify(r,I),s=o.invokeDotNetFromJS(e,t,n,a);return s?h(s):null}throw new Error("The current dispatcher does not support synchronous calls from JS to .NET. Use invokeMethodAsync instead.")}function p(e,t,n,r){if(e&&n)throw new Error(`For instance method calls, assemblyName should be null. Received '${e}'.`);const a=i++,s=new Promise(((e,t)=>{o[a]={resolve:e,reject:t}}));try{const o=JSON.stringify(r,I);m().beginInvokeDotNetFromJS(a,e,t,n,o)}catch(e){v(a,!1,e)}return s}function m(){if(null!==l)return l;throw new Error("No .NET call dispatcher has been set.")}function v(e,t,n){if(!o.hasOwnProperty(e))throw new Error(`There is no pending async call with ID ${e}.`);const r=o[e];delete o[e],t?r.resolve(n):r.reject(n)}function g(e){return e instanceof Error?`${e.message}\n${e.stack}`:e?e.toString():"null"}function b(e,t){let n=a[t];if(n)return n.findFunction(e);throw new Error(`JS object instance with ID ${t} does not exist (has it been disposed?).`)}function y(e){delete a[e]}e.attachDispatcher=function(e){l=e},e.attachReviver=u,e.invokeMethod=function(e,t,...n){return f(e,t,null,n)},e.invokeMethodAsync=function(e,t,...n){return p(e,t,null,n)},e.createJSObjectReference=d,e.disposeJSObjectReference=function(e){const t=e&&e.__jsObjectId;"number"==typeof t&&y(t)},e.parseJsonWithRevivers=h,function(e){e[e.Default=0]="Default",e[e.JSObjectReference=1]="JSObjectReference"}(s=e.JSCallResultType||(e.JSCallResultType={})),e.jsCallDispatcher={findJSFunction:b,disposeJSObjectReferenceById:y,invokeJSFromDotNet:(e,t,n,r)=>{const o=w(b(e,r).apply(null,h(t)),n);return null==o?null:JSON.stringify(o,I)},beginInvokeJSFromDotNet:(e,t,n,r,o)=>{const a=new Promise((e=>{e(b(t,o).apply(null,h(n)))}));e&&a.then((t=>m().endInvokeJSFromDotNet(e,!0,JSON.stringify([e,!0,w(t,r)],I))),(t=>m().endInvokeJSFromDotNet(e,!1,JSON.stringify([e,!1,g(t)]))))},endInvokeDotNetFromJS:(e,t,n)=>{const r=t?n:new Error(n);v(parseInt(e),t,r)}};class E{constructor(e){this._id=e}invokeMethod(e,...t){return f(null,e,this._id,t)}invokeMethodAsync(e,...t){return p(null,e,this._id,t)}dispose(){p(null,"__Dispose",this._id,null).catch((e=>console.error(e)))}serializeAsArg(){return{__dotNetObject:this._id}}}function w(e,t){switch(t){case s.Default:return e;case s.JSObjectReference:return d(e);default:throw new Error(`Invalid JS call result type '${t}'.`)}}function I(e,t){return t instanceof E?t.serializeAsArg():t}u((function(e,t){return t&&"object"==typeof t&&t.hasOwnProperty("__dotNetObject")?new E(t.__dotNetObject):t})),u((function(e,t){if(t&&"object"==typeof t&&t.hasOwnProperty(r)){const e=t.__jsObjectId,n=a[e];if(n)return n.getWrappedObject();throw new Error(`JS object instance with ID ${e} does not exist (has it been disposed?).`)}return t}))}(e||(e={})),function(e){e[e.prependFrame=1]="prependFrame",e[e.removeFrame=2]="removeFrame",e[e.setAttribute=3]="setAttribute",e[e.removeAttribute=4]="removeAttribute",e[e.updateText=5]="updateText",e[e.stepIn=6]="stepIn",e[e.stepOut=7]="stepOut",e[e.updateMarkup=8]="updateMarkup",e[e.permutationListEntry=9]="permutationListEntry",e[e.permutationListEnd=10]="permutationListEnd"}(t||(t={})),function(e){e[e.element=1]="element",e[e.text=2]="text",e[e.attribute=3]="attribute",e[e.component=4]="component",e[e.region=5]="region",e[e.elementReferenceCapture=6]="elementReferenceCapture",e[e.markup=8]="markup"}(n||(n={}));class r{constructor(e,t){this.componentId=e,this.fieldValue=t}static fromEvent(e,t){const n=t.target;if(n instanceof Element){const t=function(e){return e instanceof HTMLInputElement?e.type&&"checkbox"===e.type.toLowerCase()?{value:e.checked}:{value:e.value}:e instanceof HTMLSelectElement||e instanceof HTMLTextAreaElement?{value:e.value}:null}(n);if(t)return new r(e,t.value)}return null}}let o;function a(e,t){if(!o)throw new Error("eventDispatcher not initialized. Call 'setEventDispatcher' to configure it.");o(e,t)}const s=new Map,i=new Map,c={createEventArgs:()=>({})},l=[];function u(e){return s.get(e)}function d(e){const t=s.get(e);return(null==t?void 0:t.browserEventName)||e}function h(e,t){e.forEach((e=>s.set(e,t)))}function f(e){const t=[];for(let n=0;n{return{...p(t=e),dataTransfer:t.dataTransfer?{dropEffect:t.dataTransfer.dropEffect,effectAllowed:t.dataTransfer.effectAllowed,files:Array.from(t.dataTransfer.files).map((e=>e.name)),items:Array.from(t.dataTransfer.items).map((e=>({kind:e.kind,type:e.type}))),types:t.dataTransfer.types}:null};var t}}),h(["focus","blur","focusin","focusout"],c),h(["keydown","keyup","keypress"],{createEventArgs:e=>{return{key:(t=e).key,code:t.code,location:t.location,repeat:t.repeat,ctrlKey:t.ctrlKey,shiftKey:t.shiftKey,altKey:t.altKey,metaKey:t.metaKey};var t}}),h(["contextmenu","click","mouseover","mouseout","mousemove","mousedown","mouseup","dblclick"],{createEventArgs:e=>p(e)}),h(["error"],{createEventArgs:e=>{return{message:(t=e).message,filename:t.filename,lineno:t.lineno,colno:t.colno};var t}}),h(["loadstart","timeout","abort","load","loadend","progress"],{createEventArgs:e=>{return{lengthComputable:(t=e).lengthComputable,loaded:t.loaded,total:t.total};var t}}),h(["touchcancel","touchend","touchmove","touchenter","touchleave","touchstart"],{createEventArgs:e=>{return{detail:(t=e).detail,touches:f(t.touches),targetTouches:f(t.targetTouches),changedTouches:f(t.changedTouches),ctrlKey:t.ctrlKey,shiftKey:t.shiftKey,altKey:t.altKey,metaKey:t.metaKey};var t}}),h(["gotpointercapture","lostpointercapture","pointercancel","pointerdown","pointerenter","pointerleave","pointermove","pointerout","pointerover","pointerup"],{createEventArgs:e=>{return{...p(t=e),pointerId:t.pointerId,width:t.width,height:t.height,pressure:t.pressure,tiltX:t.tiltX,tiltY:t.tiltY,pointerType:t.pointerType,isPrimary:t.isPrimary};var t}}),h(["wheel","mousewheel"],{createEventArgs:e=>{return{...p(t=e),deltaX:t.deltaX,deltaY:t.deltaY,deltaZ:t.deltaZ,deltaMode:t.deltaMode};var t}}),h(["toggle"],c);const m=["date","datetime-local","month","time","week"],v=I(["abort","blur","change","error","focus","load","loadend","loadstart","mouseenter","mouseleave","progress","reset","scroll","submit","unload","toggle","DOMNodeInsertedIntoDocument","DOMNodeRemovedFromDocument"]),g={submit:!0},b=I(["click","dblclick","mousedown","mousemove","mouseup"]);class y{constructor(e){this.browserRendererId=e,this.afterClickCallbacks=[];const t=++y.nextEventDelegatorId;this.eventsCollectionKey=`_blazorEvents_${t}`,this.eventInfoStore=new E(this.onGlobalEvent.bind(this))}setListener(e,t,n,r){const o=this.getEventHandlerInfosForElement(e,!0),a=o.getHandler(t);if(a)this.eventInfoStore.update(a.eventHandlerId,n);else{const a={element:e,eventName:t,eventHandlerId:n,renderingComponentId:r};this.eventInfoStore.add(a),o.setHandler(t,a)}}getHandler(e){return this.eventInfoStore.get(e)}removeListener(e){const t=this.eventInfoStore.remove(e);if(t){const e=t.element,n=this.getEventHandlerInfosForElement(e,!1);n&&n.removeHandler(t.eventName)}}notifyAfterClick(e){this.afterClickCallbacks.push(e),this.eventInfoStore.addGlobalListener("click")}setStopPropagation(e,t,n){this.getEventHandlerInfosForElement(e,!0).stopPropagation(t,n)}setPreventDefault(e,t,n){this.getEventHandlerInfosForElement(e,!0).preventDefault(t,n)}onGlobalEvent(e){if(!(e.target instanceof Element))return;this.dispatchGlobalEventToAllElements(e.type,e);const t=(n=e.type,i.get(n));var n;t&&t.forEach((t=>this.dispatchGlobalEventToAllElements(t,e))),"click"===e.type&&this.afterClickCallbacks.forEach((t=>t(e)))}dispatchGlobalEventToAllElements(e,t){let n=t.target,o=null,s=!1;const i=v.hasOwnProperty(e);let c=!1;for(;n;){const h=this.getEventHandlerInfosForElement(n,!1);if(h){const i=h.getHandler(e);if(i&&(l=n,d=t.type,!((l instanceof HTMLButtonElement||l instanceof HTMLInputElement||l instanceof HTMLTextAreaElement||l instanceof HTMLSelectElement)&&b.hasOwnProperty(d)&&l.disabled))){if(!s){const n=u(e);o=(null==n?void 0:n.createEventArgs)?n.createEventArgs(t):{},s=!0}g.hasOwnProperty(t.type)&&t.preventDefault(),a({browserRendererId:this.browserRendererId,eventHandlerId:i.eventHandlerId,eventName:e,eventFieldInfo:r.fromEvent(i.renderingComponentId,t)},o)}h.stopPropagation(e)&&(c=!0),h.preventDefault(e)&&t.preventDefault()}n=i||c?null:n.parentElement}var l,d}getEventHandlerInfosForElement(e,t){return e.hasOwnProperty(this.eventsCollectionKey)?e[this.eventsCollectionKey]:t?e[this.eventsCollectionKey]=new w:null}}y.nextEventDelegatorId=0;class E{constructor(e){this.globalListener=e,this.infosByEventHandlerId={},this.countByEventName={},l.push(this.handleEventNameAliasAdded.bind(this))}add(e){if(this.infosByEventHandlerId[e.eventHandlerId])throw new Error(`Event ${e.eventHandlerId} is already tracked`);this.infosByEventHandlerId[e.eventHandlerId]=e,this.addGlobalListener(e.eventName)}get(e){return this.infosByEventHandlerId[e]}addGlobalListener(e){if(e=d(e),this.countByEventName.hasOwnProperty(e))this.countByEventName[e]++;else{this.countByEventName[e]=1;const t=v.hasOwnProperty(e);document.addEventListener(e,this.globalListener,t)}}update(e,t){if(this.infosByEventHandlerId.hasOwnProperty(t))throw new Error(`Event ${t} is already tracked`);const n=this.infosByEventHandlerId[e];delete this.infosByEventHandlerId[e],n.eventHandlerId=t,this.infosByEventHandlerId[t]=n}remove(e){const t=this.infosByEventHandlerId[e];if(t){delete this.infosByEventHandlerId[e];const n=d(t.eventName);0==--this.countByEventName[n]&&(delete this.countByEventName[n],document.removeEventListener(n,this.globalListener))}return t}handleEventNameAliasAdded(e,t){if(this.countByEventName.hasOwnProperty(e)){const n=this.countByEventName[e];delete this.countByEventName[e],document.removeEventListener(e,this.globalListener),this.addGlobalListener(t),this.countByEventName[t]+=n-1}}}class w{constructor(){this.handlers={},this.preventDefaultFlags=null,this.stopPropagationFlags=null}getHandler(e){return this.handlers.hasOwnProperty(e)?this.handlers[e]:null}setHandler(e,t){this.handlers[e]=t}removeHandler(e){delete this.handlers[e]}preventDefault(e,t){return void 0!==t&&(this.preventDefaultFlags=this.preventDefaultFlags||{},this.preventDefaultFlags[e]=t),!!this.preventDefaultFlags&&this.preventDefaultFlags[e]}stopPropagation(e,t){return void 0!==t&&(this.stopPropagationFlags=this.stopPropagationFlags||{},this.stopPropagationFlags[e]=t),!!this.stopPropagationFlags&&this.stopPropagationFlags[e]}}function I(e){const t={};return e.forEach((e=>{t[e]=!0})),t}const S=M("_blazorLogicalChildren"),D=M("_blazorLogicalParent"),C=M("_blazorLogicalEnd");function T(e,t){if(e.childNodes.length>0&&!t)throw new Error("New logical elements must start empty, or allowExistingContents must be true");return S in e||(e[S]=[]),e}function N(e,t){const n=document.createComment("!");return A(n,e,t),n}function A(e,t,n){const r=e;if(e instanceof Comment&&x(r)&&x(r).length>0)throw new Error("Not implemented: inserting non-empty logical container");if(R(r))throw new Error("Not implemented: moving existing logical children");const o=x(t);if(n0;)k(n,0)}const r=n;r.parentNode.removeChild(r)}function R(e){return e[D]||null}function O(e,t){return x(e)[t]}function F(e){return"http://www.w3.org/2000/svg"===H(e).namespaceURI}function x(e){return e[S]}function _(e,t){const n=x(e);t.forEach((e=>{e.moveRangeStart=n[e.fromSiblingIndex],e.moveRangeEnd=U(e.moveRangeStart)})),t.forEach((t=>{const r=t.moveToBeforeMarker=document.createComment("marker"),o=n[t.toSiblingIndex+1];o?o.parentNode.insertBefore(r,o):P(r,e)})),t.forEach((e=>{const t=e.moveToBeforeMarker,n=t.parentNode,r=e.moveRangeStart,o=e.moveRangeEnd;let a=r;for(;a;){const e=a.nextSibling;if(n.insertBefore(a,t),a===o)break;a=e}n.removeChild(t)})),t.forEach((e=>{n[e.toSiblingIndex]=e.moveRangeStart}))}function H(e){if(e instanceof Element)return e;if(e instanceof Comment)return e.parentNode;throw new Error("Not a valid logical element")}function L(e){const t=x(R(e));return t[Array.prototype.indexOf.call(t,e)+1]||null}function P(e,t){if(t instanceof Element)t.appendChild(e);else{if(!(t instanceof Comment))throw new Error(`Cannot append node because the parent is not a valid logical element. Parent: ${t}`);{const n=L(t);n?n.parentNode.insertBefore(e,n):P(e,R(t))}}}function U(e){if(e instanceof Element)return e;const t=L(e);if(t)return t.previousSibling;{const t=R(e);return t instanceof Element?t.lastChild:U(t)}}function M(e){return"function"==typeof Symbol?Symbol():e}function B(e){return`_bl_${e}`}e.attachReviver(((e,t)=>t&&"object"==typeof t&&t.hasOwnProperty("__internalId")&&"string"==typeof t.__internalId?function(e){const t=`[${B(e)}]`;return document.querySelector(t)}(t.__internalId):t));const J="_blazorSelectValue",j=document.createElement("template"),K=document.createElementNS("http://www.w3.org/2000/svg","g"),$={},V="__internal_",z="preventDefault_",X="stopPropagation_";class Y{constructor(e){this.childComponentLocations={},this.eventDelegator=new y(e),this.eventDelegator.notifyAfterClick((e=>{if(!ee)return;if(0!==e.button||function(e){return e.ctrlKey||e.shiftKey||e.altKey||e.metaKey}(e))return;if(e.defaultPrevented)return;const t=function(e){const t=!window._blazorDisableComposedPath&&e.composedPath&&e.composedPath();if(t){for(let e=0;ese(!1))))},enableNavigationInterception:function(){ee=!0},navigateTo:oe,getBaseURI:()=>document.baseURI,getLocationHref:()=>location.href};function oe(e,t,n=!1){const r=ce(e);if(!t&&ue(r))ae(r,!1,n);else if(t&&location.href===e){const t=e+"?";history.replaceState(null,"",t),location.replace(e)}else n?history.replaceState(null,"",r):location.href=e}function ae(e,t,n=!1){Q=!0,n?history.replaceState(null,"",e):history.pushState(null,"",e),se(t)}async function se(e){ne&&await ne(location.href,e)}let ie;function ce(e){return ie=ie||document.createElement("a"),ie.href=e,ie.href}function le(e,t){return e?e.tagName===t?e:le(e.parentElement,t):null}function ue(e){const t=(n=document.baseURI).substr(0,n.lastIndexOf("/")+1);var n;return e.startsWith(t)}const de={init:function(e,t,n,r=50){const o=fe(t);(o||document.documentElement).style.overflowAnchor="none";const a=new IntersectionObserver((function(r){r.forEach((r=>{var o;if(!r.isIntersecting)return;const a=t.getBoundingClientRect(),s=n.getBoundingClientRect().top-a.bottom,i=null===(o=r.rootBounds)||void 0===o?void 0:o.height;r.target===t?e.invokeMethodAsync("OnSpacerBeforeVisible",r.intersectionRect.top-r.boundingClientRect.top,s,i):r.target===n&&n.offsetHeight>0&&e.invokeMethodAsync("OnSpacerAfterVisible",r.boundingClientRect.bottom-r.intersectionRect.bottom,s,i)}))}),{root:o,rootMargin:`${r}px`});a.observe(t),a.observe(n);const s=c(t),i=c(n);function c(e){const t=new MutationObserver((()=>{a.unobserve(e),a.observe(e)}));return t.observe(e,{attributes:!0}),t}he[e._id]={intersectionObserver:a,mutationObserverBefore:s,mutationObserverAfter:i}},dispose:function(e){const t=he[e._id];t&&(t.intersectionObserver.disconnect(),t.mutationObserverBefore.disconnect(),t.mutationObserverAfter.disconnect(),e.dispose(),delete he[e._id])}},he={};function fe(e){return e?"visible"!==getComputedStyle(e).overflowY?e:fe(e.parentElement):null}const pe={navigateTo:oe,registerCustomEventType:function(e,t){if(!t)throw new Error("The options parameter is required.");if(s.has(e))throw new Error(`The event '${e}' is already registered.`);if(t.browserEventName){const n=i.get(t.browserEventName);n?n.push(e):i.set(t.browserEventName,[e]),l.forEach((n=>n(e,t.browserEventName)))}s.set(e,t)},_internal:{navigationManager:re,domWrapper:{focus:function(e,t){if(!(e instanceof HTMLElement))throw new Error("Unable to focus an invalid element.");e.focus({preventScroll:t})}},Virtualize:de}};window.Blazor=pe;let me=!1;const ve="function"==typeof TextDecoder?new TextDecoder("utf-8"):null,ge=ve?ve.decode.bind(ve):function(e){let t=0;const n=e.length,r=[],o=[];for(;t65535&&(o-=65536,r.push(o>>>10&1023|55296),o=56320|1023&o),r.push(o)}r.length>1024&&(o.push(String.fromCharCode.apply(null,r)),r.length=0)}return o.push(String.fromCharCode.apply(null,r)),o.join("")},be=Math.pow(2,32),ye=Math.pow(2,21)-1;function Ee(e,t){return e[t]|e[t+1]<<8|e[t+2]<<16|e[t+3]<<24}function we(e,t){return e[t]+(e[t+1]<<8)+(e[t+2]<<16)+(e[t+3]<<24>>>0)}function Ie(e,t){const n=we(e,t+4);if(n>ye)throw new Error(`Cannot read uint64 with high order part ${n}, because the result would exceed Number.MAX_SAFE_INTEGER.`);return n*be+we(e,t)}class Se{constructor(e){this.batchData=e;const t=new Ne(e);this.arrayRangeReader=new Ae(e),this.arrayBuilderSegmentReader=new ke(e),this.diffReader=new De(e),this.editReader=new Ce(e,t),this.frameReader=new Te(e,t)}updatedComponents(){return Ee(this.batchData,this.batchData.length-20)}referenceFrames(){return Ee(this.batchData,this.batchData.length-16)}disposedComponentIds(){return Ee(this.batchData,this.batchData.length-12)}disposedEventHandlerIds(){return Ee(this.batchData,this.batchData.length-8)}updatedComponentsEntry(e,t){const n=e+4*t;return Ee(this.batchData,n)}referenceFramesEntry(e,t){return e+20*t}disposedComponentIdsEntry(e,t){const n=e+4*t;return Ee(this.batchData,n)}disposedEventHandlerIdsEntry(e,t){const n=e+8*t;return Ie(this.batchData,n)}}class De{constructor(e){this.batchDataUint8=e}componentId(e){return Ee(this.batchDataUint8,e)}edits(e){return e+4}editsEntry(e,t){return e+16*t}}class Ce{constructor(e,t){this.batchDataUint8=e,this.stringReader=t}editType(e){return Ee(this.batchDataUint8,e)}siblingIndex(e){return Ee(this.batchDataUint8,e+4)}newTreeIndex(e){return Ee(this.batchDataUint8,e+8)}moveToSiblingIndex(e){return Ee(this.batchDataUint8,e+8)}removedAttributeName(e){const t=Ee(this.batchDataUint8,e+12);return this.stringReader.readString(t)}}class Te{constructor(e,t){this.batchDataUint8=e,this.stringReader=t}frameType(e){return Ee(this.batchDataUint8,e)}subtreeLength(e){return Ee(this.batchDataUint8,e+4)}elementReferenceCaptureId(e){const t=Ee(this.batchDataUint8,e+4);return this.stringReader.readString(t)}componentId(e){return Ee(this.batchDataUint8,e+8)}elementName(e){const t=Ee(this.batchDataUint8,e+8);return this.stringReader.readString(t)}textContent(e){const t=Ee(this.batchDataUint8,e+4);return this.stringReader.readString(t)}markupContent(e){const t=Ee(this.batchDataUint8,e+4);return this.stringReader.readString(t)}attributeName(e){const t=Ee(this.batchDataUint8,e+4);return this.stringReader.readString(t)}attributeValue(e){const t=Ee(this.batchDataUint8,e+8);return this.stringReader.readString(t)}attributeEventHandlerId(e){return Ie(this.batchDataUint8,e+12)}}class Ne{constructor(e){this.batchDataUint8=e,this.stringTableStartIndex=Ee(e,e.length-4)}readString(e){if(-1===e)return null;{const n=Ee(this.batchDataUint8,this.stringTableStartIndex+4*e),r=function(e,t){let n=0,r=0;for(let o=0;o<4;o++){const a=e[t+o];if(n|=(127&a)<{!function(e,t,n){const r=document.querySelector(e);if(!r)throw new Error(`Could not find any element matching selector '${e}'.`);!function(e,t,n){let r=Z[0];r||(r=Z[0]=new Y(0)),r.attachRootComponentToLogicalElement(n,t)}(0,T(r,!0),t)}(t,e)},RenderBatch:(e,t)=>{try{const n=function(e){const t=atob(e),n=t.length,r=new Uint8Array(n);for(let e=0;e{Oe=!0,console.error(`${e}\n${t}`),async function(){let e=document.querySelector("#blazor-error-ui");e&&(e.style.display="block"),me||(me=!0,document.querySelectorAll("#blazor-error-ui .reload").forEach((e=>{e.onclick=function(e){location.reload(),e.preventDefault()}})),document.querySelectorAll("#blazor-error-ui .dismiss").forEach((e=>{e.onclick=function(e){const t=document.querySelector("#blazor-error-ui");t&&(t.style.display="none"),e.preventDefault()}})))}()},BeginInvokeJS:e.jsCallDispatcher.beginInvokeJSFromDotNet,EndInvokeDotNet:e.jsCallDispatcher.endInvokeDotNetFromJS,Navigate:re.navigateTo};window.external.receiveMessage((e=>{const n=function(e){if(Oe||!e||!e.startsWith(Re))return null;const t=e.substring(Re.length),[n,...r]=JSON.parse(t);return{messageType:n,args:r}}(e);if(n){if(!t.hasOwnProperty(n.messageType))throw new Error(`Unsupported IPC message type '${n.messageType}'`);t[n.messageType].apply(null,n.args)}}))}(),e.attachDispatcher({beginInvokeDotNetFromJS:xe,endInvokeJSFromDotNet:_e}),re.enableNavigationInterception(),re.listenForNavigationEvents(He),Le("AttachPage",re.getBaseURI(),re.getLocationHref())}o=function(e,t){Le("DispatchBrowserEvent",e,t)},pe.start=Ue,document&&document.currentScript&&"false"!==document.currentScript.getAttribute("autostart")&&Ue()})(); \ No newline at end of file diff --git a/src/Components/Web.JS/src/Boot.WebView.ts b/src/Components/Web.JS/src/Boot.WebView.ts new file mode 100644 index 000000000000..d168efc330dc --- /dev/null +++ b/src/Components/Web.JS/src/Boot.WebView.ts @@ -0,0 +1,36 @@ +import { DotNet } from '@microsoft/dotnet-js-interop'; +import { Blazor } from './GlobalExports'; +import { shouldAutoStart } from './BootCommon'; +import { internalFunctions as navigationManagerFunctions } from './Services/NavigationManager'; +import { setEventDispatcher } from './Rendering/Events/EventDispatcher'; +import { startIpcReceiver } from './Platform/WebView/WebViewIpcReceiver'; +import { sendBrowserEvent, sendAttachPage, sendBeginInvokeDotNetFromJS, sendEndInvokeJSFromDotNet, sendLocationChanged } from './Platform/WebView/WebViewIpcSender'; + +let started = false; + +async function boot(): Promise { + if (started) { + throw new Error('Blazor has already started.'); + } + started = true; + + startIpcReceiver(); + + DotNet.attachDispatcher({ + beginInvokeDotNetFromJS: sendBeginInvokeDotNetFromJS, + endInvokeJSFromDotNet: sendEndInvokeJSFromDotNet, + }); + + navigationManagerFunctions.enableNavigationInterception(); + navigationManagerFunctions.listenForNavigationEvents(sendLocationChanged); + + sendAttachPage(navigationManagerFunctions.getBaseURI(), navigationManagerFunctions.getLocationHref()); +} + +setEventDispatcher(sendBrowserEvent); + +Blazor.start = boot; + +if (shouldAutoStart()) { + boot(); +} diff --git a/src/Components/Web.JS/src/Platform/WebView/WebViewIpcCommon.ts b/src/Components/Web.JS/src/Platform/WebView/WebViewIpcCommon.ts new file mode 100644 index 000000000000..72db02f201c8 --- /dev/null +++ b/src/Components/Web.JS/src/Platform/WebView/WebViewIpcCommon.ts @@ -0,0 +1,30 @@ +const ipcMessagePrefix = '__bwv:'; +let applicationIsTerminated = false; + +export function trySerializeMessage(messageType: string, args: any[]): string | null { + return applicationIsTerminated + ? null + : `${ipcMessagePrefix}${JSON.stringify([messageType, ...args])}`; +} + +export function tryDeserializeMessage(message: string): IpcMessage | null { + if (applicationIsTerminated || !message || !message.startsWith(ipcMessagePrefix)) { + return null; + } + + const messageAfterPrefix = message.substring(ipcMessagePrefix.length); + const [messageType, ...args] = JSON.parse(messageAfterPrefix); + return { messageType, args }; +} + +export function setApplicationIsTerminated() { + // If there's an unhandled exception, we'll prevent the webview from doing anything else until + // it reloads the page. This is equivalent to what happens in Blazor Server, and avoids anyone + // taking a dependency on being able to continue interacting after a fatal error. + applicationIsTerminated = true; +} + +interface IpcMessage { + messageType: string; + args: any[]; +} diff --git a/src/Components/Web.JS/src/Platform/WebView/WebViewIpcReceiver.ts b/src/Components/Web.JS/src/Platform/WebView/WebViewIpcReceiver.ts new file mode 100644 index 000000000000..b28338435acb --- /dev/null +++ b/src/Components/Web.JS/src/Platform/WebView/WebViewIpcReceiver.ts @@ -0,0 +1,62 @@ +import { DotNet } from '@microsoft/dotnet-js-interop'; +import { showErrorNotification } from '../../BootErrors'; +import { OutOfProcessRenderBatch } from '../../Rendering/RenderBatch/OutOfProcessRenderBatch'; +import { attachRootComponentToElement, renderBatch } from '../../Rendering/Renderer'; +import { setApplicationIsTerminated, tryDeserializeMessage } from './WebViewIpcCommon'; +import { sendRenderCompleted } from './WebViewIpcSender'; +import { internalFunctions as navigationManagerFunctions } from '../../Services/NavigationManager'; + +export function startIpcReceiver() { + const messageHandlers = { + + 'AttachToDocument': (componentId: number, elementSelector: string) => { + attachRootComponentToElement(elementSelector, componentId); + }, + + 'RenderBatch': (batchId: number, batchDataBase64: string) => { + try { + const batchData = base64ToArrayBuffer(batchDataBase64); + renderBatch(0, new OutOfProcessRenderBatch(batchData)); + sendRenderCompleted(batchId, null); + } catch (ex) { + sendRenderCompleted(batchId, ex.toString()); + } + }, + + 'NotifyUnhandledException': (message: string, stackTrace: string) => { + setApplicationIsTerminated(); + console.error(`${message}\n${stackTrace}`); + showErrorNotification(); + }, + + 'BeginInvokeJS': DotNet.jsCallDispatcher.beginInvokeJSFromDotNet, + + 'EndInvokeDotNet': DotNet.jsCallDispatcher.endInvokeDotNetFromJS, + + 'Navigate': navigationManagerFunctions.navigateTo, + }; + + (window.external as any).receiveMessage((message: string) => { + const parsedMessage = tryDeserializeMessage(message); + if (parsedMessage) { + if (messageHandlers.hasOwnProperty(parsedMessage.messageType)) { + messageHandlers[parsedMessage.messageType].apply(null, parsedMessage.args); + } else { + throw new Error(`Unsupported IPC message type '${parsedMessage.messageType}'`); + } + } + }); +} + +// https://stackoverflow.com/a/21797381 +// TODO: If the data is large, consider switching over to the native decoder as in https://stackoverflow.com/a/54123275 +// But don't force it to be async all the time. Yielding execution leads to perceptible lag. +function base64ToArrayBuffer(base64: string) { + const binaryString = atob(base64); + const length = binaryString.length; + const result = new Uint8Array(length); + for (let i = 0; i < length; i++) { + result[i] = binaryString.charCodeAt(i); + } + return result; +} diff --git a/src/Components/Web.JS/src/Platform/WebView/WebViewIpcSender.ts b/src/Components/Web.JS/src/Platform/WebView/WebViewIpcSender.ts new file mode 100644 index 000000000000..c3412d8edb33 --- /dev/null +++ b/src/Components/Web.JS/src/Platform/WebView/WebViewIpcSender.ts @@ -0,0 +1,34 @@ +import { EventDescriptor } from '../../Rendering/Events/EventDispatcher'; +import { trySerializeMessage } from './WebViewIpcCommon'; + +export function sendAttachPage(baseUrl: string, startUrl: string) { + send('AttachPage', baseUrl, startUrl); +} + +export function sendRenderCompleted(batchId: number, errorOrNull: string | null) { + send('OnRenderCompleted', batchId, errorOrNull); +} + +export function sendBrowserEvent(descriptor: EventDescriptor, eventArgs: any) { + send('DispatchBrowserEvent', descriptor, eventArgs); +} + +export function sendBeginInvokeDotNetFromJS(callId: number, assemblyName: string | null, methodIdentifier: string, dotNetObjectId: number | null, argsJson: string): void { + send('BeginInvokeDotNet', callId ? callId.toString() : null, assemblyName, methodIdentifier, dotNetObjectId || 0, argsJson); +} + +export function sendEndInvokeJSFromDotNet(asyncHandle: number, succeeded: boolean, argsJson: any) { + send('EndInvokeJS', asyncHandle, succeeded, argsJson); +} + +export function sendLocationChanged(uri: string, intercepted: boolean) { + send('OnLocationChanged', uri, intercepted); + return Promise.resolve(); // Like in Blazor Server, we only issue the notification here - there's no need to wait for a response +} + +function send(messageType: string, ...args: any[]) { + const serializedMessage = trySerializeMessage(messageType, args); + if (serializedMessage) { + (window.external as any).sendMessage(serializedMessage); + } +} diff --git a/src/Components/Web.JS/src/webpack.config.js b/src/Components/Web.JS/src/webpack.config.js index 6cb06cac3cc6..b0651fa75cff 100644 --- a/src/Components/Web.JS/src/webpack.config.js +++ b/src/Components/Web.JS/src/webpack.config.js @@ -4,16 +4,17 @@ const TerserJsPlugin = require("terser-webpack-plugin"); const { DuplicatesPlugin } = require("inspectpack/plugin"); module.exports = (env, args) => ({ - resolve: { + resolve: { extensions: ['.ts', '.js'], }, - devtool: args.mode === 'development' ? 'source-map' : undefined, + devtool: false, // Source maps configured below module: { rules: [{ test: /\.ts?$/, loader: 'ts-loader' }] }, entry: { - 'blazor.webassembly': './Boot.WebAssembly.ts', 'blazor.server': './Boot.Server.ts', + 'blazor.webassembly': './Boot.WebAssembly.ts', + 'blazor.webview': './Boot.WebView.ts', }, output: { path: path.join(__dirname, '/..', '/dist', args.mode == 'development' ? '/Debug' : '/Release'), filename: '[name].js' }, performance: { @@ -26,10 +27,10 @@ module.exports = (env, args) => ({ usedExports: true, innerGraph: true, minimize: true, - minimizer: [new TerserJsPlugin({ + minimizer: [new TerserJsPlugin({ terserOptions: { ecma: 2019, - compress: { + compress: { passes: 3 }, mangle: { @@ -43,8 +44,8 @@ module.exports = (env, args) => ({ toplevel: true } })] - }, - plugins: [ + }, + plugins: Array.prototype.concat.apply([ new webpack.DefinePlugin({ 'process.env.NODE_DEBUG': false, 'Platform.isNode': false @@ -54,8 +55,19 @@ module.exports = (env, args) => ({ emitHandler: undefined, ignoredPackages: undefined, verbose: false - }) - ], + }), + + ], args.mode !== 'development' ? [] : [ + // In most cases we want to use external source map files + new webpack.SourceMapDevToolPlugin({ + filename: '[name].js.map', + exclude: 'blazor.webview.js', + }), + // ... but for blazor.webview.js, it has to be internal, due to https://github.com/MicrosoftEdge/WebView2Feedback/issues/961 + new webpack.SourceMapDevToolPlugin({ + include: 'blazor.webview.js', + }), + ]), stats: { //all: true, warnings: true, diff --git a/src/Components/WebView/Directory.Build.props b/src/Components/WebView/Directory.Build.props new file mode 100644 index 000000000000..4b5c3fb85c65 --- /dev/null +++ b/src/Components/WebView/Directory.Build.props @@ -0,0 +1,8 @@ + + + + + true + + + diff --git a/src/Components/WebView/Platforms/WebView2/src/IWebView2Wrapper.cs b/src/Components/WebView/Platforms/WebView2/src/IWebView2Wrapper.cs new file mode 100644 index 000000000000..e406af0b9c8b --- /dev/null +++ b/src/Components/WebView/Platforms/WebView2/src/IWebView2Wrapper.cs @@ -0,0 +1,41 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Web.WebView2.Core; + +namespace Microsoft.AspNetCore.Components.WebView.WebView2 +{ + /// + /// Provides an abstraction for different UI frameworks to provide access to APIs from + /// and related controls. + /// + public interface IWebView2Wrapper + { + /// + /// Gets the instance on the control. This is only available + /// once the returned by + /// has completed. + /// + CoreWebView2 CoreWebView2 { get; } + + /// + /// Gets or sets the source URI of the control. Setting the source URI causes page navigation. + /// + Uri Source { get; set; } + + /// + /// Initializes the instance on the control. This should only be called once + /// per control. + /// + /// A that can be used to customize the control's behavior. + /// A that will complete once the is initialized and attached to the control. + Task EnsureCoreWebView2Async(CoreWebView2Environment environment = null); + + /// + /// Event that occurs when an accelerator key is pressed. + /// + event EventHandler AcceleratorKeyPressed; + } +} diff --git a/src/Components/WebView/Platforms/WebView2/src/Microsoft.AspNetCore.Components.WebView.WebView2.csproj b/src/Components/WebView/Platforms/WebView2/src/Microsoft.AspNetCore.Components.WebView.WebView2.csproj new file mode 100644 index 000000000000..5460d88de80c --- /dev/null +++ b/src/Components/WebView/Platforms/WebView2/src/Microsoft.AspNetCore.Components.WebView.WebView2.csproj @@ -0,0 +1,19 @@ + + + + + $(DefaultNetCoreTargetFramework)-windows + WebView2 wrappers for BlazorWebView components on Windows. + false + + false + + + + + + + + diff --git a/src/Components/WebView/Platforms/WebView2/src/WebView2WebViewManager.cs b/src/Components/WebView/Platforms/WebView2/src/WebView2WebViewManager.cs new file mode 100644 index 000000000000..c3701d8f443e --- /dev/null +++ b/src/Components/WebView/Platforms/WebView2/src/WebView2WebViewManager.cs @@ -0,0 +1,120 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.FileProviders; +using Microsoft.Web.WebView2.Core; + +namespace Microsoft.AspNetCore.Components.WebView.WebView2 +{ + /// + /// An implementation of that uses the Edge WebView2 browser control + /// to render web content. + /// + public class WebView2WebViewManager : WebViewManager + { + // Using an IP address means that WebView2 doesn't wait for any DNS resolution, + // making it substantially faster. Note that this isn't real HTTP traffic, since + // we intercept all the requests within this origin. + private const string AppOrigin = "https://0.0.0.0/"; + + private readonly IWebView2Wrapper _webview; + private readonly Task _webviewReadyTask; + + /// + /// Constructs an instance of . + /// + /// A wrapper to access platform-specific WebView2 APIs. + /// A service provider containing services to be used by this class and also by application code. + /// A instance that can marshal calls to the required thread or sync context. + /// Provides static content to the webview. + /// Path to the host page within the . + public WebView2WebViewManager(IWebView2Wrapper webview, IServiceProvider services, Dispatcher dispatcher, IFileProvider fileProvider, string hostPageRelativePath) + : base(services, dispatcher, new Uri(AppOrigin), fileProvider, hostPageRelativePath) + { + _webview = webview ?? throw new ArgumentNullException(nameof(webview)); + + // Unfortunately the CoreWebView2 can only be instantiated asynchronously. + // We want the external API to behave as if initalization is synchronous, + // so keep track of a task we can await during LoadUri. + _webviewReadyTask = InitializeWebView2(); + } + + /// + protected override void NavigateCore(Uri absoluteUri) + { + _ = Dispatcher.InvokeAsync(async () => + { + await _webviewReadyTask; + _webview.Source = absoluteUri; + }); + } + + /// + protected override void SendMessage(string message) + => _webview.CoreWebView2.PostWebMessageAsString(message); + + private async Task InitializeWebView2() + { + var environment = await CoreWebView2Environment.CreateAsync().ConfigureAwait(true); + await _webview.EnsureCoreWebView2Async(environment); + ApplyDefaultWebViewSettings(); + + _webview.CoreWebView2.AddWebResourceRequestedFilter($"{AppOrigin}*", CoreWebView2WebResourceContext.All); + _webview.CoreWebView2.WebResourceRequested += (sender, eventArgs) => + { + // Unlike server-side code, we get told exactly why the browser is making the request, + // so we can be smarter about fallback. We can ensure that 'fetch' requests never result + // in fallback, for example. + var allowFallbackOnHostPage = + eventArgs.ResourceContext == CoreWebView2WebResourceContext.Document || + eventArgs.ResourceContext == CoreWebView2WebResourceContext.Other; // e.g., dev tools requesting page source + + if (TryGetResponseContent(eventArgs.Request.Uri, allowFallbackOnHostPage, out var statusCode, out var statusMessage, out var content, out var headers)) + { + eventArgs.Response = environment.CreateWebResourceResponse(content, statusCode, statusMessage, headers); + } + }; + + // The code inside blazor.webview.js is meant to be agnostic to specific webview technologies, + // so the following is an adaptor from blazor.webview.js conventions to WebView2 APIs + await _webview.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(@" + window.external = { + sendMessage: message => { + window.chrome.webview.postMessage(message); + }, + receiveMessage: callback => { + window.chrome.webview.addEventListener('message', e => callback(e.data)); + } + }; + ").ConfigureAwait(true); + + _webview.CoreWebView2.WebMessageReceived += (sender, eventArgs) + => MessageReceived(new Uri(eventArgs.Source), eventArgs.TryGetWebMessageAsString()); + } + + private void ApplyDefaultWebViewSettings() + { + // Desktop applications typically don't want the default web browser context menu + _webview.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false; + + // Desktop applications almost never want to show a URL preview when hovering over a link + _webview.CoreWebView2.Settings.IsStatusBarEnabled = false; + + // Desktop applications don't normally want to enable things like "alt-left to go back" + // or "ctrl+f to find". Developers should explicitly opt into allowing these. + // TODO: Create a way of opting back in. + _webview.AcceleratorKeyPressed += (sender, eventArgs) => + { + if (eventArgs.VirtualKey != 0x49) // Allow ctrl+shift+i to open dev tools, at least for now + { + // Note: due to what seems like a bug (https://github.com/MicrosoftEdge/WebView2Feedback/issues/549), + // setting eventArgs.Handled doesn't actually have any effect in WPF, even though it works fine in + // WinForms. Leaving the code here because it's supposedly fixed in a newer version. + eventArgs.Handled = true; + } + }; + } + } +} diff --git a/src/Components/WebView/Platforms/WindowsForms/src/BlazorWebView.cs b/src/Components/WebView/Platforms/WindowsForms/src/BlazorWebView.cs new file mode 100644 index 000000000000..50e94f7f765e --- /dev/null +++ b/src/Components/WebView/Platforms/WindowsForms/src/BlazorWebView.cs @@ -0,0 +1,154 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.IO; +using System.Linq; +using System.Windows.Forms; +using Microsoft.AspNetCore.Components.WebView.WebView2; +using Microsoft.Extensions.FileProviders; +using WebView2Control = Microsoft.Web.WebView2.WinForms.WebView2; + +namespace Microsoft.AspNetCore.Components.WebView.WindowsForms +{ + /// + /// A Windows Forms control for hosting Blazor web components locally in Windows desktop applications. + /// + public sealed class BlazorWebView : Control, IDisposable + { + private WebView2Control _webview; + private WebView2WebViewManager _webviewManager; + + private string _hostPage; + private IServiceProvider _services; + + /// + /// Creates a new instance of . + /// + public BlazorWebView() + { + Dispatcher = new WindowsFormsDispatcher(this); + RootComponents.CollectionChanged += HandleRootComponentsCollectionChanged; + + _webview = new WebView2Control() + { + Dock = DockStyle.Fill, + }; + Controls.Add(_webview); + } + + private WindowsFormsDispatcher Dispatcher { get; } + + /// + protected override void OnCreateControl() + { + base.OnCreateControl(); + + StartWebViewCoreIfPossible(); + } + + /// + /// Path to the host page within the application's static files. For example, wwwroot\index.html. + /// This property must be set to a valid value for the Blazor components to start. + /// + public string HostPage + { + get => _hostPage; + set + { + _hostPage = value; + OnHostPagePropertyChanged(); + } + } + + /// + /// A collection of instances that specify the Blazor types + /// to be used directly in the specified . + /// + public ObservableCollection RootComponents { get; } = new(); + + /// + /// Gets or sets an containing services to be used by this control and also by application code. + /// This property must be set to a valid value for the Blazor components to start. + /// + public IServiceProvider Services + { + get => _services; + set + { + _services = value; + OnServicesPropertyChanged(); + } + } + + private void OnHostPagePropertyChanged() => StartWebViewCoreIfPossible(); + + private void OnServicesPropertyChanged() => StartWebViewCoreIfPossible(); + + private bool RequiredStartupPropertiesSet => + Created && + _webview != null && + HostPage != null && + Services != null; + + private void StartWebViewCoreIfPossible() + { + if (!RequiredStartupPropertiesSet || _webviewManager != null) + { + return; + } + + // We assume the host page is always in the root of the content directory, because it's + // unclear there's any other use case. We can add more options later if so. + var contentRootDir = Path.GetDirectoryName(Path.GetFullPath(HostPage)); + var hostPageRelativePath = Path.GetRelativePath(contentRootDir, HostPage); + var fileProvider = new PhysicalFileProvider(contentRootDir); + + _webviewManager = new WebView2WebViewManager(new WindowsFormsWebView2Wrapper(_webview), Services, Dispatcher, fileProvider, hostPageRelativePath); + foreach (var rootComponent in RootComponents) + { + // Since the page isn't loaded yet, this will always complete synchronously + _ = rootComponent.AddToWebViewManagerAsync(_webviewManager); + } + _webviewManager.Navigate("/"); + } + + private void HandleRootComponentsCollectionChanged(object sender, NotifyCollectionChangedEventArgs eventArgs) + { + // If we haven't initialized yet, this is a no-op + if (_webviewManager != null) + { + // Dispatch because this is going to be async, and we want to catch any errors + _ = Dispatcher.InvokeAsync(async () => + { + var newItems = eventArgs.NewItems.Cast(); + var oldItems = eventArgs.OldItems.Cast(); + + foreach (var item in newItems.Except(oldItems)) + { + await item.AddToWebViewManagerAsync(_webviewManager); + } + + foreach (var item in oldItems.Except(newItems)) + { + await item.RemoveFromWebViewManagerAsync(_webviewManager); + } + }); + } + } + + /// + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (disposing) + { + _webviewManager?.Dispose(); + _webview?.Dispose(); + } + } + } +} diff --git a/src/Components/WebView/Platforms/WindowsForms/src/Microsoft.AspNetCore.Components.WebView.WindowsForms.csproj b/src/Components/WebView/Platforms/WindowsForms/src/Microsoft.AspNetCore.Components.WebView.WindowsForms.csproj new file mode 100644 index 000000000000..78b3a6364383 --- /dev/null +++ b/src/Components/WebView/Platforms/WindowsForms/src/Microsoft.AspNetCore.Components.WebView.WindowsForms.csproj @@ -0,0 +1,17 @@ + + + + $(DefaultNetCoreTargetFramework)-windows + Build Windows Forms applications with Blazor and WebView2. + false + true + + false + + + + + + + + diff --git a/src/Components/WebView/Platforms/WindowsForms/src/RootComponent.cs b/src/Components/WebView/Platforms/WindowsForms/src/RootComponent.cs new file mode 100644 index 000000000000..037ada5ce24f --- /dev/null +++ b/src/Components/WebView/Platforms/WindowsForms/src/RootComponent.cs @@ -0,0 +1,61 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.WebView.WebView2; + +namespace Microsoft.AspNetCore.Components.WebView.WindowsForms +{ + /// + /// Describes a root component that can be added to a . + /// + public class RootComponent + { + /// + /// Constructs an instance of . + /// + /// The CSS selector string that specifies where in the document the component should be placed. This must be unique among the root components within the . + /// The type of the root component. This type must implement . + /// An optional dictionary of parameters to pass to the root component. + public RootComponent(string selector, Type componentType, IDictionary parameters) + { + if (string.IsNullOrWhiteSpace(selector)) + { + throw new ArgumentException($"'{nameof(selector)}' cannot be null or whitespace.", nameof(selector)); + } + + Selector = selector; + ComponentType = componentType ?? throw new ArgumentNullException(nameof(componentType)); + Parameters = parameters; + } + + /// + /// Gets the CSS selector string that specifies where in the document the component should be placed. + /// This must be unique among the root components within the . + /// + public string Selector { get; } + + /// + /// Gets the type of the root component. This type must implement . + /// + public Type ComponentType { get; } + + /// + /// Gets an optional dictionary of parameters to pass to the root component. + /// + public IDictionary Parameters { get; } + + internal Task AddToWebViewManagerAsync(WebViewManager webViewManager) + { + var parameterView = Parameters == null ? ParameterView.Empty : ParameterView.FromDictionary(Parameters); + return webViewManager.AddRootComponentAsync(ComponentType, Selector, parameterView); + } + + internal Task RemoveFromWebViewManagerAsync(WebView2WebViewManager webviewManager) + { + return webviewManager.RemoveRootComponentAsync(Selector); + } + } +} diff --git a/src/Components/WebView/Platforms/WindowsForms/src/RootComponentCollectionExtensions.cs b/src/Components/WebView/Platforms/WindowsForms/src/RootComponentCollectionExtensions.cs new file mode 100644 index 000000000000..a41971e4ef5d --- /dev/null +++ b/src/Components/WebView/Platforms/WindowsForms/src/RootComponentCollectionExtensions.cs @@ -0,0 +1,50 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace Microsoft.AspNetCore.Components.WebView.WindowsForms +{ + /// + /// Provides a set of extension methods for modifying collections of objects. + /// + public static class RootComponentCollectionExtensions + { + /// + /// Adds the component specified by to the collection specified by + /// to be associated with the selector specified by + /// and to be instantiated with the parameters specified by . + /// + /// The to add to the collection. + /// The collection to which the component should be added. + /// The selector to which the component will be associated. + /// The optional creation parameters for the component. + public static void Add(this ObservableCollection components, string selector, IDictionary parameters = null) + where TComponent : IComponent + { + components.Add(new RootComponent(selector, typeof(TComponent), parameters)); + } + + /// + /// Removes the component associated with the specified from the collection + /// specified by . + /// + /// The collection from which the component associated with the selector should be removed. + /// The selector associated with the component to be removed. + public static void Remove(this ObservableCollection components, string selector) + { + for (var i = 0; i < components.Count; i++) + { + if (components[i].Selector.Equals(selector, StringComparison.Ordinal)) + { + components.RemoveAt(i); + return; + } + } + + throw new ArgumentException($"There is no root component with selector '{selector}'.", nameof(selector)); + } + } +} diff --git a/src/Components/WebView/Platforms/WindowsForms/src/WindowsFormsDispatcher.cs b/src/Components/WebView/Platforms/WindowsForms/src/WindowsFormsDispatcher.cs new file mode 100644 index 000000000000..7b33e5f1d119 --- /dev/null +++ b/src/Components/WebView/Platforms/WindowsForms/src/WindowsFormsDispatcher.cs @@ -0,0 +1,137 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Runtime.ExceptionServices; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace Microsoft.AspNetCore.Components.WebView.WindowsForms +{ + /// + /// Dispatcher implementation for Windows Forms that invokes methods on the UI thread. The + /// class uses the async pattern so everything must be mapped from the + /// pattern using techniques listed in https://docs.microsoft.com/dotnet/standard/asynchronous-programming-patterns/interop-with-other-asynchronous-patterns-and-types. + /// + internal class WindowsFormsDispatcher : Dispatcher + { + private static Action RethrowException = exception => + ExceptionDispatchInfo.Capture(exception).Throw(); + private readonly Control _dispatchThreadControl; + + /// + /// Creates a new instance of . + /// + /// A control that was created on the thread from which UI dispatches must + /// occur. This can typically be any control because all controls must have been created on the UI thread to + /// begin with. + public WindowsFormsDispatcher(Control dispatchThreadControl) + { + if (dispatchThreadControl is null) + { + throw new ArgumentNullException(nameof(dispatchThreadControl)); + } + + _dispatchThreadControl = dispatchThreadControl; + } + + public override bool CheckAccess() + => !_dispatchThreadControl.InvokeRequired; + + public override async Task InvokeAsync(Action workItem) + { + try + { + if (CheckAccess()) + { + workItem(); + } + else + { + var asyncResult = _dispatchThreadControl.BeginInvoke(workItem); + await Task.Factory.FromAsync(asyncResult, _dispatchThreadControl.EndInvoke); + } + } + catch (Exception ex) + { + // TODO: Determine whether this is the right kind of rethrowing pattern + // You do have to do something like this otherwise unhandled exceptions + // throw from inside Dispatcher.InvokeAsync are simply lost. + _ = _dispatchThreadControl.BeginInvoke(RethrowException, ex); + throw; + } + } + + public override async Task InvokeAsync(Func workItem) + { + try + { + if (CheckAccess()) + { + await workItem(); + } + else + { + var asyncResult = _dispatchThreadControl.BeginInvoke(workItem); + await Task.Factory.FromAsync(asyncResult, _dispatchThreadControl.EndInvoke); + } + } + catch (Exception ex) + { + // TODO: Determine whether this is the right kind of rethrowing pattern + // You do have to do something like this otherwise unhandled exceptions + // throw from inside Dispatcher.InvokeAsync are simply lost. + _ = _dispatchThreadControl.BeginInvoke(RethrowException, ex); + throw; + } + } + + public override async Task InvokeAsync(Func workItem) + { + try + { + if (CheckAccess()) + { + return workItem(); + } + else + { + var asyncResult = _dispatchThreadControl.BeginInvoke(workItem); + return await Task.Factory.FromAsync(asyncResult, result => (TResult)_dispatchThreadControl.EndInvoke(result)); + } + } + catch (Exception ex) + { + // TODO: Determine whether this is the right kind of rethrowing pattern + // You do have to do something like this otherwise unhandled exceptions + // throw from inside Dispatcher.InvokeAsync are simply lost. + _ = _dispatchThreadControl.BeginInvoke(RethrowException, ex); + throw; + } + } + + public override async Task InvokeAsync(Func> workItem) + { + try + { + if (CheckAccess()) + { + return await workItem(); + } + else + { + var asyncResult = _dispatchThreadControl.BeginInvoke(workItem); + return await Task.Factory.FromAsync(asyncResult, result => (TResult)_dispatchThreadControl.EndInvoke(result)); + } + } + catch (Exception ex) + { + // TODO: Determine whether this is the right kind of rethrowing pattern + // You do have to do something like this otherwise unhandled exceptions + // throw from inside Dispatcher.InvokeAsync are simply lost. + _ = _dispatchThreadControl.BeginInvoke(RethrowException, ex); + throw; + } + } + } +} diff --git a/src/Components/WebView/Platforms/WindowsForms/src/WindowsFormsWebView2Wrapper.cs b/src/Components/WebView/Platforms/WindowsForms/src/WindowsFormsWebView2Wrapper.cs new file mode 100644 index 000000000000..6459ea4cedd7 --- /dev/null +++ b/src/Components/WebView/Platforms/WindowsForms/src/WindowsFormsWebView2Wrapper.cs @@ -0,0 +1,45 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.WebView.WebView2; +using Microsoft.Web.WebView2.Core; +using WebView2Control = Microsoft.Web.WebView2.WinForms.WebView2; + +namespace Microsoft.AspNetCore.Components.WebView.WindowsForms +{ + internal class WindowsFormsWebView2Wrapper : IWebView2Wrapper + { + private readonly WebView2Control _webView2; + + public WindowsFormsWebView2Wrapper(WebView2Control webView2) + { + if (webView2 is null) + { + throw new ArgumentNullException(nameof(webView2)); + } + + _webView2 = webView2; + } + + public CoreWebView2 CoreWebView2 => _webView2.CoreWebView2; + + public Uri Source + { + get => _webView2.Source; + set => _webView2.Source = value; + } + + public event EventHandler AcceleratorKeyPressed + { + add => _webView2.AcceleratorKeyPressed += value; + remove => _webView2.AcceleratorKeyPressed -= value; + } + + public Task EnsureCoreWebView2Async(CoreWebView2Environment environment = null) + { + return _webView2.EnsureCoreWebView2Async(environment); + } + } +} diff --git a/src/Components/WebView/Platforms/Wpf/src/BlazorWebView.cs b/src/Components/WebView/Platforms/Wpf/src/BlazorWebView.cs new file mode 100644 index 000000000000..b344d12c4594 --- /dev/null +++ b/src/Components/WebView/Platforms/Wpf/src/BlazorWebView.cs @@ -0,0 +1,194 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.IO; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using Microsoft.AspNetCore.Components.WebView.WebView2; +using Microsoft.Extensions.FileProviders; +using WebView2Control = Microsoft.Web.WebView2.Wpf.WebView2; + +namespace Microsoft.AspNetCore.Components.WebView.Wpf +{ + /// + /// A Windows Presentation Foundation (WPF) control for hosting Blazor web components locally in Windows desktop applications. + /// + public sealed class BlazorWebView : Control, IDisposable + { + #region Dependency property definitions + /// + /// The backing store for the property. + /// + public static readonly DependencyProperty HostPageProperty = DependencyProperty.Register( + name: nameof(HostPage), + propertyType: typeof(string), + ownerType: typeof(BlazorWebView), + typeMetadata: new PropertyMetadata(OnHostPagePropertyChanged)); + + /// + /// The backing store for the property. + /// + public static readonly DependencyProperty RootComponentsProperty = DependencyProperty.Register( + name: nameof(RootComponents), + propertyType: typeof(ObservableCollection), + ownerType: typeof(BlazorWebView)); + + /// + /// The backing store for the property. + /// + public static readonly DependencyProperty ServicesProperty = DependencyProperty.Register( + name: nameof(Services), + propertyType: typeof(IServiceProvider), + ownerType: typeof(BlazorWebView), + typeMetadata: new PropertyMetadata(OnServicesPropertyChanged)); + #endregion + + private const string webViewTemplateChildName = "WebView"; + private WebView2Control _webview; + private WebView2WebViewManager _webviewManager; + + /// + /// Creates a new instance of . + /// + public BlazorWebView() + { + SetValue(RootComponentsProperty, new ObservableCollection()); + RootComponents.CollectionChanged += HandleRootComponentsCollectionChanged; + + Template = new ControlTemplate + { + VisualTree = new FrameworkElementFactory(typeof(WebView2Control), webViewTemplateChildName) + }; + + // TODO: Implement correct WPF disposal pattern, if this isn't already it + Unloaded += (sender, eventArgs) => Dispose(); + Application.Current.Exit += HandleApplicationExiting; + } + + /// + /// Path to the host page within the application's static files. For example, wwwroot\index.html. + /// This property must be set to a valid value for the Blazor components to start. + /// + public string HostPage + { + get => (string)GetValue(HostPageProperty); + set => SetValue(HostPageProperty, value); + } + + /// + /// A collection of instances that specify the Blazor types + /// to be used directly in the specified . + /// + public ObservableCollection RootComponents => + (ObservableCollection)GetValue(RootComponentsProperty); + + /// + /// Gets or sets an containing services to be used by this control and also by application code. + /// This property must be set to a valid value for the Blazor components to start. + /// + public IServiceProvider Services + { + get => (IServiceProvider)GetValue(ServicesProperty); + set => SetValue(ServicesProperty, value); + } + + private static void OnServicesPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) => ((BlazorWebView)d).OnServicesPropertyChanged(e); + + private void OnServicesPropertyChanged(DependencyPropertyChangedEventArgs e) => StartWebViewCoreIfPossible(); + + private static void OnHostPagePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) => ((BlazorWebView)d).OnHostPagePropertyChanged(e); + + private void OnHostPagePropertyChanged(DependencyPropertyChangedEventArgs e) => StartWebViewCoreIfPossible(); + + private bool RequiredStartupPropertiesSet => + _webview != null && + HostPage != null && + Services != null; + + /// + public override void OnApplyTemplate() + { + // Called when the control is created after its child control (the WebView2) is created from the Template property + base.OnApplyTemplate(); + + if (_webview == null) + { + _webview = (WebView2Control)GetTemplateChild(webViewTemplateChildName); + StartWebViewCoreIfPossible(); + } + } + + /// + protected override void OnInitialized(EventArgs e) + { + // Called when BeginInit/EndInit are used, such as when creating the control from XAML + base.OnInitialized(e); + StartWebViewCoreIfPossible(); + } + + private void StartWebViewCoreIfPossible() + { + if (!RequiredStartupPropertiesSet || _webviewManager != null) + { + return; + } + + // We assume the host page is always in the root of the content directory, because it's + // unclear there's any other use case. We can add more options later if so. + var contentRootDir = Path.GetDirectoryName(Path.GetFullPath(HostPage)); + var hostPageRelativePath = Path.GetRelativePath(contentRootDir, HostPage); + var fileProvider = new PhysicalFileProvider(contentRootDir); + + _webviewManager = new WebView2WebViewManager(new WpfWeb2ViewWrapper(_webview), Services, WpfDispatcher.Instance, fileProvider, hostPageRelativePath); + foreach (var rootComponent in RootComponents) + { + // Since the page isn't loaded yet, this will always complete synchronously + _ = rootComponent.AddToWebViewManagerAsync(_webviewManager); + } + _webviewManager.Navigate("/"); + } + + private void HandleRootComponentsCollectionChanged(object sender, NotifyCollectionChangedEventArgs eventArgs) + { + // If we haven't initialized yet, this is a no-op + if (_webviewManager != null) + { + // Dispatch because this is going to be async, and we want to catch any errors + WpfDispatcher.Instance.InvokeAsync(async () => + { + var newItems = eventArgs.OldItems.Cast(); + var oldItems = eventArgs.NewItems.Cast(); + + foreach (var item in newItems.Except(oldItems)) + { + await item.AddToWebViewManagerAsync(_webviewManager); + } + + foreach (var item in oldItems.Except(newItems)) + { + await item.RemoveFromWebViewManagerAsync(_webviewManager); + } + }); + } + } + + private void HandleApplicationExiting(object sender, ExitEventArgs e) + { + Dispose(); + } + + /// + /// Releases all resources used by the control. + /// + public void Dispose() + { + Application.Current.Exit -= HandleApplicationExiting; + _webviewManager?.Dispose(); + _webview?.Dispose(); + } + } +} diff --git a/src/Components/WebView/Platforms/Wpf/src/Microsoft.AspNetCore.Components.WebView.Wpf.csproj b/src/Components/WebView/Platforms/Wpf/src/Microsoft.AspNetCore.Components.WebView.Wpf.csproj new file mode 100644 index 000000000000..863a055c0d02 --- /dev/null +++ b/src/Components/WebView/Platforms/Wpf/src/Microsoft.AspNetCore.Components.WebView.Wpf.csproj @@ -0,0 +1,17 @@ + + + + $(DefaultNetCoreTargetFramework)-windows + Build WPF applications with Blazor and WebView2. + false + true + + false + + + + + + + + diff --git a/src/Components/WebView/Platforms/Wpf/src/RootComponent.cs b/src/Components/WebView/Platforms/Wpf/src/RootComponent.cs new file mode 100644 index 000000000000..efac2b40cfca --- /dev/null +++ b/src/Components/WebView/Platforms/Wpf/src/RootComponent.cs @@ -0,0 +1,57 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.WebView.WebView2; + +namespace Microsoft.AspNetCore.Components.WebView.Wpf +{ + /// + /// Describes a root component that can be added to a . + /// + public class RootComponent + { + /// + /// Gets or sets the CSS selector string that specifies where in the document the component should be placed. + /// This must be unique among the root components within the . + /// + public string Selector { get; set; } + + /// + /// Gets or sets the type of the root component. This type must implement . + /// + public Type ComponentType { get; set; } + + /// + /// Gets or sets an optional dictionary of parameters to pass to the root component. + /// + public IDictionary Parameters { get; set; } + + internal Task AddToWebViewManagerAsync(WebViewManager webViewManager) + { + // As a characteristic of XAML,we can't rely on non-default constructors. So we have to + // validate that the required properties were set. We could skip validating this and allow + // the lower-level renderer code to throw, but that would be harder for developers to understand. + + if (string.IsNullOrWhiteSpace(Selector)) + { + throw new InvalidOperationException($"{nameof(RootComponent)} requires a value for its {nameof(Selector)} property, but no value was set."); + } + + if (ComponentType is null) + { + throw new InvalidOperationException($"{nameof(RootComponent)} requires a value for its {nameof(ComponentType)} property, but no value was set."); + } + + var parameterView = Parameters == null ? ParameterView.Empty : ParameterView.FromDictionary(Parameters); + return webViewManager.AddRootComponentAsync(ComponentType, Selector, parameterView); + } + + internal Task RemoveFromWebViewManagerAsync(WebView2WebViewManager webviewManager) + { + return webviewManager.RemoveRootComponentAsync(Selector); + } + } +} diff --git a/src/Components/WebView/Platforms/Wpf/src/WpfDispatcher.cs b/src/Components/WebView/Platforms/Wpf/src/WpfDispatcher.cs new file mode 100644 index 000000000000..f71c8b6f204c --- /dev/null +++ b/src/Components/WebView/Platforms/Wpf/src/WpfDispatcher.cs @@ -0,0 +1,113 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Runtime.ExceptionServices; +using System.Threading.Tasks; +using static System.Windows.Threading.Dispatcher; + +namespace Microsoft.AspNetCore.Components.WebView.Wpf +{ + internal class WpfDispatcher : Dispatcher + { + public static Dispatcher Instance { get; } = new WpfDispatcher(); + + private static Action RethrowException = exception => + ExceptionDispatchInfo.Capture(exception).Throw(); + + public override bool CheckAccess() + => CurrentDispatcher.CheckAccess(); + + public override async Task InvokeAsync(Action workItem) + { + try + { + if (CurrentDispatcher.CheckAccess()) + { + workItem(); + } + else + { + await CurrentDispatcher.InvokeAsync(workItem); + } + } + catch (Exception ex) + { + // TODO: Determine whether this is the right kind of rethrowing pattern + // You do have to do something like this otherwise unhandled exceptions + // throw from inside Dispatcher.InvokeAsync are simply lost. + _ = CurrentDispatcher.BeginInvoke(RethrowException, ex); + throw; + } + } + + public override async Task InvokeAsync(Func workItem) + { + try + { + if (CurrentDispatcher.CheckAccess()) + { + await workItem(); + } + else + { + await CurrentDispatcher.InvokeAsync(workItem); + } + } + catch (Exception ex) + { + // TODO: Determine whether this is the right kind of rethrowing pattern + // You do have to do something like this otherwise unhandled exceptions + // throw from inside Dispatcher.InvokeAsync are simply lost. + _ = CurrentDispatcher.BeginInvoke(RethrowException, ex); + throw; + } + } + + public override async Task InvokeAsync(Func workItem) + { + try + { + if (CurrentDispatcher.CheckAccess()) + { + return workItem(); + } + else + { + return await CurrentDispatcher.InvokeAsync(workItem); + } + } + catch (Exception ex) + { + // TODO: Determine whether this is the right kind of rethrowing pattern + // You do have to do something like this otherwise unhandled exceptions + // throw from inside Dispatcher.InvokeAsync are simply lost. + _ = CurrentDispatcher.BeginInvoke(RethrowException, ex); + throw; + } + } + + public override async Task InvokeAsync(Func> workItem) + { + try + { + if (CurrentDispatcher.CheckAccess()) + { + return await workItem(); + } + else + { + return await CurrentDispatcher.InvokeAsync(workItem).Task.Unwrap(); + } + } + catch (Exception ex) + { + // TODO: Determine whether this is the right kind of rethrowing pattern + // You do have to do something like this otherwise unhandled exceptions + // throw from inside Dispatcher.InvokeAsync are simply lost. + _ = CurrentDispatcher.BeginInvoke(RethrowException, ex); + throw; + } + } + } +} diff --git a/src/Components/WebView/Platforms/Wpf/src/WpfWebView2Wrapper.cs b/src/Components/WebView/Platforms/Wpf/src/WpfWebView2Wrapper.cs new file mode 100644 index 000000000000..3ec16a7d40b5 --- /dev/null +++ b/src/Components/WebView/Platforms/Wpf/src/WpfWebView2Wrapper.cs @@ -0,0 +1,48 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.WebView.WebView2; +using Microsoft.Web.WebView2.Core; +using WebView2Control = Microsoft.Web.WebView2.Wpf.WebView2; + +namespace Microsoft.AspNetCore.Components.WebView.Wpf +{ + internal class WpfWeb2ViewWrapper : IWebView2Wrapper + { + private readonly WebView2Control _webView2; + private bool _hasInitialized; + + public WpfWeb2ViewWrapper(WebView2Control webView2) + { + _webView2 = webView2 ?? throw new ArgumentNullException(nameof(webView2)); + } + + public CoreWebView2 CoreWebView2 => _webView2.CoreWebView2; + + public Uri Source + { + get => _webView2.Source; + set => _webView2.Source = value; + } + + public event EventHandler AcceleratorKeyPressed + { + add => _webView2.AcceleratorKeyPressed += value; + remove => _webView2.AcceleratorKeyPressed -= value; + } + + public Task EnsureCoreWebView2Async(CoreWebView2Environment environment = null) + { + if (_hasInitialized) + { + // We don't want people to think they can set more than one environment + throw new InvalidOperationException($"{nameof(EnsureCoreWebView2Async)} may only be called once per control."); + } + + _hasInitialized = true; + return _webView2.EnsureCoreWebView2Async(environment); + } + } +} diff --git a/src/Components/WebView/Samples/BlazorWinFormsApp/BlazorWinFormsApp.csproj b/src/Components/WebView/Samples/BlazorWinFormsApp/BlazorWinFormsApp.csproj new file mode 100644 index 000000000000..cafb88085e68 --- /dev/null +++ b/src/Components/WebView/Samples/BlazorWinFormsApp/BlazorWinFormsApp.csproj @@ -0,0 +1,20 @@ + + + + $(DefaultNetCoreTargetFramework)-windows + WinExe + true + false + + + + + + + + + PreserveNewest + + + + diff --git a/src/Components/WebView/Samples/BlazorWinFormsApp/Form1.cs b/src/Components/WebView/Samples/BlazorWinFormsApp/Form1.cs new file mode 100644 index 000000000000..0ea3d353ac16 --- /dev/null +++ b/src/Components/WebView/Samples/BlazorWinFormsApp/Form1.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Components.WebView.WindowsForms; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Windows.Forms; + +namespace BlazorWinFormsApp +{ + public partial class Form1 : Form + { + //private readonly AppState _appState; + + public Form1() + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddBlazorWebView(); + InitializeComponent(); + + blazorWebView1.HostPage = @"wwwroot\index.html"; + blazorWebView1.Services = serviceCollection.BuildServiceProvider(); + blazorWebView1.RootComponents.Add
("#app"); + } + + private void button1_Click(object sender, EventArgs e) + { + MessageBox.Show($"Current counter value is ", "Counter Value"); + } + } +} diff --git a/src/Components/WebView/Samples/BlazorWinFormsApp/Form1.designer.cs b/src/Components/WebView/Samples/BlazorWinFormsApp/Form1.designer.cs new file mode 100644 index 000000000000..068eda4c345b --- /dev/null +++ b/src/Components/WebView/Samples/BlazorWinFormsApp/Form1.designer.cs @@ -0,0 +1,102 @@ +namespace BlazorWinFormsApp +{ + partial class Form1 + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.groupBox1 = new System.Windows.Forms.GroupBox(); + this.label1 = new System.Windows.Forms.Label(); + this.button1 = new System.Windows.Forms.Button(); + this.blazorWebView1 = new Microsoft.AspNetCore.Components.WebView.WindowsForms.BlazorWebView(); + this.groupBox1.SuspendLayout(); + this.SuspendLayout(); + // + // groupBox1 + // + this.groupBox1.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.groupBox1.Controls.Add(this.label1); + this.groupBox1.Controls.Add(this.button1); + this.groupBox1.Location = new System.Drawing.Point(13, 13); + this.groupBox1.Name = "groupBox1"; + this.groupBox1.Size = new System.Drawing.Size(775, 162); + this.groupBox1.TabIndex = 0; + this.groupBox1.TabStop = false; + this.groupBox1.Text = "Native Windows Forms UI"; + // + // label1 + // + this.label1.AutoSize = true; + this.label1.Location = new System.Drawing.Point(28, 68); + this.label1.Name = "label1"; + this.label1.Size = new System.Drawing.Size(157, 32); + this.label1.TabIndex = 1; + this.label1.Text = "This is a label"; + // + // button1 + // + this.button1.Location = new System.Drawing.Point(364, 68); + this.button1.Name = "button1"; + this.button1.Size = new System.Drawing.Size(342, 46); + this.button1.TabIndex = 0; + this.button1.Text = "&Click to see counter value"; + this.button1.UseVisualStyleBackColor = true; + this.button1.Click += new System.EventHandler(this.button1_Click); + // + // blazorWebView1 + // + this.blazorWebView1.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.blazorWebView1.Location = new System.Drawing.Point(13, 182); + this.blazorWebView1.Name = "blazorWebView1"; + this.blazorWebView1.Size = new System.Drawing.Size(775, 256); + this.blazorWebView1.TabIndex = 1; + // + // Form1 + // + this.AutoScaleDimensions = new System.Drawing.SizeF(13F, 32F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(800, 450); + this.Controls.Add(this.blazorWebView1); + this.Controls.Add(this.groupBox1); + this.Name = "Form1"; + this.Text = "Blazor Web in Windows Forms"; + this.groupBox1.ResumeLayout(false); + this.groupBox1.PerformLayout(); + this.ResumeLayout(false); + + } + + #endregion + + private System.Windows.Forms.GroupBox groupBox1; + private System.Windows.Forms.Label label1; + private System.Windows.Forms.Button button1; + private Microsoft.AspNetCore.Components.WebView.WindowsForms.BlazorWebView blazorWebView1; + } +} diff --git a/src/Components/WebView/Samples/BlazorWinFormsApp/Main.razor b/src/Components/WebView/Samples/BlazorWinFormsApp/Main.razor new file mode 100644 index 000000000000..b9fe989af765 --- /dev/null +++ b/src/Components/WebView/Samples/BlazorWinFormsApp/Main.razor @@ -0,0 +1,12 @@ + + + Home | + Other +
+ +
+ +

Not found

+

Sorry, there's nothing here.

+
+
diff --git a/src/Components/WebView/Samples/BlazorWinFormsApp/Pages/Index.razor b/src/Components/WebView/Samples/BlazorWinFormsApp/Pages/Index.razor new file mode 100644 index 000000000000..304ae1774bfb --- /dev/null +++ b/src/Components/WebView/Samples/BlazorWinFormsApp/Pages/Index.razor @@ -0,0 +1,22 @@ +@page "/" + +

Hello, world!

+ +

The current count is @count

+ + + + +@code { + int count; + + void IncrementCount() + { + count++; + } + + void TriggerException() + { + throw new InvalidTimeZoneException("This is an exception from an event handler"); + } +} diff --git a/src/Components/WebView/Samples/BlazorWinFormsApp/Pages/Other.razor b/src/Components/WebView/Samples/BlazorWinFormsApp/Pages/Other.razor new file mode 100644 index 000000000000..a840a595f9a9 --- /dev/null +++ b/src/Components/WebView/Samples/BlazorWinFormsApp/Pages/Other.razor @@ -0,0 +1,13 @@ +@page "/other" +@inject NavigationManager NavigationManager + +Here is another page. Looks like navigation works. + + + +@code { + void BackToHome() + { + NavigationManager.NavigateTo(""); + } +} diff --git a/src/Components/WebView/Samples/BlazorWinFormsApp/Program.cs b/src/Components/WebView/Samples/BlazorWinFormsApp/Program.cs new file mode 100644 index 000000000000..bf14982f3db1 --- /dev/null +++ b/src/Components/WebView/Samples/BlazorWinFormsApp/Program.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace BlazorWinFormsApp +{ + static class Program + { + /// + /// The main entry point for the application. + /// + [STAThread] + static void Main() + { + AppDomain.CurrentDomain.UnhandledException += (sender, error) => + { + MessageBox.Show(text: error.ExceptionObject.ToString(), caption: "Error"); + }; + + Application.SetHighDpiMode(HighDpiMode.SystemAware); + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.Run(new Form1()); + } + } +} diff --git a/src/Components/WebView/Samples/BlazorWinFormsApp/_Imports.razor b/src/Components/WebView/Samples/BlazorWinFormsApp/_Imports.razor new file mode 100644 index 000000000000..6ba5da5dbac6 --- /dev/null +++ b/src/Components/WebView/Samples/BlazorWinFormsApp/_Imports.razor @@ -0,0 +1,7 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop diff --git a/src/Components/WebView/Samples/BlazorWinFormsApp/wwwroot/css/app.css b/src/Components/WebView/Samples/BlazorWinFormsApp/wwwroot/css/app.css new file mode 100644 index 000000000000..4f895ce71e4d --- /dev/null +++ b/src/Components/WebView/Samples/BlazorWinFormsApp/wwwroot/css/app.css @@ -0,0 +1,18 @@ +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + +#blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; +} diff --git a/src/Components/WebView/Samples/BlazorWinFormsApp/wwwroot/index.html b/src/Components/WebView/Samples/BlazorWinFormsApp/wwwroot/index.html new file mode 100644 index 000000000000..ebfb7123838b --- /dev/null +++ b/src/Components/WebView/Samples/BlazorWinFormsApp/wwwroot/index.html @@ -0,0 +1,24 @@ + + + + + + + Blazor WinForms app + + + + + +
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + + diff --git a/src/Components/WebView/Samples/BlazorWpfApp/App.xaml b/src/Components/WebView/Samples/BlazorWpfApp/App.xaml new file mode 100644 index 000000000000..7cf33e4771f6 --- /dev/null +++ b/src/Components/WebView/Samples/BlazorWpfApp/App.xaml @@ -0,0 +1,10 @@ + + + + + diff --git a/src/Components/WebView/Samples/BlazorWpfApp/App.xaml.cs b/src/Components/WebView/Samples/BlazorWpfApp/App.xaml.cs new file mode 100644 index 000000000000..69a54128c91c --- /dev/null +++ b/src/Components/WebView/Samples/BlazorWpfApp/App.xaml.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Windows; + +namespace BlazorWpfApp +{ + /// + /// Interaction logic for App.xaml + /// + public partial class App : Application + { + private void Application_Startup(object sender, StartupEventArgs e) + { + AppDomain.CurrentDomain.UnhandledException += (sender, error) => + { + MessageBox.Show(error.ExceptionObject.ToString(), "Error", MessageBoxButton.OK, MessageBoxImage.Error); + }; + } + } +} diff --git a/src/Components/WebView/Samples/BlazorWpfApp/BlazorWpfApp.csproj b/src/Components/WebView/Samples/BlazorWpfApp/BlazorWpfApp.csproj new file mode 100644 index 000000000000..7aee04cb06ac --- /dev/null +++ b/src/Components/WebView/Samples/BlazorWpfApp/BlazorWpfApp.csproj @@ -0,0 +1,20 @@ + + + + $(DefaultNetCoreTargetFramework)-windows + WinExe + true + false + + + + + + + + + PreserveNewest + + + + diff --git a/src/Components/WebView/Samples/BlazorWpfApp/Main.razor b/src/Components/WebView/Samples/BlazorWpfApp/Main.razor new file mode 100644 index 000000000000..b9fe989af765 --- /dev/null +++ b/src/Components/WebView/Samples/BlazorWpfApp/Main.razor @@ -0,0 +1,12 @@ + + + Home | + Other +
+ +
+ +

Not found

+

Sorry, there's nothing here.

+
+
diff --git a/src/Components/WebView/Samples/BlazorWpfApp/MainWindow.xaml b/src/Components/WebView/Samples/BlazorWpfApp/MainWindow.xaml new file mode 100644 index 000000000000..7385b25d4b3c --- /dev/null +++ b/src/Components/WebView/Samples/BlazorWpfApp/MainWindow.xaml @@ -0,0 +1,20 @@ + + + + + + + + + + + + diff --git a/src/Components/WebView/Samples/BlazorWpfApp/MainWindow.xaml.cs b/src/Components/WebView/Samples/BlazorWpfApp/MainWindow.xaml.cs new file mode 100644 index 000000000000..a8a22d1d8f87 --- /dev/null +++ b/src/Components/WebView/Samples/BlazorWpfApp/MainWindow.xaml.cs @@ -0,0 +1,27 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Windows; +using Microsoft.Extensions.DependencyInjection; + +namespace BlazorWpfApp +{ + /// + /// Interaction logic for MainWindow.xaml + /// + public partial class MainWindow : Window + { + public MainWindow() + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddBlazorWebView(); + Resources.Add("services", serviceCollection.BuildServiceProvider()); + + InitializeComponent(); + } + } + + // Workaround for compiler error "error MC3050: Cannot find the type 'local:Main'" + // It seems that, although WPF's design-time build can see Razor components, its runtime build cannot. + public partial class Main { } +} diff --git a/src/Components/WebView/Samples/BlazorWpfApp/Pages/Index.razor b/src/Components/WebView/Samples/BlazorWpfApp/Pages/Index.razor new file mode 100644 index 000000000000..304ae1774bfb --- /dev/null +++ b/src/Components/WebView/Samples/BlazorWpfApp/Pages/Index.razor @@ -0,0 +1,22 @@ +@page "/" + +

Hello, world!

+ +

The current count is @count

+ + + + +@code { + int count; + + void IncrementCount() + { + count++; + } + + void TriggerException() + { + throw new InvalidTimeZoneException("This is an exception from an event handler"); + } +} diff --git a/src/Components/WebView/Samples/BlazorWpfApp/Pages/Other.razor b/src/Components/WebView/Samples/BlazorWpfApp/Pages/Other.razor new file mode 100644 index 000000000000..a840a595f9a9 --- /dev/null +++ b/src/Components/WebView/Samples/BlazorWpfApp/Pages/Other.razor @@ -0,0 +1,13 @@ +@page "/other" +@inject NavigationManager NavigationManager + +Here is another page. Looks like navigation works. + + + +@code { + void BackToHome() + { + NavigationManager.NavigateTo(""); + } +} diff --git a/src/Components/WebView/Samples/BlazorWpfApp/_Imports.razor b/src/Components/WebView/Samples/BlazorWpfApp/_Imports.razor new file mode 100644 index 000000000000..6ba5da5dbac6 --- /dev/null +++ b/src/Components/WebView/Samples/BlazorWpfApp/_Imports.razor @@ -0,0 +1,7 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop diff --git a/src/Components/WebView/Samples/BlazorWpfApp/wwwroot/css/app.css b/src/Components/WebView/Samples/BlazorWpfApp/wwwroot/css/app.css new file mode 100644 index 000000000000..4f895ce71e4d --- /dev/null +++ b/src/Components/WebView/Samples/BlazorWpfApp/wwwroot/css/app.css @@ -0,0 +1,18 @@ +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + +#blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; +} diff --git a/src/Components/WebView/Samples/BlazorWpfApp/wwwroot/index.html b/src/Components/WebView/Samples/BlazorWpfApp/wwwroot/index.html new file mode 100644 index 000000000000..b3e976b666c1 --- /dev/null +++ b/src/Components/WebView/Samples/BlazorWpfApp/wwwroot/index.html @@ -0,0 +1,24 @@ + + + + + + + Blazor WPF app + + + + + +
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + + diff --git a/src/Components/WebView/WebView/src/ComponentsWebViewServiceCollectionExtensions.cs b/src/Components/WebView/WebView/src/ComponentsWebViewServiceCollectionExtensions.cs new file mode 100644 index 000000000000..c627c279b1c4 --- /dev/null +++ b/src/Components/WebView/WebView/src/ComponentsWebViewServiceCollectionExtensions.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Routing; +using Microsoft.AspNetCore.Components.WebView.Services; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.JSInterop; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Extensions for adding component webview services to the . + /// + public static class ComponentsWebViewServiceCollectionExtensions + { + /// + /// Adds component webview services to the collection. + /// + /// The to add the component webview services to. + /// + public static IServiceCollection AddBlazorWebView(this IServiceCollection services) + { + services.AddLogging(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + return services; + } + } +} diff --git a/src/Components/WebView/WebView/src/Directory.Build.targets b/src/Components/WebView/WebView/src/Directory.Build.targets new file mode 100644 index 000000000000..09953b9b6c9d --- /dev/null +++ b/src/Components/WebView/WebView/src/Directory.Build.targets @@ -0,0 +1,6 @@ + + + + + diff --git a/src/Components/WebView/WebView/src/IpcCommon.cs b/src/Components/WebView/WebView/src/IpcCommon.cs new file mode 100644 index 000000000000..475cd8e6b24b --- /dev/null +++ b/src/Components/WebView/WebView/src/IpcCommon.cs @@ -0,0 +1,75 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using System.Text.Json; + +namespace Microsoft.AspNetCore.Components.WebView +{ + internal class IpcCommon + { + private const string _ipcMessagePrefix = "__bwv:"; + + public static string Serialize(OutgoingMessageType messageType, params object[] args) + => Serialize(messageType.ToString(), args); + + public static string Serialize(IncomingMessageType messageType, params object[] args) + => Serialize(messageType.ToString(), args); + + public static bool TryDeserializeIncoming(string message, out IncomingMessageType messageType, out ArraySegment args) + => TryDeserialize(message, out messageType, out args); + + public static bool TryDeserializeOutgoing(string message, out OutgoingMessageType messageType, out ArraySegment args) + => TryDeserialize(message, out messageType, out args); + + private static string Serialize(string messageType, object[] args) + { + // We could come up with something a little more low-level here if we + // wanted to avoid a couple of allocations + var messageTypeAndArgs = args.Prepend(messageType); + return $"{_ipcMessagePrefix}{JsonSerializer.Serialize(messageTypeAndArgs, JsonSerializerOptionsProvider.Options)}"; + } + + private static bool TryDeserialize(string message, out T messageType, out ArraySegment args) + { + // We don't want to get confused by unrelated messages that the developer is sending + // over the same webview IPC channel, so ignore anything else + if (message != null && message.StartsWith(_ipcMessagePrefix, StringComparison.Ordinal)) + { + var messageAfterPrefix = message.AsSpan(_ipcMessagePrefix.Length); + var parsed = (JsonElement[])JsonSerializer.Deserialize(messageAfterPrefix, typeof(JsonElement[]), JsonSerializerOptionsProvider.Options); + messageType = (T)Enum.Parse(typeof(T), parsed[0].GetString()); + args = new ArraySegment(parsed, 1, parsed.Length - 1); + return true; + } + else + { + messageType = default; + args = default; + return false; + } + } + + public enum IncomingMessageType + { + AttachPage, + BeginInvokeDotNet, + EndInvokeJS, + DispatchBrowserEvent, + OnRenderCompleted, + OnLocationChanged, + } + + public enum OutgoingMessageType + { + RenderBatch, + Navigate, + AttachToDocument, + DetachFromDocument, + EndInvokeDotNet, + NotifyUnhandledException, + BeginInvokeJS, + } + } +} diff --git a/src/Components/WebView/WebView/src/IpcReceiver.cs b/src/Components/WebView/WebView/src/IpcReceiver.cs new file mode 100644 index 000000000000..ebea13922f98 --- /dev/null +++ b/src/Components/WebView/WebView/src/IpcReceiver.cs @@ -0,0 +1,122 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.JSInterop.Infrastructure; + +// Sync vs Async APIs for this. +// This mainly depends on the underlying support for the browser. Assuming that there is no synchronous API +// communication is safer, since it's not guaranteed. +// In that scenario, some APIs need to expose the async nature of the communication. That happens when some +// component like the renderer needs to know the results of the operation. For example when updating the UI +// since more code needs to execute afterwards. +// In other cases like when we try to attach a component to the document, we don't necessarily need to do that +// since we only care about errors that might happen while attaching the component and the renderer doesn't +// necessarily need to know about those if we are terminating the component/host as a result. +// If we decide we need to expose the async nature of the communication channel, then we will need to keep track +// of all the message pairs/completions across the IPC channel. +namespace Microsoft.AspNetCore.Components.WebView +{ + // These are all the messages .NET Host needs to know how to receive from JS + + // This class is a "Proxy" or "front-controller" for the incoming messages from the Browser via the transport channel. + // It receives messages on OnMessageReceived, interprets the payload and dispatches them to the appropriate method + internal class IpcReceiver + { + private readonly Func _onAttachMessage; + + public IpcReceiver(Func onAttachMessage) + { + _onAttachMessage = onAttachMessage; + } + + public async Task OnMessageReceivedAsync(PageContext pageContext, string message) + { + // Ignore other messages as they may be unrelated to Blazor WebView + if (IpcCommon.TryDeserializeIncoming(message, out var messageType, out var args)) + { + if (messageType == IpcCommon.IncomingMessageType.AttachPage) + { + await _onAttachMessage(args[0].GetString(), args[1].GetString()); + return; + } + + // For any other message, you have to have a page attached already + if (pageContext == null) + { + // TODO: Should we just ignore these messages? Is there any way their delivery + // might be delayed until after a page has detached? + throw new InvalidOperationException("Cannot receive IPC messages when no page is attached"); + } + + switch (messageType) + { + case IpcCommon.IncomingMessageType.BeginInvokeDotNet: + BeginInvokeDotNet(pageContext, args[0].GetString(), args[1].GetString(), args[2].GetString(), args[3].GetInt64(), args[4].GetString()); + break; + case IpcCommon.IncomingMessageType.EndInvokeJS: + EndInvokeJS(pageContext, args[0].GetInt64(), args[1].GetBoolean(), args[2].GetString()); + break; + case IpcCommon.IncomingMessageType.DispatchBrowserEvent: + await DispatchBrowserEventAsync(pageContext, args[0].GetRawText(), args[1].GetRawText()); + break; + case IpcCommon.IncomingMessageType.OnRenderCompleted: + OnRenderCompleted(pageContext, args[0].GetInt64(), args[1].GetString()); + break; + case IpcCommon.IncomingMessageType.OnLocationChanged: + OnLocationChanged(pageContext, args[0].GetString(), args[1].GetBoolean()); + break; + default: + throw new InvalidOperationException($"Unknown message type '{messageType}'."); + } + } + } + + private void BeginInvokeDotNet(PageContext pageContext, string callId, string assemblyName, string methodIdentifier, long dotNetObjectId, string argsJson) + { + DotNetDispatcher.BeginInvokeDotNet( + pageContext.JSRuntime, + new DotNetInvocationInfo(assemblyName, methodIdentifier, dotNetObjectId, callId), + argsJson); + } + + private void EndInvokeJS(PageContext pageContext, long asyncHandle, bool succeeded, string argumentsOrError) + { + if (succeeded) + { + DotNetDispatcher.EndInvokeJS(pageContext.JSRuntime, argumentsOrError); + } + else + { + throw new InvalidOperationException(argumentsOrError); + } + } + + private Task DispatchBrowserEventAsync(PageContext pageContext, string eventDescriptor, string eventArgs) + { + var renderer = pageContext.Renderer; + var webEventData = WebEventData.Parse(renderer, eventDescriptor, eventArgs); + return renderer.DispatchEventAsync( + webEventData.EventHandlerId, + webEventData.EventFieldInfo, + webEventData.EventArgs); + } + + private void OnRenderCompleted(PageContext pageContext, long batchId, string errorMessageOrNull) + { + if (errorMessageOrNull != null) + { + throw new InvalidOperationException(errorMessageOrNull); + } + + pageContext.Renderer.NotifyRenderCompleted(batchId); + } + + private void OnLocationChanged(PageContext pageContext, string uri, bool intercepted) + { + pageContext.NavigationManager.LocationUpdated(uri, intercepted); + } + } +} diff --git a/src/Components/WebView/WebView/src/IpcSender.cs b/src/Components/WebView/WebView/src/IpcSender.cs new file mode 100644 index 000000000000..0dfced973ac6 --- /dev/null +++ b/src/Components/WebView/WebView/src/IpcSender.cs @@ -0,0 +1,116 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.JSInterop; + +// Sync vs Async APIs for this. +// This mainly depends on the underlying support for the browser. Assuming that there is no synchronous API +// communication is safer, since it's not guaranteed. +// In that scenario, some APIs need to expose the async nature of the communication. That happens when some +// component like the renderer needs to know the results of the operation. For example when updating the UI +// since more code needs to execute afterwards. +// In other cases like when we try to attach a component to the document, we don't necessarily need to do that +// since we only care about errors that might happen while attaching the component and the renderer doesn't +// necessarily need to know about those if we are terminating the component/host as a result. +// If we decide we need to expose the async nature of the communication channel, then we will need to keep track +// of all the message pairs/completions across the IPC channel. +namespace Microsoft.AspNetCore.Components.WebView +{ + // These are all the messages .NET needs to know how to dispatch to JS + // TODO: Proper serialization, error handling, etc. + + // Handles comunication between the component abstractions (Renderer, NavigationManager, JSInterop, etc.) + // and the underlying transport channel + internal class IpcSender + { + private readonly Dispatcher _dispatcher; + private readonly Action _messageDispatcher; + + public IpcSender(Dispatcher dispatcher, Action messageDispatcher) + { + _dispatcher = dispatcher; + _messageDispatcher = messageDispatcher; + } + + public void ApplyRenderBatch(long batchId, RenderBatch renderBatch) + { + var arrayBuilder = new ArrayBuilder(2048); + using var memoryStream = new ArrayBuilderMemoryStream(arrayBuilder); + using (var renderBatchWriter = new RenderBatchWriter(memoryStream, false)) + { + renderBatchWriter.Write(in renderBatch); + } + var message = IpcCommon.Serialize(IpcCommon.OutgoingMessageType.RenderBatch, batchId, Convert.ToBase64String(arrayBuilder.Buffer, 0, arrayBuilder.Count)); + DispatchMessageWithErrorHandling(message); + } + + // This is called by the navigation manager and needs to be forwarded to the WebView + // It might trigger the WebView to change the location of the URL and cause a LocationUpdated event. + public void Navigate(string uri, bool forceLoad) + { + DispatchMessageWithErrorHandling(IpcCommon.Serialize(IpcCommon.OutgoingMessageType.Navigate, uri, forceLoad)); + } + + // TODO: Make these APIs async if we want the renderer to be able to deal with errors. + // Called from Renderer to attach a new component ID to a given selector. + public void AttachToDocument(int componentId, string selector) + { + DispatchMessageWithErrorHandling(IpcCommon.Serialize(IpcCommon.OutgoingMessageType.AttachToDocument, componentId, selector)); + } + + // Called from the WebView to detach a root component from the document. + public void DetachFromDocument(int componentId) + { + DispatchMessageWithErrorHandling(IpcCommon.Serialize(IpcCommon.OutgoingMessageType.DetachFromDocument, componentId)); + } + + // Interop calls emitted by the JSRuntime + public void BeginInvokeJS(long taskId, string identifier, string argsJson, JSCallResultType resultType, long targetInstanceId) + { + DispatchMessageWithErrorHandling(IpcCommon.Serialize(IpcCommon.OutgoingMessageType.BeginInvokeJS, taskId, identifier, argsJson, resultType, targetInstanceId)); + } + + // TODO: We need to think about this, the invocation result contains the triplet [callId, successOrError, resultOrError] + // serialized as JSON with the options provided by the JSRuntime. The host can't operate on the "unserialized" + // data since it needs to deal with DotNetObjectReferences and JSObjectReference which the host doesn't have any visibility + // over how to serialize or deserialize. + // The strongest limitation we can find on a platform is that we might only be able to communicate with the host via "strings" (post-message) + // and in that situation we can define a separator within the string like (callId,success,resultOrError) that the + // side running in the browser can parse for processing. + public void EndInvokeDotNet(string callId, bool success, string invocationResultOrError) + { + DispatchMessageWithErrorHandling(IpcCommon.Serialize(IpcCommon.OutgoingMessageType.EndInvokeDotNet, callId, success, invocationResultOrError)); + } + + public void NotifyUnhandledException(Exception exception) + { + var message = IpcCommon.Serialize(IpcCommon.OutgoingMessageType.NotifyUnhandledException, exception.Message, exception.StackTrace); + _dispatcher.InvokeAsync(() => _messageDispatcher(message)); + } + + private void DispatchMessageWithErrorHandling(string message) + { + NotifyErrors(_dispatcher.InvokeAsync(() => _messageDispatcher(message))); + } + + private void NotifyErrors(Task task) + { + _ = AwaitAndNotify(); + + async Task AwaitAndNotify() + { + try + { + await task; + } + catch (Exception ex) + { + NotifyUnhandledException(ex); + } + } + } + } +} diff --git a/src/Components/WebView/WebView/src/Microsoft.AspNetCore.Components.WebView.csproj b/src/Components/WebView/WebView/src/Microsoft.AspNetCore.Components.WebView.csproj new file mode 100644 index 000000000000..af89f25af168 --- /dev/null +++ b/src/Components/WebView/WebView/src/Microsoft.AspNetCore.Components.WebView.csproj @@ -0,0 +1,75 @@ + + + + $(DefaultNetCoreTargetFramework) + Build desktop applications with Blazor and a webview. + true + Microsoft.Extensions.FileProviders.Embedded.Manifest.xml + true + $(DefineConstants);BLAZOR_WEBVIEW + $(NoWarn);BL0006 + true + annotations + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_FileProviderTaskAssembly>$(ArtifactsDir)bin\Microsoft.Extensions.FileProviders.Embedded.Manifest.Task\$(Configuration)\netstandard2.0\Microsoft.Extensions.FileProviders.Embedded.Manifest.Task.dll + + + + + + blazor.webview.js + + ..\..\..\Web.JS\dist\Debug\$(BlazorWebViewJSFilename) + ..\..\..\Web.JS\dist\Release\$(BlazorWebViewJSFilename) + + + + + + + ..\..\..\Web.JS\dist\Release\$(BlazorWebViewJSFilename) + + + + + + + + + + + diff --git a/src/Components/WebView/WebView/src/PageContext.cs b/src/Components/WebView/WebView/src/PageContext.cs new file mode 100644 index 000000000000..4ab64181c886 --- /dev/null +++ b/src/Components/WebView/WebView/src/PageContext.cs @@ -0,0 +1,54 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Components.WebView.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.JSInterop; + +namespace Microsoft.AspNetCore.Components.WebView +{ + /// + /// Represents the services that are scoped to a single page load. Grouping them like this + /// means we don't have to check that each of them are available individually. + /// + /// This has roughly the same role as a circuit in Blazor Server. One key difference is that, + /// for web views, the IPC channel is outside the page context, whereas in Blazor Server, + /// the IPC channel is within the circuit. + /// + internal class PageContext : IDisposable + { + private readonly IServiceScope _serviceScope; + + public WebViewNavigationManager NavigationManager { get; } + public WebViewJSRuntime JSRuntime { get; } + public WebViewRenderer Renderer { get; } + + public PageContext( + Dispatcher dispatcher, + IServiceScope serviceScope, + IpcSender ipcSender, + string baseUrl, + string startUrl) + { + _serviceScope = serviceScope; + var services = serviceScope.ServiceProvider; + + NavigationManager = (WebViewNavigationManager)services.GetRequiredService(); + NavigationManager.AttachToWebView(ipcSender, baseUrl, startUrl); + + JSRuntime = (WebViewJSRuntime)services.GetRequiredService(); + JSRuntime.AttachToWebView(ipcSender); + + var loggerFactory = services.GetRequiredService(); + Renderer = new WebViewRenderer(services, dispatcher, ipcSender, loggerFactory); + } + + public void Dispose() + { + Renderer.Dispose(); + _serviceScope.Dispose(); + } + } +} diff --git a/src/Components/WebView/WebView/src/PublicAPI.Shipped.txt b/src/Components/WebView/WebView/src/PublicAPI.Shipped.txt new file mode 100644 index 000000000000..ab058de62d44 --- /dev/null +++ b/src/Components/WebView/WebView/src/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Components/WebView/WebView/src/PublicAPI.Unshipped.txt b/src/Components/WebView/WebView/src/PublicAPI.Unshipped.txt new file mode 100644 index 000000000000..599a60601862 --- /dev/null +++ b/src/Components/WebView/WebView/src/PublicAPI.Unshipped.txt @@ -0,0 +1,14 @@ +Microsoft.AspNetCore.Components.WebView.WebViewManager +Microsoft.AspNetCore.Components.WebView.WebViewManager.AddRootComponentAsync(System.Type! componentType, string! selector, Microsoft.AspNetCore.Components.ParameterView parameters) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Components.WebView.WebViewManager.Dispatcher.get -> Microsoft.AspNetCore.Components.Dispatcher! +Microsoft.AspNetCore.Components.WebView.WebViewManager.Dispose() -> void +Microsoft.AspNetCore.Components.WebView.WebViewManager.MessageReceived(System.Uri! sourceUri, string! message) -> void +Microsoft.AspNetCore.Components.WebView.WebViewManager.Navigate(string! url) -> void +Microsoft.AspNetCore.Components.WebView.WebViewManager.RemoveRootComponentAsync(string! selector) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Components.WebView.WebViewManager.TryGetResponseContent(string! uri, bool allowFallbackOnHostPage, out int statusCode, out string! statusMessage, out System.IO.Stream! content, out string! headers) -> bool +Microsoft.AspNetCore.Components.WebView.WebViewManager.WebViewManager(System.IServiceProvider! provider, Microsoft.AspNetCore.Components.Dispatcher! dispatcher, System.Uri! appBaseUri, Microsoft.Extensions.FileProviders.IFileProvider! fileProvider, string! hostPageRelativePath) -> void +Microsoft.Extensions.DependencyInjection.ComponentsWebViewServiceCollectionExtensions +abstract Microsoft.AspNetCore.Components.WebView.WebViewManager.NavigateCore(System.Uri! absoluteUri) -> void +abstract Microsoft.AspNetCore.Components.WebView.WebViewManager.SendMessage(string! message) -> void +static Microsoft.Extensions.DependencyInjection.ComponentsWebViewServiceCollectionExtensions.AddBlazorWebView(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +virtual Microsoft.AspNetCore.Components.WebView.WebViewManager.Dispose(bool disposing) -> void \ No newline at end of file diff --git a/src/Components/WebView/WebView/src/Services/WebViewJSRuntime.cs b/src/Components/WebView/WebView/src/Services/WebViewJSRuntime.cs new file mode 100644 index 000000000000..d3a338310369 --- /dev/null +++ b/src/Components/WebView/WebView/src/Services/WebViewJSRuntime.cs @@ -0,0 +1,45 @@ +using System.Text.Json; +using Microsoft.JSInterop; +using Microsoft.JSInterop.Infrastructure; + +namespace Microsoft.AspNetCore.Components.WebView.Services +{ + internal class WebViewJSRuntime : JSRuntime + { + private IpcSender _ipcSender; + + public WebViewJSRuntime() + { + JsonSerializerOptions.Converters.Add( + new ElementReferenceJsonConverter( + new WebElementReferenceContext(this))); + } + + public void AttachToWebView(IpcSender ipcSender) + { + _ipcSender = ipcSender; + } + + protected override void BeginInvokeJS(long taskId, string identifier, string argsJson, JSCallResultType resultType, long targetInstanceId) + { + _ipcSender.BeginInvokeJS(taskId, identifier, argsJson, resultType, targetInstanceId); + } + + protected override void EndInvokeDotNet(DotNetInvocationInfo invocationInfo, in DotNetInvocationResult invocationResult) + { + if (!invocationResult.Success) + { + EndInvokeDotNetCore(invocationInfo.CallId, success: false, invocationResult.Exception.ToString()); + } + else + { + EndInvokeDotNetCore(invocationInfo.CallId, success: true, invocationResult.Result); + } + + void EndInvokeDotNetCore(string callId, bool success, object resultOrError) + { + _ipcSender.EndInvokeDotNet(callId, success, JsonSerializer.Serialize(resultOrError, JsonSerializerOptions)); + } + } + } +} diff --git a/src/Components/WebView/WebView/src/Services/WebViewNavigationInterception.cs b/src/Components/WebView/WebView/src/Services/WebViewNavigationInterception.cs new file mode 100644 index 000000000000..884efd3fefe4 --- /dev/null +++ b/src/Components/WebView/WebView/src/Services/WebViewNavigationInterception.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.Routing; + +namespace Microsoft.AspNetCore.Components.WebView.Services +{ + internal class WebViewNavigationInterception : INavigationInterception + { + // On this platform, it's sufficient for the JS-side code to enable it unconditionally, + // so there's no need to send a notification. + public Task EnableNavigationInterceptionAsync() => Task.CompletedTask; + } +} diff --git a/src/Components/WebView/WebView/src/Services/WebViewNavigationManager.cs b/src/Components/WebView/WebView/src/Services/WebViewNavigationManager.cs new file mode 100644 index 000000000000..69baf3f3c700 --- /dev/null +++ b/src/Components/WebView/WebView/src/Services/WebViewNavigationManager.cs @@ -0,0 +1,27 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Components.WebView.Services +{ + internal class WebViewNavigationManager : NavigationManager + { + private IpcSender _ipcSender; + + public void AttachToWebView(IpcSender ipcSender, string baseUrl, string initialUrl) + { + _ipcSender = ipcSender; + Initialize(baseUrl, initialUrl); + } + + public void LocationUpdated(string newUrl, bool intercepted) + { + Uri = newUrl; + NotifyLocationChanged(intercepted); + } + + protected override void NavigateToCore(string uri, bool forceLoad) + { + _ipcSender.Navigate(uri, forceLoad); + } + } +} diff --git a/src/Components/WebView/WebView/src/Services/WebViewRenderer.cs b/src/Components/WebView/WebView/src/Services/WebViewRenderer.cs new file mode 100644 index 000000000000..b9fbf6bee594 --- /dev/null +++ b/src/Components/WebView/WebView/src/Services/WebViewRenderer.cs @@ -0,0 +1,103 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Runtime.ExceptionServices; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Components.WebView.Services +{ + internal class WebViewRenderer : Renderer + { + private readonly Queue _unacknowledgedRenderBatches = new(); + private readonly Dictionary _componentIdBySelector = new(); + private readonly Dispatcher _dispatcher; + private readonly IpcSender _ipcSender; + private long nextRenderBatchId = 1; + + public WebViewRenderer( + IServiceProvider serviceProvider, + Dispatcher dispatcher, + IpcSender ipcSender, + ILoggerFactory loggerFactory) : + base(serviceProvider, loggerFactory) + { + _dispatcher = dispatcher; + _ipcSender = ipcSender; + } + + public override Dispatcher Dispatcher => _dispatcher; + + protected override void HandleException(Exception exception) + { + // Notify the JS code so it can show the in-app UI + _ipcSender.NotifyUnhandledException(exception); + + // Also rethrow so the AppDomain's UnhandledException handler gets notified + ExceptionDispatchInfo.Capture(exception).Throw(); + } + + protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) + { + var batchId = nextRenderBatchId++; + var tcs = new TaskCompletionSource(); + _unacknowledgedRenderBatches.Enqueue(new UnacknowledgedRenderBatch + { + BatchId = batchId, + CompletionSource = tcs, + }); + + _ipcSender.ApplyRenderBatch(batchId, renderBatch); + return tcs.Task; + } + + public async Task AddRootComponentAsync(Type componentType, string selector, ParameterView parameters) + { + if (_componentIdBySelector.ContainsKey(selector)) + { + throw new InvalidOperationException("A component is already associated with the given selector."); + } + + var component = InstantiateComponent(componentType); + var componentId = AssignRootComponentId(component); + + _componentIdBySelector.Add(selector, componentId); + _ipcSender.AttachToDocument(componentId, selector); + + await RenderRootComponentAsync(componentId, parameters); + } + + public async Task RemoveRootComponentAsync(string selector) + { + if (!_componentIdBySelector.TryGetValue(selector, out var componentId)) + { + throw new InvalidOperationException("Could not find a component Id associated with the given selector."); + } + + // TODO: The renderer needs an API to do trigger the disposal of the component tree. + await Task.CompletedTask; + + _ipcSender.DetachFromDocument(componentId); + } + + public void NotifyRenderCompleted(long batchId) + { + var nextUnacknowledgedBatch = _unacknowledgedRenderBatches.Dequeue(); + if (nextUnacknowledgedBatch.BatchId != batchId) + { + throw new InvalidOperationException($"Received unexpected acknowledgement for render batch {batchId} (next batch should be {nextUnacknowledgedBatch.BatchId})"); + } + + nextUnacknowledgedBatch.CompletionSource.SetResult(); + } + + record UnacknowledgedRenderBatch + { + public long BatchId { get; init; } + public TaskCompletionSource CompletionSource { get; init; } + } + } +} diff --git a/src/Components/WebView/WebView/src/StaticContentProvider.cs b/src/Components/WebView/WebView/src/StaticContentProvider.cs new file mode 100644 index 000000000000..fb4aa1db96a1 --- /dev/null +++ b/src/Components/WebView/WebView/src/StaticContentProvider.cs @@ -0,0 +1,114 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Text; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.FileProviders; + +namespace Microsoft.AspNetCore.Components.WebView +{ + internal class StaticContentProvider + { + private readonly IFileProvider _fileProvider; + private readonly Uri _appBaseUri; + private readonly string _hostPageRelativePath; + private static readonly FileExtensionContentTypeProvider ContentTypeProvider = new(); + private static ManifestEmbeddedFileProvider _manifestProvider = + new ManifestEmbeddedFileProvider(typeof(StaticContentProvider).Assembly); + + public StaticContentProvider(IFileProvider fileProvider, Uri appBaseUri, string hostPageRelativePath) + { + _fileProvider = fileProvider ?? throw new ArgumentNullException(nameof(fileProvider)); + _appBaseUri = appBaseUri ?? throw new ArgumentNullException(nameof(appBaseUri)); + _hostPageRelativePath = hostPageRelativePath ?? throw new ArgumentNullException(nameof(hostPageRelativePath)); + } + + public bool TryGetResponseContent(string requestUri, bool allowFallbackOnHostPage, out int statusCode, out string statusMessage, out Stream content, out string headers) + { + var fileUri = new Uri(requestUri); + if (_appBaseUri.IsBaseOf(fileUri)) + { + var relativePath = _appBaseUri.MakeRelativeUri(fileUri).ToString(); + + // Content in the file provider takes first priority + // Next we may fall back on supplying the host page to support deep linking + // If there's no match, fall back on serving embedded framework content + string contentType; + var found = TryGetFromFileProvider(relativePath, out content, out contentType) + || (allowFallbackOnHostPage && TryGetFromFileProvider(_hostPageRelativePath, out content, out contentType)) + || TryGetFrameworkFile(relativePath, out content, out contentType); + + if (found) + { + statusCode = 200; + statusMessage = "OK"; + headers = GetResponseHeaders(contentType); + } + else + { + content = new MemoryStream(Encoding.UTF8.GetBytes($"There is no content at {relativePath}")); + statusCode = 404; + statusMessage = "Not found"; + headers = GetResponseHeaders("text/plain"); + } + + // Always respond to requests within the base URI, even if there's no matching file + return true; + } + else + { + // URL isn't within application base path, so let the network handle it + statusCode = default; + statusMessage = default; + headers = default; + content = default; + return false; + } + } + + private bool TryGetFromFileProvider(string relativePath, out Stream content, out string contentType) + { + if (!string.IsNullOrEmpty(relativePath)) + { + var fileInfo = _fileProvider.GetFileInfo(relativePath); + if (fileInfo.Exists) + { + content = fileInfo.CreateReadStream(); + contentType = GetResponseContentTypeOrDefault(fileInfo.PhysicalPath); + return true; + } + } + + content = default; + contentType = default; + return false; + } + + private static bool TryGetFrameworkFile(string relativePath, out Stream content, out string contentType) + { + // We're not trying to simulate everything a real webserver does. We don't need to + // support querystring parameters, for example. It's enough to require an exact match. + var file = _manifestProvider.GetFileInfo(relativePath); + if (file.Exists) + { + content = file.CreateReadStream(); + contentType = GetResponseContentTypeOrDefault(relativePath); + return true; + } + + content = default; + contentType = default; + return false; + } + + private static string GetResponseContentTypeOrDefault(string path) + => ContentTypeProvider.TryGetContentType(path, out var matchedContentType) + ? matchedContentType + : "application/octet-stream"; + + private static string GetResponseHeaders(string contentType) + => $"Content-Type: {contentType}{Environment.NewLine}Cache-Control: no-cache, max-age=0, must-revalidate, no-store"; + } +} diff --git a/src/Components/WebView/WebView/src/WebViewManager.cs b/src/Components/WebView/WebView/src/WebViewManager.cs new file mode 100644 index 000000000000..1fb19a48d332 --- /dev/null +++ b/src/Components/WebView/WebView/src/WebViewManager.cs @@ -0,0 +1,226 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; + +namespace Microsoft.AspNetCore.Components.WebView +{ + /// + /// Manages activities within a web view that hosts Blazor components. Platform authors + /// should subclass this to wire up the abstract and protected methods to the APIs of + /// the platform's web view. + /// + public abstract class WebViewManager : IDisposable + { + // These services are not DI services, because their lifetime isn't limited to a single + // per-page-load scope. Instead, their lifetime matches the webview itself. + private readonly IServiceProvider _provider; + private readonly Dispatcher _dispatcher; + private readonly IpcSender _ipcSender; + private readonly IpcReceiver _ipcReceiver; + private readonly Uri _appBaseUri; + private readonly StaticContentProvider _staticContentProvider; + private readonly Dictionary _rootComponentsBySelector = new(); + + // Each time a web page connects, we establish a new per-page context + private PageContext _currentPageContext; + private bool _disposed; + + /// + /// Constructs an instance of . + /// + /// The for the application. + /// A instance that can marshal calls to the required thread or sync context. + /// The base URI for the application. Since this is a webview, the base URI is typically on a private origin such as http://0.0.0.0/ or app://example/ + /// Provides static content to the webview. + /// Path to the host page within the . + public WebViewManager(IServiceProvider provider, Dispatcher dispatcher, Uri appBaseUri, IFileProvider fileProvider, string hostPageRelativePath) + { + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _appBaseUri = EnsureTrailingSlash(appBaseUri ?? throw new ArgumentNullException(nameof(appBaseUri))); + _staticContentProvider = new StaticContentProvider(fileProvider, appBaseUri, hostPageRelativePath); + _ipcSender = new IpcSender(_dispatcher, SendMessage); + _ipcReceiver = new IpcReceiver(AttachToPageAsync); + } + + /// + /// Gets the used by this instance. + /// + public Dispatcher Dispatcher => _dispatcher; + + /// + /// Instructs the web view to navigate to the specified location, bypassing any + /// client-side routing. + /// + /// The URL, which may be absolute or relative to the application root. + public void Navigate(string url) + => NavigateCore(new Uri(_appBaseUri, url)); + + /// + /// Instructs the web view to navigate to the specified location, bypassing any + /// client-side routing. + /// + /// The absolute URI. + protected abstract void NavigateCore(Uri absoluteUri); + + /// + /// Sends a message to JavaScript code running in the attached web view. This must + /// be forwarded to the Blazor JavaScript code. + /// + /// The message. + protected abstract void SendMessage(string message); + + /// + /// Adds a root component to the attached page. + /// + /// The type of the root component. This must implement . + /// The CSS selector describing where in the page the component should be placed. + /// Parameters for the component. + public Task AddRootComponentAsync(Type componentType, string selector, ParameterView parameters) + { + var rootComponent = new RootComponent { ComponentType = componentType, Parameters = parameters }; + if (!_rootComponentsBySelector.TryAdd(selector, rootComponent)) + { + throw new InvalidOperationException($"There is already a root component with selector '{selector}'."); + } + + // If the page is already attached, add the root component to it now. Otherwise we'll + // add it when the page attaches later. + if (_currentPageContext != null) + { + return Dispatcher.InvokeAsync(() => _currentPageContext.Renderer.AddRootComponentAsync(componentType, selector, parameters)); + } + else + { + return Task.CompletedTask; + } + } + + /// + /// Removes a previously-attached root component from the current page. + /// + /// The CSS selector describing where in the page the component was placed. This must exactly match the selector provided on an earlier call to . + public Task RemoveRootComponentAsync(string selector) + { + if (!_rootComponentsBySelector.Remove(selector)) + { + throw new InvalidOperationException($"There is no root component with selector '{selector}'."); + } + + // If the page is already attached, remove the root component from it now. Otherwise it's + // enough to have updated the dictionary. + if (_currentPageContext != null) + { + return Dispatcher.InvokeAsync(() => _currentPageContext.Renderer.RemoveRootComponentAsync(selector)); + } + else + { + return Task.CompletedTask; + } + } + + /// + /// Notifies the about a message from JavaScript running within the web view. + /// + /// The source URI for the message. + /// The message. + protected void MessageReceived(Uri sourceUri, string message) + { + if (!_appBaseUri.IsBaseOf(sourceUri)) + { + // It's important that we ignore messages from other origins, otherwise if the webview + // navigates to a remote location, it could send commands that execute locally + return; + } + + _ = _dispatcher.InvokeAsync(async () => + { + // TODO: Verify this produces the correct exception-surfacing behaviors. + // For example, JS interop exceptions should flow back into JS, whereas + // renderer exceptions should be fatal. + try + { + await _ipcReceiver.OnMessageReceivedAsync(_currentPageContext, message); + } + catch (Exception ex) + { + _ipcSender.NotifyUnhandledException(ex); + throw; + } + }); + } + + /// + /// Tries to provide the response content for a given network request. + /// + /// The uri of the request + /// Whether or not to fallback to the host page. + /// The status code of the response. + /// The response status message. + /// The response content + /// The response headers + /// true if the response can be provided; false otherwise. + protected bool TryGetResponseContent(string uri, bool allowFallbackOnHostPage, out int statusCode, out string statusMessage, out Stream content, out string headers) + => _staticContentProvider.TryGetResponseContent(uri, allowFallbackOnHostPage, out statusCode, out statusMessage, out content, out headers); + + internal async Task AttachToPageAsync(string baseUrl, string startUrl) + { + // If there was some previous attached page, dispose all its resources. TODO: Are we happy + // with this pattern? The alternative would be requiring the platform author to notify us + // when the webview is navigating away so we could dispose more eagerly then. + _currentPageContext?.Dispose(); + + var serviceScope = _provider.CreateScope(); + _currentPageContext = new PageContext(_dispatcher, serviceScope, _ipcSender, baseUrl, startUrl); + + // Add any root components that were registered before the page attached + foreach (var (selector, rootComponent) in _rootComponentsBySelector) + { + await _currentPageContext.Renderer.AddRootComponentAsync( + rootComponent.ComponentType, + selector, + rootComponent.Parameters); + } + } + + private static Uri EnsureTrailingSlash(Uri uri) + => uri.AbsoluteUri.EndsWith('/') ? uri : new Uri(uri.AbsoluteUri + '/'); + + record RootComponent + { + public Type ComponentType { get; init; } + public ParameterView Parameters { get; set; } + } + + /// + /// Disposes the current instance. + /// + /// true when dispose was called explicitly; false when it is called as part of the finalizer. + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _currentPageContext?.Dispose(); + } + + _disposed = true; + } + } + + /// + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + } +} diff --git a/src/Components/WebView/WebView/test/Infrastructure/AssertHelpers.cs b/src/Components/WebView/WebView/test/Infrastructure/AssertHelpers.cs new file mode 100644 index 000000000000..1e4b08cc6725 --- /dev/null +++ b/src/Components/WebView/WebView/test/Infrastructure/AssertHelpers.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Components.RenderTree; +using Xunit; + +namespace Microsoft.AspNetCore.Components.WebView +{ + public class AssertHelpers + { + internal static void IsAttachToDocumentMessage(string message, int componentId, string selector) + { + Assert.True(IpcCommon.TryDeserializeOutgoing(message, out var messageType, out var args)); + Assert.Equal(IpcCommon.OutgoingMessageType.AttachToDocument, messageType); + Assert.Equal(2, args.Count); + Assert.Equal(componentId, args[0].GetInt32()); + Assert.Equal(selector, args[1].GetString()); + } + + internal static RenderBatch IsRenderBatch(string message) + { + Assert.True(IpcCommon.TryDeserializeOutgoing(message, out var messageType, out var args)); + Assert.Equal(IpcCommon.OutgoingMessageType.RenderBatch, messageType); + Assert.Equal(2, args.Count); + Assert.Equal(1, args[0].GetInt64()); // Batch ID + + // At least validate we can base64 decode the batch data + var _ = Convert.FromBase64String(args[1].GetString()); + // TODO: Produce the render batch if we want to grab info from it. + return default; + } + } +} diff --git a/src/Components/WebView/WebView/test/Infrastructure/ComponentNode.cs b/src/Components/WebView/WebView/test/Infrastructure/ComponentNode.cs new file mode 100644 index 000000000000..02b1e91fd8db --- /dev/null +++ b/src/Components/WebView/WebView/test/Infrastructure/ComponentNode.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Components.WebView.Document +{ + internal class ComponentNode : ContainerNode + { + public ComponentNode(int componentId) + { + ComponentId = componentId; + } + + public int ComponentId { get; } + } +} diff --git a/src/Components/WebView/WebView/test/Infrastructure/ContainerNode.cs b/src/Components/WebView/WebView/test/Infrastructure/ContainerNode.cs new file mode 100644 index 000000000000..0029f1b21b5c --- /dev/null +++ b/src/Components/WebView/WebView/test/Infrastructure/ContainerNode.cs @@ -0,0 +1,77 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.Components.WebView.Document +{ + internal class ContainerNode : TestNode + { + public List Children { get; } = new(); + + internal void RemoveLogicalChild(int childIndex) + { + var childToRemove = Children[childIndex]; + Children.RemoveAt(childIndex); + + // If it's a logical container, also remove its descendants + if (childToRemove is LogicalContainerNode container) + { + while (container.Children.Count > 0) + { + container.RemoveLogicalChild(0); + } + } + } + + internal ContainerNode CreateAndInsertContainer(int childIndex) + { + var containerElement = new LogicalContainerNode(); + InsertLogicalChild(containerElement, childIndex); + return containerElement; + } + + internal void InsertLogicalChild(TestNode child, int childIndex) + { + if (child is LogicalContainerNode comment && comment.Children.Count > 0) + { + // There's nothing to stop us implementing support for this scenario, and it's not difficult + // (after inserting 'child' itself, also iterate through its logical children and physically + // put them as following-siblings in the DOM). However there's no scenario that requires it + // presently, so if we did implement it there'd be no good way to have tests for it. + throw new Exception("Not implemented: inserting non-empty logical container"); + } + + if (child.Parent != null) + { + // Likewise, we could easily support this scenario too (in this 'if' block, just splice + // out 'child' from the logical children array of its previous logical parent by using + // Array.prototype.indexOf to determine its previous sibling index). + // But again, since there's not currently any scenario that would use it, we would not + // have any test coverage for such an implementation. + throw new NotSupportedException("Not implemented: moving existing logical children"); + } + + if (childIndex < Children.Count) + { + // Insert + Children.Insert(childIndex, child); + } + else + { + // Append + Children.Add(child); + } + + child.Parent = this; + } + + internal ComponentNode CreateAndInsertComponent(int childComponentId, int childIndex) + { + var componentElement = new ComponentNode(childComponentId); + InsertLogicalChild(componentElement, childIndex); + return componentElement; + } + } +} diff --git a/src/Components/WebView/WebView/test/Infrastructure/ElementNode.cs b/src/Components/WebView/WebView/test/Infrastructure/ElementNode.cs new file mode 100644 index 000000000000..c72ad89dbb70 --- /dev/null +++ b/src/Components/WebView/WebView/test/Infrastructure/ElementNode.cs @@ -0,0 +1,74 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.Components.WebView.Document +{ + internal class ElementNode : ContainerNode + { + private readonly Dictionary _attributes; + private readonly Dictionary _properties; + private readonly Dictionary _events; + + public ElementNode(string elementName) + { + TagName = elementName; + _attributes = new Dictionary(StringComparer.Ordinal); + _properties = new Dictionary(StringComparer.Ordinal); + _events = new Dictionary(StringComparer.Ordinal); + } + + public string TagName { get; } + + public IReadOnlyDictionary Attributes => _attributes; + + public IReadOnlyDictionary Properties => _properties; + + public IReadOnlyDictionary Events => _events; + + internal void RemoveAttribute(string key) + { + _attributes.Remove(key); + } + + internal void SetAttribute(string key, object value) + { + _attributes[key] = value; + } + + internal void SetEvent(string eventName, ElementEventDescriptor descriptor) + { + if (eventName is null) + { + throw new ArgumentNullException(nameof(eventName)); + } + + if (descriptor is null) + { + throw new ArgumentNullException(nameof(descriptor)); + } + + _events[eventName] = descriptor; + } + + internal void SetProperty(string key, object value) + { + _properties[key] = value; + } + + public class ElementEventDescriptor + { + public ElementEventDescriptor(string eventName, ulong eventId) + { + EventName = eventName ?? throw new ArgumentNullException(nameof(eventName)); + EventId = eventId; + } + + public string EventName { get; } + + public ulong EventId { get; } + } + } +} diff --git a/src/Components/WebView/WebView/test/Infrastructure/LogicalContainerNode.cs b/src/Components/WebView/WebView/test/Infrastructure/LogicalContainerNode.cs new file mode 100644 index 000000000000..18d5724f4f27 --- /dev/null +++ b/src/Components/WebView/WebView/test/Infrastructure/LogicalContainerNode.cs @@ -0,0 +1,9 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Components.WebView.Document +{ + internal class LogicalContainerNode : ContainerNode + { + } +} diff --git a/src/Components/WebView/WebView/test/Infrastructure/MarkupNode.cs b/src/Components/WebView/WebView/test/Infrastructure/MarkupNode.cs new file mode 100644 index 000000000000..0c7308be5612 --- /dev/null +++ b/src/Components/WebView/WebView/test/Infrastructure/MarkupNode.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Components.WebView.Document +{ + internal class MarkupNode : TestNode + { + public MarkupNode(string markupContent) + { + Content = markupContent; + } + + public string Content { get; } + } +} diff --git a/src/Components/WebView/WebView/test/Infrastructure/RootComponentNode.cs b/src/Components/WebView/WebView/test/Infrastructure/RootComponentNode.cs new file mode 100644 index 000000000000..6279a4519e43 --- /dev/null +++ b/src/Components/WebView/WebView/test/Infrastructure/RootComponentNode.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Components.WebView.Document +{ + internal class RootComponentNode : ComponentNode + { + public RootComponentNode(int componentId, string selector) : base(componentId) + { + Selector = selector; + } + + public string Selector { get; } + } +} diff --git a/src/Components/WebView/WebView/test/Infrastructure/TestDocument.cs b/src/Components/WebView/WebView/test/Infrastructure/TestDocument.cs new file mode 100644 index 000000000000..ff7552890f80 --- /dev/null +++ b/src/Components/WebView/WebView/test/Infrastructure/TestDocument.cs @@ -0,0 +1,512 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Components.RenderTree; + +namespace Microsoft.AspNetCore.Components.WebView.Document +{ + public class TestDocument + { + private const string SelectValuePropname = "_blazorSelectValue"; + + private readonly Dictionary _componentsById = new(); + + public void AddRootComponent(int componentId, string selector) + { + if (_componentsById.ContainsKey(componentId)) + { + throw new InvalidOperationException($"Component with Id '{componentId}' already exists."); + } + + _componentsById.Add(componentId, new RootComponentNode(componentId, selector)); + } + + public void ApplyChanges(RenderBatch batch) + { + for (var i = 0; i < batch.UpdatedComponents.Count; i++) + { + var diff = batch.UpdatedComponents.Array[i]; + var componentId = diff.ComponentId; + var edits = diff.Edits; + UpdateComponent(batch, componentId, edits); + } + + for (var i = 0; i < batch.DisposedComponentIDs.Count; i++) + { + DisposeComponent(batch.DisposedComponentIDs.Array[i]); + } + + for (var i = 0; i < batch.DisposedEventHandlerIDs.Count; i++) + { + DisposeEventHandler(batch.DisposedEventHandlerIDs.Array[i]); + } + } + + private void UpdateComponent(RenderBatch batch, int componentId, ArrayBuilderSegment edits) + { + if (!_componentsById.TryGetValue(componentId, out var component)) + { + component = new ComponentNode(componentId); + _componentsById.Add(componentId, component); + } + + ApplyEdits(batch, component, 0, edits); + } + + private void DisposeComponent(int componentId) + { + + } + + private void DisposeEventHandler(ulong eventHandlerId) + { + + } + + private void ApplyEdits(RenderBatch batch, ContainerNode parent, int childIndex, ArrayBuilderSegment edits) + { + var currentDepth = 0; + var childIndexAtCurrentDepth = childIndex; + var permutations = new List(); + + for (var editIndex = edits.Offset; editIndex < edits.Offset + edits.Count; editIndex++) + { + var edit = edits.Array[editIndex]; + switch (edit.Type) + { + case RenderTreeEditType.PrependFrame: + { + var frame = batch.ReferenceFrames.Array[edit.ReferenceFrameIndex]; + var siblingIndex = edit.SiblingIndex; + InsertFrame(batch, parent, childIndexAtCurrentDepth + siblingIndex, batch.ReferenceFrames.Array, frame, edit.ReferenceFrameIndex); + break; + } + + case RenderTreeEditType.RemoveFrame: + { + var siblingIndex = edit.SiblingIndex; + parent.RemoveLogicalChild(childIndexAtCurrentDepth + siblingIndex); + break; + } + + case RenderTreeEditType.SetAttribute: + { + var frame = batch.ReferenceFrames.Array[edit.ReferenceFrameIndex]; + var siblingIndex = edit.SiblingIndex; + var node = parent.Children[childIndexAtCurrentDepth + siblingIndex]; + if (node is ElementNode element) + { + ApplyAttribute(batch, element, frame); + } + else + { + throw new Exception("Cannot set attribute on non-element child"); + } + break; + } + + case RenderTreeEditType.RemoveAttribute: + { + // Note that we don't have to dispose the info we track about event handlers here, because the + // disposed event handler IDs are delivered separately (in the 'disposedEventHandlerIds' array) + var siblingIndex = edit.SiblingIndex; + var node = parent.Children[childIndexAtCurrentDepth + siblingIndex]; + if (node is ElementNode element) + { + var attributeName = edit.RemovedAttributeName; + + // First try to remove any special property we use for this attribute + if (!TryApplySpecialProperty(batch, element, attributeName, default)) + { + // If that's not applicable, it's a regular DOM attribute so remove that + element.RemoveAttribute(attributeName); + } + } + else + { + throw new Exception("Cannot remove attribute from non-element child"); + } + break; + } + + case RenderTreeEditType.UpdateText: + { + var frame = batch.ReferenceFrames.Array[edit.ReferenceFrameIndex]; + var siblingIndex = edit.SiblingIndex; + var node = parent.Children[childIndexAtCurrentDepth + siblingIndex]; + if (node is TextNode textNode) + { + textNode.Text = frame.TextContent; + } + else + { + throw new Exception("Cannot set text content on non-text child"); + } + break; + } + + + case RenderTreeEditType.UpdateMarkup: + { + var frame = batch.ReferenceFrames.Array[edit.ReferenceFrameIndex]; + var siblingIndex = edit.SiblingIndex; + parent.RemoveLogicalChild(childIndexAtCurrentDepth + siblingIndex); + InsertMarkup(parent, childIndexAtCurrentDepth + siblingIndex, frame); + break; + } + + case RenderTreeEditType.StepIn: + { + var siblingIndex = edit.SiblingIndex; + parent = (ContainerNode)parent.Children[childIndexAtCurrentDepth + siblingIndex]; + currentDepth++; + childIndexAtCurrentDepth = 0; + break; + } + + case RenderTreeEditType.StepOut: + { + parent = parent.Parent ?? throw new InvalidOperationException($"Cannot step out of {parent}"); + currentDepth--; + childIndexAtCurrentDepth = currentDepth == 0 ? childIndex : 0; // The childIndex is only ever nonzero at zero depth + break; + } + + case RenderTreeEditType.PermutationListEntry: + { + permutations.Add(new PermutationListEntry(childIndexAtCurrentDepth + edit.SiblingIndex, childIndexAtCurrentDepth + edit.MoveToSiblingIndex)); + break; + } + + case RenderTreeEditType.PermutationListEnd: + { + throw new NotSupportedException(); + //permuteLogicalChildren(parent, permutations!); + //permutations.Clear(); + //break; + } + + default: + { + throw new Exception($"Unknown edit type: '{edit.Type}'"); + } + } + } + } + + private int InsertFrame(RenderBatch batch, ContainerNode parent, int childIndex, ArraySegment frames, RenderTreeFrame frame, int frameIndex) + { + switch (frame.FrameType) + { + case RenderTreeFrameType.Element: + { + InsertElement(batch, parent, childIndex, frames, frame, frameIndex); + return 1; + } + + case RenderTreeFrameType.Text: + { + InsertText(parent, childIndex, frame); + return 1; + } + + case RenderTreeFrameType.Attribute: + { + throw new Exception("Attribute frames should only be present as leading children of element frames."); + } + + case RenderTreeFrameType.Component: + { + InsertComponent(parent, childIndex, frame); + return 1; + } + + case RenderTreeFrameType.Region: + { + return InsertFrameRange(batch, parent, childIndex, frames, frameIndex + 1, frameIndex + frame.RegionSubtreeLength); + } + + case RenderTreeFrameType.ElementReferenceCapture: + { + if (parent is ElementNode) + { + return 0; // A "capture" is a child in the diff, but has no node in the DOM + } + else + { + throw new Exception("Reference capture frames can only be children of element frames."); + } + } + + case RenderTreeFrameType.Markup: + { + InsertMarkup(parent, childIndex, frame); + return 1; + } + + } + + throw new Exception($"Unknown frame type: {frame.FrameType}"); + } + + private void InsertText(ContainerNode parent, int childIndex, RenderTreeFrame frame) + { + var textContent = frame.TextContent; + var newTextNode = new TextNode(textContent); + parent.InsertLogicalChild(newTextNode, childIndex); + } + + private void InsertComponent(ContainerNode parent, int childIndex, RenderTreeFrame frame) + { + // All we have to do is associate the child component ID with its location. We don't actually + // do any rendering here, because the diff for the child will appear later in the render batch. + var childComponentId = frame.ComponentId; + var containerElement = parent.CreateAndInsertComponent(childComponentId, childIndex); + + _componentsById[childComponentId] = containerElement; + } + + private int InsertFrameRange(RenderBatch batch, ContainerNode parent, int childIndex, ArraySegment frames, int startIndex, int endIndexExcl) + { + var origChildIndex = childIndex; + for (var index = startIndex; index < endIndexExcl; index++) + { + var frame = batch.ReferenceFrames.Array[index]; + var numChildrenInserted = InsertFrame(batch, parent, childIndex, frames, frame, index); + childIndex += numChildrenInserted; + + // Skip over any descendants, since they are already dealt with recursively + index += CountDescendantFrames(frame); + } + + return childIndex - origChildIndex; // Total number of children inserted + } + + private void InsertElement(RenderBatch batch, ContainerNode parent, int childIndex, ArraySegment frames, RenderTreeFrame frame, int frameIndex) + { + // Note: we don't handle SVG here + var newElement = new ElementNode(frame.ElementName); + + var inserted = false; + + // Apply attributes + for (var i = frameIndex + 1; i < frameIndex + frame.ElementSubtreeLength; i++) + { + var descendantFrame = batch.ReferenceFrames.Array[i]; + if (descendantFrame.FrameType == RenderTreeFrameType.Attribute) + { + ApplyAttribute(batch, newElement, descendantFrame); + } + else + { + parent.InsertLogicalChild(newElement, childIndex); + inserted = true; + + // As soon as we see a non-attribute child, all the subsequent child frames are + // not attributes, so bail out and insert the remnants recursively + InsertFrameRange(batch, newElement, 0, frames, i, frameIndex + frame.ElementSubtreeLength); + break; + } + } + + // this element did not have any children, so it's not inserted yet. + if (!inserted) + { + parent.InsertLogicalChild(newElement, childIndex); + } + } + + private void ApplyAttribute(RenderBatch batch, ElementNode elementNode, RenderTreeFrame attributeFrame) + { + var attributeName = attributeFrame.AttributeName; + var eventHandlerId = attributeFrame.AttributeEventHandlerId; + + if (eventHandlerId != 0) + { + var firstTwoChars = attributeName.Substring(0, 2); + var eventName = attributeName.Substring(2); + if (firstTwoChars != "on" || string.IsNullOrEmpty(eventName)) + { + throw new InvalidOperationException($"Attribute has nonzero event handler ID, but attribute name '${attributeName}' does not start with 'on'."); + } + var descriptor = new ElementNode.ElementEventDescriptor(eventName, eventHandlerId); + elementNode.SetEvent(eventName, descriptor); + + return; + } + + // First see if we have special handling for this attribute + if (!TryApplySpecialProperty(batch, elementNode, attributeName, attributeFrame)) + { + // If not, treat it as a regular string-valued attribute + elementNode.SetAttribute( + attributeName, + attributeFrame.AttributeValue); + } + } + + private bool TryApplySpecialProperty(RenderBatch batch, ElementNode element, string attributeName, RenderTreeFrame attributeFrame) + { + switch (attributeName) + { + case "value": + return TryApplyValueProperty(element, attributeFrame); + case "checked": + return TryApplyCheckedProperty(element, attributeFrame); + default: + return false; + } + } + + private bool TryApplyValueProperty(ElementNode element, RenderTreeFrame attributeFrame) + { + // Certain elements have built-in behaviour for their 'value' property + switch (element.TagName) + { + case "INPUT": + case "SELECT": + case "TEXTAREA": + { + var value = attributeFrame.AttributeValue; + element.SetProperty("value", value); + + if (element.TagName == "SELECT") + { + //