mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:27:15 +01:00
Let a desktop wrapper provision the sync account, no UI required
Desktop builds of todo/diet-guard/home_inventory are Flutter *web* in a Chrome --app window, so FlutterSecureStorage resolves to the web backend: AES-encrypted localStorage keyed inside the browser profile. That makes the account unsettable from outside the page -- `secret-tool` writes the wrong store (libsecret, which only the Linux build would read), and a Dart CLI cannot even import flutter_secure_storage, since it is a Flutter plugin and fails to resolve on the VM. Verified both, rather than assumed. The one process that can read the filesystem *and* reach the page is the wrapper already serving it -- the same seam diet-guard uses to keep the GitHub token out of the browser. So this adds the shared half of a wrapper route: the path constant, the opt-in env var, an opt-out marker key, and accountFromWrapper(), which fetches and hands the body to the existing FirebaseAccount.tryParse. Additive only, so consumers adopt it per-app on their own schedule; nothing forces a phone rebuild. Route handlers stay per-app because each wrapper owns its own server. Deliberately opt-in (CRDT_SYNC_SERVE_ACCOUNT): the route hands a credential with database write access to anything that can reach the wrapper's port, so it is set once to provision an install rather than left on. kSyncAccountOptOutKey exists so "Disconnect" cannot be silently undone by the next launch re-adopting the account. Every failure path returns null rather than throwing -- an absent route is the *normal* case -- so the caller degrades to "not configured", which the settings screens already handle by asking. 254 tests, analyzer clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
This commit is contained in:
parent
a4165e0dc8
commit
6a080333c2
@ -2,6 +2,7 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
export 'src/device.dart';
|
export 'src/device.dart';
|
||||||
|
export 'src/firebase_account_provisioning.dart';
|
||||||
export 'src/firebase_auth_rest.dart';
|
export 'src/firebase_auth_rest.dart';
|
||||||
export 'src/firebase_client.dart';
|
export 'src/firebase_client.dart';
|
||||||
export 'src/firebase_config.dart';
|
export 'src/firebase_config.dart';
|
||||||
|
|||||||
62
crdt_sync_dart/lib/src/firebase_account_provisioning.dart
Normal file
62
crdt_sync_dart/lib/src/firebase_account_provisioning.dart
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
/// Provisioning the sync account on a desktop (Chrome web) build.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import 'firebase_settings.dart';
|
||||||
|
|
||||||
|
/// The wrapper route that serves this machine's shared sync account.
|
||||||
|
///
|
||||||
|
/// Desktop builds of these apps are Flutter **web** running in a Chrome
|
||||||
|
/// `--app` window, so `FlutterSecureStorage` resolves to the web backend:
|
||||||
|
/// AES-encrypted `localStorage`, keyed inside the browser profile. Nothing
|
||||||
|
/// outside that profile can write it -- not `secret-tool` (wrong store), not
|
||||||
|
/// a Dart CLI (`flutter_secure_storage` is a Flutter plugin and will not even
|
||||||
|
/// import on the VM). The only process that can both read the filesystem and
|
||||||
|
/// reach the page is the wrapper that serves it, so the account arrives the
|
||||||
|
/// same way the GitHub token already does: from the wrapper, never from
|
||||||
|
/// source and never from prefs.
|
||||||
|
const kSyncAccountPath = '/sync-account';
|
||||||
|
|
||||||
|
/// Env var a wrapper checks before serving [kSyncAccountPath].
|
||||||
|
///
|
||||||
|
/// Off by default. The route hands out a credential with database write
|
||||||
|
/// access to anything that can reach the wrapper's port, so it is opt-in per
|
||||||
|
/// launch rather than always-on: set it once to provision a new desktop
|
||||||
|
/// install, then launch normally.
|
||||||
|
const kSyncAccountEnvVar = 'CRDT_SYNC_SERVE_ACCOUNT';
|
||||||
|
|
||||||
|
/// Marker written when the user disconnects, suppressing re-provisioning.
|
||||||
|
///
|
||||||
|
/// Without it, "Disconnect" would delete the stored account and the next
|
||||||
|
/// launch would silently adopt it again from the wrapper, making the button
|
||||||
|
/// look broken.
|
||||||
|
const kSyncAccountOptOutKey = 'firebase.account.optOut';
|
||||||
|
|
||||||
|
/// Fetches the shared account the wrapper holds, or null.
|
||||||
|
///
|
||||||
|
/// Returns null for every failure -- route absent (the normal case, since it
|
||||||
|
/// is opt-in), wrapper not running, malformed body -- because the caller's
|
||||||
|
/// fallback is simply "not configured", which the settings screen already
|
||||||
|
/// handles by asking the user.
|
||||||
|
///
|
||||||
|
/// Args:
|
||||||
|
/// base: Origin of the local wrapper, e.g. `http://localhost:8730`.
|
||||||
|
/// client: Injected for tests.
|
||||||
|
Future<FirebaseAccount?> accountFromWrapper(
|
||||||
|
Uri base, {
|
||||||
|
http.Client? client,
|
||||||
|
}) async {
|
||||||
|
final httpClient = client ?? http.Client();
|
||||||
|
try {
|
||||||
|
final response = await httpClient.get(base.resolve(kSyncAccountPath));
|
||||||
|
if (response.statusCode != 200) return null;
|
||||||
|
return FirebaseAccount.tryParse(utf8.decode(response.bodyBytes));
|
||||||
|
} on Exception {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (client == null) httpClient.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@ name: crdt_sync
|
|||||||
description: >-
|
description: >-
|
||||||
Shared CRDT merge scheme + GitHub-Contents-API sync transport for
|
Shared CRDT merge scheme + GitHub-Contents-API sync transport for
|
||||||
cross-device apps. Pure Dart, no Flutter dependency.
|
cross-device apps. Pure Dart, no Flutter dependency.
|
||||||
version: 0.7.0
|
version: 0.8.0
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
72
crdt_sync_dart/test/firebase_account_provisioning_test.dart
Normal file
72
crdt_sync_dart/test/firebase_account_provisioning_test.dart
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart' as http_testing;
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
final _base = Uri.parse('http://localhost:8730');
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('accountFromWrapper', () {
|
||||||
|
test('parses the account the wrapper serves', () async {
|
||||||
|
final client = http_testing.MockClient(
|
||||||
|
(request) async => http.Response(
|
||||||
|
jsonEncode({'email': 'a@b.c', 'password': 'pw'}),
|
||||||
|
200,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final account = await accountFromWrapper(_base, client: client);
|
||||||
|
|
||||||
|
expect(account?.email, 'a@b.c');
|
||||||
|
expect(account?.password, 'pw');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requests the shared route path', () async {
|
||||||
|
var requested = '';
|
||||||
|
final client = http_testing.MockClient((request) async {
|
||||||
|
requested = request.url.path;
|
||||||
|
return http.Response('{}', 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
await accountFromWrapper(_base, client: client);
|
||||||
|
|
||||||
|
expect(requested, kSyncAccountPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a missing route is not configured, not an error', () async {
|
||||||
|
// The normal case: the route is opt-in, so 404 is what most launches
|
||||||
|
// see and it must degrade to "ask the user" rather than throw.
|
||||||
|
final client = http_testing.MockClient(
|
||||||
|
(request) async => http.Response('', 404),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await accountFromWrapper(_base, client: client), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a malformed body yields null rather than throwing', () async {
|
||||||
|
final client = http_testing.MockClient(
|
||||||
|
(request) async => http.Response('{not json', 200),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await accountFromWrapper(_base, client: client), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a body missing the password yields null', () async {
|
||||||
|
final client = http_testing.MockClient(
|
||||||
|
(request) async => http.Response(jsonEncode({'email': 'a@b.c'}), 200),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await accountFromWrapper(_base, client: client), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a wrapper that is not running yields null', () async {
|
||||||
|
final client = http_testing.MockClient(
|
||||||
|
(request) async => throw http.ClientException('refused'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await accountFromWrapper(_base, client: client), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user