todo-app/lib/desktop/wrapper_server.dart
Krzysztof kuhy Rudnicki 3c6c071513 Let the desktop provision its sync account from the wrapper
The desktop app is a Flutter web build in a Chrome --app window, so
FlutterSecureStorage resolves to the web backend: AES-encrypted localStorage
keyed inside the browser profile. That left the sync account settable only by
typing it into the settings screen — nothing outside the page can write that
store (verified: `secret-tool` targets libsecret, which only a Linux build
would read, and a Dart CLI cannot even import flutter_secure_storage since it
is a Flutter plugin that fails to resolve on the VM).

So the account now arrives the same way the GitHub token already does: from
the wrapper, which owns the filesystem and serves the page. loadAccount()
falls back to GET /sync-account when the store is empty and persists the
result, so the route is consulted once per install rather than being
load-bearing forever.

The route reads ~/.config/crdt-sync/{firebase.json,password} — the same pair
the Python daemons already use — so there is one source of truth per machine
instead of a per-app copy.

Off unless CRDT_SYNC_SERVE_ACCOUNT is set, because it hands a credential with
database write access to anything that can reach the port. Verified both
ways against the running wrapper: 404 without the env var, 200 with it, and
the app serves normally either way. It answers 404 rather than 403 when
disabled, so a probe cannot tell the route exists.

clearAccount() now writes an opt-out marker that the fallback checks —
without it, Disconnect would delete the account and the next launch would
silently re-adopt it, making the button look broken.

252 tests, analyzer clean. Pinned to crdt_sync_dart-v0.8.0 for the shared
path/env-var constants and accountFromWrapper().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
2026-08-09 21:39:16 +02:00

212 lines
7.6 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
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);
}
if (path == kSyncAccountPath) {
return _syncAccount(request);
}
return _static(request, path);
}
/// Serves the shared sync account so a desktop install can self-provision.
///
/// Off unless CRDT_SYNC_SERVE_ACCOUNT is set: this hands out a credential
/// with database write access to anything that can reach the port, so it is
/// something you switch on once to set an install up, not a standing route.
/// 404 (rather than 403) when disabled, so a probe cannot tell the route
/// exists.
///
/// The account is read from the same ~/.config/crdt-sync/ pair the Python
/// daemons already use, so there is one source of truth per machine rather
/// than a per-app copy.
Future<void> _syncAccount(HttpRequest request) async {
if ((Platform.environment[kSyncAccountEnvVar] ?? '').isEmpty) {
request.response.statusCode = HttpStatus.notFound;
return;
}
final home = Platform.environment['HOME'] ?? '';
final configDir = p.join(home, '.config', 'crdt-sync');
final configFile = File(p.join(configDir, 'firebase.json'));
final passwordFile = File(p.join(configDir, 'password'));
if (!configFile.existsSync() || !passwordFile.existsSync()) {
stderr.writeln(
'Sync account requested but ~/.config/crdt-sync/ is incomplete — '
'the desktop app will keep asking for credentials.',
);
request.response.statusCode = HttpStatus.notFound;
return;
}
final email = (jsonDecode(await configFile.readAsString())
as Map<String, dynamic>)['email'];
if (email is! String || email.isEmpty) {
stderr.writeln('firebase.json has no usable "email" — not serving it.');
request.response.statusCode = HttpStatus.notFound;
return;
}
request.response.headers.contentType = ContentType.json;
request.response.write(
jsonEncode({
'email': email,
'password': (await passwordFile.readAsString()).trim(),
}),
);
}
/// 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;
}
}
}