Swimburger

How to deploy Blazor WASM & Azure Functions to Azure Static Web Apps using GitHub

Niels Swimberghe

Niels Swimberghe - - .NET

Follow me on Twitter, buy me a coffee

How to deploy ASP.NET Blazor WebAssembly to Azure Static Web Apps. Blazor logo pointing to a GitHub logo pointing to an Azure logo.

Update 09/28/20: Azure Static Web Apps was officially announced at September's MSIgnite. This tutorial has been updated to include support for Azure Functions.

With ASP.NET Blazor WebAssembly (WASM) you can create .NET web applications that run completely inside of the browser sandbox. The publish output of a Blazor WASM project are static files. Now that you can run .NET web applications without server side code, you can deploy these applications to various static site hosts, such as Azure Static Web Apps.

Check out how to deploy Blazor WASM to these alternative static site hosts:

This walkthrough will show you how to deploy Blazor WASM to Azure Static Web Apps communicating with the built-in Azure Functions integration. Azure Static Web Apps (in preview) is a new free service that let's you host static sites deployed from GitHub. In addition to static site hosting, the service provides additional features:

  • Generate continuous integration and deployment using GitHub Actions to build and deploy your static web application
  • Automatically create hosting environments for different Git branches and pull requests
  • Azure Functions integration
  • Custom domain names

This walkthrough will show you how to deploy to Azure Static Web Apps following these high level steps:

  1. Create Blazor WebAssembly project
  2. Create .NET Core Azure Functions project
  3. Commit the projects to a Git repository
  4. Create a new GitHub project and push the Git repository to GitHub
  5. Create a new Azure Static Web App
  6. Integrate GitHub with Azure Static Web App
  7. Commit GitHub Actions Workflow
  8. Run GitHub Actions Workflow

Prerequisites:

You can find the source code for this how-to on GitHub.

Create ASP.NET Core Blazor WebAssembly project #

Run the following commands to create a new Blazor WASM project:

mkdir AzureSwaBlazorWasmDemo
cd AzureSwaBlazorWasmDemo
mkdir Client
cd Client
dotnet new blazorwasm

To give your application a try, run dotnet run and browse to the URL in the output (by default https://localhost:5001):

dotnet run
# info: Microsoft.Hosting.Lifetime[0]
#       Now listening on: https://localhost:5001
# info: Microsoft.Hosting.Lifetime[0]
#       Now listening on: http://localhost:5000
# info: Microsoft.Hosting.Lifetime[0]
#       Application started. Press Ctrl+C to shut down.
# info: Microsoft.Hosting.Lifetime[0]
#       Hosting environment: Development
# info: Microsoft.Hosting.Lifetime[0]
#       Content root path: C:\Users\niels\source\repos\AzureSwaBlazorWasmDemo\Client

Optional: You can use the dotnet publish command to publish the project and verify the output:

dotnet publish -c Release
# Microsoft (R) Build Engine version 16.7.0+b89cb5fde for .NET
# Copyright (C) Microsoft Corporation. All rights reserved.
# 
#   Determining projects to restore...
#   All projects are up-to-date for restore.
#   Client -> C:\Users\niels\source\repos\AzureSwaBlazorWasmDemo\Client\bin\Release\netstandard2.1\Client.dll
#   Client (Blazor output) -> C:\Users\niels\source\repos\AzureSwaBlazorWasmDemo\Client\bin\Release\netstandard2.1\wwwroot
#   Client -> C:\Users\niels\source\repos\AzureSwaBlazorWasmDemo\Client\bin\Release\netstandard2.1\publish\

In the publish directory, you will find a web.config file and a wwwroot folder. The config file helps you host your application in IIS, but you don't need this file for static site hosts. Everything you need will be inside of the wwwroot folder. The wwwroot folder contains the index.html, CSS, JS, and DLL files necessary to run the Blazor application.

Create .NET Core Azure Functions project #

You'll need to install the Azure Functions Core Tools before you can create and run a .NET Core Azure Functions project. Follow the installation instructions from the Microsoft Documentation. Make sure to restart your console/terminal to make the new func commands available.

Use the following commands to create a new Azure Functions project and create an Azure Function called WeatherForecast:

mkdir Api
cd Api
# creates Azure Functions project
func init --worker-runtime dotnet
# creates Azure Function called 'WeatherForecast'
func new --language C# --template "Http Trigger" --name WeatherForecast

Azure Static Web Apps only supports 'HTTP Trigger' functions. Azure Functions triggered by other events like a CRON schedule, queues, etc. are not supported.

Update the Azure Function file WeatherForecast.cs with the code below:

using System;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;

namespace Api
{
    public static class WeatherForecast
    {
        public static readonly Random random = new Random();
        public static readonly string[] summaries = new string[] {
            "Freezing",
            "Bracing",
            "Balmy",
            "Chilly"
        };
        
        // This function will create 5 random weather forecasts, matching the format of Client/wwwroot/sample-data/weather.json
        [FunctionName("WeatherForecast")]
        public static IActionResult Run(
            [HttpTrigger(AuthorizationLevel.Function, "get", Route = null)] HttpRequest req,
            ILogger log)
        {
            var forecasts = new object[5];
            var currentDate = DateTime.Today;
            for (int i = 0; i < 5; i++)
            {
                var futureDate = currentDate.AddDays(i);
                var temperatureC = random.Next(-10, 35);
                forecasts[i] = new {
                    date = futureDate.ToString("yyyy-MM-dd"),
                    temperatureC = temperatureC,
                    summary = summaries[random.Next(0, summaries.Length)]
                };
            }
            return new OkObjectResult(forecasts);
        }
    }
}

By default, the Blazor WASM project will make an ajax request to the file located at Client/wwwroot/sample-data/weather.json. The Azure Function mimics the format of that file so you can replace the ajax call to the sample file with an ajax call to the Azure Function.

For now, test out your new Azure Function by running func start and browse to the URL found in the console output. The URL should be http://localhost:7071/api/WeatherForecast. The result should look like an unformatted version of this:

[
    {
        "date": "2020-09-28",
        "temperatureC": 17,
        "summary": "Bracing"
    },
    {
        "date": "2020-09-29",
        "temperatureC": 0,
        "summary": "Bracing"
    },
    {
        "date": "2020-09-30",
        "temperatureC": 18,
        "summary": "Chilly"
    },
    {
        "date": "2020-10-01",
        "temperatureC": 15,
        "summary": "Bracing"
    },
    {
        "date": "2020-10-02",
        "temperatureC": 1,
        "summary": "Freezing"
    }
]

In the next section, you'll integrate the Blazor Client with the Azure Functions API.

Integrating Blazor with Azure Functions #

You need to update the Blazor Client to make an ajax call to your Azure Function instead of the sample data at Client/wwwroot/sample-data/weather.json. Update the Blazor component at Client\Pages\FetchData.razor, by replacing the commented out line below, with the line not commented out:

protected override async Task OnInitializedAsync()
{
    // forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("sample-data/weather.json");
    forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("http://localhost:7071/api/WeatherForecast");
}

To run the Blazor client and Azure Function, you'll need to run the following two commands in separate consoles/terminals:

  • At the root folder run dotnet run --project Client
  • At the Api folder run func start --cors "https://localhost:5001,http://localhost:5000"

The --cors argument is required to allow communication between localhost:5001 or localhost:5000 and the Azure Function at localhost:7071.

Browse to /fetchdata in your Blazor application and notice how the weather forecasts changes randomly with every refresh.

Screenshot of the &#x27;Fetch data&#x27; page of the Blazor Client

This works well locally, but the hardcoded URL http://localhost:7071/api/WeatherForecast won't work when anywhere but locally. To make this work both locally and elsewhere, update the following files:

Update Client\Program.cs:

using System;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection;

namespace Client
{
    public class Program
    {
        public static async Task Main(string[] args)
        {
            var builder = WebAssemblyHostBuilder.CreateDefault(args);
            builder.RootComponents.Add<App>("app");

            // UPDATE
            var baseAddress = builder.Configuration["BaseAddress"] ?? builder.HostEnvironment.BaseAddress;
            builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(baseAddress) });
            // END UPDATE

            await builder.Build().RunAsync();
        }
    }
}

Add Client\wwwroot\appsettings.Development.json:

{
  "BaseAddress": "http://localhost:7071/"
}

Update OnInitializedAsync at Client\Pages\FetchData.razor:

@page "/fetchdata"
@inject HttpClient Http

...

@code {
    ...
    protected override async Task OnInitializedAsync()
    {
        // UPDATE
        forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("/api/WeatherForecast");
        // END UPDATE
    }
    ...
}

Here's what changed:

  • in Startup.cs an HttpClient is configured with a BaseAddress. This HttpClient is injected into FetchData using dependency injection.
    • Locally, the base address will use the BaseAddress from appsettings.Development.json which is http://localhost:7071/.
    • In Azure Static Web Apps, the root address of the Blazor application will be used as the BaseAddress for the HttpClient because the appsettings file won't be deployed.
      This will result in ajax calls being made to https://yourapp.azurestaticapps.net/api/weatherforecast.
      Azure Static Web Apps will intercept any call made to /api and forward it to the Azure Functions project.
  • appsettings.Development.json will not be deployed to Azure Static Web Apps.
  • FetchData.razor will receive an HttpClient from the Dependency Injection container and will make ajax calls to the right address whether it is hosted locally or on Azure Static Web Apps.

Push projects to GitHub #

Azure Static Web Apps requires your application source code to be inside of a GitHub repository.
First, you need to create a local Git repository and commit your source code to the repository using these commands:

# add the gitignore file tailored for dotnet applications, this will ignore bin/obj and many other non-source code files
dotnet new gitignore
# create the git repository
git init
# track all files that are not ignore by .gitignore
git add --all
# commit all changes to the repository
git commit -m "Initial commit"

Create a new GitHub repository (public or private). Copy the commands from the empty GitHub repository page under the title: "push an existing repository from the command line". Here's what it should looks like but with a different URL:

git remote add origin https://github.com/Swimburger/AzureSwaBlazorWasmDemo.git
git push -u origin master

These commands will push the repository up to GitHub.

Create Azure Static Web Application #

Navigate to the Azure Portal (portal.azure.com) and click on "Create a resource" in the navigation menu on the left.

Screenshot of Azure left navigation pane

Search for 'Static Web App' and click on the 'Static Web App (preview)' card.

Search for Azure Static Web App

On the Static Web App details page, click the 'Create'-button.

Azure Static Web Apps marketplace page

Fill out the fields on the create static web app blade:

Screenshot of Create Azure Static Web App Basics tab

Click on the 'Sign in with GitHub' button. This will show a GitHub dialog prompting you to give permissions to Azure. These permissions are required so that Azure can commit the GitHub actions YAML-file for CI/CD and so GitHub can deploy to Azure.

Screenshot of selecting GitHub repository in Azure Static Web App

Now that you've connected GitHub to your Azure Static Web App, select your GitHub repository by filling out the 'Organization', 'Repository', and 'Branch' field.

Once you have selected your Git repository, the 'Build Details' section will show up.
In the 'Build Presets' dropdown, select Blazor. This will prefill the next fields:

  • App location: Client
    This is the folder of your Blazor WASM project.
  • Api location: Api
    This is the folder for your Azure Functions.
  • App artifact location: wwwroot
    Specify the 'wwwroot' subfolder. When GitHub Actions builds your Blazor project, it will grab the files from the published 'wwwroot' folder.

These fields will be used to create a GitHub Actions Workflow YAML-file.

Azure Static Web App Build Details

Next, click the 'Review + create' button and then click the 'Create' button.
Wait for the deployment to complete and then click the 'Go to resource' button:

Azure Static Web Apps Deployment

On the overview page of your Azure Static Web App you will find a lot of links:

  • URL: this is where your web app is hosted
  • Source: this is a link to the selected branch in your GitHub repository
  • Deployment history: this is a link to GitHub Action in your GitHub repository
  • Workflow file: this is a link to the GitHub Actions YAML file in your GitHub repository.
Screenshot of an Azure Static Web App resource

Click on the URL to view your new static web app. At this point, your Blazor application should've been deployed. If not wait a little and refresh the page.

GitHub Actions YAML file explained #

You might be wondering, how did Azure/GitHub know how to build and deploy my application? Let's take a look at the GitHub Actions YAML file that Azure generated and committed into our GitHub repository:

name: Azure Static Web Apps CI/CD

on:
  push:
    branches:
      - master
  pull_request:
    types: [opened, synchronize, reopened, closed]
    branches:
      - master

jobs:
  build_and_deploy_job:
    if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed')
    runs-on: ubuntu-latest
    name: Build and Deploy Job
    steps:
      - uses: actions/checkout@v2
        with:
          submodules: true
      - name: Build And Deploy
        id: builddeploy
        uses: Azure/static-web-apps-deploy@v0.0.1-preview
        with:
          azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_PROUD_FLOWER_0EB79A90F }}
          repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments)
          action: "upload"
          ###### Repository/Build Configurations - These values can be configured to match you app requirements. ######
          # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
          app_location: "Client" # App source code path
          api_location: "Api" # Api source code path - optional
          app_artifact_location: "wwwroot" # Built app content directory - optional
          ###### End of Repository/Build Configurations ######

  close_pull_request_job:
    if: github.event_name == 'pull_request' && github.event.action == 'closed'
    runs-on: ubuntu-latest
    name: Close Pull Request Job
    steps:
      - name: Close Pull Request
        id: closepullrequest
        uses: Azure/static-web-apps-deploy@v0.0.1-preview
        with:
          azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_PROUD_FLOWER_0EB79A90F }}
          action: "close"

The first section 'on' instructs GitHub Actions when to run the workflow. The workflow will run every time a commit is pushed to the master-branch and every time a pull request is made against the master branch.

on:
  push:
    branches:
      - master
  pull_request:
    types: [opened, synchronize, reopened, closed]
    branches:
      - master

The if statements makes it so build_and_deploy_job will always run except when a pull request is closed.
runs-on specifies the job to run on an Ubuntu VM. The first step under steps of the job will checkout the latest code from the master branch.

The next step will use Azure's 'static-web-apps-deploy' Action. This Action will build, deploy your code, and also create environments for pull requests. This GitHub Action is hiding all of the 'magic', but more on that later.
For more information on this action and how to configure it, you can read the Microsoft documentation: "GitHub Actions workflows for Azure Static Web Apps Preview".

In the with section, you will find the parameters you configured when creating the Azure resource. The other parameters are secrets created by Azure when you created the Azure Static Web App. These secrets are used to authenticate and securely perform actions in Azure and in GitHub.

jobs:
  build_and_deploy_job:
    if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed')
    runs-on: ubuntu-latest
    name: Build and Deploy Job
    steps:
      - uses: actions/checkout@v2
        with:
          submodules: true
      - name: Build And Deploy
        id: builddeploy
        uses: Azure/static-web-apps-deploy@v0.0.1-preview
        with:
          azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_PROUD_FLOWER_0EB79A90F }}
          repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments)
          action: "upload"
          ###### Repository/Build Configurations - These values can be configured to match you app requirements. ######
          # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
          app_location: "Client" # App source code path
          api_location: "Api" # Api source code path - optional
          app_artifact_location: "wwwroot" # Built app content directory - optional
          ###### End of Repository/Build Configurations ######

The next job will only run if a pull request is closed as specified by the if property. This will use the same Azure GitHub Action but the action parameter under the with section is set to 'close'. 
This step will delete the environment in Azure that was generated automatically when a pull request was opened against the master-branch. 

  close_pull_request_job:
    if: github.event_name == 'pull_request' && github.event.action == 'closed'
    runs-on: ubuntu-latest
    name: Close Pull Request Job
    steps:
      - name: Close Pull Request
        id: closepullrequest
        uses: Azure/static-web-apps-deploy@v0.0.1-preview
        with:
          azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_PROUD_FLOWER_0EB79A90F }}
          action: "close"

Now, you still might be wondering, how did this 'static-web-apps-deploy' GitHub Action actually know how to build the Blazor application without any hints of it being a .NET Core application?
The GitHub repository of the action readme notes it uses another open-source project by Microsoft called 'Oryx'. 

"Oryx is a build system which automatically compiles source code repos into runnable artifacts. It is used to build web apps for Azure App Service and other platforms."
- Oryx GitHub readme.md

When you poke around the project and its source code, you learn that this project tries to build any source code without giving it build instructions or other hints. Oryx will attempt to detect the type of project by scanning through your source code and based on that run the build commands.

You can see this in action by looking at the logs of your GitHub Action Workflow runs. Navigate to details of your GitHub Actions Workflows and inspect the logs:

Azure Static Web App GitHub Workflow Run output

Under Build And Deploy you will find the familiar output of the .NET Core publish command:

Starting to build app with Oryx
Azure Static Web Apps utilizes Oryx to build both static applications and Azure Functions. You can find more details on Oryx here: https://github.com/microsoft/Oryx
---Oryx build logs---


Operation performed by Microsoft Oryx, https://github.com/Microsoft/Oryx
You can report issues at https://github.com/Microsoft/Oryx/issues

Oryx Version: 0.2.20200719.1, Commit: 80603ef82312ab8b6d950149f332638c5707de74, ReleaseTagName: 20200719.1

Build Operation ID: |IuZ/1SZ+OuM=.e7ee18e8_
Repository Commit : 61dc6f43a550eb495c25379fcbc66f9e22c96823

Detecting platforms...
Detected following platforms:
  dotnet: 3.1.8
Version '3.1.8' of platform 'dotnet' is not installed. Generating script to install it...


Source directory     : /github/workspace/Client
Destination directory: /bin/staticsites/ss-oryx/app


Downloading and extracting dotnet version 3.1.402 to /tmp/oryx/platforms/dotnet/sdks/3.1.402...
Downloaded in 2 sec(s).
Verifying checksum...
Extracting contents...
Done in 6 sec(s).


Using .NET Core SDK Version: 3.1.402

Welcome to .NET Core 3.1!
---------------------
SDK Version: 3.1.402

Telemetry
---------
The .NET Core tools collect usage data in order to help us improve your experience. The data is anonymous. It is collected by Microsoft and shared with the community. You can opt-out of telemetry by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your favorite shell.

Read more about .NET Core CLI Tools telemetry: https://aka.ms/dotnet-cli-telemetry

----------------
Explore documentation: https://aka.ms/dotnet-docs
Report issues and find source on GitHub: https://github.com/dotnet/core
Find out what's new: https://aka.ms/dotnet-whats-new
Learn about the installed HTTPS developer cert: https://aka.ms/aspnet-core-https
Use 'dotnet --help' to see available commands or visit: https://aka.ms/dotnet-cli-docs
Write your first app: https://aka.ms/first-net-core-app
--------------------------------------------------------------------------------------
  Determining projects to restore...
  Restored /github/workspace/Client/Client.csproj (in 2.05 sec).

Publishing to directory /bin/staticsites/ss-oryx/app...

Microsoft (R) Build Engine version 16.7.0+7fb82e5b2 for .NET
Copyright (C) Microsoft Corporation. All rights reserved.

  Determining projects to restore...
  All projects are up-to-date for restore.
  Client -> /github/workspace/Client/bin/Release/netstandard2.1/Client.dll
  Client (Blazor output) -> /github/workspace/Client/bin/Release/netstandard2.1/wwwroot
  Client -> /bin/staticsites/ss-oryx/app/

Removing existing manifest file
Creating a manifest file...
Manifest file created.

Done in 31 sec(s).


---End of Oryx build logs---
Finished building app with Oryx
Starting to build function app with Oryx
---Oryx build logs---


Operation performed by Microsoft Oryx, https://github.com/Microsoft/Oryx
You can report issues at https://github.com/Microsoft/Oryx/issues

Oryx Version: 0.2.20200719.1, Commit: 80603ef82312ab8b6d950149f332638c5707de74, ReleaseTagName: 20200719.1

Build Operation ID: |EUVmP4lReLM=.a5668776_
Repository Commit : 61dc6f43a550eb495c25379fcbc66f9e22c96823

Detecting platforms...
Detected following platforms:
  dotnet: 3.1.8


Source directory     : /github/workspace/Api
Destination directory: /bin/staticsites/ss-oryx/api


Using .NET Core SDK Version: 3.1.402
  Determining projects to restore...
  Restored /github/workspace/Api/Api.csproj (in 6.44 sec).

Publishing to directory /bin/staticsites/ss-oryx/api...

Microsoft (R) Build Engine version 16.7.0+7fb82e5b2 for .NET
Copyright (C) Microsoft Corporation. All rights reserved.

  Determining projects to restore...
  All projects are up-to-date for restore.
  Api -> /github/workspace/Api/bin/Release/netcoreapp3.1/bin/Api.dll
  Api -> /bin/staticsites/ss-oryx/api/

Removing existing manifest file
Creating a manifest file...
Manifest file created.

Done in 11 sec(s).


---End of Oryx build logs---

Oryx installs the .NET Core 3.1 SDK and then runs a dotnet publish.

If for some reason the default behavior of Oryx is undesirable, you can override the build command by adding an extra parameter app_build_command to the Static Site action or build your application in an earlier step.

Fix 404's with SPA routing rule #

You will be greeted with a 404 page if you navigate to the counter or fetch data page and then refresh the page.

404 page returned by Microsoft Azure Static Web App

You can resolve this by telling Azure Static Web Apps to reroute all requests to the index.html file if no file at the requested path was found. To do this create a routes.json file at Client\wwwroot\routes.json with the following contents:

{
  "routes": [
    {
      "route": "/*",
      "serve": "/index.html",
      "statusCode": 200
    }
  ]
}

This routes.json file has to be uploaded to the root of the Azure Static Web App and that's why it has to be in the wwwroot-folder. To learn more about the capabilities of the routes.json file, check out Microsoft's documentation

Stage, commit, and push the changes to the GitHub repository and wait for your application to be redeployed:

git add *
git commit -m "Add routes.json"
git push

Browsing directly to /counter or /fetchdata or refreshing these pages should no longer result in 404 errors.

Bonus: Create a pull request #

When you create a pull request to your master branch, a new environment will automatically be created which hosts the version of your application in that pull request.

Create a new branch by running this command:

git checkout -b ChangeTitle

Change the title tag text inside of wwwroot/index.html to "Hello".
Run the following script to commit the change and push the branch to GitHub:

git add --all
git commit -m "change title"
git push --set-upstream origin ChangeTitle

Navigate to your GitHub repository and click on the "Pull Requests tab". Click on the "New pull request" button.

GitHub Pull Request

Select the 'ChangeTitle' branch as the second dropdown, click 'Create pull request' and click 'Create pull request' again.

The GitHub Action Workflow will automatically run for your pull request. Once finished, Azure will leave a comment on your pull request with the URL to the new environment where your code change is hosted.
If you close the pull request the workflow will run again, but this time the workflow will delete your environment.

Screenshot of GitHub pull request where Azure comments the newly generate environment URL

Summary #

Azure Static Web Apps will automatically generate a CI/CD pipeline for you using GitHub Actions. The Azure Static Web App action knows how to build your application automagically by using Oryx. Oryx scans your code to detect what type of code it is dealing with and attempts to run the right build commands for you. You can provide the build command yourself or customize the GitHub Actions YAML to build your project before passing the result to the Static Web Apps action.

Related Posts

Related Posts