feat: extension store (#1301)

This commit is contained in:
Levi authored and GitHub committed 2026-03-06 21:53:19 +08:00
1 parent bc5c818eae
commit d36b345f92
12 files changed
+1791 -425

No files matched your search

@@ -1,38 +1,278 @@
import 'package:get/get.dart';
import 'package:gopeed/api/api.dart';
import 'dart:async';
import 'dart:collection';
import 'package:get/get.dart';
import '../../../../api/api.dart';
import '../../../../api/gopeed_site_api.dart';
import '../../../../api/model/extension.dart';
import '../../../../api/model/install_extension.dart';
import '../../../../api/model/store_extension.dart';
import '../../../../api/model/switch_extension.dart';
enum ExtensionListFilter {
market,
installed,
}
class ExtensionListItem {
final Extension? installed;
final StoreExtension? store;
const ExtensionListItem({this.installed, this.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 {
final installedHomepage = installed?.homepage;
if (installedHomepage != null && installedHomepage.isNotEmpty) {
return installedHomepage;
}
return store?.homepage;
}
String? get repoUrl {
final installedRepo = installed?.repository?.url;
if (installedRepo != null && installedRepo.isNotEmpty) {
return installedRepo;
}
return store?.repoUrl;
}
int get stars => store?.stars ?? 0;
int get installCount => store?.installCount ?? 0;
String? get icon => store?.icon;
}
class ExtensionController extends GetxController {
final extensions = <Extension>[].obs;
static const manualInstallBusyKey = '__manual_install__';
final installedExtensions = <Extension>[].obs;
final updateFlags = <String, String>{}.obs;
final storeExtensions = <StoreExtension>[].obs;
final storePagination = Rxn<StorePagination>();
final storeQuery = ''.obs;
final storeSort = StoreExtensionSort.stars.obs;
final listFilter = ExtensionListFilter.market.obs;
final loadingInstalled = false.obs;
final loadingStore = false.obs;
final loadingMoreStore = false.obs;
final busyExtensionIds = <String>{}.obs;
final showInstallTools = false.obs;
final devMode = false.obs;
var _devModeCount = 0;
/// Flag to prevent handling pending install multiple times
bool pendingInstallHandled = false;
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.value == 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();
}
@override
void onInit() async {
Future<void> onInit() async {
super.onInit();
await load();
checkUpdate();
await loadInitialData();
}
Future<void> load() async {
extensions.value = await getExtensions();
Future<void> loadInitialData() async {
await Future.wait([loadInstalled(refreshUpdates: true), refreshStore()]);
}
Future<void> checkUpdate() async {
for (final ext in extensions) {
final resp = await upgradeCheckExtension(ext.identity);
if (resp.newVersion.isNotEmpty) {
updateFlags[ext.identity] = resp.newVersion;
Future<void> loadInstalled({bool refreshUpdates = false}) async {
loadingInstalled.value = true;
try {
installedExtensions.value = await getExtensions();
if (refreshUpdates) {
await checkUpdate();
}
} finally {
loadingInstalled.value = false;
}
}
// Try to open dev mode when install button is clicked 5 times in 2 seconds
Future<void> checkUpdate() async {
final nextFlags = <String, String>{};
for (final ext in installedExtensions) {
try {
final resp = await upgradeCheckExtension(ext.identity);
if (resp.newVersion.isNotEmpty) {
nextFlags[ext.identity] = resp.newVersion;
}
} catch (_) {
// Ignore single extension check failures to avoid breaking the whole list.
}
}
updateFlags.assignAll(nextFlags);
}
Future<void> refreshStore() async {
loadingStore.value = true;
try {
final page = await GopeedSiteApi.instance.getExtensions(
page: 1,
limit: 20,
sort: storeSort.value,
query: storeQuery.value,
);
storeExtensions.assignAll(page.data);
storePagination.value = page.pagination;
} finally {
loadingStore.value = false;
}
}
Future<void> loadMoreStore() async {
final pagination = storePagination.value;
if (pagination == null || !pagination.hasNext || loadingMoreStore.value) {
return;
}
loadingMoreStore.value = true;
try {
final page = await GopeedSiteApi.instance.getExtensions(
page: pagination.page + 1,
limit: pagination.limit,
sort: storeSort.value,
query: storeQuery.value,
);
storeExtensions.addAll(page.data);
storePagination.value = page.pagination;
} finally {
loadingMoreStore.value = false;
}
}
Future<void> searchStore(String query) async {
storeQuery.value = query.trim();
await refreshStore();
}
Future<void> changeSort(StoreExtensionSort sort) async {
if (storeSort.value == sort) {
await refreshStore();
return;
}
storeSort.value = sort;
await refreshStore();
}
void changeFilter(ExtensionListFilter filter) {
listFilter.value = filter;
}
void toggleInstallTools() {
showInstallTools.value = !showInstallTools.value;
}
Extension? findInstalled(StoreExtension extension) {
return installedMap[extension.id];
}
bool canUpdateFromStore(StoreExtension extension) {
final installed = findInstalled(extension);
if (installed == null) return false;
return _compareVersion(extension.version, installed.version) > 0;
}
bool canUpdateItem(ExtensionListItem item) {
if (item.installed == null) return false;
if (listFilter.value == ExtensionListFilter.market && item.store != null) {
return canUpdateFromStore(item.store!);
}
return updateFlags.containsKey(item.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 installExtension(InstallExtension(url: installUrl));
final statsId = installedId.isNotEmpty ? installedId : extension.id;
await loadInstalled(refreshUpdates: false);
_backgroundCheckUpdate();
_bumpStoreInstallCount(statsId);
_reportInstallSafe(statsId);
});
}
Future<void> installFromUrl(String url,
{bool devInstall = false, String? statsId}) async {
await _runBusy(manualInstallBusyKey, () async {
final installedId = await installExtension(
InstallExtension(devMode: devInstall, url: url));
await loadInstalled(refreshUpdates: false);
_backgroundCheckUpdate();
if (installedId.isNotEmpty) {
_bumpStoreInstallCount(installedId);
_reportInstallSafe(installedId);
} else if (statsId != null && statsId.isNotEmpty) {
_bumpStoreInstallCount(statsId);
_reportInstallSafe(statsId);
}
});
}
Future<void> toggleExtension(Extension extension, bool enabled) async {
await _runBusy(extension.identity, () async {
await switchExtension(
extension.identity, SwitchExtension(status: enabled));
await loadInstalled(refreshUpdates: false);
});
}
Future<void> removeExtension(Extension extension) async {
await _runBusy(extension.identity, () async {
await deleteExtension(extension.identity);
await loadInstalled(refreshUpdates: false);
updateFlags.remove(extension.identity);
});
}
Future<void> upgradeExtension(Extension extension) async {
await _runBusy(extension.identity, () async {
await updateExtension(extension.identity);
await loadInstalled(refreshUpdates: false);
_backgroundCheckUpdate();
_bumpStoreInstallCount(extension.identity);
_reportInstallSafe(extension.identity);
});
}
void tryOpenDevMode() {
if (_devModeCount == 0) {
Future.delayed(const Duration(seconds: 2), () {
@@ -46,4 +286,76 @@ class ExtensionController extends GetxController {
devMode.value = true;
}
}
static int _compareVersion(String a, String b) {
final aNums = _toVersionNumbers(a);
final bNums = _toVersionNumbers(b);
final maxLen = aNums.length > bNums.length ? aNums.length : bNums.length;
for (var i = 0; i < maxLen; i++) {
final left = i < aNums.length ? aNums[i] : 0;
final right = i < bNums.length ? bNums[i] : 0;
if (left > right) return 1;
if (left < right) return -1;
}
return 0;
}
static List<int> _toVersionNumbers(String version) {
return version
.split(RegExp(r'[^0-9]+'))
.where((part) => part.isNotEmpty)
.map((part) => int.tryParse(part) ?? 0)
.toList();
}
Future<void> _runBusy(String id, Future<void> Function() action) async {
if (busyExtensionIds.contains(id)) return;
busyExtensionIds.add(id);
try {
await action();
} finally {
busyExtensionIds.remove(id);
}
}
void _backgroundCheckUpdate() {
unawaited(checkUpdate());
}
void _reportInstallSafe(String id) {
unawaited(() async {
try {
await GopeedSiteApi.instance.reportExtensionInstall(id);
} catch (_) {
// Ignore stats reporting failures.
}
}());
}
void _bumpStoreInstallCount(String id) {
final index = storeExtensions.indexWhere((e) => e.id == id);
if (index < 0) return;
final ext = storeExtensions[index];
storeExtensions[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,
);
}
}
@@ -0,0 +1,273 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:path/path.dart' as path;
import 'package:url_launcher/url_launcher.dart';
import '../../../../api/api.dart';
import '../../../../database/database.dart';
import '../../../../util/util.dart';
import '../controllers/extension_controller.dart';
class ExtensionCard extends StatelessWidget {
const ExtensionCard({
super.key,
required this.item,
required this.busy,
required this.canUpdate,
this.onTap,
this.onToggle,
this.onOpenSetting,
this.onUpdate,
this.onDelete,
this.onInstall,
});
final ExtensionListItem item;
final bool busy;
final bool canUpdate;
final VoidCallback? onTap;
final Future<void> Function(bool value)? onToggle;
final VoidCallback? onOpenSetting;
final VoidCallback? onUpdate;
final VoidCallback? onDelete;
final VoidCallback? onInstall;
@override
Widget build(BuildContext context) {
final installed = item.installed;
final store = item.store;
final installedFlag = item.isInstalled;
final content = Stack(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).dividerColor.withValues(alpha: 0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildCardIcon(item),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 2),
Text(
'${item.author} · v${item.version}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
if (installed != null) ...[
Transform.scale(
scale: 0.82,
child: Switch(
value: !installed.disabled,
onChanged: busy || onToggle == null
? null
: (value) async {
await onToggle!(value);
},
),
),
],
],
),
const SizedBox(height: 8),
Text(
item.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const Spacer(),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Row(
children: [
if (store != null) ...[
_metricItem(
context, Icons.star_rounded, item.stars.toString()),
const SizedBox(width: 10),
_metricItem(context, Icons.download_outlined,
item.installCount.toString()),
],
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (installed != null && canUpdate)
IconButton.filledTonal(
tooltip: 'newVersionUpdate'.tr,
onPressed: busy ? null : onUpdate,
icon: busy
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh_rounded),
),
if (!installedFlag && store != null)
IconButton.filledTonal(
tooltip: 'extensionInstall'.tr,
onPressed: busy ? null : onInstall,
icon: busy
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download),
),
if ((item.homepage ?? '').isNotEmpty)
IconButton(
tooltip: 'homepage'.tr,
onPressed: () => launchUrl(Uri.parse(item.homepage!)),
icon: const Icon(Icons.home_outlined),
),
if ((item.repoUrl ?? '').isNotEmpty)
IconButton(
tooltip: 'GitHub',
onPressed: () => launchUrl(Uri.parse(item.repoUrl!)),
icon: const Icon(Icons.code),
),
if (installed != null &&
installed.settings?.isNotEmpty == true)
IconButton(
tooltip: 'setting'.tr,
onPressed: busy ? null : onOpenSetting,
icon: const Icon(Icons.settings),
),
if (installed != null)
IconButton(
tooltip: 'delete'.tr,
onPressed: busy ? null : onDelete,
icon: const Icon(Icons.delete_outline),
),
],
),
],
),
],
),
),
if (installedFlag && canUpdate)
Positioned(
top: 10,
right: 10,
child: _updateDot(),
),
],
);
if (onTap == null) return content;
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: content,
);
}
Widget _buildCardIcon(ExtensionListItem item) {
final storeIcon = item.icon;
if (storeIcon != null && storeIcon.isNotEmpty) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
storeIcon,
width: 42,
height: 42,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Image.asset(
'assets/extension/default_icon.png',
width: 42,
height: 42),
),
);
}
final extension = item.installed;
if (extension == null) {
return Image.asset('assets/extension/default_icon.png',
width: 42, height: 42);
}
final image = extension.icon.isEmpty
? Image.asset('assets/extension/default_icon.png',
width: 42, height: 42)
: Util.isWeb()
? Image.network(
join('/fs/extensions/${extension.identity}/${extension.icon}'),
width: 42,
height: 42,
headers: {
'Authorization': 'Bearer ${Database.instance.getWebToken()}'
},
errorBuilder: (_, __, ___) => Image.asset(
'assets/extension/default_icon.png',
width: 42,
height: 42),
)
: Image.file(
extension.devMode
? File(path.join(extension.devPath, extension.icon))
: File(path.join(Util.getStorageDir(), 'extensions',
extension.identity, extension.icon)),
width: 42,
height: 42,
errorBuilder: (_, __, ___) => Image.asset(
'assets/extension/default_icon.png',
width: 42,
height: 42),
);
return ClipRRect(borderRadius: BorderRadius.circular(8), child: image);
}
Widget _metricItem(BuildContext context, IconData icon, String text) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18, color: Theme.of(context).hintColor),
const SizedBox(width: 4),
Text(text, style: Theme.of(context).textTheme.bodyMedium),
],
);
}
Widget _updateDot() {
return Container(
width: 9,
height: 9,
decoration: const BoxDecoration(
color: Colors.redAccent,
shape: BoxShape.circle,
),
);
}
}
@@ -0,0 +1,324 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:get/get.dart';
import 'package:path/path.dart' as path;
import 'package:url_launcher/url_launcher.dart';
import '../../../../api/model/extension.dart';
import '../../../../api/model/store_extension.dart';
import '../../../../util/message.dart';
import '../../../../util/util.dart';
import '../controllers/extension_controller.dart';
class ExtensionDetailDrawer extends GetView<ExtensionController> {
const ExtensionDetailDrawer({
super.key,
required this.extension,
required this.onClose,
this.installed,
});
final StoreExtension extension;
final Extension? installed;
final VoidCallback onClose;
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).colorScheme.surface,
child: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 12, 10),
child: Row(
children: [
Expanded(
child: Text(
extension.title,
style: Theme.of(context).textTheme.titleLarge,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
onPressed: onClose,
icon: const Icon(Icons.close),
)
],
),
),
const Divider(height: 1),
Expanded(
child: Obx(() {
final localInstalled =
installed ?? controller.findInstalled(extension);
final canUpdate = controller.canUpdateFromStore(extension);
final busy =
controller.busyExtensionIds.contains(extension.id) ||
(localInstalled != null &&
controller.busyExtensionIds
.contains(localInstalled.identity));
return ListView(
padding: const EdgeInsets.all(16),
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildIcon(),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${extension.author} • v${extension.version}'),
const SizedBox(height: 6),
Text(
extension.description,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
],
),
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (localInstalled == null)
ElevatedButton.icon(
onPressed: busy
? null
: () async {
try {
await controller
.installFromStore(extension);
showMessage('tip'.tr,
'extensionInstallSuccess'.tr);
} catch (e) {
showErrorMessage(e);
}
},
icon: busy
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2),
)
: const Icon(Icons.download),
label: Text('extensionInstall'.tr),
),
if (localInstalled != null && canUpdate)
ElevatedButton.icon(
onPressed: busy
? null
: () async {
try {
await controller
.upgradeExtension(localInstalled);
showMessage('tip'.tr,
'extensionUpdateSuccess'.tr);
} catch (e) {
showErrorMessage(e);
}
},
icon: busy
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2),
)
: const Icon(Icons.refresh_rounded),
label: Text('newVersionUpdate'.tr),
),
if ((extension.homepage ?? '').isNotEmpty)
OutlinedButton.icon(
onPressed: () =>
launchUrl(Uri.parse(extension.homepage!)),
icon: const Icon(Icons.home_outlined),
label: Text('homepage'.tr),
),
OutlinedButton.icon(
onPressed: () =>
launchUrl(Uri.parse(extension.repoUrl)),
icon: const Icon(Icons.code),
label: const Text('GitHub'),
),
],
),
const SizedBox(height: 14),
const Divider(height: 1),
const SizedBox(height: 12),
_buildReadme(context, localInstalled),
],
);
}),
),
],
),
),
);
}
Widget _buildReadme(BuildContext context, Extension? installed) {
return FutureBuilder<_ReadmeInfo>(
future: _loadReadme(installed),
builder: (context, snapshot) {
final info = snapshot.data;
final markdown = info?.content ?? extension.readme ?? '';
if (markdown.trim().isEmpty) {
return Text('No README',
style: Theme.of(context).textTheme.bodyMedium);
}
return MarkdownBody(
data: markdown,
selectable: true,
onTapLink: (text, href, title) {
if (href == null || href.isEmpty) return;
final resolved = _resolvePath(href, info, forImage: false);
if (resolved == null) return;
launchUrl(Uri.parse(resolved),
mode: LaunchMode.externalApplication);
},
imageBuilder: (uri, title, alt) {
final resolved = _resolvePath(uri.toString(), info, forImage: true);
if (resolved == null) return const SizedBox.shrink();
if (resolved.startsWith('file://')) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Image.file(
File(Uri.parse(resolved).toFilePath()),
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Image.network(
resolved,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
);
},
);
},
);
}
Future<_ReadmeInfo> _loadReadme(Extension? installed) async {
if (installed == null) {
return _ReadmeInfo(
content: extension.readme ?? '',
mode: _ReadmeMode.remote,
localReadmePath: null,
);
}
final rootDir = installed.devMode
? installed.devPath
: path.join(Util.getStorageDir(), 'extensions', installed.identity);
final candidates = [
path.join(rootDir, 'README.md'),
path.join(rootDir, 'readme.md'),
path.join(rootDir, 'README.MD'),
];
for (final filePath in candidates) {
final file = File(filePath);
if (await file.exists()) {
return _ReadmeInfo(
content: await file.readAsString(),
mode: _ReadmeMode.local,
localReadmePath: filePath,
);
}
}
return _ReadmeInfo(
content: extension.readme ?? '',
mode: _ReadmeMode.remote,
localReadmePath: null,
);
}
String? _resolvePath(String raw, _ReadmeInfo? info,
{required bool forImage}) {
final value = raw.trim();
if (value.isEmpty) return null;
final uri = Uri.tryParse(value);
if (uri != null && uri.hasScheme) {
return uri.toString();
}
if (info?.mode == _ReadmeMode.local &&
info?.localReadmePath != null &&
forImage) {
final readmeDir = path.dirname(info!.localReadmePath!);
final clean = value.split('#').first;
final absolute = path.normalize(path.join(readmeDir, clean));
return Uri.file(absolute).toString();
}
final ref =
extension.commitSha?.isNotEmpty == true ? extension.commitSha! : 'HEAD';
final dir = (extension.directory ?? '').trim();
final baseSegments = [
if (dir.isNotEmpty) ...dir.split('/').where((e) => e.isNotEmpty),
'',
];
final base = forImage
? Uri.https('raw.githubusercontent.com',
'/${extension.repoFullName}/$ref/${baseSegments.join('/')}')
: Uri.https('github.com',
'/${extension.repoFullName}/blob/$ref/${baseSegments.join('/')}');
return base.resolve(value).toString();
}
Widget _buildIcon() {
if ((extension.icon ?? '').isEmpty) {
return Image.asset('assets/extension/default_icon.png',
width: 56, height: 56);
}
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
extension.icon!,
width: 56,
height: 56,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) {
return Image.asset('assets/extension/default_icon.png',
width: 56, height: 56);
},
),
);
}
}
enum _ReadmeMode {
remote,
local,
}
class _ReadmeInfo {
final String content;
final _ReadmeMode mode;
final String? localReadmePath;
_ReadmeInfo({
required this.content,
required this.mode,
required this.localReadmePath,
});
}
File diff suppressed because it is too large. Load diff