Page loads of errors show an icon page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-10 18:16:04 +01:00
parent 91a9ee5295
commit 8068205d0c
5 changed files with 65 additions and 5 deletions

View File

@ -0,0 +1,34 @@
/// A wordless page for an error the window itself loaded: Chrome would
/// otherwise paint its own "This page isn't working" sentences over an
/// empty error body. Only the title carries the app's name (OS-level label).
library error_page;
import 'dart:io';
/// HTML page with only an icon — no words painted by the browser.
const String errorIconPage =
'<!doctype html><html><head><meta charset="utf-8"> '
'<title>Diet Guard</title><style>html,body{margin:0;height:100%;'
'background:#211D1B;display:flex;align-items:center;'
'justify-content:center}</style></head><body> '
'<svg width="96" height="96" viewBox="0 0 24 24" fill="none" '
' stroke="#A39892" stroke-width="2" stroke-linecap="round" '
' aria-hidden="true"><circle cx="12" cy="12" r="9"/>'
'<line x1="5.6" y1="18.4" x2="18.4" y2="5.6"/></svg></body></html>';
/// Whether [request] is the window loading a page, not the app's fetch().
bool isNavigation(HttpRequest request) =>
request.headers.value('sec-fetch-mode') == 'navigate';
/// Replaces an error answer to a page load with [errorIconPage]. Does
/// nothing for fetch() calls, successes, or a response already written.
void paintErrorIcon(HttpRequest request) {
final response = request.response;
if (!isNavigation(request) || response.statusCode < 400) return;
try {
response.headers.contentType = ContentType.html;
response.write(errorIconPage);
} on Exception {
return; // body already sent: leave it alone
}
}

View File

@ -19,6 +19,7 @@ library;
import 'dart:convert';
import 'dart:io';
import 'package:diet_guard_app/desktop/error_page.dart' show isNavigation;
import 'package:http/http.dart' as http;
/// GitHub's REST API base, which [GitHubProxy] fronts.
@ -177,9 +178,8 @@ class GitHubProxy {
}
void _passThrough(HttpRequest request, http.Response response) {
final isNavigate = request.headers.value('sec-fetch-mode') == 'navigate';
request.response.statusCode = response.statusCode;
if (isNavigate) {
if (isNavigation(request)) {
// Top-level navigation (window load) must not paint error JSON; the app's
// own fetch() calls never send 'navigate', so they still get the body.
return;

View File

@ -24,6 +24,7 @@ import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:diet_guard_app/desktop/content_types.dart' as content_types;
import 'package:diet_guard_app/desktop/error_page.dart';
import 'package:diet_guard_app/desktop/github_proxy.dart';
import 'package:diet_guard_app/services/desktop_wrapper.dart';
import 'package:path/path.dart' as p;
@ -91,6 +92,7 @@ class WrapperServer {
} on Exception {
request.response.statusCode = HttpStatus.internalServerError;
}
paintErrorIcon(request);
await request.response.close();
}
}

View File

@ -2,7 +2,7 @@ name: diet_guard_app
description: "Companion phone app for diet_guard: log meals on the go."
publish_to: 'none'
version: 1.0.152+1
version: 1.0.153+1
environment:
sdk: ^3.12.2

View File

@ -13,6 +13,16 @@ import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:path/path.dart' as p;
/// Removes <title> and <style> tags, strips all remaining HTML tags, and
/// returns every [A-Za-z]{2,} match — i.e. all visible words the browser would
/// paint for a top-level navigation.
List<String> paintedWords(String html) {
var noTitle = html.replaceAll(RegExp(r'<title>.*?</title>', dotAll: true), '');
var noStyle = noTitle.replaceAll(RegExp(r'<style>.*?</style>', dotAll: true), '');
var noTags = noStyle.replaceAll(RegExp(r'<[^>]*>'), '');
return RegExp(r'[A-Za-z]{2,}').allMatches(noTags).map((m) => m.group(0)!).toList();
}
void main() {
late Directory tempDir;
late WrapperServer server;
@ -69,6 +79,18 @@ void main() {
expect(response.statusCode, status);
expect(response.body, isEmpty, reason: 'nothing painted in the window');
});
test('$method $path loaded as a page shows only an icon', () async {
final request = http.Request(method, url(path))
..headers['sec-fetch-mode'] = 'navigate';
final response = await http.Response.fromStream(
await http.Client().send(request),
);
expect(response.statusCode, status);
expect(response.headers['content-type'], contains('text/html'));
expect(response.body, contains('<svg'));
expect(paintedWords(response.body), isEmpty);
});
}
group('proxy error responses', () {
@ -105,14 +127,16 @@ void main() {
Uri url(String path) => Uri.parse('http://localhost:${server.port}$path');
test('a proxy error loaded as a page has an empty body', () async {
test('a proxy error loaded as a page shows only an icon', () async {
final request = http.Request('GET', url('/github/api/repos/o/r/contents/x'))
..headers['sec-fetch-mode'] = 'navigate';
final response = await http.Response.fromStream(
await http.Client().send(request),
);
expect(response.statusCode, HttpStatus.unauthorized);
expect(response.body, isEmpty, reason: 'nothing painted in the window');
expect(response.headers['content-type'], contains('text/html'));
expect(response.body, contains('<svg'));
expect(paintedWords(response.body), isEmpty);
});
test('the app fetch still gets the proxy error body', () async {