diff --git a/src/Components/Blazor/Build/src/Tasks/GenerateServiceWorkerAssetsManifest.cs b/src/Components/Blazor/Build/src/Tasks/GenerateServiceWorkerAssetsManifest.cs new file mode 100644 index 0000000000..95e2027046 --- /dev/null +++ b/src/Components/Blazor/Build/src/Tasks/GenerateServiceWorkerAssetsManifest.cs @@ -0,0 +1,82 @@ +// 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.IO; +using System.Linq; +using System.Runtime.Serialization.Json; +using System.Text; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Microsoft.AspNetCore.Blazor.Build +{ + public class GenerateServiceWorkerAssetsManifest : Task + { + [Required] + public string Version { get; set; } + + [Required] + public ITaskItem[] AssetsWithHashes { get; set; } + + [Required] + public string OutputPath { get; set; } + + public override bool Execute() + { + using var fileStream = File.Create(OutputPath); + WriteFile(fileStream); + return true; + } + + internal void WriteFile(Stream stream) + { + var data = new AssetsManifestFile + { + version = Version, + assets = AssetsWithHashes.Select(item => new AssetsManifestFileEntry + { + url = item.GetMetadata("AssetUrl"), + hash = $"sha256-{item.GetMetadata("FileHash")}", + }).ToArray() + }; + + using var streamWriter = new StreamWriter(stream, Encoding.UTF8, bufferSize: 50, leaveOpen: true); + streamWriter.Write("self.assetsManifest = "); + streamWriter.Flush(); + + using var jsonWriter = JsonReaderWriterFactory.CreateJsonWriter(stream, Encoding.UTF8, ownsStream: false, indent: true); + new DataContractJsonSerializer(typeof(AssetsManifestFile)).WriteObject(jsonWriter, data); + jsonWriter.Flush(); + + streamWriter.WriteLine(";"); + } + +#pragma warning disable IDE1006 // Naming Styles + public class AssetsManifestFile + { + /// + /// Gets or sets a version string. + /// + public string version { get; set; } + + /// + /// Gets or sets the assets. Keys are URLs; values are base-64-formatted SHA256 content hashes. + /// + public AssetsManifestFileEntry[] assets { get; set; } + } + + public class AssetsManifestFileEntry + { + /// + /// Gets or sets the asset URL. Normally this will be relative to the application's base href. + /// + public string url { get; set; } + + /// + /// Gets or sets the file content hash. This should be the base-64-formatted SHA256 value. + /// + public string hash { get; set; } + } +#pragma warning restore IDE1006 // Naming Styles + } +} diff --git a/src/Components/Blazor/Build/src/targets/All.targets b/src/Components/Blazor/Build/src/targets/All.targets index 6c69e85a40..f44a825c23 100644 --- a/src/Components/Blazor/Build/src/targets/All.targets +++ b/src/Components/Blazor/Build/src/targets/All.targets @@ -21,6 +21,7 @@ + diff --git a/src/Components/Blazor/Build/src/targets/Blazor.MonoRuntime.targets b/src/Components/Blazor/Build/src/targets/Blazor.MonoRuntime.targets index 911c6167c8..f5fea05473 100644 --- a/src/Components/Blazor/Build/src/targets/Blazor.MonoRuntime.targets +++ b/src/Components/Blazor/Build/src/targets/Blazor.MonoRuntime.targets @@ -21,7 +21,7 @@ @@ -133,7 +133,7 @@ ReferenceCopyLocalPaths includes all files that are part of the build out with CopyLocalLockFileAssemblies on. Remove assemblies that are inputs to calculating the assembly closure. Instead use the resolved outputs, since it is the minimal set. --> - <_BlazorCopyLocalPaths Include="@(ReferenceCopyLocalPaths)" /> + <_BlazorCopyLocalPaths Include="@(ReferenceCopyLocalPaths)" Condition="'%(Extension)' == '.dll'" /> <_BlazorCopyLocalPaths Remove="@(_BlazorManagedRuntimeAssemby)" /> diff --git a/src/Components/Blazor/Build/src/targets/ServiceWorkerAssetsManifest.targets b/src/Components/Blazor/Build/src/targets/ServiceWorkerAssetsManifest.targets new file mode 100644 index 0000000000..c92c8af17e --- /dev/null +++ b/src/Components/Blazor/Build/src/targets/ServiceWorkerAssetsManifest.targets @@ -0,0 +1,98 @@ + + + + <_BlazorCopyFilesToOutputDirectoryDependsOn> + $(_BlazorCopyFilesToOutputDirectoryDependsOn); + _ComputeServiceWorkerAssetsManifestInputs; + _WriteServiceWorkerAssetsManifest; + + + + + + + <_ServiceWorkerAssetsManifestIntermediateOutputPath>$(BlazorIntermediateOutputPath)serviceworkerassets.js + + + + + + $([System.String]::Copy('%(BlazorOutputWithTargetPath.TargetOutputPath)').Replace('\','/').Substring(5)) + + + + + $([System.String]::Copy('%(ContentWithTargetPath.TargetPath)').Replace('\','/').Substring(8)) + + + + + %(StaticWebAsset.BasePath)/%(StaticWebAsset.RelativePath) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_CombinedHashIntermediatePath>$(BlazorIntermediateOutputPath)serviceworkerhashes.txt + + + + + + + + + + $([System.String]::Copy('%(_ServiceWorkerAssetsManifestCombinedHash.FileHash)').Substring(0, 8)) + + + + diff --git a/src/Components/startvs.cmd b/src/Components/startvs.cmd index 1eb5256122..962cc73686 100644 --- a/src/Components/startvs.cmd +++ b/src/Components/startvs.cmd @@ -1,3 +1,3 @@ @ECHO OFF -%~dp0..\..\startvs.cmd %~dp0Components.sln +%~dp0..\..\startvs.cmd %~dp0Blazor.sln diff --git a/src/ProjectTemplates/BlazorWasm.ProjectTemplates/BlazorWasm-CSharp.Client.csproj.in b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/BlazorWasm-CSharp.Client.csproj.in index 70927b08e3..d1a3d37097 100644 --- a/src/ProjectTemplates/BlazorWasm.ProjectTemplates/BlazorWasm-CSharp.Client.csproj.in +++ b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/BlazorWasm-CSharp.Client.csproj.in @@ -3,6 +3,7 @@ netstandard2.1 3.0 + service-worker-assets.js @@ -11,10 +12,22 @@ + - + + + + + + + PreserveNewest + wwwroot\service-worker.js + + + + diff --git a/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/.template.config/dotnetcli.host.json b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/.template.config/dotnetcli.host.json index 4e89e1d2dc..be1ce91d66 100644 --- a/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/.template.config/dotnetcli.host.json +++ b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/.template.config/dotnetcli.host.json @@ -5,9 +5,12 @@ "longName": "no-restore", "shortName": "" }, - "Hosted": { - "longName": "hosted" - }, + "Hosted": { + "longName": "hosted" + }, + "PWA": { + "longName": "pwa" + }, "Framework": { "longName": "framework" } diff --git a/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/.template.config/template.json b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/.template.config/template.json index 1c18d08504..73de9b1a06 100644 --- a/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/.template.config/template.json +++ b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/.template.config/template.json @@ -74,6 +74,14 @@ "exclude": [ "*.sln" ] + }, + { + "condition": "(!PWA)", + "exclude": [ + "Client/wwwroot/service-worker*.js", + "Client/wwwroot/manifest.json", + "Client/wwwroot/icon-512.png" + ] } ] } @@ -147,6 +155,12 @@ "fallbackVariableName": "HttpsPortGenerated" }, "replaces": "44300" + }, + "PWA": { + "type": "parameter", + "datatype": "bool", + "defaultValue": "false", + "description": "If specified, produces a Progressive Web Application (PWA) supporting installation and offline use." } }, "tags": { diff --git a/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/.template.config/vs-2017.3.host.json b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/.template.config/vs-2017.3.host.json index 5cb50d10a5..b87413a03f 100644 --- a/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/.template.config/vs-2017.3.host.json +++ b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/.template.config/vs-2017.3.host.json @@ -25,6 +25,13 @@ "text": "ASP.NET Core _hosted" }, "isVisible": "true" + }, + { + "id": "PWA", + "name": { + "text": "_Progressive Web Application" + }, + "isVisible": "true" } ] } diff --git a/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/icon-512.png b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/icon-512.png new file mode 100644 index 0000000000..370c2082b6 Binary files /dev/null and b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/icon-512.png differ diff --git a/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/index.html b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/index.html index 7c402d8073..124cae8316 100644 --- a/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/index.html +++ b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/index.html @@ -8,6 +8,9 @@ + + + @@ -19,6 +22,9 @@ 🗙 + + + diff --git a/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/manifest.json b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/manifest.json new file mode 100644 index 0000000000..d986d96b02 --- /dev/null +++ b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/manifest.json @@ -0,0 +1,15 @@ +{ + "name": "BlazorWasm-CSharp", + "short_name": "BlazorWasm-CSharp", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#03173d", + "icons": [ + { + "src": "icon-512.png", + "type": "image/png", + "sizes": "512x512" + } + ] +} diff --git a/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/service-worker.js b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/service-worker.js new file mode 100644 index 0000000000..fe614daee0 --- /dev/null +++ b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/service-worker.js @@ -0,0 +1,4 @@ +// In development, always fetch from the network and do not enable offline support. +// This is because caching would make development more difficult (changes would not +// be reflected on the first load after each change). +self.addEventListener('fetch', () => { }); diff --git a/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/service-worker.published.js b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/service-worker.published.js new file mode 100644 index 0000000000..220bba532e --- /dev/null +++ b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/service-worker.published.js @@ -0,0 +1,48 @@ +// Caution! Be sure you understand the caveats before publishing an application with +// offline support. See https://aka.ms/blazor-offline-considerations + +self.importScripts('./service-worker-assets.js'); +self.addEventListener('install', event => event.waitUntil(onInstall(event))); +self.addEventListener('activate', event => event.waitUntil(onActivate(event))); +self.addEventListener('fetch', event => event.respondWith(onFetch(event))); + +const cacheNamePrefix = 'offline-cache-'; +const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`; +const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/ ]; +const offlineAssetsExclude = [ /^service-worker\.js$/ ]; + +async function onInstall(event) { + console.info('Service worker: Install'); + + // Fetch and cache all matching items from the assets manifest + const assetsRequests = self.assetsManifest.assets + .filter(asset => offlineAssetsInclude.some(pattern => pattern.test(asset.url))) + .filter(asset => !offlineAssetsExclude.some(pattern => pattern.test(asset.url))) + .map(asset => new Request(asset.url, { integrity: asset.hash })); + await caches.open(cacheName).then(cache => cache.addAll(assetsRequests)); +} + +async function onActivate(event) { + console.info('Service worker: Activate'); + + // Delete unused caches + const cacheKeys = await caches.keys(); + await Promise.all(cacheKeys + .filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName) + .map(key => caches.delete(key))); +} + +async function onFetch(event) { + let cachedResponse = null; + if (event.request.method === 'GET') { + // For all navigation requests, try to serve index.html from cache + // If you need some URLs to be server-rendered, edit the following check to exclude those URLs + const shouldServeIndexHtml = event.request.mode === 'navigate'; + + const request = shouldServeIndexHtml ? 'index.html' : event.request; + const cache = await caches.open(cacheName); + cachedResponse = await cache.match(request); + } + + return cachedResponse || fetch(event.request); +} diff --git a/src/ProjectTemplates/test/BaselineTest.cs b/src/ProjectTemplates/test/BaselineTest.cs new file mode 100644 index 0000000000..3da015f691 --- /dev/null +++ b/src/ProjectTemplates/test/BaselineTest.cs @@ -0,0 +1,150 @@ +// 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.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Templates.Test.Helpers; +using Xunit; +using Xunit.Abstractions; + +namespace Templates.Test +{ + public class BaselineTest + { + private static readonly Regex TemplateNameRegex = new Regex( + "new (?