From 55fade1e15229416b4787598dfb2f7bb838bcf4b Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 9 Aug 2026 21:41:08 +0200 Subject: [PATCH] Let the desktop provision its sync account from the wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same change todo just took, for the same reason: app/'s desktop target 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). Nothing outside the page can write that store — `secret-tool` targets libsecret, which only a Linux embedder 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. Both verified rather than assumed. The wrapper is the one process that can read the filesystem and serve the page, which is exactly why it already holds the GitHub token instead of the browser (see github_proxy.dart). So the sync account now arrives the same way: loadAccount() falls back to GET /sync-account when the store is empty and persists the result, making the route a one-shot provisioning step rather than a standing dependency. It reads ~/.config/crdt-sync/{firebase.json,password} — the same pair `diet_guard sync` already uses — so the machine keeps one source of truth. Off unless CRDT_SYNC_SERVE_ACCOUNT is set, and 404 (not 403) when disabled so a probe cannot tell the route exists. On Android there is no wrapper: the request simply fails and loadAccount() returns null exactly as before. clearAccount() writes an opt-out marker the fallback checks, so Disconnect cannot be silently undone by the next launch re-adopting the account. 518 tests, analyzer clean, pre-commit clean. Pinned to crdt_sync_dart-v0.8.0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9 --- app/lib/desktop/wrapper_server.dart | 54 ++++++++++++++++++++++++++ app/lib/services/firebase_backend.dart | 13 ++++++- app/pubspec.lock | 6 +-- app/pubspec.yaml | 2 +- 4 files changed, 70 insertions(+), 5 deletions(-) diff --git a/app/lib/desktop/wrapper_server.dart b/app/lib/desktop/wrapper_server.dart index a87d56d..220f26f 100644 --- a/app/lib/desktop/wrapper_server.dart +++ b/app/lib/desktop/wrapper_server.dart @@ -18,8 +18,11 @@ library; import 'dart:async'; +import 'dart:convert'; import 'dart:io'; +import 'package:crdt_sync/crdt_sync.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; @@ -93,9 +96,60 @@ class WrapperServer { path.substring(WrapperPaths.documents.length), ); } + 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 it exists. + /// + /// Reads the same ~/.config/crdt-sync/ pair `diet_guard sync` already uses, + /// so the machine has one source of truth rather than a per-app copy — + /// the same reasoning that keeps the GitHub token in [gitHubProxy] instead + /// of the browser. + Future _syncAccount(HttpRequest request) async { + if ((Platform.environment[kSyncAccountEnvVar] ?? '').isEmpty) { + request.response.statusCode = HttpStatus.notFound; + return; + } + final configDir = p.join( + Platform.environment['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)['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 stored bytes (404 when absent); POST overwrites them. /// /// [name] is rejected unless it is a plain filename: these handlers write diff --git a/app/lib/services/firebase_backend.dart b/app/lib/services/firebase_backend.dart index 433126b..8b86649 100644 --- a/app/lib/services/firebase_backend.dart +++ b/app/lib/services/firebase_backend.dart @@ -47,9 +47,18 @@ SecureCredentialStore credentialStore() => SecureCredentialStore( /// Reads the per-device account, or null when sync has not been set up. Future loadAccount() async { try { - return FirebaseAccount.tryParse( + final stored = FirebaseAccount.tryParse( await _secure.read(key: kFirebaseAccountKey), ); + if (stored != null) return stored; + // Disconnect must stick: without this the next launch would silently + // re-adopt the account and the button would look broken. + if (await _secure.read(key: kSyncAccountOptOutKey) != null) return null; + // Desktop only in practice: on Android the wrapper does not exist, the + // request fails, and this returns null exactly as before. + final provisioned = await accountFromWrapper(Uri.base); + if (provisioned != null) await saveAccount(provisioned); + return provisioned; } on Object { // No secret service available: behave as "not configured" rather than // crashing the settings screen. @@ -64,6 +73,8 @@ Future saveAccount(FirebaseAccount account) => /// Forgets the account and any cached session. Future clearAccount() async { await _secure.delete(key: kFirebaseAccountKey); + // Suppress wrapper re-provisioning; see loadAccount(). + await _secure.write(key: kSyncAccountOptOutKey, value: 'true'); await credentialStore().clear(); } diff --git a/app/pubspec.lock b/app/pubspec.lock index 386fc70..4cf100c 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -85,11 +85,11 @@ packages: dependency: "direct main" description: path: crdt_sync_dart - ref: "crdt_sync_dart-v0.7.0" - resolved-ref: "6eef99d66132e12eb5056ae8e1780b34237fa013" + ref: "crdt_sync_dart-v0.8.0" + resolved-ref: "6a080333c2ed2b2773fee01605dd67ba713e54ea" url: "https://github.com/kuhyx/utils" source: git - version: "0.7.0" + version: "0.8.0" crypto: dependency: "direct main" description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 42d2d1a..8deeaf0 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -11,7 +11,7 @@ dependencies: crdt_sync: git: url: https://github.com/kuhyx/utils - ref: crdt_sync_dart-v0.7.0 + ref: crdt_sync_dart-v0.8.0 path: crdt_sync_dart crypto: ^3.0.6 flutter: