From 3c6c07151378d3be40d37f3efea562eaec23462d Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 9 Aug 2026 21:39:16 +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 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) Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9 --- lib/desktop/wrapper_server.dart | 48 +++++++++++++++++++++++++++++++++ lib/sync/firebase_backend.dart | 17 +++++++++++- pubspec.lock | 6 ++--- pubspec.yaml | 2 +- 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/lib/desktop/wrapper_server.dart b/lib/desktop/wrapper_server.dart index 83631e3..09537e8 100644 --- a/lib/desktop/wrapper_server.dart +++ b/lib/desktop/wrapper_server.dart @@ -2,6 +2,7 @@ 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. @@ -79,9 +80,56 @@ class WrapperServer { 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 _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)['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 _file(HttpRequest request, String filePath) async { final file = File(filePath); diff --git a/lib/sync/firebase_backend.dart b/lib/sync/firebase_backend.dart index a88b650..934ea0b 100644 --- a/lib/sync/firebase_backend.dart +++ b/lib/sync/firebase_backend.dart @@ -45,11 +45,24 @@ SecureCredentialStore credentialStore() => SecureCredentialStore( ); /// Reads the per-device account, or null when sync has not been set up. +/// +/// Falls back to the local wrapper when the store is empty, so a desktop +/// install can provision itself from `~/.config/crdt-sync/` instead of the +/// account being retyped per machine. The fetched account is written to the +/// store on first success, so the route is consulted once rather than being +/// load-bearing forever. 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; + final provisioned = await accountFromWrapper(Uri.base); + if (provisioned != null) await saveAccount(provisioned); + return provisioned; } on Exception { // No secret service available: behave as "not configured" rather than // crashing the settings screen. @@ -64,6 +77,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/pubspec.lock b/pubspec.lock index 17f56de..b3e50ac 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -101,11 +101,11 @@ packages: dependency: "direct main" description: path: crdt_sync_dart - ref: "crdt_sync_dart-v0.6.0" - resolved-ref: fc99eff00599a4c3195e4c1573ba08f15909a10d + ref: "crdt_sync_dart-v0.8.0" + resolved-ref: "6a080333c2ed2b2773fee01605dd67ba713e54ea" url: "https://github.com/kuhyx/utils" source: git - version: "0.6.0" + version: "0.8.0" cross_file: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ae93879..a96e3d6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,7 +33,7 @@ dependencies: crdt_sync: git: url: https://github.com/kuhyx/utils - ref: crdt_sync_dart-v0.6.0 + ref: crdt_sync_dart-v0.8.0 path: crdt_sync_dart # The following adds the Cupertino Icons font to your application.