mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-13 00:30:58 +01:00
An optional '' default reads as "this parameter is usually irrelevant", and it made `avoid_redundant_argument_values` fire in the first app to pass '' explicitly -- pushing that app toward deleting the argument, which is exactly the line that must stay for when the Desktop client id arrives. Requiring it forces each app to state which halves it configured. Passing '' still means "not configured"; it just has to be said out loud. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
208 lines
7.3 KiB
Markdown
208 lines
7.3 KiB
Markdown
# crdt_sync_flutter
|
|
|
|
The Flutter half of [`crdt_sync`](../crdt_sync_dart). Adds the OS-keystore
|
|
adapter, a persisted per-install device id, the account store and a one-call
|
|
bootstrap, so a new app reaches "syncing" without writing any of it.
|
|
|
|
## Why this exists
|
|
|
|
`crdt_sync` is pure Dart on purpose — it runs from command-line tools and from
|
|
`wake_alarm`'s headless systemd job, so it cannot depend on
|
|
`flutter_secure_storage` or `google_sign_in`. It takes closures instead.
|
|
|
|
Every Flutter app then wrote the same ~390 lines to supply those closures
|
|
(`firebase_backend.dart` + `google_sign_in_backend.dart` + `google_platform*`),
|
|
and five apps ended up carrying near-identical copies. They then **drifted**:
|
|
`todo` gained an opt-out flag and a seeded-session path that `home_inventory`
|
|
and `wake_alarm` never received, so the same bug was fixed in one copy and
|
|
left live in the others.
|
|
|
|
This package owns that glue once.
|
|
|
|
## Install
|
|
|
|
```yaml
|
|
dependencies:
|
|
crdt_sync_flutter:
|
|
git:
|
|
url: https://github.com/kuhyx/utils
|
|
ref: crdt_sync_flutter-v0.2.1
|
|
path: crdt_sync_flutter
|
|
```
|
|
|
|
It depends on `crdt_sync` itself, so you do not need to list both unless you
|
|
use the merge primitives (`Hlc`, `Record`, `LogStore`) directly — which most
|
|
apps do.
|
|
|
|
## Adding sync to a new app
|
|
|
|
**One const and one call.**
|
|
|
|
```dart
|
|
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart';
|
|
|
|
/// The shared `kuhy-syncs` project. Safe to commit: the Web API key is a
|
|
/// public identifier that already ships inside every APK, and the security
|
|
/// rules -- not its secrecy -- are what protect the data.
|
|
const kApp = SyncApp(
|
|
project: FirebaseProject(
|
|
apiKey: 'AIzaSyCF_sA3xCMehAYXK8eND-rAygb9NXXW_8E',
|
|
databaseUrl:
|
|
'https://kuhy-syncs-default-rtdb.europe-west1.firebasedatabase.app',
|
|
),
|
|
expectedUid: 'OvA2REQyLIhAHOEjzwS1o877rgG3',
|
|
);
|
|
|
|
// At a sync tick:
|
|
final client = await openSync(kApp);
|
|
if (client != null) {
|
|
// ... syncLog(client: client, deviceId: identity.deviceId, ...)
|
|
client.close();
|
|
}
|
|
```
|
|
|
|
`openSync` returns **null when this device is not set up**, which is a normal
|
|
state and not an error — the app keeps working against its local store and
|
|
simply does not sync.
|
|
|
|
For the device id:
|
|
|
|
```dart
|
|
final identity = await loadDeviceIdentity();
|
|
// identity.deviceId -- a per-install uuid under `crdt.nodeId`
|
|
```
|
|
|
|
The key matches what the existing apps already wrote, so an app adopting this
|
|
package keeps its identity rather than appearing as a brand-new device and
|
|
re-merging its own history as a stranger's. An app migrating off a role
|
|
constant passes `legacyId: 'phone'` so skip-own-writes stays correct.
|
|
|
|
### `databaseUrl` must be the regional host
|
|
|
|
The plain `*.firebaseio.com` form answers 404 with a `correctUrl` body rather
|
|
than an obvious error, so a wrong value reads like an auth failure and wastes
|
|
a debugging session.
|
|
|
|
### `expectedUid` is load-bearing
|
|
|
|
`signInWithIdp` signs in **or signs up**. An unlinked Google account is
|
|
accepted as a new uid, authenticates fine, and is then denied every read and
|
|
write — a sync that silently never syncs. `expectedUid` catches it at
|
|
sign-in instead.
|
|
|
|
## Signing in
|
|
|
|
There is no way to make a fresh install sync with zero interaction, and that
|
|
is deliberate: these repos are public, so the account's email and password
|
|
cannot be committed. The best achievable is **install → sign in once →
|
|
syncing forever**, and the refresh token then lives in the OS keystore.
|
|
|
|
Use [`sync_settings_ui`](../sync_settings_ui) for that screen rather than
|
|
building one; it takes the closures this package provides:
|
|
|
|
```dart
|
|
/// The project's **Web** OAuth client id, used as the audience for Android's
|
|
/// ID tokens. Public by design, exactly like the `apiKey`.
|
|
const kServerClientId = '845446124781-...apps.googleusercontent.com';
|
|
|
|
/// A **Desktop**-type OAuth client, for the loopback flow. A Web client id
|
|
/// will not work here; a Desktop client is the only type allowed to redirect
|
|
/// to loopback.
|
|
const kDesktopClientId = '845446124781-...apps.googleusercontent.com';
|
|
|
|
SyncSettingsScreen(
|
|
accountLoader: () => loadAccount(),
|
|
accountSaver: saveAccount,
|
|
accountClearer: clearAccount,
|
|
sessionProbe: () => isSyncConfigured(kApp),
|
|
firebaseFactory: () => openSync(kApp),
|
|
googleFirebaseFactory: () => signInWithGoogle(
|
|
kApp,
|
|
tokenFetcher: () => googleAnyIdToken(
|
|
serverClientId: kServerClientId,
|
|
desktopClientId: kDesktopClientId,
|
|
),
|
|
),
|
|
googleAvailable: googleAnySignInSupported(
|
|
serverClientId: kServerClientId,
|
|
desktopClientId: kDesktopClientId,
|
|
),
|
|
)
|
|
```
|
|
|
|
### One tap on every platform
|
|
|
|
`google_sign_in` ships **android, ios and web** implementations only — there
|
|
is no `google_sign_in_linux` — so a real GTK desktop build cannot use the
|
|
plugin at all. This package covers both halves and picks between them:
|
|
|
|
| Platform | Flow | Entry point |
|
|
|---|---|---|
|
|
| Android / iOS | `google_sign_in` plugin, OS account picker | `googleIdToken` |
|
|
| Linux / Windows / macOS | OAuth **loopback** + PKCE, system browser | `googleDesktopIdToken` |
|
|
| Web | none (GIS renders its own button) | falls back to password |
|
|
|
|
Both return a Google ID token of the same shape, so `signInWithGoogle` cannot
|
|
tell them apart.
|
|
|
|
`googleSignInSupported(clientId)` takes the client id rather than reading a
|
|
const so its second, easily-lost conjunct — *is a client id actually compiled
|
|
in?* — cannot be dropped by a consuming app. A build that forgot the constant
|
|
would otherwise show a button that always reports "cancelled".
|
|
|
|
**`openSync` deliberately never offers Google.** It runs from background ticks
|
|
and, in some apps, before `runApp`; offering Google there would raise the OS
|
|
account picker with no user action behind it.
|
|
|
|
## Desktop provisioning
|
|
|
|
An app whose desktop build is the web build behind a local wrapper can let
|
|
that wrapper hand over a seeded session, so the account is not retyped per
|
|
machine:
|
|
|
|
```dart
|
|
const kApp = SyncApp(
|
|
project: ...,
|
|
expectedUid: ...,
|
|
routes: WrapperRoutes(
|
|
credentialsPath: '/sync-credentials',
|
|
accountPath: '/sync-account', // optional legacy fallback
|
|
),
|
|
);
|
|
```
|
|
|
|
`routes` is per-app because each wrapper serves its own seeded file (the
|
|
`todo` app's is `~/.config/todo/firebase_auth.json`, written by
|
|
`seed_session.py`). Pass nothing and the whole fallback is skipped — which is
|
|
what Android does in practice, since `Uri.base` there is `file:///`.
|
|
|
|
Nothing in this package reads `~/.config/crdt-sync/` directly. That is the
|
|
desktop/Python half, reached over the wrapper's HTTP routes.
|
|
|
|
## Testing
|
|
|
|
The keystore fake ships with the package, so a consuming app does not rebuild
|
|
it:
|
|
|
|
```dart
|
|
import 'package:crdt_sync_flutter/testing/fake_secure_storage.dart';
|
|
|
|
installFakeSecureStorage(); // auto-removes on tear down
|
|
installFakeSecureStorage(throwing: true); // a host with no secret service
|
|
```
|
|
|
|
Nothing in this package's own suite reaches the network or a real keystore.
|
|
|
|
```bash
|
|
flutter test --coverage # 35 tests, 100% line coverage
|
|
flutter analyze
|
|
```
|
|
|
|
## Not yet migrated
|
|
|
|
`todo`, `home_inventory`, `diet_guard`, `workout_app` and `wake_alarm` still
|
|
carry their own copies and stay pinned to `crdt_sync_dart-v0.10.0`. That is
|
|
deliberate — those fallbacks are load-bearing and the migration is forward-only
|
|
— but it means **a fix made here does not reach them**. Converging them is a
|
|
separate task; see the drift note above for what each copy is missing.
|