From 8068205d0c8609d2f08cb72908a8dcd09b46331e Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Tue, 10 Nov 2026 18:16:04 +0100 Subject: [PATCH] Page loads of errors show an icon page Co-Authored-By: Claude Opus 5.5 --- app/lib/desktop/error_page.dart | 34 +++++++++++++++++++ app/lib/desktop/github_proxy.dart | 4 +-- app/lib/desktop/wrapper_server.dart | 2 ++ app/pubspec.yaml | 2 +- .../desktop/desktop_wrapper_no_text_test.dart | 28 +++++++++++++-- 5 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 app/lib/desktop/error_page.dart diff --git a/app/lib/desktop/error_page.dart b/app/lib/desktop/error_page.dart new file mode 100644 index 0000000..8c0ab3b --- /dev/null +++ b/app/lib/desktop/error_page.dart @@ -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 = + ' ' + 'Diet Guard ' + ''; + +/// 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 + } +} diff --git a/app/lib/desktop/github_proxy.dart b/app/lib/desktop/github_proxy.dart index 7ddec8e..54c8477 100644 --- a/app/lib/desktop/github_proxy.dart +++ b/app/lib/desktop/github_proxy.dart @@ -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; diff --git a/app/lib/desktop/wrapper_server.dart b/app/lib/desktop/wrapper_server.dart index 3fa9696..b4729c0 100644 --- a/app/lib/desktop/wrapper_server.dart +++ b/app/lib/desktop/wrapper_server.dart @@ -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(); } } diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 10f8dfa..154d3b1 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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 diff --git a/app/test/desktop/desktop_wrapper_no_text_test.dart b/app/test/desktop/desktop_wrapper_no_text_test.dart index 75d2d4b..a33b4f9 100644 --- a/app/test/desktop/desktop_wrapper_no_text_test.dart +++ b/app/test/desktop/desktop_wrapper_no_text_test.dart @@ -13,6 +13,16 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:path/path.dart' as p; +/// Removes 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>.*?', dotAll: true), ''); + var noStyle = noTitle.replaceAll(RegExp(r'', 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(' 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('