2026-09-28 23:49:39 +03:00
|
|
|
// The async surface over the raw ABI: every operation runs on a short-lived
|
|
|
|
|
// isolate via Isolate.run, because the Rust side blocks — a fetch can hold a
|
|
|
|
|
// thread for seconds, which must never be the UI thread. Handles are plain
|
|
|
|
|
// addresses (ints) and the master is bytes, so both cross isolates freely,
|
|
|
|
|
// and fumi-core's `Send + Sync` store is safe to call from any of them.
|
|
|
|
|
//
|
|
|
|
|
// Error codes cross in two ranges: 0-10 are wire statuses, 64 up are local
|
|
|
|
|
// failures (see ffi.dart) — recovery paths branch on the range. After a
|
|
|
|
|
// smolPanic the store's lock is poisoned and every later call panics; the
|
|
|
|
|
// UI treats that as "close and reopen the store", not an unsolvable error.
|
|
|
|
|
|
|
|
|
|
import "dart:isolate";
|
|
|
|
|
import "dart:typed_data";
|
|
|
|
|
|
|
|
|
|
import "package:smol_mail/native/ffi.dart";
|
|
|
|
|
|
|
|
|
|
class FumiNative {
|
|
|
|
|
FumiNative(this.dbPath);
|
|
|
|
|
|
|
|
|
|
/// The SQLite file the store owns; one store per process.
|
|
|
|
|
final String dbPath;
|
|
|
|
|
|
|
|
|
|
int? _store;
|
|
|
|
|
int? _account;
|
|
|
|
|
int? _flag;
|
|
|
|
|
|
|
|
|
|
/// The 32-byte master, held by the app (Hive in kirakira), never by the
|
|
|
|
|
/// library.
|
|
|
|
|
Uint8List? _master;
|
|
|
|
|
|
|
|
|
|
int? get store => _store;
|
|
|
|
|
|
|
|
|
|
/// The account handle, when a master is set; readers need it for describe.
|
|
|
|
|
int? get account => _account;
|
|
|
|
|
|
|
|
|
|
/// Binds the account locally, as register and restore do at their end.
|
|
|
|
|
void setAccount(String address) =>
|
|
|
|
|
SmolFfi.open().setAccount(_store!, address);
|
|
|
|
|
|
|
|
|
|
Future<T> _io<T>(T Function(SmolFfi ffi) op) => Isolate.run(() {
|
|
|
|
|
final ffi = SmolFfi.open();
|
|
|
|
|
return op(ffi);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
Future<void> open() async {
|
|
|
|
|
if (_store != null) return;
|
|
|
|
|
_store = await _io((ffi) => ffi.openStore(dbPath));
|
|
|
|
|
_flag = await _io((ffi) => ffi.newFlag());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Drops the identity from memory the honest way Dart allows: the
|
|
|
|
|
/// master's bytes are overwritten before the reference goes, and the
|
|
|
|
|
/// account handle is freed. What persists is the caller's business
|
|
|
|
|
/// (the store wipes its own copy).
|
|
|
|
|
void clearMaster() {
|
|
|
|
|
_master?.fillRange(0, _master!.length, 0);
|
|
|
|
|
_master = null;
|
|
|
|
|
final old = _account;
|
|
|
|
|
if (old != null) {
|
|
|
|
|
SmolFfi.open().freeAccount(old);
|
|
|
|
|
}
|
|
|
|
|
_account = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Sets the master and rebuilds the account handle synchronously: the
|
|
|
|
|
/// rebuild is a few key derivations (microseconds native), not a network
|
|
|
|
|
/// operation, so it never needs an isolate — and widget tests can settle
|
|
|
|
|
/// it inside their fake-async zones.
|
|
|
|
|
void setMaster(Uint8List master, {int? rotations}) {
|
2026-09-29 20:35:16 +03:00
|
|
|
// Own copy: the caller keeps its buffer (the onboarding screen zeroes
|
|
|
|
|
// its reference on dispose, and wipe zeroes Hive's), and the account
|
|
|
|
|
// rebuilds after register/restore/rotate must derive from untouched
|
|
|
|
|
// bytes — a shared buffer zeroized elsewhere would rebuild an account
|
|
|
|
|
// that matches none of our keys.
|
|
|
|
|
_master = Uint8List.fromList(master);
|
2026-09-28 23:49:39 +03:00
|
|
|
_rebuildAccount(rotations: rotations);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Rebuilds the account handle from the master and the store's rotation
|
|
|
|
|
/// index — required after register, restore and rotate, which all change
|
|
|
|
|
/// the index the account stands at.
|
|
|
|
|
void _rebuildAccount({int? rotations}) {
|
|
|
|
|
final ffi = SmolFfi.open();
|
|
|
|
|
final index = rotations ?? ffi.rotations(_store!);
|
|
|
|
|
final old = _account;
|
|
|
|
|
if (old != null) {
|
|
|
|
|
ffi.freeAccount(old);
|
|
|
|
|
}
|
|
|
|
|
_account = ffi.newAccount(_master!, index);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int get _s => _store!;
|
|
|
|
|
int get _a => _account!;
|
|
|
|
|
|
|
|
|
|
Future<void> close() async {
|
|
|
|
|
final store = _store;
|
|
|
|
|
final account = _account;
|
|
|
|
|
final flag = _flag;
|
|
|
|
|
_store = null;
|
|
|
|
|
_account = null;
|
|
|
|
|
_flag = null;
|
|
|
|
|
if (account != null) await _io((ffi) => ffi.freeAccount(account));
|
|
|
|
|
if (flag != null) await _io((ffi) => ffi.freeFlag(flag));
|
|
|
|
|
if (store != null) await _io((ffi) => ffi.closeStore(store));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- identity and pins ------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
Future<String> accountPk() => _io((ffi) => ffi.accountPk(_a));
|
|
|
|
|
|
|
|
|
|
Future<String?> accountAddress() => _io((ffi) => ffi.accountAddress(_s));
|
|
|
|
|
|
|
|
|
|
Future<int> rotations() => _io((ffi) => ffi.rotations(_s));
|
|
|
|
|
|
2026-09-30 08:52:57 +03:00
|
|
|
Future<void> pinServer(String host, String keyB32, int port) =>
|
|
|
|
|
_io((ffi) => ffi.pinServer(_s, host, keyB32, port));
|
2026-09-28 23:49:39 +03:00
|
|
|
|
2026-09-30 08:52:57 +03:00
|
|
|
Future<String?> serverPin(String host, int port) =>
|
|
|
|
|
_io((ffi) => ffi.serverPin(_s, host, port));
|
2026-09-28 23:49:39 +03:00
|
|
|
|
2026-09-30 08:52:57 +03:00
|
|
|
Future<void> unpinServer(String host, int port) =>
|
|
|
|
|
_io((ffi) => ffi.unpinServer(_s, host, port));
|
2026-09-28 23:49:39 +03:00
|
|
|
|
|
|
|
|
Future<List<dynamic>> pins() => _io((ffi) => ffi.pins(_s));
|
|
|
|
|
|
|
|
|
|
Future<bool> syncOk() => _io((ffi) => ffi.syncOk(_s));
|
|
|
|
|
|
|
|
|
|
Future<void> saveContact(String address, String keyB32,
|
|
|
|
|
{required bool verified}) =>
|
|
|
|
|
_io((ffi) => ffi.saveContact(_s, address, keyB32, verified: verified));
|
|
|
|
|
|
|
|
|
|
// --- account lifecycle ------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
Future<void> register(String address,
|
|
|
|
|
{String? invite, String? dial, int timeout = 30}) =>
|
|
|
|
|
_io((ffi) {
|
|
|
|
|
ffi.register(_s, _a, address,
|
|
|
|
|
invite: invite, dial: dial, timeout: timeout);
|
|
|
|
|
}).then((_) => _rebuildAccount());
|
|
|
|
|
|
|
|
|
|
Future<void> restore(String address, {String? dial, int timeout = 30}) =>
|
|
|
|
|
_io((ffi) =>
|
|
|
|
|
ffi.restore(_s, _master!, address, dial: dial, timeout: timeout))
|
|
|
|
|
.then((_) => _rebuildAccount());
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> rotate({String? dial, int timeout = 30}) =>
|
|
|
|
|
_io((ffi) => ffi.rotate(_s, _a, dial: dial, timeout: timeout))
|
|
|
|
|
.then((out) {
|
|
|
|
|
_rebuildAccount();
|
|
|
|
|
return out;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// --- mail -------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/// Raises the cancellation flag; the running fetch stops between
|
|
|
|
|
/// envelopes and returns a partial summary.
|
|
|
|
|
void cancelFetch() => SmolFfi.open().setFlag(_flag ?? 0, true);
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> fetch(
|
|
|
|
|
{bool keep = false,
|
|
|
|
|
bool reset = false,
|
|
|
|
|
String? dial,
|
|
|
|
|
int timeout = 30}) =>
|
|
|
|
|
_io((ffi) {
|
|
|
|
|
ffi.setFlag(_flag!, false);
|
|
|
|
|
return ffi.fetch(_s, _a,
|
|
|
|
|
keep: keep, reset: reset, dial: dial, timeout: timeout,
|
|
|
|
|
cancel: _flag);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> send(String to, String body,
|
|
|
|
|
{String? subject, String? replyTo, String? dial, int timeout = 30}) =>
|
|
|
|
|
_io((ffi) => ffi.send(_s, _a, to, body,
|
|
|
|
|
subject: subject, replyTo: replyTo, dial: dial));
|
|
|
|
|
|
|
|
|
|
Future<void> delete(List<String> idsHex, {String? dial, int timeout = 30}) =>
|
|
|
|
|
_io((ffi) => ffi.delete(_s, _a, idsHex, dial: dial, timeout: timeout));
|
|
|
|
|
|
|
|
|
|
/// The reader's delete: local removal plus seen-marking, no server round
|
|
|
|
|
/// trip. Use [delete] for the server-side DELETE.
|
|
|
|
|
Future<void> deleteLocal(String folder, List<String> idsHex) =>
|
|
|
|
|
_io((ffi) => ffi.deleteLocal(_s, folder, idsHex));
|
|
|
|
|
|
|
|
|
|
Future<List<dynamic>> mail(String folder) =>
|
|
|
|
|
_io((ffi) => ffi.mail(_s, folder));
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> describe(String idHex) =>
|
|
|
|
|
_io((ffi) => ffi.describe(_s, _a, idHex));
|
|
|
|
|
|
|
|
|
|
// --- contacts ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
Future<List<dynamic>> contacts() => _io((ffi) => ffi.contacts(_s));
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> contact(String address) =>
|
|
|
|
|
_io((ffi) => ffi.contact(_s, address));
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> resolve(String address,
|
|
|
|
|
{String? dial, int timeout = 30}) =>
|
|
|
|
|
_io((ffi) => ffi.resolve(_s, address, dial: dial, timeout: timeout));
|
|
|
|
|
|
|
|
|
|
Future<void> importContact(String uri) =>
|
|
|
|
|
_io((ffi) => ffi.importContact(_s, uri));
|
|
|
|
|
|
|
|
|
|
Future<int> accept(String address, {String? dial, int timeout = 30}) =>
|
|
|
|
|
_io((ffi) => ffi.accept(_s, _a, address, dial: dial, timeout: timeout));
|
|
|
|
|
|
|
|
|
|
Future<void> block(String address, {String? dial, int timeout = 30}) =>
|
|
|
|
|
_io((ffi) => ffi.block(_s, _a, address, dial: dial, timeout: timeout));
|
|
|
|
|
|
|
|
|
|
// --- backups ----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
Future<String> exportBackup() => _io((ffi) => ffi.exportBackup(_s, _master!));
|
|
|
|
|
|
|
|
|
|
Future<String> importBackup(String text) =>
|
|
|
|
|
_io((ffi) => ffi.importBackup(_s, _master!, text));
|
|
|
|
|
}
|