feat(sync): device-to-device inventory sync, end to end
Wires the encrypted sync transport into a working replication between one identity's own devices, reusing the CRDT-ready model (HLC + tombstones + LWW) the data model was built for. - SocialSession gains a 5th interface, sync, over the shared connection, built with a per-install deviceId + the app's inventory namespace. - DeviceIdStore: a stable random per-install id in the keystore — distinct from the seed-derived CRDT node id, which is IDENTICAL across a user's devices (so it couldn't tell them apart / they'd clobber each other's replaceable record). - InventorySnapshotIO (implemented by ExportImportService): build/apply the inventory as the SAME interchange JSON as backups but UNSEALED — the sync transport does the encryption, so it must not double-seal. Apply = the existing idempotent LWW importInventory. - SyncService: on (re)connect publishes this device's snapshot + subscribes to the others'; a local edit (debounced, off tableUpdates) republishes; incoming snapshots merge idempotently; skips its own echo; a byte check skips re-publishing an unchanged snapshot so the ping-pong converges. Foreground only, like the inbox. - DI/Bootstrap/switchSocialAccount wire and lifecycle it per identity. Tests: DeviceIdStore, ExportImportService snapshot round-trip (unsealed + idempotent), SyncService.handleRemote (applies others', skips own). Follow-ups: deltas vs full-snapshot pushes; background sync; conflict UI.
This commit is contained in:
parent
182c334883
commit
225880fc64
10 changed files with 363 additions and 12 deletions
|
|
@ -22,6 +22,7 @@ import 'services/social_account_store.dart';
|
|||
import 'services/social_connection.dart';
|
||||
import 'services/social_service.dart';
|
||||
import 'services/social_settings.dart';
|
||||
import 'services/sync_service.dart';
|
||||
import 'services/trust_referents.dart';
|
||||
import 'services/wot_settings.dart';
|
||||
import 'ui/theme.dart';
|
||||
|
|
@ -65,9 +66,12 @@ class _BootstrapState extends State<Bootstrap> {
|
|||
// Ask for notification permission and set up the OS channel (no-op on
|
||||
// unsupported platforms). Done before the inbox starts listening.
|
||||
if (notifications != null) await notifications.initialize();
|
||||
// Subscribe the inbox listener BEFORE the shared connection starts
|
||||
final sync =
|
||||
getIt.isRegistered<SyncService>() ? getIt<SyncService>() : null;
|
||||
// Subscribe the inbox + sync listeners BEFORE the shared connection starts
|
||||
// connecting, so the first session is caught; then bring the connection up.
|
||||
inbox?.start();
|
||||
sync?.start();
|
||||
connection?.start();
|
||||
|
||||
return TaneApp(
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import '../security/secret_store.dart';
|
|||
import '../security/secure_key_store.dart';
|
||||
import '../services/auto_backup_service.dart';
|
||||
import '../services/auto_backup_store.dart';
|
||||
import '../services/device_id_store.dart';
|
||||
import '../services/export_import_service.dart';
|
||||
import '../services/file_picker_file_service.dart';
|
||||
import '../services/file_service.dart';
|
||||
|
|
@ -40,6 +41,7 @@ import '../services/social_account_store.dart';
|
|||
import '../services/social_connection.dart';
|
||||
import '../services/social_service.dart';
|
||||
import '../services/social_settings.dart';
|
||||
import '../services/sync_service.dart';
|
||||
import '../services/trust_referents.dart';
|
||||
import '../services/unread_service.dart';
|
||||
import '../services/wot_settings.dart';
|
||||
|
|
@ -88,6 +90,10 @@ Future<void> configureDependencies() async {
|
|||
final activeAccount = await accounts.active();
|
||||
final scope = socialAccountScope(activeAccount);
|
||||
|
||||
// Stable per-install id for device-to-device sync (distinct from the
|
||||
// seed-derived CRDT node id, which is shared across a user's devices).
|
||||
final deviceId = await DeviceIdStore(secretStore).deviceId();
|
||||
|
||||
final database = AppDatabase(
|
||||
openEncryptedExecutor(await _databaseFile(), dbKeyHex),
|
||||
);
|
||||
|
|
@ -104,8 +110,11 @@ Future<void> configureDependencies() async {
|
|||
// profile stay hidden) rather than blanking the whole app.
|
||||
SocialService? socialService;
|
||||
try {
|
||||
socialService =
|
||||
await SocialService.fromRootSeedHex(rootSeedHex, account: activeAccount);
|
||||
socialService = await SocialService.fromRootSeedHex(
|
||||
rootSeedHex,
|
||||
account: activeAccount,
|
||||
deviceId: deviceId,
|
||||
);
|
||||
} catch (e, s) {
|
||||
debugPrint('Social identity derivation failed; inventory-only mode: $e\n$s');
|
||||
}
|
||||
|
|
@ -224,6 +233,16 @@ Future<void> configureDependencies() async {
|
|||
unread: getIt<UnreadService>(),
|
||||
notifications: getIt<NotificationService>(),
|
||||
),
|
||||
)
|
||||
// Replicates the inventory across this identity's own devices over the
|
||||
// shared connection. Started in `Bootstrap`.
|
||||
..registerSingleton<SyncService>(
|
||||
SyncService(
|
||||
connection: connection,
|
||||
io: getIt<ExportImportService>(),
|
||||
localChanges: database.tableUpdates().map<void>((_) {}),
|
||||
selfDeviceId: deviceId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -244,8 +263,14 @@ Future<void> switchSocialAccount(int account) async {
|
|||
await getIt<SocialAccountStore>().setActive(account);
|
||||
final scope = socialAccountScope(account);
|
||||
final rootSeedHex = await getIt<SecureKeyStore>().rootSeedHex();
|
||||
// Per-install, so it survives an identity switch.
|
||||
final deviceId = await DeviceIdStore(secretStore).deviceId();
|
||||
|
||||
// Tear down the old identity's listener + shared connection before replacing.
|
||||
// Tear down the old identity's listener + sync + shared connection first.
|
||||
if (getIt.isRegistered<SyncService>()) {
|
||||
await getIt<SyncService>().stop();
|
||||
await getIt.unregister<SyncService>();
|
||||
}
|
||||
if (getIt.isRegistered<InboxService>()) {
|
||||
await getIt<InboxService>().stop();
|
||||
await getIt.unregister<InboxService>();
|
||||
|
|
@ -275,8 +300,11 @@ Future<void> switchSocialAccount(int account) async {
|
|||
..registerSingleton<UnreadService>(
|
||||
UnreadService(getIt<MessageStore>(), secretStore));
|
||||
|
||||
final social =
|
||||
await SocialService.fromRootSeedHex(rootSeedHex, account: account);
|
||||
final social = await SocialService.fromRootSeedHex(
|
||||
rootSeedHex,
|
||||
account: account,
|
||||
deviceId: deviceId,
|
||||
);
|
||||
final connection =
|
||||
SocialConnection(social: social, settings: getIt<SocialSettings>());
|
||||
final inbox = InboxService(
|
||||
|
|
@ -287,12 +315,20 @@ Future<void> switchSocialAccount(int account) async {
|
|||
unread: getIt<UnreadService>(),
|
||||
notifications: getIt<NotificationService>(),
|
||||
);
|
||||
final sync = SyncService(
|
||||
connection: connection,
|
||||
io: getIt<ExportImportService>(),
|
||||
localChanges: getIt<AppDatabase>().tableUpdates().map<void>((_) {}),
|
||||
selfDeviceId: deviceId,
|
||||
);
|
||||
getIt
|
||||
..registerSingleton<SocialService>(social)
|
||||
..registerSingleton<SocialConnection>(connection)
|
||||
..registerSingleton<InboxService>(inbox);
|
||||
// Subscribe the listener BEFORE the connection starts connecting.
|
||||
..registerSingleton<InboxService>(inbox)
|
||||
..registerSingleton<SyncService>(sync);
|
||||
// Subscribe the listeners BEFORE the connection starts connecting.
|
||||
inbox.start();
|
||||
sync.start();
|
||||
connection.start();
|
||||
}
|
||||
|
||||
|
|
|
|||
34
apps/app_seeds/lib/services/device_id_store.dart
Normal file
34
apps/app_seeds/lib/services/device_id_store.dart
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import 'dart:math';
|
||||
|
||||
import '../security/secret_store.dart';
|
||||
|
||||
/// A stable per-INSTALL device id, kept in the keystore. Distinct from the
|
||||
/// seed-derived CRDT node id (`rootSeedHex.substring(0,16)`), which is IDENTICAL
|
||||
/// across a user's devices — so it can't tell them apart. Device-to-device sync
|
||||
/// needs each device to own a separate record, hence a random per-install id.
|
||||
class DeviceIdStore {
|
||||
DeviceIdStore(this._store, {Random? random}) : _random = random ?? Random.secure();
|
||||
|
||||
final SecretStore _store;
|
||||
final Random _random;
|
||||
|
||||
static const _key = 'tane.device.id';
|
||||
|
||||
Future<String>? _cached;
|
||||
|
||||
/// This install's device id, generated and persisted on first use.
|
||||
Future<String> deviceId() => _cached ??= _readOrCreate();
|
||||
|
||||
Future<String> _readOrCreate() async {
|
||||
final existing = await _store.read(_key);
|
||||
if (existing != null && existing.isNotEmpty) return existing;
|
||||
final id = _randomHex(8); // 64 bits — plenty to avoid device collisions
|
||||
await _store.write(_key, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
String _randomHex(int bytes) => [
|
||||
for (var i = 0; i < bytes; i++)
|
||||
_random.nextInt(256).toRadixString(16).padLeft(2, '0'),
|
||||
].join();
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import '../data/export_import/inventory_snapshot.dart';
|
|||
import '../data/variety_repository.dart';
|
||||
import '../security/secure_key_store.dart';
|
||||
import 'file_service.dart';
|
||||
import 'inventory_snapshot_io.dart';
|
||||
|
||||
/// A picked backup file awaiting restore — kept in memory so asking the user
|
||||
/// for a recovery code never forces a second file dialog.
|
||||
|
|
@ -27,7 +28,7 @@ class PendingBackup {
|
|||
/// Backups are **sealed** (encrypted under a key derived from the root seed;
|
||||
/// backup-and-recovery.md): a copy that lands on someone's cloud folder
|
||||
/// reveals nothing. The printed recovery code opens them on any device.
|
||||
class ExportImportService {
|
||||
class ExportImportService implements InventorySnapshotIO {
|
||||
ExportImportService({
|
||||
required VarietyRepository repository,
|
||||
required FileService files,
|
||||
|
|
@ -49,6 +50,18 @@ class ExportImportService {
|
|||
/// The backup file extension. The content is the sealed interchange JSON.
|
||||
static const backupExtension = 'tanemaki';
|
||||
|
||||
/// Raw (unsealed) interchange snapshot for device-to-device sync — the same
|
||||
/// JSON as a backup, but the sync transport (not this) does the encryption.
|
||||
@override
|
||||
Future<List<int>> buildSnapshot() async =>
|
||||
utf8.encode(_jsonCodec.encode(await _repository.exportInventory()));
|
||||
|
||||
/// Merges a raw interchange snapshot from another device (LWW, idempotent).
|
||||
@override
|
||||
Future<void> applySnapshot(List<int> bytes) async {
|
||||
await _repository.importInventory(_jsonCodec.decode(utf8.decode(bytes)));
|
||||
}
|
||||
|
||||
/// Builds the full inventory as a sealed (encrypted) backup — the same bytes
|
||||
/// [exportBackup] writes to a file. Shared with the automatic backup so both
|
||||
/// paths seal identically.
|
||||
|
|
|
|||
12
apps/app_seeds/lib/services/inventory_snapshot_io.dart
Normal file
12
apps/app_seeds/lib/services/inventory_snapshot_io.dart
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/// Reads/writes the inventory as a raw (unsealed) interchange snapshot — the
|
||||
/// same JSON a backup uses, but WITHOUT the file-level sealing, because the sync
|
||||
/// transport already encrypts on the wire. [SyncService] depends on this
|
||||
/// interface (not the whole export service) so its logic is fakeable.
|
||||
abstract interface class InventorySnapshotIO {
|
||||
/// The full inventory serialized to interchange bytes.
|
||||
Future<List<int>> buildSnapshot();
|
||||
|
||||
/// Merges an incoming snapshot into the local inventory (LWW by HLC,
|
||||
/// idempotent — re-applying the same or older state is a no-op).
|
||||
Future<void> applySnapshot(List<int> bytes);
|
||||
}
|
||||
|
|
@ -9,14 +9,23 @@ import 'package:commons_core/commons_core.dart';
|
|||
/// social sessions on demand. Local-first: constructing this is cheap and
|
||||
/// offline; nothing connects to a relay until [openSession] is called, so the
|
||||
/// app runs fully without network and the social layer only enriches.
|
||||
/// The app-data namespace this identity's devices sync their inventory under.
|
||||
/// Lives here (app layer), not in `commons_core`, which stays seed-agnostic.
|
||||
const kInventorySyncNamespace = 'org.comunes.tane/inventory';
|
||||
|
||||
class SocialService {
|
||||
SocialService({
|
||||
required this.identity,
|
||||
this.account = 0,
|
||||
this.rootSeedHex = '',
|
||||
this.deviceId = '',
|
||||
List<String> relays = const [],
|
||||
}) : relays = List.unmodifiable(relays);
|
||||
|
||||
/// This install's device id (for device-to-device sync). Empty in tests /
|
||||
/// when sync isn't wired.
|
||||
final String deviceId;
|
||||
|
||||
/// The derived Nostr identity (secp256k1). Same key across reinstalls that
|
||||
/// restore the same seed (for a given [account]).
|
||||
final NostrIdentity identity;
|
||||
|
|
@ -43,6 +52,7 @@ class SocialService {
|
|||
static Future<SocialService> fromRootSeedHex(
|
||||
String rootSeedHex, {
|
||||
int account = 0,
|
||||
String deviceId = '',
|
||||
List<String> relays = const [],
|
||||
}) async {
|
||||
final identity = await NostrKeyDerivation.deriveFromSeed(
|
||||
|
|
@ -53,6 +63,7 @@ class SocialService {
|
|||
identity: identity,
|
||||
account: account,
|
||||
rootSeedHex: rootSeedHex,
|
||||
deviceId: deviceId,
|
||||
relays: relays,
|
||||
);
|
||||
}
|
||||
|
|
@ -74,18 +85,23 @@ class SocialService {
|
|||
/// skipped; throws only when none are reachable. Caller closes it.
|
||||
Future<SocialSession> openSession(List<String> relayUrls) async {
|
||||
final pool = await RelayPool.connect(relayUrls, identity: identity);
|
||||
return SocialSession(pool);
|
||||
return SocialSession(pool, deviceId: deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
/// One relay channel with the three transports on top — the
|
||||
/// "one channel, three interfaces" shape, at the app layer.
|
||||
class SocialSession {
|
||||
SocialSession(this._channel)
|
||||
SocialSession(this._channel, {String deviceId = ''})
|
||||
: offers = NostrOfferTransport(_channel),
|
||||
messages = NostrMessageTransport(_channel),
|
||||
trust = NostrTrustTransport(_channel),
|
||||
profile = NostrProfileTransport(_channel);
|
||||
profile = NostrProfileTransport(_channel),
|
||||
sync = NostrSyncTransport(
|
||||
_channel,
|
||||
namespace: kInventorySyncNamespace,
|
||||
deviceId: deviceId,
|
||||
);
|
||||
|
||||
final NostrChannel _channel;
|
||||
|
||||
|
|
@ -94,6 +110,9 @@ class SocialSession {
|
|||
final TrustTransport trust;
|
||||
final ProfileTransport profile;
|
||||
|
||||
/// Device-to-device inventory sync for THIS identity's own devices.
|
||||
final SyncTransport sync;
|
||||
|
||||
Future<void> close() => _channel.close();
|
||||
}
|
||||
|
||||
|
|
|
|||
122
apps/app_seeds/lib/services/sync_service.dart
Normal file
122
apps/app_seeds/lib/services/sync_service.dart
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'inventory_snapshot_io.dart';
|
||||
import 'social_connection.dart';
|
||||
import 'social_service.dart';
|
||||
|
||||
/// Keeps this identity's inventory replicated across its OWN devices, over the
|
||||
/// shared [SocialConnection]'s [SyncTransport].
|
||||
///
|
||||
/// On (re)connect it publishes this device's snapshot and subscribes to the
|
||||
/// others'; a local edit (debounced) republishes. Incoming snapshots are merged
|
||||
/// with the existing idempotent LWW importer — so applying one you already have
|
||||
/// writes nothing and can't echo back into a push loop. A content check skips
|
||||
/// re-publishing an unchanged snapshot, bounding the ping-pong to convergence.
|
||||
///
|
||||
/// Foreground only, like the inbox — a background sync daemon is a later concern.
|
||||
class SyncService {
|
||||
SyncService({
|
||||
required SocialConnection connection,
|
||||
required InventorySnapshotIO io,
|
||||
required Stream<void> localChanges,
|
||||
required String selfDeviceId,
|
||||
Duration debounce = const Duration(seconds: 2),
|
||||
}) : _connection = connection,
|
||||
_io = io,
|
||||
_localChanges = localChanges,
|
||||
_selfDeviceId = selfDeviceId,
|
||||
_debounce = debounce;
|
||||
|
||||
final SocialConnection _connection;
|
||||
final InventorySnapshotIO _io;
|
||||
final Stream<void> _localChanges;
|
||||
final String _selfDeviceId;
|
||||
final Duration _debounce;
|
||||
|
||||
StreamSubscription<SocialSession?>? _sessionsSub;
|
||||
StreamSubscription<SyncSnapshot>? _snapSub;
|
||||
StreamSubscription<void>? _changesSub;
|
||||
Timer? _debounceTimer;
|
||||
SocialSession? _session;
|
||||
List<int>? _lastPushed; // the snapshot we last published (for change dedup)
|
||||
bool _started = false;
|
||||
|
||||
/// Begins syncing. Subscribe to the connection's sessions BEFORE it starts
|
||||
/// connecting, so the first session is caught too.
|
||||
void start() {
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
_sessionsSub = _connection.sessions.listen(_onSession);
|
||||
_changesSub = _localChanges.listen((_) => _schedulePush());
|
||||
final current = _connection.current;
|
||||
if (current != null) _onSession(current);
|
||||
}
|
||||
|
||||
void _onSession(SocialSession? session) {
|
||||
if (identical(session, _session) && _snapSub != null) return;
|
||||
unawaited(_snapSub?.cancel());
|
||||
_snapSub = null;
|
||||
_session = session;
|
||||
if (session != null) {
|
||||
_snapSub = session.sync.snapshots().listen(handleRemote, onError: (_) {});
|
||||
_lastPushed = null; // a fresh session should re-publish
|
||||
unawaited(pushLocal()); // publish our latest on (re)connect
|
||||
}
|
||||
}
|
||||
|
||||
/// Merges one incoming [snapshot], skipping our own device's echo. A testable
|
||||
/// seam (no relay).
|
||||
@visibleForTesting
|
||||
Future<void> handleRemote(SyncSnapshot snapshot) async {
|
||||
if (snapshot.deviceId == _selfDeviceId) return; // our own — nothing to do
|
||||
try {
|
||||
await _io.applySnapshot(snapshot.data);
|
||||
} catch (_) {
|
||||
// A malformed/old snapshot merges to nothing; ignore.
|
||||
}
|
||||
}
|
||||
|
||||
void _schedulePush() {
|
||||
_debounceTimer?.cancel();
|
||||
_debounceTimer = Timer(_debounce, () => unawaited(pushLocal()));
|
||||
}
|
||||
|
||||
/// Publishes this device's current inventory, unless it's byte-identical to
|
||||
/// what we last published (so a merge that changed nothing doesn't loop).
|
||||
@visibleForTesting
|
||||
Future<void> pushLocal() async {
|
||||
final session = _session;
|
||||
if (session == null) return;
|
||||
try {
|
||||
final bytes = await _io.buildSnapshot();
|
||||
if (_sameBytes(bytes, _lastPushed)) return;
|
||||
await session.sync.pushSnapshot(bytes);
|
||||
_lastPushed = bytes;
|
||||
} catch (_) {
|
||||
// offline / relay refused — the next connect or change retries.
|
||||
}
|
||||
}
|
||||
|
||||
static bool _sameBytes(List<int> a, List<int>? b) {
|
||||
if (b == null || a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
_started = false;
|
||||
_debounceTimer?.cancel();
|
||||
await _changesSub?.cancel();
|
||||
_changesSub = null;
|
||||
await _sessionsSub?.cancel();
|
||||
_sessionsSub = null;
|
||||
await _snapSub?.cancel();
|
||||
_snapSub = null;
|
||||
_session = null;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue