mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-04 08:10:55 +00:00
418 lines
15 KiB
Dart
418 lines
15 KiB
Dart
import 'dart:async';
|
|
import 'dart:collection';
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../../api/model/extension.dart';
|
|
import '../../../api/model/install_extension.dart';
|
|
import '../../../api/model/store_extension.dart';
|
|
import '../../../api/model/switch_extension.dart';
|
|
import '../../../api/model/update_extension_settings.dart';
|
|
import '../../../core/capabilities/app_capabilities.dart';
|
|
import 'store_extension_details_provider.dart';
|
|
|
|
final extensionsControllerProvider = AsyncNotifierProvider<ExtensionsController, ExtensionsState>(
|
|
ExtensionsController.new,
|
|
);
|
|
|
|
enum ExtensionListFilter { market, installed }
|
|
|
|
class ExtensionListItem {
|
|
const ExtensionListItem({this.installed, this.store});
|
|
|
|
final Extension? installed;
|
|
final StoreExtension? store;
|
|
|
|
bool get isInstalled => installed != null;
|
|
String get id => installed?.identity ?? store!.id;
|
|
String get title => installed?.title ?? store?.title ?? '';
|
|
String get author => installed?.author ?? store?.author ?? '';
|
|
String get description => installed?.description ?? store?.description ?? '';
|
|
String get version => installed?.version ?? store?.version ?? '0.0.0';
|
|
String? get homepage => installed?.homepage.isNotEmpty == true ? installed!.homepage : store?.homepage;
|
|
String? get repoUrl => installed?.repository?.url.isNotEmpty == true ? installed!.repository!.url : store?.repoUrl;
|
|
int get stars => store?.stars ?? 0;
|
|
int get installCount => store?.installCount ?? 0;
|
|
}
|
|
|
|
class ExtensionsState {
|
|
const ExtensionsState({
|
|
this.installedExtensions = const [],
|
|
this.storeExtensions = const [],
|
|
this.updateFlags = const {},
|
|
this.storePagination,
|
|
this.storeQuery = '',
|
|
this.storeSort = StoreExtensionSort.stars,
|
|
this.listFilter = ExtensionListFilter.market,
|
|
this.loadingInstalled = false,
|
|
this.loadingStore = false,
|
|
this.loadingMoreStore = false,
|
|
this.busyExtensionIds = const {},
|
|
this.devMode = false,
|
|
});
|
|
|
|
final List<Extension> installedExtensions;
|
|
final List<StoreExtension> storeExtensions;
|
|
final Map<String, String> updateFlags;
|
|
final StorePagination? storePagination;
|
|
final String storeQuery;
|
|
final StoreExtensionSort storeSort;
|
|
final ExtensionListFilter listFilter;
|
|
final bool loadingInstalled;
|
|
final bool loadingStore;
|
|
final bool loadingMoreStore;
|
|
final Set<String> busyExtensionIds;
|
|
final bool devMode;
|
|
|
|
UnmodifiableMapView<String, Extension> get installedMap =>
|
|
UnmodifiableMapView({for (final ext in installedExtensions) ext.identity: ext});
|
|
|
|
UnmodifiableMapView<String, StoreExtension> get storeMap =>
|
|
UnmodifiableMapView({for (final ext in storeExtensions) ext.id: ext});
|
|
|
|
List<ExtensionListItem> get displayItems {
|
|
if (listFilter == ExtensionListFilter.installed) {
|
|
return installedExtensions
|
|
.map((ext) => ExtensionListItem(installed: ext, store: storeMap[ext.identity]))
|
|
.toList();
|
|
}
|
|
return storeExtensions.map((ext) => ExtensionListItem(installed: installedMap[ext.id], store: ext)).toList();
|
|
}
|
|
|
|
ExtensionListItem? findItem(String id) {
|
|
final installed = installedMap[id];
|
|
final store = storeMap[id];
|
|
if (installed == null && store == null) return null;
|
|
return ExtensionListItem(installed: installed, store: store);
|
|
}
|
|
|
|
ExtensionsState copyWith({
|
|
List<Extension>? installedExtensions,
|
|
List<StoreExtension>? storeExtensions,
|
|
Map<String, String>? updateFlags,
|
|
StorePagination? storePagination,
|
|
bool clearStorePagination = false,
|
|
String? storeQuery,
|
|
StoreExtensionSort? storeSort,
|
|
ExtensionListFilter? listFilter,
|
|
bool? loadingInstalled,
|
|
bool? loadingStore,
|
|
bool? loadingMoreStore,
|
|
Set<String>? busyExtensionIds,
|
|
bool? devMode,
|
|
}) {
|
|
return ExtensionsState(
|
|
installedExtensions: installedExtensions ?? this.installedExtensions,
|
|
storeExtensions: storeExtensions ?? this.storeExtensions,
|
|
updateFlags: updateFlags ?? this.updateFlags,
|
|
storePagination: clearStorePagination ? null : storePagination ?? this.storePagination,
|
|
storeQuery: storeQuery ?? this.storeQuery,
|
|
storeSort: storeSort ?? this.storeSort,
|
|
listFilter: listFilter ?? this.listFilter,
|
|
loadingInstalled: loadingInstalled ?? this.loadingInstalled,
|
|
loadingStore: loadingStore ?? this.loadingStore,
|
|
loadingMoreStore: loadingMoreStore ?? this.loadingMoreStore,
|
|
busyExtensionIds: busyExtensionIds ?? this.busyExtensionIds,
|
|
devMode: devMode ?? this.devMode,
|
|
);
|
|
}
|
|
}
|
|
|
|
class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
|
static const manualInstallBusyKey = '__manual_install__';
|
|
int _devModeCount = 0;
|
|
int _installedRevision = 0;
|
|
int _storeRevision = 0;
|
|
Future<void>? _updateCheck;
|
|
bool _checkUpdatesAgain = false;
|
|
|
|
@override
|
|
Future<ExtensionsState> build() => _loadInitialData();
|
|
|
|
Future<ExtensionsState> _loadInitialData({bool forceRefresh = false}) async {
|
|
final next = _current.copyWith(loadingInstalled: true, loadingStore: true);
|
|
state = AsyncValue.data(next);
|
|
await Future.wait([
|
|
_loadInstalled(refreshUpdates: true, current: next),
|
|
_refreshStore(current: _current, forceRefresh: forceRefresh),
|
|
]);
|
|
return ref.mounted ? _current : next;
|
|
}
|
|
|
|
Future<void> loadInitialData() async {
|
|
await _loadInitialData(forceRefresh: true);
|
|
}
|
|
|
|
Future<void> loadInstalled({bool refreshUpdates = false}) async {
|
|
if (!ref.mounted) return;
|
|
await _loadInstalled(refreshUpdates: refreshUpdates, current: _current.copyWith(loadingInstalled: true));
|
|
}
|
|
|
|
Future<void> refreshStore() async {
|
|
await _refreshStore(current: _current.copyWith(loadingStore: true), forceRefresh: true);
|
|
}
|
|
|
|
Future<void> loadMoreStore() async {
|
|
final current = _current;
|
|
final pagination = current.storePagination;
|
|
if (pagination == null || !pagination.hasNext || current.loadingMoreStore) return;
|
|
final revision = _storeRevision;
|
|
state = AsyncValue.data(current.copyWith(loadingMoreStore: true));
|
|
try {
|
|
final page = await ref
|
|
.read(gopeedSiteApiProvider)
|
|
.getExtensions(
|
|
page: pagination.page + 1,
|
|
limit: pagination.limit,
|
|
sort: current.storeSort,
|
|
query: current.storeQuery,
|
|
);
|
|
if (!ref.mounted || revision != _storeRevision) return;
|
|
state = AsyncValue.data(
|
|
_current.copyWith(
|
|
// Cached pages can briefly overlap when stars/install counts change.
|
|
storeExtensions: {
|
|
for (final extension in _current.storeExtensions) extension.id: extension,
|
|
for (final extension in page.data) extension.id: extension,
|
|
}.values.toList(),
|
|
storePagination: page.pagination,
|
|
loadingMoreStore: false,
|
|
),
|
|
);
|
|
} catch (_) {
|
|
if (!ref.mounted || revision != _storeRevision) return;
|
|
state = AsyncValue.data(_current.copyWith(loadingMoreStore: false));
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<void> searchStore(String query) async {
|
|
if (query.trim() == _current.storeQuery) return;
|
|
state = AsyncValue.data(_current.copyWith(storeQuery: query.trim()));
|
|
await _refreshStore(current: _current);
|
|
}
|
|
|
|
Future<void> changeSort(StoreExtensionSort sort) async {
|
|
if (sort == _current.storeSort) return;
|
|
state = AsyncValue.data(_current.copyWith(storeSort: sort));
|
|
await _refreshStore(current: _current);
|
|
}
|
|
|
|
void changeFilter(ExtensionListFilter filter) {
|
|
state = AsyncValue.data(_current.copyWith(listFilter: filter));
|
|
}
|
|
|
|
bool canUpdateItem(ExtensionListItem item) {
|
|
final installed = item.installed;
|
|
if (installed == null) return false;
|
|
// The backend checks GitHub live for every installed extension; the store
|
|
// index may lag behind and must not decide update visibility.
|
|
return _current.updateFlags.containsKey(installed.identity);
|
|
}
|
|
|
|
Future<void> installFromStore(StoreExtension extension) async {
|
|
await _runBusy(extension.id, () async {
|
|
final installUrl = (extension.directory ?? '').trim().isEmpty
|
|
? extension.repoUrl
|
|
: '${extension.repoUrl}#${extension.directory!.trim()}';
|
|
final installedId = await ref.read(gopeedServiceProvider).installExtension(InstallExtension(url: installUrl));
|
|
await loadInstalled(refreshUpdates: false);
|
|
unawaited(checkUpdate());
|
|
_bumpStoreInstallCount(installedId.isNotEmpty ? installedId : extension.id);
|
|
_reportInstallSafe(installedId.isNotEmpty ? installedId : extension.id);
|
|
});
|
|
}
|
|
|
|
Future<void> installFromUrl(String url, {bool devInstall = false}) async {
|
|
await _runBusy(manualInstallBusyKey, () async {
|
|
final installedId = await ref
|
|
.read(gopeedServiceProvider)
|
|
.installExtension(InstallExtension(devMode: devInstall, url: url));
|
|
await loadInstalled(refreshUpdates: false);
|
|
unawaited(checkUpdate());
|
|
if (installedId.isNotEmpty) {
|
|
_bumpStoreInstallCount(installedId);
|
|
_reportInstallSafe(installedId);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> toggleExtension(Extension extension, bool enabled) async {
|
|
await _runBusy(extension.identity, () async {
|
|
await ref.read(gopeedServiceProvider).switchExtension(extension.identity, SwitchExtension(status: enabled));
|
|
await loadInstalled(refreshUpdates: false);
|
|
});
|
|
}
|
|
|
|
Future<void> removeExtension(Extension extension) async {
|
|
await _runBusy(extension.identity, () async {
|
|
await ref.read(gopeedServiceProvider).deleteExtension(extension.identity);
|
|
await loadInstalled(refreshUpdates: false);
|
|
final flags = Map<String, String>.of(_current.updateFlags)..remove(extension.identity);
|
|
state = AsyncValue.data(_current.copyWith(updateFlags: flags));
|
|
});
|
|
}
|
|
|
|
Future<void> upgradeExtension(Extension extension) async {
|
|
await _runBusy(extension.identity, () async {
|
|
await ref.read(gopeedServiceProvider).updateExtension(extension.identity);
|
|
await loadInstalled(refreshUpdates: false);
|
|
// Drop the stale flag immediately; the background re-check confirms it.
|
|
final flags = Map<String, String>.of(_current.updateFlags)..remove(extension.identity);
|
|
state = AsyncValue.data(_current.copyWith(updateFlags: flags));
|
|
unawaited(checkUpdate());
|
|
_bumpStoreInstallCount(extension.identity);
|
|
_reportInstallSafe(extension.identity);
|
|
});
|
|
}
|
|
|
|
Future<void> saveExtensionSettings(Extension extension, Map<String, dynamic> settings) async {
|
|
await _runBusy(extension.identity, () async {
|
|
await ref
|
|
.read(gopeedServiceProvider)
|
|
.updateExtensionSettings(extension.identity, UpdateExtensionSettings(settings: settings));
|
|
await loadInstalled(refreshUpdates: false);
|
|
});
|
|
}
|
|
|
|
Future<void> checkUpdate() {
|
|
if (!ref.mounted) return Future.value();
|
|
_checkUpdatesAgain = true;
|
|
// Installation/refresh can request a fresh scan while one is running.
|
|
// Coalesce those requests instead of launching overlapping Git operations.
|
|
return _updateCheck ??= _runUpdateChecks().whenComplete(() => _updateCheck = null);
|
|
}
|
|
|
|
Future<void> _runUpdateChecks() async {
|
|
final service = ref.read(gopeedServiceProvider);
|
|
while (ref.mounted && _checkUpdatesAgain) {
|
|
_checkUpdatesAgain = false;
|
|
final revision = _installedRevision;
|
|
final installed = List<Extension>.of(_current.installedExtensions);
|
|
final flags = <String, String>{};
|
|
for (final ext in installed) {
|
|
if (!ref.mounted) return;
|
|
if (revision != _installedRevision) break;
|
|
try {
|
|
final resp = await service.upgradeCheckExtension(ext.identity);
|
|
if (!ref.mounted) return;
|
|
if (revision != _installedRevision) break;
|
|
if (resp.newVersion.isNotEmpty) flags[ext.identity] = resp.newVersion;
|
|
} catch (_) {
|
|
// A failed update check must never prevent loading the store.
|
|
}
|
|
}
|
|
if (ref.mounted && revision == _installedRevision) {
|
|
state = AsyncValue.data(_current.copyWith(updateFlags: flags));
|
|
}
|
|
}
|
|
}
|
|
|
|
void tryOpenDevMode() {
|
|
if (_devModeCount == 0) {
|
|
Future.delayed(const Duration(seconds: 2), () {
|
|
if (!ref.mounted || _current.devMode) return;
|
|
_devModeCount = 0;
|
|
});
|
|
}
|
|
_devModeCount++;
|
|
if (_devModeCount >= 5) {
|
|
state = AsyncValue.data(_current.copyWith(devMode: true));
|
|
}
|
|
}
|
|
|
|
ExtensionsState get _current => state.value ?? const ExtensionsState();
|
|
|
|
Future<ExtensionsState> _loadInstalled({required bool refreshUpdates, required ExtensionsState current}) async {
|
|
final revision = ++_installedRevision;
|
|
state = AsyncValue.data(current.copyWith(loadingInstalled: true));
|
|
final installed = await ref.read(gopeedServiceProvider).getExtensions();
|
|
if (!ref.mounted) return current;
|
|
if (revision != _installedRevision) return _current;
|
|
state = AsyncValue.data(_current.copyWith(installedExtensions: installed, loadingInstalled: false));
|
|
if (refreshUpdates) {
|
|
unawaited(checkUpdate());
|
|
}
|
|
return _current;
|
|
}
|
|
|
|
Future<void> _refreshStore({required ExtensionsState current, bool forceRefresh = false}) async {
|
|
final revision = ++_storeRevision;
|
|
state = AsyncValue.data(current.copyWith(loadingStore: true, loadingMoreStore: false));
|
|
try {
|
|
final page = await ref
|
|
.read(gopeedSiteApiProvider)
|
|
.getExtensions(
|
|
page: 1,
|
|
limit: 20,
|
|
sort: current.storeSort,
|
|
query: current.storeQuery,
|
|
forceRefresh: forceRefresh,
|
|
);
|
|
if (!ref.mounted || revision != _storeRevision) return;
|
|
state = AsyncValue.data(
|
|
_current.copyWith(storeExtensions: page.data, storePagination: page.pagination, loadingStore: false),
|
|
);
|
|
} catch (_) {
|
|
if (!ref.mounted || revision != _storeRevision) return;
|
|
state = AsyncValue.data(_current.copyWith(loadingStore: false));
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<void> _runBusy(String id, Future<void> Function() action) async {
|
|
if (_current.busyExtensionIds.contains(id)) return;
|
|
state = AsyncValue.data(_current.copyWith(busyExtensionIds: {..._current.busyExtensionIds, id}));
|
|
try {
|
|
await action();
|
|
} finally {
|
|
if (ref.mounted) {
|
|
final busy = Set<String>.of(_current.busyExtensionIds)..remove(id);
|
|
state = AsyncValue.data(_current.copyWith(busyExtensionIds: busy));
|
|
}
|
|
}
|
|
}
|
|
|
|
void _bumpStoreInstallCount(String id) {
|
|
if (!ref.mounted) return;
|
|
final current = _current;
|
|
final index = current.storeExtensions.indexWhere((e) => e.id == id);
|
|
if (index < 0) return;
|
|
final ext = current.storeExtensions[index];
|
|
final updated = [...current.storeExtensions];
|
|
updated[index] = StoreExtension(
|
|
id: ext.id,
|
|
repoFullName: ext.repoFullName,
|
|
repoUrl: ext.repoUrl,
|
|
directory: ext.directory,
|
|
commitSha: ext.commitSha,
|
|
name: ext.name,
|
|
author: ext.author,
|
|
title: ext.title,
|
|
description: ext.description,
|
|
icon: ext.icon,
|
|
version: ext.version,
|
|
homepage: ext.homepage,
|
|
readme: ext.readme,
|
|
installCount: ext.installCount + 1,
|
|
stars: ext.stars,
|
|
topics: ext.topics,
|
|
createdAt: ext.createdAt,
|
|
updatedAt: ext.updatedAt,
|
|
hasDetails: ext.hasDetails,
|
|
);
|
|
state = AsyncValue.data(current.copyWith(storeExtensions: updated));
|
|
}
|
|
|
|
void _reportInstallSafe(String id) {
|
|
if (!ref.mounted) return;
|
|
final siteApi = ref.read(gopeedSiteApiProvider);
|
|
unawaited(() async {
|
|
try {
|
|
await siteApi.reportExtensionInstall(id);
|
|
} catch (_) {}
|
|
}());
|
|
}
|
|
|
|
}
|