Add the desktop wrapper, and stop the test suite eating BACKLOG.md

The desktop app is a browser now, so it cannot write files. This adds the other
half: a loopback HTTP server that serves the web build and owns the two files
the browser cannot touch -- `~/todo/BACKLOG.md`, which the user's tooling and
the todo MCP server read, and a disk copy of the note log so a wiped Chrome
profile is not a total-loss event for unsynced notes.

Notable details:

- Binds to 127.0.0.1 only. The endpoints overwrite files in the user's home
  with no authentication, so a routable bind would let anything on the network
  rewrite the backlog.
- Serves explicit content types. CanvasKit refuses a `.wasm` labelled anything
  else and the app then renders nothing at all.
- Mirroring to disk is debounced. The store persists on every keystroke, so
  mirroring eagerly meant an HTTP request plus a whole-log write per character;
  IndexedDB still gets every keystroke, only the durability copy waits.

Separately, this fixes a pre-existing destructive bug found while testing:
`settings_screen_test` did real file I/O and overwrote the user's canonical
`~/todo/BACKLOG.md` with a single fake note on *every* `flutter test` run. It
went unnoticed because the app rewrites the file whenever it launches. The
export destination is now injectable and the test redirects it to a temp dir;
verified that a full suite run leaves the real file byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-07-20 13:33:25 +02:00
parent 66143bdfe3
commit c37a9ef118
6 changed files with 450 additions and 4 deletions

76
bin/todo_desktop.dart Normal file
View File

@ -0,0 +1,76 @@
// coverage:ignore-file
// Entry point for the desktop wrapper: resolves real paths, starts the server,
// and launches the browser. The serving logic it delegates to is covered by
// test/wrapper_server_test.dart.
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:todo/desktop/wrapper_server.dart';
/// Port must stay fixed: the browser keys `localStorage` (the GitHub token) and
/// IndexedDB (the notes) by origin, so a different port looks like a different
/// app with no token and no notes. Kept in step with lib/sync/desktop_wrapper.
const _port = 8730;
Future<void> main(List<String> args) async {
final home = Platform.environment['HOME'];
if (home == null) {
stderr.writeln('HOME is not set; cannot resolve the backlog path.');
exit(1);
}
// Assets sit next to this executable when installed; --web-root overrides for
// development runs straight out of the repo.
final webRoot = _argValue(args, '--web-root') ??
p.join(p.dirname(Platform.resolvedExecutable), 'web');
if (!Directory(webRoot).existsSync()) {
stderr.writeln('web assets not found at $webRoot');
exit(1);
}
final server = WrapperServer(
webRoot: webRoot,
backlogPath: p.join(home, 'todo', 'BACKLOG.md'),
logPath: p.join(home, '.local', 'share', 'todo-desktop', 'todo_notes.json'),
);
await server.start(_port);
stdout.writeln('todo desktop serving on http://localhost:$_port');
if (_argValue(args, '--no-browser') == null) {
await _launchBrowser(home);
// The browser owns the session: when its window closes, so does the app.
await server.stop();
}
}
/// Launches the app in a Chrome-family browser with a **stable** profile
/// directory, since the token and notes live in that profile.
Future<void> _launchBrowser(String home) async {
const candidates = [
'/opt/google/chrome/chrome',
'/usr/bin/chromium',
'/usr/bin/google-chrome-stable',
];
final browser = candidates.firstWhere(
(path) => File(path).existsSync(),
orElse: () => '',
);
if (browser.isEmpty) {
stderr.writeln('No Chrome-family browser found; open '
'http://localhost:$_port manually.');
return;
}
final profile = p.join(home, '.local', 'share', 'todo-desktop', 'profile');
final process = await Process.start(browser, [
'--app=http://localhost:$_port',
'--user-data-dir=$profile',
'--no-first-run',
]);
await process.exitCode;
}
String? _argValue(List<String> args, String name) {
final index = args.indexOf(name);
if (index == -1 || index + 1 >= args.length) return null;
return args[index + 1];
}

View File

@ -18,14 +18,27 @@ import 'package:todo/data/desktop_backup_client.dart';
/// absent (for instance when the same build is opened in a plain browser tab).
class WebLogPersistence implements LogPersistence {
/// Creates a persistence over [database], mirroring writes to [backup].
WebLogPersistence({required Database database, this.backup})
: _db = database;
WebLogPersistence({
required Database database,
this.backup,
this.mirrorDebounce = const Duration(seconds: 2),
}) : _db = database;
final Database _db;
/// Wrapper client that mirrors writes to disk, or null when absent.
final DesktopBackupClient? backup;
/// How long to coalesce disk mirroring after the last write.
///
/// The store persists on *every* keystroke, so mirroring eagerly would mean
/// an HTTP request and a whole-log disk write per character. IndexedDB is
/// still written synchronously with each keystroke; only the durability copy
/// is debounced.
final Duration mirrorDebounce;
Timer? _mirrorTimer;
/// Object store holding a single record: the serialised log.
static const storeName = 'log';
@ -64,6 +77,18 @@ class WebLogPersistence implements LogPersistence {
final txn = _db.transaction(storeName, idbModeReadWrite);
await txn.objectStore(storeName).put(text, recordKey);
await txn.completed;
unawaited(backup?.writeLog(text) ?? Future<void>.value());
_scheduleMirror(text);
}
void _scheduleMirror(String text) {
final client = backup;
if (client == null) return;
_mirrorTimer?.cancel();
_mirrorTimer = Timer(mirrorDebounce, () {
unawaited(client.writeLog(text));
});
}
/// Cancels any pending disk mirror. Call when tearing the store down.
void dispose() => _mirrorTimer?.cancel();
}

View File

@ -0,0 +1,163 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
/// Local HTTP server backing the desktop app.
///
/// The desktop app is a Flutter **web** build (Flutter's Linux embedder manages
/// only ~20fps at 4K — see `docs/desktop-performance-findings.md`), so it runs
/// in a browser and cannot touch the filesystem. This process is the other half
/// of the desktop app: it serves the build and owns the two files a browser
/// cannot write.
///
/// * `~/todo/BACKLOG.md` — the canonical backlog path the user's tooling and
/// the `todo` MCP server read.
/// * a copy of the note log — so a wiped Chrome profile is not a total-loss
/// event for notes that have not yet synced to GitHub.
///
/// Binds to loopback only. The endpoints read and overwrite files in the user's
/// home directory with no authentication, so exposing them on a routable
/// address would let anything on the network rewrite the backlog.
class WrapperServer {
/// Creates a server serving [webRoot] and persisting under [backlogPath] and
/// [logPath].
WrapperServer({
required this.webRoot,
required this.backlogPath,
required this.logPath,
});
/// Directory holding the built Flutter web assets.
final String webRoot;
/// Absolute path of the Markdown backlog export.
final String backlogPath;
/// Absolute path of the on-disk note-log copy.
final String logPath;
HttpServer? _server;
/// Port the server is listening on, once [start] has completed.
int get port => _server!.port;
/// Binds to loopback on [requestedPort] and begins serving.
///
/// Pass 0 to let the OS choose a port (tests do this); the desktop launcher
/// passes the fixed port, because the browser keys `localStorage` (the GitHub
/// token) and IndexedDB (the notes) by origin — a changing port would
/// silently hide both.
Future<void> start(int requestedPort) async {
_server = await HttpServer.bind(
InternetAddress.loopbackIPv4,
requestedPort,
);
unawaited(_serve(_server!));
}
/// Stops serving and releases the port.
Future<void> stop() async => _server?.close(force: true);
Future<void> _serve(HttpServer server) async {
await for (final request in server) {
try {
await _handle(request);
} on Exception {
request.response.statusCode = HttpStatus.internalServerError;
}
await request.response.close();
}
}
Future<void> _handle(HttpRequest request) async {
final path = request.uri.path;
if (path == '/backup/backlog') {
return _file(request, backlogPath);
}
if (path == '/backup/log') {
return _file(request, logPath);
}
return _static(request, path);
}
/// GET returns the file's contents (404 when absent); POST overwrites it.
Future<void> _file(HttpRequest request, String filePath) async {
final file = File(filePath);
if (request.method == 'POST') {
await file.parent.create(recursive: true);
await file.writeAsString(await utf8.decodeStream(request));
request.response.statusCode = HttpStatus.noContent;
return;
}
if (request.method != 'GET') {
request.response.statusCode = HttpStatus.methodNotAllowed;
return;
}
if (!file.existsSync()) {
request.response.statusCode = HttpStatus.notFound;
return;
}
request.response.headers.contentType = ContentType.text;
request.response.write(await file.readAsString());
}
Future<void> _static(HttpRequest request, String path) async {
final relative = path == '/' ? 'index.html' : path.substring(1);
// Reject traversal before touching the filesystem: the served root sits
// next to the user's files, so `../` must not escape it.
final resolved = p.normalize(p.join(webRoot, relative));
// coverage:ignore-start
// Defence in depth, and currently unreachable: Dart's HttpServer decodes
// and normalises the path before a handler runs, so even `%2e%2e` arrives
// already collapsed (verified in wrapper_server_test). Kept so the
// guarantee does not depend on that implementation detail holding.
if (!p.isWithin(webRoot, resolved) && resolved != p.normalize(webRoot)) {
request.response.statusCode = HttpStatus.forbidden;
return;
}
// coverage:ignore-end
final file = File(resolved);
if (!file.existsSync()) {
request.response.statusCode = HttpStatus.notFound;
return;
}
request.response.headers.contentType = contentTypeFor(resolved);
await request.response.addStream(file.openRead());
}
/// Content type for [filePath].
///
/// Flutter web is strict here: CanvasKit refuses to instantiate a `.wasm`
/// served as anything but `application/wasm`, and the app silently fails to
/// render if the bootstrap `.js` is mislabelled.
static ContentType contentTypeFor(String filePath) {
switch (p.extension(filePath).toLowerCase()) {
case '.html':
return ContentType.html;
case '.js' || '.mjs':
return ContentType('text', 'javascript', charset: 'utf-8');
case '.json':
return ContentType.json;
case '.wasm':
return ContentType('application', 'wasm');
case '.css':
return ContentType('text', 'css', charset: 'utf-8');
case '.png':
return ContentType('image', 'png');
case '.svg':
return ContentType('image', 'svg+xml');
case '.ttf':
return ContentType('font', 'ttf');
case '.otf':
return ContentType('font', 'otf');
case '.woff2':
return ContentType('font', 'woff2');
case '.bin' || '.symbols':
return ContentType.binary;
default:
return ContentType.binary;
}
}
}

View File

@ -3,6 +3,14 @@ import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
/// Resolves the home directory the desktop export writes under.
///
/// Overridable **so the test suite never writes to the real `~/todo`**. This is
/// not hypothetical: before this seam existed, running `flutter test` exported
/// a fake note over the user's canonical `~/todo/BACKLOG.md` every time.
String Function() resolveExportHome =
() => Platform.environment['HOME'] ?? Directory.current.path;
/// Writes the Markdown export somewhere the user can get at it, and returns a
/// human-readable description of where it went.
///
@ -26,7 +34,7 @@ Future<String> exportBacklog(String markdown, int noteCount) async {
return 'Shared $noteCount notes';
}
// coverage:ignore-end
final home = Platform.environment['HOME'] ?? Directory.current.path;
final home = resolveExportHome();
final dir = Directory('$home/todo');
if (!dir.existsSync()) dir.createSync(recursive: true);
final file = File('${dir.path}/BACKLOG.md');

View File

@ -1,3 +1,4 @@
import 'dart:io';
import 'dart:convert';
import 'package:file_selector_platform_interface/file_selector_platform_interface.dart';
@ -8,6 +9,7 @@ import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:todo/sync/backlog_export_io.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/sync/notes_markdown.dart';
@ -275,7 +277,26 @@ void main() {
expect(find.textContaining('sync failed'), findsOneWidget);
});
test('the default export home is the real HOME', () {
// Guards the production default, which every other test overrides so the
// suite cannot write to the user's ~/todo.
expect(resolveExportHome(), Platform.environment['HOME']);
});
testWidgets('Export notes writes the backlog file (desktop)', (tester) async {
// Redirect HOME: this test does real file I/O, and without the override it
// overwrites the user's canonical ~/todo/BACKLOG.md with the fake note
// below on every test run.
// Sync creation: awaiting real file I/O under the widget tester's fake
// clock never completes.
final home = Directory.systemTemp.createTempSync('todo_export_home');
resolveExportHome = () => home.path;
addTearDown(() {
resolveExportHome =
() => Platform.environment['HOME'] ?? Directory.current.path;
home.deleteSync(recursive: true);
});
await pumpSettings(
tester,
seed: [

View File

@ -0,0 +1,153 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:todo/desktop/wrapper_server.dart';
void main() {
late Directory root;
late WrapperServer server;
late String base;
setUp(() async {
root = await Directory.systemTemp.createTemp('wrapper');
final webRoot = Directory(p.join(root.path, 'web'))..createSync();
File(p.join(webRoot.path, 'index.html')).writeAsStringSync('<h1>todo</h1>');
File(p.join(webRoot.path, 'main.dart.js')).writeAsStringSync('console;');
File(p.join(webRoot.path, 'canvaskit.wasm')).writeAsStringSync('binary');
server = WrapperServer(
webRoot: webRoot.path,
backlogPath: p.join(root.path, 'todo', 'BACKLOG.md'),
logPath: p.join(root.path, 'state', 'todo_notes.json'),
);
// Port 0 lets the OS pick, so tests never collide with a running app.
await server.start(0);
base = 'http://localhost:${server.port}';
});
tearDown(() async {
await server.stop();
root.deleteSync(recursive: true);
});
test('serves index.html at the root', () async {
final response = await http.get(Uri.parse('$base/'));
expect(response.statusCode, 200);
expect(response.body, contains('todo'));
});
test('serves nested assets and 404s unknown paths', () async {
expect((await http.get(Uri.parse('$base/main.dart.js'))).statusCode, 200);
expect((await http.get(Uri.parse('$base/nope.js'))).statusCode, 404);
});
test('refuses to serve outside the web root', () async {
// The served directory sits beside the user's files, so traversal must not
// be able to read them. Sent over a raw socket because http.get normalises
// `..` away client-side, so the server would never see the attack.
File(p.join(root.path, 'secret')).writeAsStringSync('do not serve me');
// Percent-encoded, because both the http client and Dart's HttpServer
// collapse a literal `..` before any handler sees it; `%2e%2e` survives
// that and is decoded into the path afterwards.
final socket = await Socket.connect('localhost', server.port);
socket.write('GET /%2e%2e/secret HTTP/1.1\r\nHost: localhost\r\n\r\n');
await socket.flush();
final response = await utf8.decoder.bind(socket).first;
await socket.close();
// Dart's HttpServer decodes and then normalises, so traversal is already
// collapsed before any handler runs and this comes back 404 rather than
// 403. What matters is the property, not which code rejected it: the file
// outside the web root is never served.
expect(response, isNot(contains('do not serve me')));
expect(response, anyOf(contains('403'), contains('404')));
});
test('a failing write reports a server error rather than crashing', () async {
// Parent path is a regular file, so creating the directory for the backup
// throws. The serve loop must survive it and keep answering requests.
final blocked = Directory(p.join(root.path, 'blocked'))..createSync();
File(p.join(blocked.path, 'wall')).writeAsStringSync('');
final wedged = WrapperServer(
webRoot: p.join(root.path, 'web'),
backlogPath: p.join(blocked.path, 'wall', 'nested', 'BACKLOG.md'),
logPath: p.join(root.path, 'state', 'todo_notes.json'),
);
await wedged.start(0);
addTearDown(wedged.stop);
final response = await http.post(
Uri.parse('http://localhost:${wedged.port}/backup/backlog'),
body: 'x',
);
expect(response.statusCode, 500);
// Still alive afterwards.
final after = await http.get(
Uri.parse('http://localhost:${wedged.port}/'),
);
expect(after.statusCode, 200);
});
test('POST then GET round-trips the backlog', () async {
final posted = await http.post(
Uri.parse('$base/backup/backlog'),
body: '# backlog\n\nnote one\n',
);
expect(posted.statusCode, 204);
// Written to the real path, which is what the user's tooling reads.
final onDisk = File(p.join(root.path, 'todo', 'BACKLOG.md'));
expect(onDisk.existsSync(), isTrue);
expect(onDisk.readAsStringSync(), contains('note one'));
final fetched = await http.get(Uri.parse('$base/backup/backlog'));
expect(fetched.statusCode, 200);
expect(fetched.body, contains('note one'));
});
test('POST then GET round-trips the note log', () async {
await http.post(Uri.parse('$base/backup/log'), body: '{"a":1}');
final fetched = await http.get(Uri.parse('$base/backup/log'));
expect(fetched.body, '{"a":1}');
});
test('GET on a backup that does not exist yet 404s', () async {
expect((await http.get(Uri.parse('$base/backup/log'))).statusCode, 404);
});
test('rejects unsupported methods on a backup path', () async {
final response = await http.delete(Uri.parse('$base/backup/log'));
expect(response.statusCode, 405);
});
test('labels wasm and js correctly', () async {
// CanvasKit refuses a .wasm served as anything else, and the app then
// renders nothing at all.
final wasm = await http.get(Uri.parse('$base/canvaskit.wasm'));
expect(wasm.headers['content-type'], contains('application/wasm'));
final js = await http.get(Uri.parse('$base/main.dart.js'));
expect(js.headers['content-type'], contains('javascript'));
});
test('contentTypeFor covers the asset kinds the build emits', () {
String kind(String name) =>
WrapperServer.contentTypeFor(name).mimeType;
expect(kind('a.html'), 'text/html');
expect(kind('a.json'), 'application/json');
expect(kind('a.css'), 'text/css');
expect(kind('a.png'), 'image/png');
expect(kind('a.svg'), 'image/svg+xml');
expect(kind('a.ttf'), 'font/ttf');
expect(kind('a.otf'), 'font/otf');
expect(kind('a.woff2'), 'font/woff2');
expect(kind('a.mjs'), 'text/javascript');
expect(kind('a.bin'), 'application/octet-stream');
expect(kind('a.unknown'), 'application/octet-stream');
});
}