mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:27:15 +01:00
Some checks are pending
crdt_sync_flutter tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Android now uses google_account_token (no googleid or play-services-auth). Desktop PKCE sends client_secret. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWY1k4muZfdVJfb7YGJ
214 lines
7.6 KiB
Markdown
214 lines
7.6 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 the Android Google plugin. 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';
|
|
|
|
/// That Desktop client's secret. Google rejects the token exchange without
|
|
/// it, but treats it as non-confidential for installed apps: inject it at
|
|
/// build time (`--dart-define`), never commit it. '' sends none.
|
|
const kDesktopClientSecret = String.fromEnvironment('DESKTOP_CLIENT_SECRET');
|
|
|
|
SyncSettingsScreen(
|
|
accountLoader: () => loadAccount(),
|
|
accountSaver: saveAccount,
|
|
accountClearer: clearAccount,
|
|
sessionProbe: () => isSyncConfigured(kApp),
|
|
firebaseFactory: () => openSync(kApp),
|
|
googleFirebaseFactory: () => signInWithGoogle(
|
|
kApp,
|
|
tokenFetcher: () => googleAnyIdToken(
|
|
serverClientId: kServerClientId,
|
|
desktopClientId: kDesktopClientId,
|
|
desktopClientSecret: kDesktopClientSecret,
|
|
),
|
|
),
|
|
googleAvailable: googleAnySignInSupported(
|
|
serverClientId: kServerClientId,
|
|
desktopClientId: kDesktopClientId,
|
|
),
|
|
)
|
|
```
|
|
|
|
### One tap on every platform
|
|
|
|
[`google_account_token`](../google_account_token) wraps Android's AccountManager
|
|
(no Google client library in the APK), so a real GTK desktop build cannot use
|
|
it. This package covers both halves and picks between them:
|
|
|
|
| Platform | Flow | Entry point |
|
|
|---|---|---|
|
|
| Android | `google_account_token` plugin, OS account chooser | `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.
|