Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions apps/pwabuilder-google-play/services/packageCreator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,9 @@ export class PackageCreator {
} catch (error) {
const errorMessage = (error as Error)?.message || `${error}`;

// Is it a 403 Forbidden, a timeout, or a wrong content-type error?
// Is it a 403 Forbidden, a timeout, a wrong content-type error, or an unrecognized image MIME type?
// If so, try using our PWABuilder image proxy, which can get around some of these issues.
this.dispatchProgressEvent("Unable to generate app package due to error. Checking if error is 403 Forbidden, timeout, or wrong content-type error. " + errorMessage, "warn");
this.dispatchProgressEvent("Unable to generate app package due to error. Checking if error is 403 Forbidden, timeout, wrong content-type, or invalid image MIME error. " + errorMessage, "warn");
const is403Error =
(error as any)?.status === 403 ||
(error as any)?.response?.status === 403 ||
Expand All @@ -187,12 +187,15 @@ export class PackageCreator {
const isTimeout = errorMessage.includes('ETIMEDOUT') || errorMessage.includes('ESOCKETTIMEDOUT');
const isWrongContentType = errorMessage.includes('with invalid Content-Type'); // Some AI app generation sites will put your web app to sleep while not in use. Then PWAbuilder access an image, and the site serves HTML loading screen instead of image, resulting in invalid Content-Type. In that case, we'll use our image proxy to see if we can access it. See https://github.com/pwa-builder/PWABuilder/issues/5937
const isRunDotAppManifestError = options.pwaUrl.indexOf("run.app/") && errorMessage.includes("Unexpected token"); // Sites on run.app spin up when you access them, rendering a loading screen. This is problematic when fetching resources like web manifest, which expects JSON, not HTML. See https://github.com/pwa-builder/PWABuilder/issues/5380
if (is403Error || isTimeout || isRunDotAppManifestError || isWrongContentType) {
const isInvalidImageMime = errorMessage.includes('Could not find MIME for Buffer'); // Images that are missing or corrupt (e.g. HTML served where a PNG is expected) cause Bubblewrap to fail. Retry using our image proxy, which validates images and can provide a clearer error. See https://github.com/pwa-builder/PWABuilder/issues/6127
if (is403Error || isTimeout || isRunDotAppManifestError || isWrongContentType || isInvalidImageMime) {
const optionsWithSafeUrl = this.getAndroidOptionsWithProxiedUrls(options);
// See if it's Cloudflare. Check the Server response header for "cloudflare".
const isCloudflare = await this.TryCheckCloudflare(options.iconUrl);
if (isCloudflare) {
this.dispatchProgressEvent("Cloudflare is blocking PWABuilder from accessing your app's images. If the problem persists, please temporarily disable Cloudflare's \"Bot fight mode\" while you're packaging with PWABuilder. For more help, see https://docs.pwabuilder.com/#/builder/faq?id=error-403-forbidden-during-analysis-or-packaging", "warn");
} else if (isInvalidImageMime) {
this.dispatchProgressEvent("One or more images in your web manifest appear to be missing or invalid. PWABuilder will retry using its image proxy service, which may provide a clearer error message.", "warn");
} else {
this.dispatchProgressEvent("Your web app is blocking PWABuilder from accessing your app's images, serving 403 Forbidden errors to PWABuilder. If the problem persists, please temporarily disable your firewall, CDN, or Cloudflare while packaging with PWABuilder. For more help, see https://docs.pwabuilder.com/#/builder/faq?id=error-403-forbidden-during-analysis-or-packaging", "warn");
}
Expand Down
41 changes: 29 additions & 12 deletions apps/pwabuilder/Services/ImageValidationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public async Task<Result<bool>> TryImageExistsAsync(Uri imageUrl, CancellationTo
using var headResponse = await httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, imageUrl), cancelToken);
if (headResponse.IsSuccessStatusCode)
{
return await ValidateFetchedImageAsync(imageUrl, headResponse.Content.Headers.ContentType?.MediaType);
return await ValidateFetchedImageAsync(imageUrl, headResponse.Content.Headers.ContentType?.MediaType, cancelToken);
}
}
catch (Exception headException)
Expand All @@ -105,7 +105,7 @@ public async Task<Result<bool>> TryImageExistsAsync(Uri imageUrl, CancellationTo

if (response.IsSuccessStatusCode)
{
return await ValidateFetchedImageAsync(imageUrl, response.Content.Headers.ContentType?.MediaType);
return await ValidateFetchedImageAsync(imageUrl, response.Content.Headers.ContentType?.MediaType, cancelToken);
}
else if (response.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
Expand Down Expand Up @@ -326,9 +326,9 @@ private Result<bool> EnsureImageContentType(Uri imageUrl, string? contentType)
/// </summary>
/// <param name="imageUrl">The URL of the image.</param>
/// <param name="contentType">The content type returned by the server, if any.</param>
/// <param name="server">The value of the response's Server header, if any.</param>
/// <param name="cancelToken">The cancellation token.</param>
/// <returns>True if the image is valid, otherwise an error describing the problem.</returns>
private async Task<Result<bool>> ValidateFetchedImageAsync(Uri imageUrl, string? contentType)
private async Task<Result<bool>> ValidateFetchedImageAsync(Uri imageUrl, string? contentType, CancellationToken cancelToken = default)
{
// Ensure the content type is an image.
var contentTypeCheck = EnsureImageContentType(imageUrl, contentType);
Expand All @@ -338,15 +338,15 @@ private async Task<Result<bool>> ValidateFetchedImageAsync(Uri imageUrl, string?
}

// An image can exist and be served with a valid image content type but still be corrupt, or the
// host can return HTTP 200 for a missing image (e.g. Vercel, Netlify, *.run.app). When the image
// claims to be a decodable raster format, fetch and decode its bytes to confirm it's really a
// valid, existing image.
// host can return HTTP 200 for a missing image (e.g. Vercel, Netlify, *.run.app, Render.com with
// SPA routing). When the image claims to be a decodable raster format, fetch and decode its bytes
// to confirm it's really a valid, existing image.
if (IsDecodableRasterImageType(imageUrl, contentType))
{
// Only block when we actually fetched the bytes and they couldn't be decoded (i.e. the image
// is genuinely corrupt or doesn't exist). A transient fetch failure shouldn't fail an
// otherwise-valid PWA.
var decodeResult = await TryDecodeImageAsync(imageUrl);
var decodeResult = await TryDecodeImageAsync(imageUrl, cancelToken);
if (decodeResult is ImageDecodeResult.Undecodable)
{
return new HttpRequestException($"Your PWA's web manifest refers to an image that appears to be corrupt or invalid: {imageUrl}. The server returned it as {contentType ?? "an image"}, but its contents couldn't be read as an image. Try re-exporting and re-uploading the image.", null, System.Net.HttpStatusCode.UnprocessableEntity);
Expand Down Expand Up @@ -381,13 +381,14 @@ private static bool IsDecodableRasterImageType(Uri imageUrl, string? contentType
/// a transient fetch failure.
/// </summary>
/// <param name="imageUri">The URI of the image to fetch and decode.</param>
/// <param name="cancelToken">The cancellation token.</param>
/// <returns>The outcome of the fetch-and-decode attempt.</returns>
private async Task<ImageDecodeResult> TryDecodeImageAsync(Uri imageUri)
private async Task<ImageDecodeResult> TryDecodeImageAsync(Uri imageUri, CancellationToken cancelToken = default)
{
HttpResponseMessage response;
try
{
response = await httpClient.GetAsync(imageUri, HttpCompletionOption.ResponseHeadersRead);
response = await httpClient.GetAsync(imageUri, HttpCompletionOption.ResponseHeadersRead, cancelToken);
}
catch (Exception fetchError)
{
Expand All @@ -402,13 +403,29 @@ private async Task<ImageDecodeResult> TryDecodeImageAsync(Uri imageUri)
return ImageDecodeResult.FetchFailed;
}

// Check that the server is serving a valid image content type. Some hosting platforms (e.g.
// Vercel, Netlify, Render.com with SPA routing enabled) return the app's index.html with HTTP 200
// for any missing resource path. When a server returns a non-image content type (like text/html)
// for a URL ending in .png, the image file almost certainly doesn't exist.
var responseContentType = response.Content.Headers.ContentType?.MediaType;
if (!string.IsNullOrEmpty(responseContentType)
&& !responseContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)
&& responseContentType != "application/octet-stream")
{
logger.LogWarning(
"Image {imageUri} was fetched but has an unexpected content-type: {contentType}. Expected an image content-type.",
imageUri,
responseContentType);
return ImageDecodeResult.Undecodable;
}

try
{
using var imageStream = await response.Content.ReadAsStreamAsync();
using var imageStream = await response.Content.ReadAsStreamAsync(cancelToken);

// Use IdentifyAsync to read image metadata (format/dimensions) without fully decoding
// the image. This is faster and uses less memory, and still fails on corrupt images.
var info = await Image.IdentifyAsync(imageStream);
var info = await Image.IdentifyAsync(imageStream, cancelToken);
if (info is null)
{
logger.LogWarning("Could not identify image {imageUri}; it may be corrupt.", imageUri);
Expand Down
11 changes: 11 additions & 0 deletions apps/pwabuilder/wwwroot/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,17 @@ body {
overflow-x: hidden; /* Prevent horizontal scrolling at high zoom */
}

/*
* WebAwesome locks page scrolling by adding the `wa-scroll-lock` class to
* <html> while a dialog is open, but its bundled rule only targets <body>
* (`.wa-scroll-lock body { overflow: hidden }`). PWABuilder scrolls on <html>,
* so that rule is a no-op here. Mirror it on <html> so WA's built-in scroll
* lock works for every dialog without any per-dialog JavaScript.
*/
html.wa-scroll-lock {
overflow: hidden !important;
}

/* Ensure all text wraps properly */
* {
word-wrap: break-word;
Expand Down
Loading