mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-14 12:30:59 +01:00
Compile-time dart-define that prefixes every RTDB path with sandbox/<slot>; invalid values throw at startup. Empty keeps prod paths unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
189 lines
6.6 KiB
Dart
189 lines
6.6 KiB
Dart
/// The Dart counterpart to Python's `crdt_sync._config`.
|
|
///
|
|
/// Both languages talk to one Firebase project as one account, so the shape
|
|
/// of the configuration is shared even though where it is *stored* is not:
|
|
/// on Linux it is `~/.config/crdt-sync/firebase.json` (mode 0600), while on
|
|
/// Android the password and refresh token belong in `flutter_secure_storage`
|
|
/// and the file does not exist at all.
|
|
///
|
|
/// [FirebaseConfig] therefore takes plain values and stays free of `dart:io`,
|
|
/// so it works on web and mobile. The platform-specific loading is the app's
|
|
/// job -- `crdt_sync_io.dart` provides the desktop half.
|
|
library;
|
|
|
|
import 'dart:convert';
|
|
|
|
import 'package:http/http.dart' as http;
|
|
|
|
import 'firebase_auth_rest.dart';
|
|
import 'firebase_credentials.dart';
|
|
import 'firebase_client.dart';
|
|
import 'mirror_store.dart';
|
|
import 'remote_store.dart';
|
|
import 'sync_namespace.dart';
|
|
|
|
/// The shared config is missing a field, or still holds a scaffold placeholder.
|
|
class ConfigException implements Exception {
|
|
ConfigException(this.message);
|
|
|
|
final String message;
|
|
|
|
@override
|
|
String toString() => 'ConfigException: $message';
|
|
}
|
|
|
|
/// Everything needed to reach the shared Firebase project.
|
|
///
|
|
/// [apiKey] is not a secret: it ships inside the Android APKs, and the
|
|
/// security rules are what protect the data. The password is.
|
|
class FirebaseConfig {
|
|
const FirebaseConfig({
|
|
required this.apiKey,
|
|
required this.databaseUrl,
|
|
required this.projectId,
|
|
required this.uid,
|
|
required this.email,
|
|
});
|
|
|
|
/// Parses the same `firebase.json` the Python side reads.
|
|
///
|
|
/// Throws [ConfigException] naming the field at fault, because the
|
|
/// alternative is an authentication failure much later with no clue which
|
|
/// value was wrong. Keys prefixed `_comment` are the scaffold's inline
|
|
/// documentation, not configuration, and are ignored.
|
|
factory FirebaseConfig.fromJson(Map<String, dynamic> json) {
|
|
String require(String key) {
|
|
final value = json[key];
|
|
if (value == null || (value is String && value.trim().isEmpty)) {
|
|
throw ConfigException('firebase.json is missing or has empty: $key');
|
|
}
|
|
final text = value.toString();
|
|
if (text.contains('PASTE_')) {
|
|
throw ConfigException(
|
|
'firebase.json still holds the placeholder for: $key',
|
|
);
|
|
}
|
|
return text;
|
|
}
|
|
|
|
return FirebaseConfig(
|
|
apiKey: require('apiKey'),
|
|
databaseUrl: require('databaseUrl'),
|
|
projectId: require('projectId'),
|
|
uid: require('uid'),
|
|
email: require('email'),
|
|
);
|
|
}
|
|
|
|
/// Parses [text] as the shared config file's contents.
|
|
factory FirebaseConfig.parse(String text) {
|
|
final Object? decoded;
|
|
try {
|
|
decoded = jsonDecode(text);
|
|
} on FormatException catch (error) {
|
|
throw ConfigException(
|
|
'firebase.json is not valid JSON: ${error.message}',
|
|
);
|
|
}
|
|
if (decoded is! Map<String, dynamic>) {
|
|
throw ConfigException('firebase.json must contain a JSON object');
|
|
}
|
|
return FirebaseConfig.fromJson(decoded);
|
|
}
|
|
|
|
final String apiKey;
|
|
final String databaseUrl;
|
|
final String projectId;
|
|
|
|
/// The uid the security rules pin. A session for any other uid authenticates
|
|
/// fine and is then denied every read and write.
|
|
final String uid;
|
|
final String email;
|
|
}
|
|
|
|
/// Returns a client for [config], signing in only when there is no session.
|
|
///
|
|
/// The common path costs no authentication round trip: [store] holds the
|
|
/// refresh token between launches. Pass the platform-appropriate store --
|
|
/// `flutter_secure_storage` on Android, a 0600 file on desktop.
|
|
/// Supplies a Google ID token for the account to sign in as, or null when the
|
|
/// user is not signed in with Google on this device.
|
|
///
|
|
/// A closure rather than a dependency: `google_sign_in` is Android/iOS/web
|
|
/// only, and this package must keep working on Linux desktop and headless
|
|
/// under systemd. The app owns the plugin; the library only wants the token.
|
|
typedef GoogleIdTokenProvider = Future<String?> Function();
|
|
|
|
Future<FirebaseRestClient> firebaseClientFor({
|
|
required FirebaseConfig config,
|
|
required FirebaseCredentialStore store,
|
|
String? password,
|
|
GoogleIdTokenProvider? googleIdToken,
|
|
String? expectedUid,
|
|
http.Client? httpClient,
|
|
}) async {
|
|
// Before any sign-in: a bad SYNC_NAMESPACE must stop the app at startup.
|
|
checkSyncNamespace(syncNamespace);
|
|
final auth = FirebaseTokenProvider(
|
|
apiKey: config.apiKey,
|
|
store: store,
|
|
httpClient: httpClient,
|
|
);
|
|
if (await store.load() == null) {
|
|
// Google first: it is the one-tap path, and on a fresh install it is the
|
|
// only credential the user has not had to type. Password stays as the
|
|
// fallback, and is the only option on desktop and headless.
|
|
final google = await googleIdToken?.call();
|
|
if (google != null) {
|
|
// config.uid is empty on the app path (FirebaseProject.configFor leaves
|
|
// it blank), in which case signInWithGoogle skips the comparison rather
|
|
// than failing every sign-in against ''. Apps that know their uid should
|
|
// pass expectedUid so a mis-tapped account picker is caught here instead
|
|
// of turning into a silently denied sync.
|
|
await auth.signInWithGoogle(
|
|
idToken: google,
|
|
expectedUid: expectedUid ?? config.uid,
|
|
);
|
|
} else if (password != null) {
|
|
await auth.signIn(email: config.email, password: password);
|
|
} else {
|
|
throw ConfigException(
|
|
'no stored session for ${config.email}, no Google sign-in and no '
|
|
'password given; sign in once from the settings screen',
|
|
);
|
|
}
|
|
}
|
|
return FirebaseRestClient(
|
|
databaseUrl: config.databaseUrl,
|
|
auth: auth,
|
|
httpClient: httpClient,
|
|
);
|
|
}
|
|
|
|
/// Returns a Firebase-primary store that still mirrors to [githubClient].
|
|
///
|
|
/// What an app uses *during* the cutover: Firebase is authoritative, GitHub is
|
|
/// kept in step but never allowed to fail a tick, and reads union both so
|
|
/// devices can cut over one at a time. Rolling back is passing [githubClient]
|
|
/// straight to `syncLog` again; retiring the mirror is calling
|
|
/// [firebaseClientFor] instead. Both are one-line changes at the call site.
|
|
Future<MirrorStore> mirrorStoreFor({
|
|
required FirebaseConfig config,
|
|
required FirebaseCredentialStore store,
|
|
required RemoteStore githubClient,
|
|
String? password,
|
|
GoogleIdTokenProvider? googleIdToken,
|
|
String? expectedUid,
|
|
http.Client? httpClient,
|
|
}) async => MirrorStore(
|
|
primary: await firebaseClientFor(
|
|
config: config,
|
|
store: store,
|
|
password: password,
|
|
googleIdToken: googleIdToken,
|
|
expectedUid: expectedUid,
|
|
httpClient: httpClient,
|
|
),
|
|
mirror: githubClient,
|
|
);
|