mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
268 lines
7.7 KiB
Dart
268 lines
7.7 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:collection/collection.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:models/models.dart';
|
|
import 'package:zapstore/constants/app_constants.dart';
|
|
import 'package:zapstore/services/device_private_event_service.dart';
|
|
import 'package:zapstore/services/device_key_service.dart';
|
|
import 'package:zapstore/services/log_service.dart';
|
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
|
|
|
class _UnmanagedAppsSnapshot {
|
|
const _UnmanagedAppsSnapshot(this.ids, this.createdAt);
|
|
|
|
final Set<String> ids;
|
|
final DateTime? createdAt;
|
|
}
|
|
|
|
/// Returns `null` while an encrypted stack is still being decrypted. Keeping
|
|
/// that state distinct from an empty stack prevents the UI from briefly
|
|
/// forgetting every unmanaged app after each write.
|
|
final _persistedUnmanagedAppsProvider = Provider<_UnmanagedAppsSnapshot?>((
|
|
ref,
|
|
) {
|
|
final devicePubkey = ref.watch(devicePubkeyProvider);
|
|
if (devicePubkey == null) {
|
|
return const _UnmanagedAppsSnapshot({}, null);
|
|
}
|
|
|
|
final stackState = ref.watch(
|
|
query<AppStack>(
|
|
authors: {devicePubkey},
|
|
tags: {
|
|
'#d': {kUnmanagedAppsIdentifier},
|
|
},
|
|
source: const LocalSource(),
|
|
subscriptionPrefix: 'app-unmanaged-apps',
|
|
),
|
|
);
|
|
|
|
final stack = switch (stackState) {
|
|
StorageLoading(:final models) ||
|
|
StorageData(:final models) => models.firstOrNull,
|
|
StorageError() => null,
|
|
};
|
|
|
|
if (stack == null) {
|
|
return stackState is StorageData
|
|
? const _UnmanagedAppsSnapshot({}, null)
|
|
: null;
|
|
}
|
|
if (!stack.isDecrypted) return null;
|
|
|
|
return _UnmanagedAppsSnapshot(stack.privateAppIds.toSet(), stack.createdAt);
|
|
});
|
|
|
|
typedef UnmanagedAppsWriter =
|
|
Future<void> Function(Set<String> appIds, DateTime createdAt);
|
|
|
|
/// Owns the decrypted unmanaged set and serializes writes.
|
|
///
|
|
/// State changes optimistically so moving a card between sections does not
|
|
/// wait for SQLite, encryption, or relay I/O. Writes are queued to ensure a
|
|
/// second action includes the first action even while its publish is pending.
|
|
class UnmanagedAppsNotifier extends StateNotifier<Set<String>> {
|
|
UnmanagedAppsNotifier(this._write, {DateTime Function()? clock})
|
|
: _clock = clock ?? DateTime.now,
|
|
super(const {});
|
|
|
|
static const _setEquality = SetEquality<String>();
|
|
|
|
final UnmanagedAppsWriter _write;
|
|
final DateTime Function() _clock;
|
|
Future<void> _writeQueue = Future.value();
|
|
Set<String> _lastPersisted = const {};
|
|
DateTime? _lastPersistedAt;
|
|
DateTime? _lastIssuedAt;
|
|
int _pendingWrites = 0;
|
|
|
|
/// Clears in-memory unmanaged state when the active device key changes.
|
|
void reset() {
|
|
_writeQueue = Future.value();
|
|
_lastPersisted = const {};
|
|
_lastPersistedAt = null;
|
|
_lastIssuedAt = null;
|
|
_pendingWrites = 0;
|
|
state = const {};
|
|
}
|
|
|
|
void acceptPersisted(Set<String> appIds, DateTime? createdAt) {
|
|
if (createdAt == null ||
|
|
_lastPersistedAt == null ||
|
|
!createdAt.isBefore(_lastPersistedAt!)) {
|
|
_lastPersisted = Set.unmodifiable(appIds);
|
|
_lastPersistedAt = createdAt ?? _lastPersistedAt;
|
|
}
|
|
|
|
if (_pendingWrites > 0) return;
|
|
if (createdAt != null &&
|
|
_lastIssuedAt != null &&
|
|
createdAt.isBefore(_lastIssuedAt!)) {
|
|
return;
|
|
}
|
|
_setState(appIds);
|
|
}
|
|
|
|
Future<void> toggle(String appId, {required bool unmanage}) {
|
|
final updated = {...state};
|
|
if (unmanage) {
|
|
updated.add(appId);
|
|
} else {
|
|
updated.remove(appId);
|
|
}
|
|
if (_setEquality.equals(updated, state)) return Future.value();
|
|
|
|
_setState(updated);
|
|
final snapshot = Set<String>.unmodifiable(updated);
|
|
final createdAt = _nextCreatedAt();
|
|
final result = Completer<void>();
|
|
_pendingWrites++;
|
|
|
|
_writeQueue = _writeQueue.then((_) async {
|
|
try {
|
|
await _write(snapshot, createdAt);
|
|
result.complete();
|
|
} catch (error, stackTrace) {
|
|
LogService.I.warn(
|
|
'failed to persist unmanaged-apps stack',
|
|
tag: 'unmanaged-apps',
|
|
err: error,
|
|
stack: stackTrace,
|
|
);
|
|
if (error is UnmanagedAppsSaveException && _pendingWrites == 1) {
|
|
_lastIssuedAt = _lastPersistedAt;
|
|
_setState(_lastPersisted);
|
|
}
|
|
result.completeError(error, stackTrace);
|
|
} finally {
|
|
_pendingWrites--;
|
|
}
|
|
});
|
|
|
|
return result.future;
|
|
}
|
|
|
|
DateTime _nextCreatedAt() {
|
|
final now = _clock();
|
|
final candidate = DateTime.fromMillisecondsSinceEpoch(
|
|
(now.millisecondsSinceEpoch ~/ 1000) * 1000,
|
|
isUtc: now.isUtc,
|
|
);
|
|
final baseline = switch ((_lastIssuedAt, _lastPersistedAt)) {
|
|
(final issued?, final persisted?) =>
|
|
issued.isAfter(persisted) ? issued : persisted,
|
|
(final issued?, null) => issued,
|
|
(null, final persisted?) => persisted,
|
|
(null, null) => null,
|
|
};
|
|
final next = baseline != null && !candidate.isAfter(baseline)
|
|
? baseline.add(const Duration(seconds: 1))
|
|
: candidate;
|
|
_lastIssuedAt = next;
|
|
return next;
|
|
}
|
|
|
|
void _setState(Set<String> appIds) {
|
|
if (!_setEquality.equals(state, appIds)) {
|
|
state = Set.unmodifiable(appIds);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reactive set of package IDs the user has chosen as unmanaged.
|
|
///
|
|
/// Backed by an encrypted AppStack signed by the device key.
|
|
final unmanagedAppsProvider =
|
|
StateNotifierProvider<UnmanagedAppsNotifier, Set<String>>((ref) {
|
|
final notifier = UnmanagedAppsNotifier(
|
|
(appIds, createdAt) =>
|
|
_writeUnmanagedApps(ref, appIds, createdAt: createdAt),
|
|
);
|
|
ref.listen<String?>(devicePubkeyProvider, (previous, next) {
|
|
if (previous != next) notifier.reset();
|
|
});
|
|
ref.listen<_UnmanagedAppsSnapshot?>(_persistedUnmanagedAppsProvider, (
|
|
_,
|
|
snapshot,
|
|
) {
|
|
if (snapshot != null) {
|
|
notifier.acceptPersisted(snapshot.ids, snapshot.createdAt);
|
|
}
|
|
}, fireImmediately: true);
|
|
return notifier;
|
|
});
|
|
|
|
class UnmanagedAppsSaveException implements Exception {
|
|
const UnmanagedAppsSaveException(this.message);
|
|
|
|
final String message;
|
|
|
|
@override
|
|
String toString() => message;
|
|
}
|
|
|
|
class UnmanagedAppsPublishException implements Exception {
|
|
const UnmanagedAppsPublishException(this.message);
|
|
|
|
final String message;
|
|
|
|
@override
|
|
String toString() => message;
|
|
}
|
|
|
|
bool wasUnmanagedStackAccepted(PublishResponse response, AppStack stack) {
|
|
return response.results[stack.event.id]?.any((result) => result.accepted) ??
|
|
false;
|
|
}
|
|
|
|
PartialAppStack createUnmanagedAppsStack({
|
|
required Set<String> appIds,
|
|
required String platform,
|
|
required DateTime createdAt,
|
|
}) {
|
|
final stack = PartialAppStack.withEncryptedApps(
|
|
name: 'Unmanaged Apps',
|
|
identifier: kUnmanagedAppsIdentifier,
|
|
apps: appIds.toList(),
|
|
platform: platform,
|
|
);
|
|
stack.event.createdAt = createdAt;
|
|
return stack;
|
|
}
|
|
|
|
Future<void> _writeUnmanagedApps(
|
|
Ref ref,
|
|
Set<String> appIds, {
|
|
required DateTime createdAt,
|
|
}) async {
|
|
final platform = ref.read(packageManagerProvider.notifier).platform;
|
|
final partial = createUnmanagedAppsStack(
|
|
appIds: appIds,
|
|
platform: platform,
|
|
createdAt: createdAt,
|
|
);
|
|
|
|
try {
|
|
await ref
|
|
.read(devicePrivateEventServiceProvider)
|
|
.saveDraftAndQueue(partial);
|
|
} on DevicePrivateSaveException catch (error) {
|
|
throw UnmanagedAppsSaveException(error.message);
|
|
} on DevicePrivatePublishException catch (error) {
|
|
throw UnmanagedAppsPublishException(error.message);
|
|
}
|
|
}
|
|
|
|
/// Toggles [appId] in the unmanaged encrypted stack.
|
|
/// Pass [unmanage: true] to add, [unmanage: false] to remove.
|
|
Future<void> toggleUnmanagedApp(
|
|
WidgetRef ref,
|
|
String appId, {
|
|
required bool unmanage,
|
|
}) {
|
|
return ref
|
|
.read(unmanagedAppsProvider.notifier)
|
|
.toggle(appId, unmanage: unmanage);
|
|
}
|