Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions lib/core/extensions/extension_support.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import 'dart:io';

import 'package:path/path.dart' as p;
import 'package:querya_desktop/core/extensions/models/extension_manifest.dart';
import 'package:querya_desktop/core/extensions/models/extension_type.dart';
import 'package:querya_desktop/core/market/marketplace_repository.dart';

/// Support matrix for locally installed / marketplace extensions.
///
/// Themes install and apply fully. Database drivers are catalog preview until
/// the dynamic plugin runtime ships (see Marketplace API roadmap).
class ExtensionSupport {
ExtensionSupport._();

static const databaseDriverPreviewNotice =
'Database drivers in the Marketplace are preview listings only. '
'Querya connects using built-in Dart drivers (PostgreSQL, MySQL, SQLite, '
'Redis, MongoDB). External driver plugins will load via the Marketplace '
'plugin runtime in a future release.';

static const databaseDriverMissingEntryMessage =
'Driver package is missing its main entry file. Installation aborted.';

static bool isPreviewOnly(ExtensionType type) =>
type == ExtensionType.databaseDriver;

static bool isPreviewOnlyManifest(ExtensionManifest manifest) =>
isPreviewOnly(manifest.type);

/// Ensures a database driver archive contains the declared [ExtensionManifest.main].
static void validateDriverPackage({
required ExtensionManifest manifest,
required Directory installDir,
}) {
if (manifest.type != ExtensionType.databaseDriver) return;

final main = manifest.main?.trim();
if (main == null || main.isEmpty) {
throw MarketplaceException(
'Driver "${manifest.id}" is missing a main entry in manifest.json.',
);
}

final entry = File(p.join(installDir.path, main));
if (!entry.existsSync()) {
throw MarketplaceException(
'Driver package "${manifest.id}" is missing entry file "$main". '
'$databaseDriverMissingEntryMessage',
);
}
}
}
17 changes: 10 additions & 7 deletions lib/core/market/http_marketplace_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,13 @@ import 'package:archive/archive.dart';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:querya_desktop/core/extensions/extension_support.dart';
import 'package:querya_desktop/core/extensions/extension_paths.dart';
import 'package:querya_desktop/core/extensions/local_extension_registry.dart';
import 'package:querya_desktop/core/extensions/models/extension_manifest.dart';
import 'package:querya_desktop/core/extensions/models/extension_type.dart';
import 'marketplace_repository.dart';

class MarketplaceException implements Exception {
MarketplaceException(this.message);
final String message;
@override
String toString() => 'MarketplaceException: $message';
}

/// HTTP implementation of [MarketplaceRepository] connecting to MarketApi backend.
///
/// Implements secure downloading with SHA256 checksum verification and safe
Expand Down Expand Up @@ -104,6 +98,10 @@ class HttpMarketplaceRepository implements MarketplaceRepository {
ExtensionManifest manifest, {
void Function(double)? onProgress,
}) async {
if (ExtensionSupport.isPreviewOnly(manifest.type)) {
throw MarketplaceException(ExtensionSupport.databaseDriverPreviewNotice);
}

final downloadUrl = manifest.downloadUrl;
if (downloadUrl == null || downloadUrl.trim().isEmpty) {
throw MarketplaceException('Extension manifest is missing downloadUrl');
Expand Down Expand Up @@ -165,6 +163,11 @@ class HttpMarketplaceRepository implements MarketplaceRepository {

onProgress?.call(0.95);

ExtensionSupport.validateDriverPackage(
manifest: manifest,
installDir: extDir,
);

// Step 4: Write/Update manifest.json in the extension directory
final manifestFile = File(p.join(extDir.path, 'manifest.json'));
const encoder = JsonEncoder.withIndent(' ');
Expand Down
13 changes: 13 additions & 0 deletions lib/core/market/marketplace_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,22 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:querya_desktop/core/extensions/extension_support.dart';
import 'package:querya_desktop/core/extensions/extension_paths.dart';
import 'package:querya_desktop/core/extensions/local_extension_registry.dart';
import 'package:querya_desktop/core/extensions/models/extension_manifest.dart';
import 'package:querya_desktop/core/extensions/models/extension_type.dart';

export 'http_marketplace_repository.dart';

/// Thrown when marketplace install, download, or validation fails.
class MarketplaceException implements Exception {
MarketplaceException(this.message);
final String message;
@override
String toString() => 'MarketplaceException: $message';
}

/// Abstract repository contract for Marketplace operations (Block B).
///
/// See [docs/market-tech.md] and Block B specification.
Expand Down Expand Up @@ -175,6 +184,10 @@ class MockMarketplaceRepository implements MarketplaceRepository {
ExtensionManifest manifest, {
void Function(double)? onProgress,
}) async {
if (ExtensionSupport.isPreviewOnly(manifest.type)) {
throw MarketplaceException(ExtensionSupport.databaseDriverPreviewNotice);
}

// Simulate download & verification progress
for (int i = 1; i <= 10; i++) {
await Future.delayed(const Duration(milliseconds: 100));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart' as material;
import 'package:querya_desktop/core/extensions/extension_support.dart';
import 'package:querya_desktop/core/extensions/local_extension_registry.dart';
import 'package:querya_desktop/core/extensions/models/extension_manifest.dart';
import 'package:querya_desktop/core/layout/window_layout.dart';
Expand Down Expand Up @@ -82,6 +83,12 @@ class _ExtensionManagerContentState extends material.State<_ExtensionManagerCont
} catch (e) {
if (mounted) {
setState(() => _installingProgress.remove(manifest.id));
final message = e is MarketplaceException
? e.message
: 'Failed to install "${manifest.name}".';
material.ScaffoldMessenger.of(context).showSnackBar(
material.SnackBar(content: material.Text(message)),
);
}
}
}
Expand Down Expand Up @@ -234,8 +241,37 @@ class _ExtensionManagerContentState extends material.State<_ExtensionManagerCont
child: material.CircularProgressIndicator(),
);
}
final theme = Theme.of(context).colorScheme;
return material.Column(
children: [
material.Padding(
padding: const material.EdgeInsets.fromLTRB(24, 16, 24, 0),
child: material.Container(
width: double.infinity,
padding: const material.EdgeInsets.all(12),
decoration: material.BoxDecoration(
color: theme.muted.withValues(alpha: 0.35),
borderRadius: material.BorderRadius.circular(8),
border: material.Border.all(color: theme.border),
),
child: material.Row(
crossAxisAlignment: material.CrossAxisAlignment.start,
children: [
material.Icon(
material.Icons.info_outline_rounded,
size: 18,
color: theme.mutedForeground,
),
const material.SizedBox(width: 10),
material.Expanded(
child: const Text(ExtensionSupport.databaseDriverPreviewNotice)
.muted()
.small(),
),
],
),
),
),
material.Padding(
padding: const material.EdgeInsets.fromLTRB(24, 16, 24, 8),
child: TextField(
Expand Down
33 changes: 33 additions & 0 deletions lib/features/extensions/presentation/widgets/extension_card.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart' as material;
import 'package:querya_desktop/core/extensions/extension_support.dart';
import 'package:querya_desktop/core/extensions/models/extension_manifest.dart';
import 'package:querya_desktop/core/extensions/models/extension_type.dart';
import 'package:querya_desktop/shared/widgets/widgets.dart';
Expand Down Expand Up @@ -29,6 +30,7 @@ class ExtensionCard extends material.StatelessWidget {
material.Widget build(material.BuildContext context) {
final theme = Theme.of(context).colorScheme;
final radius = Theme.of(context).radiusMd;
final isPreview = ExtensionSupport.isPreviewOnlyManifest(manifest);

return material.Container(
padding: const material.EdgeInsets.all(16),
Expand All @@ -51,6 +53,10 @@ class ExtensionCard extends material.StatelessWidget {
material.Expanded(
child: Text(manifest.name).large().semiBold(),
),
if (isPreview) ...[
const material.SizedBox(width: 8),
_buildPreviewBadge(theme),
],
],
),
const material.SizedBox(height: 4),
Expand Down Expand Up @@ -130,6 +136,14 @@ class ExtensionCard extends material.StatelessWidget {
onPressed: onUninstall,
child: const Text('Uninstall'),
)
else if (isPreview)
const material.Tooltip(
message: ExtensionSupport.databaseDriverPreviewNotice,
child: OutlineButton(
onPressed: null,
child: Text('Preview'),
),
)
else
PrimaryButton(
onPressed: onInstall,
Expand Down Expand Up @@ -160,6 +174,25 @@ class ExtensionCard extends material.StatelessWidget {
);
}

material.Widget _buildPreviewBadge(ColorScheme theme) {
return material.Container(
padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: material.BoxDecoration(
color: theme.muted,
borderRadius: material.BorderRadius.circular(6),
border: material.Border.all(color: theme.border),
),
child: material.Text(
'Preview',
style: material.TextStyle(
fontSize: 11,
fontWeight: material.FontWeight.w600,
color: theme.mutedForeground,
),
),
);
}

material.Widget _buildTagBadge(ColorScheme theme, String tag) {
return material.Container(
padding: const material.EdgeInsets.symmetric(horizontal: 6, vertical: 2),
Expand Down
56 changes: 56 additions & 0 deletions test/core/extensions/extension_support_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import 'dart:io';

import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:querya_desktop/core/extensions/extension_support.dart';
import 'package:querya_desktop/core/extensions/models/extension_manifest.dart';
import 'package:querya_desktop/core/extensions/models/extension_type.dart';
import 'package:querya_desktop/core/market/marketplace_repository.dart';

void main() {
group('ExtensionSupport', () {
test('marks database drivers as preview-only', () {
expect(
ExtensionSupport.isPreviewOnly(ExtensionType.databaseDriver),
isTrue,
);
expect(ExtensionSupport.isPreviewOnly(ExtensionType.theme), isFalse);
});

test('validateDriverPackage requires main entry file', () async {
final dir = await Directory.systemTemp.createTemp('querya_driver_test_');
addTearDown(() async {
if (await dir.exists()) {
await dir.delete(recursive: true);
}
});

const manifest = ExtensionManifest(
id: 'test.driver',
name: 'Test Driver',
version: '1.0.0',
publisher: 'Test',
type: ExtensionType.databaseDriver,
engines: {'querya_desktop': '^0.4.7'},
main: 'index.js',
);

expect(
() => ExtensionSupport.validateDriverPackage(
manifest: manifest,
installDir: dir,
),
throwsA(isA<MarketplaceException>()),
);

await File(p.join(dir.path, 'index.js')).writeAsString('// stub');
expect(
() => ExtensionSupport.validateDriverPackage(
manifest: manifest,
installDir: dir,
),
returnsNormally,
);
});
});
}
69 changes: 45 additions & 24 deletions test/core/market/marketplace_repository_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,35 +52,24 @@ void main() {
expect(empty, isEmpty);
});

test('install writes manifest to disk and reloads LocalExtensionRegistry', () async {
test('install rejects preview database drivers', () async {
final repo = MockMarketplaceRepository();
final trending = await repo.getTrending();
final target = trending.firstWhere((e) => e.id == 'queryahub.clickhouse-driver');

final progressValues = <double>[];
await repo.install(target, onProgress: (p) => progressValues.add(p));

expect(progressValues, isNotEmpty);
expect(progressValues.last, 1.0);

expect(LocalExtensionRegistry.instance.manifests.any((e) => e.id == target.id), isTrue);

final extDir = Directory(p.join(tempDir.path, target.id));
expect(await extDir.exists(), isTrue);
expect(await File(p.join(extDir.path, 'manifest.json')).exists(), isTrue);
});

test('uninstall removes directory and updates LocalExtensionRegistry', () async {
final repo = MockMarketplaceRepository();
final trending = await repo.getTrending();
final target = trending.firstWhere((e) => e.id == 'queryahub.clickhouse-driver');

await repo.install(target);
expect(LocalExtensionRegistry.instance.manifests.any((e) => e.id == target.id), isTrue);
expect(
() => repo.install(target),
throwsA(isA<MarketplaceException>().having(
(e) => e.message,
'message',
contains('preview listings only'),
)),
);

await repo.uninstall(target.id);
expect(LocalExtensionRegistry.instance.manifests.any((e) => e.id == target.id), isFalse);
expect(await Directory(p.join(tempDir.path, target.id)).exists(), isFalse);
expect(
LocalExtensionRegistry.instance.manifests.any((e) => e.id == target.id),
isFalse,
);
});

test('install theme creates theme.json in extension directory', () async {
Expand Down Expand Up @@ -218,6 +207,38 @@ void main() {
)),
);
});
test('install rejects preview database drivers', () async {
final archive = Archive();
archive.addFile(ArchiveFile('index.js', 4, utf8.encode('stub')));
final zipBytes = ZipEncoder().encode(archive);
final expectedSha256 = sha256.convert(zipBytes).toString();

final mockClient = MockClient((request) async {
return http.Response.bytes(zipBytes, 200);
});

final repo = HttpMarketplaceRepository(client: mockClient);
final manifest = ExtensionManifest(
id: 'test.driver',
name: 'Driver Test',
version: '1.0.0',
publisher: 'Test',
type: ExtensionType.databaseDriver,
engines: const {'querya_desktop': '*'},
main: 'index.js',
downloadUrl: 'http://localhost:8000/driver.zip',
sha256Checksum: expectedSha256,
);

expect(
() => repo.install(manifest),
throwsA(isA<MarketplaceException>().having(
(e) => e.message,
'message',
contains('preview listings only'),
)),
);
});
});
}

Expand Down
Loading
Loading