diff --git a/bin/todo_desktop.dart b/bin/todo_desktop.dart new file mode 100644 index 0000000..094547c --- /dev/null +++ b/bin/todo_desktop.dart @@ -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 main(List 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 _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 args, String name) { + final index = args.indexOf(name); + if (index == -1 || index + 1 >= args.length) return null; + return args[index + 1]; +} diff --git a/lib/data/web_log_persistence.dart b/lib/data/web_log_persistence.dart index a4b2812..9d6d4c7 100644 --- a/lib/data/web_log_persistence.dart +++ b/lib/data/web_log_persistence.dart @@ -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.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(); } diff --git a/lib/desktop/wrapper_server.dart b/lib/desktop/wrapper_server.dart new file mode 100644 index 0000000..83631e3 --- /dev/null +++ b/lib/desktop/wrapper_server.dart @@ -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 start(int requestedPort) async { + _server = await HttpServer.bind( + InternetAddress.loopbackIPv4, + requestedPort, + ); + unawaited(_serve(_server!)); + } + + /// Stops serving and releases the port. + Future stop() async => _server?.close(force: true); + + Future _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 _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 _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 _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; + } + } +} diff --git a/lib/sync/backlog_export_io.dart b/lib/sync/backlog_export_io.dart index ffb877d..43c5339 100644 --- a/lib/sync/backlog_export_io.dart +++ b/lib/sync/backlog_export_io.dart @@ -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 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'); diff --git a/test/settings_screen_test.dart b/test/settings_screen_test.dart index 579d76f..4993b1d 100644 --- a/test/settings_screen_test.dart +++ b/test/settings_screen_test.dart @@ -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: [ diff --git a/test/wrapper_server_test.dart b/test/wrapper_server_test.dart new file mode 100644 index 0000000..965de69 --- /dev/null +++ b/test/wrapper_server_test.dart @@ -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('

todo

'); + 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'); + }); +}