diff --git a/CHANGELOG.md b/CHANGELOG.md index b418fe2f..feadc289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.9] - 2026-07-10 + +PostgreSQL connection reliability and TLS improvements, plus menu and URI import polish. + +### Added + +- **Connection → New Connection from URL (#228)** — create a connection by pasting a database URI for PostgreSQL, MySQL, MongoDB, Redis, or SQLite. The URL is parsed, validated, and saved to `LocalDb`. +- **Help → About & Documentation (#229)** — added an About dialog showing the app version, MIT license, and repository link; the Documentation menu item opens the project docs in the browser. +- **PostgreSQL SSL certificate file pickers (#260)** — added optional file pickers for `sslrootcert`, `sslcert`, and `sslkey` in the PostgreSQL connection form. Paths are merged into the connection URI or used to generate a URI when in host/port mode. + +### Fixed + +- **PostgreSQL URL parser / SSL (#249, #253, #254, #257, #258)** — validate `sslmode` (reject `prefer` and unknown values), correctly map `useSSL` for `disable`/`require`/`verify-ca`/`verify-full`, use driver default ports when omitted, and preserve display host/port for URI-only connections. +- **PostgreSQL connection error reporting (#250, #251, #252)** — `testConnection` now returns the underlying error message, the form shows the real failure reason, and the connection tree displays the full exception text instead of a generic "Error" label. +- **PostgreSQL connection error typing (#256, #259)** — `connect()` throws `PostgresConnectionException` with the original cause and stack trace; the pool wraps unexpected factory errors in the same typed exception. + +## [0.4.8] - 2026-07-08 + +### Fixed + +- **SQLite / RETURNING clause support (#243)** — support RETURNING clauses for INSERT, UPDATE, and DELETE DML queries in the SQLite database driver, returning the resulting rows to the client. +- **Security / MySQL Injection Fix (#241)** — replaced manual escaping and string concatenation in schema introspection methods (`listViews`, `listColumnNames`, `listTables`) in the MySQL database driver with parameterized queries using parameter binding. + ## [0.4.7-a] - 2026-06-22 ### Added diff --git a/docs/planned-0.4.8.md b/docs/planned-0.4.8.md new file mode 100644 index 00000000..96898554 --- /dev/null +++ b/docs/planned-0.4.8.md @@ -0,0 +1,14 @@ +# Milestone 0.4.8: Extension Manager UI + +**Theme**: Разработка пользовательского интерфейса менеджера расширений (Marketplace Client). Мы создаем вкладку в настройках или отдельное окно, где пользователь сможет просматривать установленные расширения, искать новые в маркетплейсе, скачивать, обновлять и удалять их. Бэкенд маркетплейса пока будет замокан или использоваться статический JSON для тестов. + +| ID | Scope | Summary | +|----|-------|---------| +| **UI-EXT-1** | `ui`, `extensions` | **Extension Manager View** — Создать страницу/диалог для менеджера расширений. Вкладки: Installed, Marketplace, Updates. | +| **UI-EXT-2** | `ui`, `extensions` | **Extension Card Component** — Виджет карточки расширения (иконка, название, автор, версия, описание, кнопка Install/Uninstall). | +| **UI-EXT-3** | `core`, `extensions` | **MarketplaceRepository Mock** — Реализовать моковый репозиторий для симуляции запросов к API маркетплейса (пока бэкенд не готов). | +| **UI-EXT-4** | `core`, `extensions` | **Download & Install Flow** — Интеграция UI с процессом скачивания (прогресс-бар), валидации sha256 и распаковки в `~/.querya/extensions/`. | +| **UI-EXT-5** | `core`, `extensions` | **Uninstall & Update** — Механизмы удаления и обновления локальных расширений через UI менеджера. | + +## Заметки +Этот релиз фокусируется на пользовательском опыте (UX/UI) взаимодействия с расширениями. Мы не реализуем сам Marketplace API (он будет в 0.5.0), но закладываем сетевой слой клиента (`MarketplaceRepository`) и мокаем данные для того, чтобы UI можно было использовать и тестировать. Под капотом используются механизмы локального обнаружения, заложенные в релизе 0.4.7. diff --git a/lib/core/app/app_links.dart b/lib/core/app/app_links.dart new file mode 100644 index 00000000..1d6cefd9 --- /dev/null +++ b/lib/core/app/app_links.dart @@ -0,0 +1,9 @@ +/// Canonical external URLs for Querya Desktop. +abstract final class AppLinks { + static const repository = + 'https://github.com/QueryaHub/Querya-Desktop'; + static const documentation = + 'https://github.com/QueryaHub/Querya-Desktop/blob/main/docs/README.md'; + static const license = + 'https://github.com/QueryaHub/Querya-Desktop/blob/main/LICENSE'; +} diff --git a/lib/core/app/external_link.dart b/lib/core/app/external_link.dart new file mode 100644 index 00000000..49e13bef --- /dev/null +++ b/lib/core/app/external_link.dart @@ -0,0 +1,13 @@ +import 'package:querya_desktop/core/app/app_links.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// Opens [url] in the system browser. +Future launchExternalUrl(String url) { + final uri = Uri.parse(url); + return launchUrl(uri, mode: LaunchMode.externalApplication); +} + +Future launchRepositoryUrl() => launchExternalUrl(AppLinks.repository); + +Future launchDocumentationUrl() => + launchExternalUrl(AppLinks.documentation); diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index df453aeb..5c4c4c6f 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -76,9 +76,7 @@ class MysqlConnection { bool get _usesConnectionString => connectionString != null && connectionString!.trim().isNotEmpty; - static String _escapeSqlString(String s) { - return s.replaceAll(r'\', r'\\').replaceAll("'", "''"); - } + /// MySQL identifier quoting (backticks). static String quoteIdentifier(String id) { @@ -284,11 +282,11 @@ class MysqlConnection { if (!isConnected || _conn == null) { throw StateError('Not connected to MySQL'); } - final s = _escapeSqlString(schema); final rs = await execute( 'SELECT TABLE_NAME FROM information_schema.TABLES ' - "WHERE TABLE_SCHEMA = '$s' AND TABLE_TYPE = 'VIEW' " + "WHERE TABLE_SCHEMA = :schema AND TABLE_TYPE = 'VIEW' " 'ORDER BY TABLE_NAME', + {'schema': schema}, ); return rs.rows.map((r) => r.colAt(0)!).toList(); } @@ -301,12 +299,14 @@ class MysqlConnection { if (!isConnected || _conn == null) { throw StateError('Not connected to MySQL'); } - final d = _escapeSqlString(database); - final t = _escapeSqlString(table); final rs = await execute( 'SELECT COLUMN_NAME FROM information_schema.COLUMNS ' - "WHERE TABLE_SCHEMA = '$d' AND TABLE_NAME = '$t' " + "WHERE TABLE_SCHEMA = :database AND TABLE_NAME = :table " 'ORDER BY ORDINAL_POSITION', + { + 'database': database, + 'table': table, + }, ); return rs.rows.map((r) => r.colAt(0)!).toList(); } @@ -316,11 +316,11 @@ class MysqlConnection { if (!isConnected || _conn == null) { throw StateError('Not connected to MySQL'); } - final s = _escapeSqlString(schema); final rs = await execute( 'SELECT TABLE_NAME FROM information_schema.TABLES ' - "WHERE TABLE_SCHEMA = '$s' AND TABLE_TYPE = 'BASE TABLE' " + "WHERE TABLE_SCHEMA = :schema AND TABLE_TYPE = 'BASE TABLE' " 'ORDER BY TABLE_NAME', + {'schema': schema}, ); return rs.rows.map((r) => r.colAt(0)!).toList(); } diff --git a/lib/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart index 752cc0f5..8aae9434 100644 --- a/lib/core/database/postgres_connection.dart +++ b/lib/core/database/postgres_connection.dart @@ -137,10 +137,17 @@ class PostgresConnection { ); } _isConnected = true; - } catch (e) { + } catch (e, st) { _isConnected = false; _conn = null; - rethrow; + Error.throwWithStackTrace( + PostgresConnectionException( + 'Failed to connect to PostgreSQL${name.isNotEmpty ? ' ($name)' : ''}: $e', + cause: e, + stackTrace: st, + ), + st, + ); } } @@ -174,16 +181,19 @@ class PostgresConnection { ); } - Future testConnection() async { + /// Tests connectivity and returns a result with an optional error message. + Future<({bool ok, String? error})> testConnection() async { try { await connect(); if (_conn != null) { await _conn!.execute('SELECT 1'); - return true; + return (ok: true, error: null); } - return false; - } catch (_) { - return false; + return (ok: false, error: 'Connection could not be established.'); + } on PostgresConnectionException catch (e) { + return (ok: false, error: e.message); + } catch (e) { + return (ok: false, error: e.toString()); } finally { await disconnect(); } @@ -769,8 +779,16 @@ class PostgresSequenceDetails { } class PostgresConnectionException implements Exception { - PostgresConnectionException(this.message); + PostgresConnectionException( + this.message, { + this.cause, + this.stackTrace, + }); + final String message; + final Object? cause; + final StackTrace? stackTrace; + @override String toString() => message; } diff --git a/lib/core/database/postgres_connection_pool.dart b/lib/core/database/postgres_connection_pool.dart index 73c71433..e4fa225d 100644 --- a/lib/core/database/postgres_connection_pool.dart +++ b/lib/core/database/postgres_connection_pool.dart @@ -87,10 +87,23 @@ class PostgresConnectionPool { _evictIfNeededBeforeNewSlot(); - final conn = await createAndConnect(row, database: database, mode: mode); - entry = _PoolEntry(conn)..refs = 1; - _pool[k] = entry; - return PgLease._(this, k, conn); + try { + final conn = await createAndConnect(row, database: database, mode: mode); + entry = _PoolEntry(conn)..refs = 1; + _pool[k] = entry; + return PgLease._(this, k, conn); + } on PostgresConnectionException { + rethrow; + } catch (e, st) { + Error.throwWithStackTrace( + PostgresConnectionException( + 'Failed to acquire PostgreSQL connection for database "$database": $e', + cause: e, + stackTrace: st, + ), + st, + ); + } } /// Drops idle LRU slots until there is room for one more key. diff --git a/lib/core/database/sqlite_connection.dart b/lib/core/database/sqlite_connection.dart index d4585d4a..f61e7b76 100644 --- a/lib/core/database/sqlite_connection.dart +++ b/lib/core/database/sqlite_connection.dart @@ -97,18 +97,21 @@ class SqliteConnection { .toLowerCase(); // SQLite can execute PRAGMA, SELECT, EXPLAIN statements, which return data - final isQuery = sqlLower.startsWith('select') || + final isReadOnlyQuery = sqlLower.startsWith('select') || sqlLower.startsWith('pragma') || sqlLower.startsWith('explain') || sqlLower.startsWith('with') || sqlLower.startsWith('values'); - if (isQuery) { + final hasReturning = RegExp(r'\breturning\b').hasMatch(sqlLower); + + if (readOnly && !isReadOnlyQuery) { + throw StateError('Database connection is read-only'); + } + + if (isReadOnlyQuery || hasReturning) { return await _db!.rawQuery(sql, arguments); } else { - if (readOnly) { - throw StateError('Database connection is read-only'); - } await _db!.execute(sql, arguments); return []; } diff --git a/lib/core/extensions/models/extension_manifest.dart b/lib/core/extensions/models/extension_manifest.dart index cafa2e04..efd9a6e1 100644 --- a/lib/core/extensions/models/extension_manifest.dart +++ b/lib/core/extensions/models/extension_manifest.dart @@ -1,6 +1,9 @@ +import '../../theme/theme_definition.dart'; import 'extension_type.dart'; class ExtensionManifest { + static const typeTheme = ExtensionType.theme; + final String id; final String name; final String version; @@ -11,6 +14,13 @@ class ExtensionManifest { final String? icon; final String? description; final String? installPath; + final String? downloadUrl; + final String? sha256Checksum; + final String? author; + final String? homepage; + final String? license; + final String? preview; + final List tags; const ExtensionManifest({ required this.id, @@ -23,20 +33,62 @@ class ExtensionManifest { this.icon, this.description, this.installPath, + this.downloadUrl, + this.sha256Checksum, + this.author, + this.homepage, + this.license, + this.preview, + this.tags = const [], }); + /// Maps a registry [ThemeDefinition] into marketplace field names. + factory ExtensionManifest.fromThemeDefinition( + ThemeDefinition definition, { + String downloadUrl = '', + String sha256Checksum = '', + ExtensionType type = typeTheme, + }) { + final metadata = definition.metadata; + return ExtensionManifest( + id: definition.id, + name: definition.name, + publisher: metadata?.author ?? 'Unknown', + type: type, + version: metadata?.version ?? '0.0.0', + engines: const {'querya_desktop': '^0.4.7'}, + downloadUrl: downloadUrl, + sha256Checksum: sha256Checksum.isNotEmpty + ? sha256Checksum + : (definition.contentHash ?? ''), + author: metadata?.author, + description: metadata?.description, + homepage: metadata?.homepage, + license: metadata?.license, + preview: metadata?.preview, + tags: metadata?.tags ?? const [], + ); + } + factory ExtensionManifest.fromJson(Map json, {String? installPath}) { return ExtensionManifest( id: json['id'] as String, name: json['name'] as String, - version: json['version'] as String, - publisher: json['publisher'] as String, - type: ExtensionType.fromString(json['type'] as String), + version: json['version'] as String? ?? '0.0.0', + publisher: json['publisher'] as String? ?? json['author'] as String? ?? 'Unknown', + type: ExtensionType.fromString(json['type'] as String? ?? ''), engines: Map.from(json['engines'] as Map? ?? {}), main: json['main'] as String?, icon: json['icon'] as String?, description: json['description'] as String?, installPath: installPath, + downloadUrl: json['downloadUrl'] as String?, + sha256Checksum: json['sha256Checksum'] as String? ?? json['sha256'] as String?, + author: json['author'] as String?, + homepage: json['homepage'] as String?, + license: json['license'] as String?, + preview: json['preview'] as String?, + tags: List.from(json['tags'] as List? ?? []), ); } @@ -51,6 +103,13 @@ class ExtensionManifest { if (main != null) 'main': main, if (icon != null) 'icon': icon, if (description != null) 'description': description, + if (downloadUrl != null) 'downloadUrl': downloadUrl, + if (sha256Checksum != null) 'sha256Checksum': sha256Checksum, + if (author != null) 'author': author, + if (homepage != null) 'homepage': homepage, + if (license != null) 'license': license, + if (preview != null) 'preview': preview, + if (tags.isNotEmpty) 'tags': tags, }; } } diff --git a/lib/core/market/extension_manifest.dart b/lib/core/market/extension_manifest.dart index be1edec8..4cbed8b3 100644 --- a/lib/core/market/extension_manifest.dart +++ b/lib/core/market/extension_manifest.dart @@ -1,65 +1,2 @@ -import '../theme/theme_definition.dart'; +export 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; -/// Marketplace listing model (future `MarketplaceClient` response shape). -/// -/// See [docs/market-tech.md](https://github.com/QueryaHub/Querya-Desktop/blob/main/docs/market-tech.md). -class ExtensionManifest { - const ExtensionManifest({ - required this.id, - required this.name, - required this.type, - required this.version, - required this.downloadUrl, - required this.sha256Checksum, - this.author, - this.description, - this.homepage, - this.license, - this.preview, - this.tags = const [], - }); - - static const typeTheme = 'theme'; - - final String id; - final String name; - final String type; - final String version; - final String downloadUrl; - final String sha256Checksum; - final String? author; - final String? description; - final String? homepage; - final String? license; - final String? preview; - final List tags; - - /// Maps a registry [ThemeDefinition] into marketplace field names. - /// - /// [downloadUrl] and [sha256Checksum] are required for remote install (TP-F4); - /// pass empty strings when building a local-only listing stub. - factory ExtensionManifest.fromThemeDefinition( - ThemeDefinition definition, { - String downloadUrl = '', - String sha256Checksum = '', - String type = typeTheme, - }) { - final metadata = definition.metadata; - return ExtensionManifest( - id: definition.id, - name: definition.name, - type: type, - version: metadata?.version ?? '0.0.0', - downloadUrl: downloadUrl, - sha256Checksum: sha256Checksum.isNotEmpty - ? sha256Checksum - : (definition.contentHash ?? ''), - author: metadata?.author, - description: metadata?.description, - homepage: metadata?.homepage, - license: metadata?.license, - preview: metadata?.preview, - tags: metadata?.tags ?? const [], - ); - } -} diff --git a/lib/core/market/http_marketplace_repository.dart b/lib/core/market/http_marketplace_repository.dart new file mode 100644 index 00000000..03d7a7e9 --- /dev/null +++ b/lib/core/market/http_marketplace_repository.dart @@ -0,0 +1,204 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +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_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 +/// archive extraction preventing Path Traversal and Zip Bomb vulnerabilities (Issue #242). +class HttpMarketplaceRepository implements MarketplaceRepository { + HttpMarketplaceRepository({ + this.baseUrl = 'http://localhost:8000/api/v1', + http.Client? client, + }) : _client = client ?? http.Client(); + + final String baseUrl; + final http.Client _client; + + @override + Future> getTrending({ExtensionType? type}) async { + final uri = Uri.parse('$baseUrl/extensions/trending').replace( + queryParameters: type != null ? {'type': type.value} : null, + ); + final response = await _client.get(uri).timeout(const Duration(seconds: 15)); + if (response.statusCode != 200) { + throw MarketplaceException('Failed to load trending extensions (HTTP ${response.statusCode})'); + } + final List data = jsonDecode(response.body) as List; + return data.map((json) => ExtensionManifest.fromJson(json as Map)).toList(); + } + + @override + Future> search(String query, {ExtensionType? type}) async { + final uri = Uri.parse('$baseUrl/extensions/search').replace( + queryParameters: { + 'q': query.trim(), + if (type != null) 'type': type.value, + }, + ); + final response = await _client.get(uri).timeout(const Duration(seconds: 15)); + if (response.statusCode != 200) { + throw MarketplaceException('Search failed (HTTP ${response.statusCode})'); + } + final List data = jsonDecode(response.body) as List; + return data.map((json) => ExtensionManifest.fromJson(json as Map)).toList(); + } + + @override + Future download(String url, {void Function(double)? onProgress}) async { + final uri = Uri.tryParse(url); + if (uri == null) { + throw MarketplaceException('Invalid download URL: $url'); + } + + final request = http.Request('GET', uri); + final response = await _client.send(request).timeout(const Duration(seconds: 30)); + if (response.statusCode != 200) { + throw MarketplaceException('Download failed with HTTP status ${response.statusCode}'); + } + + final contentLength = response.contentLength ?? 0; + int received = 0; + + final tmpDir = Directory.systemTemp; + final file = File(p.join(tmpDir.path, 'querya_ext_${DateTime.now().millisecondsSinceEpoch}.zip')); + final sink = file.openWrite(); + + try { + await response.stream.forEach((chunk) { + sink.add(chunk); + received += chunk.length; + if (contentLength > 0 && onProgress != null) { + onProgress(received / contentLength); + } + }); + } finally { + await sink.close(); + } + + if (onProgress != null && contentLength == 0) { + onProgress(1.0); + } + + return file; + } + + @override + Future install( + ExtensionManifest manifest, { + void Function(double)? onProgress, + }) async { + final downloadUrl = manifest.downloadUrl; + if (downloadUrl == null || downloadUrl.trim().isEmpty) { + throw MarketplaceException('Extension manifest is missing downloadUrl'); + } + + // Step 1: Download archive with progress reporting (up to 80% of total progress) + final archiveFile = await download( + downloadUrl, + onProgress: (p) => onProgress?.call(p * 0.8), + ); + + try { + // Step 2: SHA-256 Integrity Verification (Critical Security Check) + if (manifest.sha256Checksum != null && manifest.sha256Checksum!.trim().isNotEmpty) { + final bytes = await archiveFile.readAsBytes(); + final actualSha256 = sha256.convert(bytes).toString().toLowerCase(); + final expectedSha256 = manifest.sha256Checksum!.trim().toLowerCase(); + if (actualSha256 != expectedSha256) { + throw MarketplaceException( + 'SHA256 checksum mismatch for "${manifest.id}". Expected: $expectedSha256, Actual: $actualSha256. Installation aborted.', + ); + } + } + + onProgress?.call(0.85); + + // Step 3: Safe Archive Extraction (Preventing Path Traversal / Zip Bomb - Issue #242) + final bytes = await archiveFile.readAsBytes(); + final archive = ZipDecoder().decodeBytes(bytes); + + final dir = await ExtensionPaths.extensionsDirectory(); + final extDir = Directory(p.join(dir.path, manifest.id)); + if (!await extDir.exists()) { + await extDir.create(recursive: true); + } + + final extDirPath = p.normalize(extDir.path); + + for (final file in archive) { + final filename = file.name; + // Check for Path Traversal attempts + if (filename.contains('..') || filename.startsWith('/') || filename.startsWith('\\')) { + throw MarketplaceException('Security violation: Path traversal detected in archive entry "$filename"'); + } + + final targetPath = p.normalize(p.join(extDirPath, filename)); + if (!targetPath.startsWith(extDirPath)) { + throw MarketplaceException('Security violation: Extraction path out of bounds "$filename"'); + } + + if (file.isFile) { + final outFile = File(targetPath); + await outFile.create(recursive: true); + await outFile.writeAsBytes(file.content as List); + } else { + await Directory(targetPath).create(recursive: true); + } + } + + onProgress?.call(0.95); + + // Step 4: Write/Update manifest.json in the extension directory + final manifestFile = File(p.join(extDir.path, 'manifest.json')); + const encoder = JsonEncoder.withIndent(' '); + await manifestFile.writeAsString(encoder.convert(manifest.toJson())); + + // Step 5: Reload local extension registry + await LocalExtensionRegistry.instance.reload(); + onProgress?.call(1.0); + } finally { + if (await archiveFile.exists()) { + await archiveFile.delete(); + } + } + } + + @override + Future uninstall(String extensionId) async { + final manifest = LocalExtensionRegistry.instance.manifests + .where((e) => e.id == extensionId) + .firstOrNull; + + if (manifest != null && manifest.installPath != null) { + final extDir = Directory(manifest.installPath!); + if (await extDir.exists()) { + await extDir.delete(recursive: true); + } + } else { + final dir = await ExtensionPaths.extensionsDirectory(); + final extDir = Directory(p.join(dir.path, extensionId)); + if (await extDir.exists()) { + await extDir.delete(recursive: true); + } + } + + await LocalExtensionRegistry.instance.reload(); + } +} diff --git a/lib/core/market/marketplace_client.dart b/lib/core/market/marketplace_client.dart index 1c450cb0..828bbf60 100644 --- a/lib/core/market/marketplace_client.dart +++ b/lib/core/market/marketplace_client.dart @@ -1,4 +1,5 @@ import 'extension_manifest.dart'; +import 'marketplace_repository.dart'; /// Future marketplace API client (mockable until backend exists). /// @@ -10,24 +11,19 @@ abstract class MarketplaceClient { }); } -/// In-memory placeholder for local development and tests. +/// In-memory placeholder for local development and tests that delegates to [MarketplaceRepository]. class MockMarketplaceClient implements MarketplaceClient { MockMarketplaceClient({List? seed}) - : _items = List.from(seed ?? const []); + : _repository = MockMarketplaceRepository(seed: seed); - final List _items; + final MockMarketplaceRepository _repository; @override Future> searchExtensions({ required String query, String? type, }) async { - final normalized = query.trim().toLowerCase(); - return _items.where((item) { - if (type != null && item.type != type) return false; - if (normalized.isEmpty) return true; - return item.name.toLowerCase().contains(normalized) || - item.id.toLowerCase().contains(normalized); - }).toList(growable: false); + return _repository.search(query); } } + diff --git a/lib/core/market/marketplace_repository.dart b/lib/core/market/marketplace_repository.dart new file mode 100644 index 00000000..421ac9b6 --- /dev/null +++ b/lib/core/market/marketplace_repository.dart @@ -0,0 +1,306 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:path/path.dart' as p; +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'; + +/// Abstract repository contract for Marketplace operations (Block B). +/// +/// See [docs/market-tech.md] and Block B specification. +abstract class MarketplaceRepository { + static MarketplaceRepository instance = MockMarketplaceRepository(); + + /// Returns trending and recommended marketplace extensions. + Future> getTrending({ExtensionType? type}); + + /// Searches the marketplace catalog by query string and optional type filter. + Future> search(String query, {ExtensionType? type}); + + /// Simulates downloading an archive from [url] with progress reporting. + Future download(String url, {void Function(double)? onProgress}); + + /// Installs an extension by downloading, validating, and registering it locally. + Future install( + ExtensionManifest manifest, { + void Function(double)? onProgress, + }); + + /// Uninstalls a locally installed extension by ID. + Future uninstall(String extensionId); +} + +/// In-memory mock implementation for local UI development and testing (Release 0.4.8). +class MockMarketplaceRepository implements MarketplaceRepository { + MockMarketplaceRepository({List? seed}) + : _items = List.from(seed ?? _defaultMocks); + + final List _items; + + static final List _defaultMocks = [ + const ExtensionManifest( + id: 'queryahub.clickhouse-driver', + name: 'ClickHouse Driver', + version: '1.0.0', + publisher: 'QueryaHub', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '^0.4.7'}, + main: 'index.js', + description: 'Full support for ClickHouse databases including Dictionaries, Materialized Views, and real-time query metrics.', + downloadUrl: 'https://cdn.queryahub.com/extensions/clickhouse-driver-1.0.0.zip', + sha256Checksum: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + homepage: 'https://queryahub.com/drivers/clickhouse', + license: 'MIT', + tags: ['database', 'clickhouse', 'olap', 'official'], + ), + const ExtensionManifest( + id: 'community.redis-driver', + name: 'Redis Driver', + version: '0.9.5', + publisher: 'Community', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '^0.4.7'}, + main: 'index.js', + description: 'Connect to Redis instances, visualize key-value storage, inspect Pub/Sub channels, and edit JSON documents.', + downloadUrl: 'https://cdn.queryahub.com/extensions/redis-driver-0.9.5.zip', + sha256Checksum: '8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4', + homepage: 'https://github.com/queryahub/redis-driver', + license: 'Apache-2.0', + tags: ['database', 'redis', 'nosql', 'key-value'], + ), + const ExtensionManifest( + id: 'queryahub.cyberpunk-neon', + name: 'Cyberpunk Neon', + version: '1.2.0', + publisher: 'QueryaHub', + type: ExtensionType.theme, + engines: {'querya_desktop': '^0.4.7'}, + main: 'theme.json', + description: 'Vibrant neon color scheme inspired by Cyberpunk 2077 with glowing syntax highlighting and dark futuristic workbench.', + downloadUrl: 'https://cdn.queryahub.com/themes/cyberpunk-neon-1.2.0.zip', + sha256Checksum: 'a1b2c3d4e5f60718293a4b5c6d7e8f90123456789abcdef0123456789abcdef0', + homepage: 'https://queryahub.com/themes/cyberpunk', + license: 'MIT', + tags: ['theme', 'dark', 'cyberpunk', 'neon', 'official'], + ), + const ExtensionManifest( + id: 'community.nord-theme', + name: 'Nord Theme', + version: '0.8.2', + publisher: 'Community', + type: ExtensionType.theme, + engines: {'querya_desktop': '^0.4.7'}, + main: 'theme.json', + description: 'An arctic, north-bluish clean and elegant color palette designed for focused, distraction-free SQL coding.', + downloadUrl: 'https://cdn.queryahub.com/themes/nord-theme-0.8.2.zip', + sha256Checksum: 'b2c3d4e5f6a10718293a4b5c6d7e8f90123456789abcdef0123456789abcdef1', + homepage: 'https://github.com/arcticicestudio/nord', + license: 'MIT', + tags: ['theme', 'dark', 'nord', 'arctic', 'minimal'], + ), + const ExtensionManifest( + id: 'queryahub.mongodb-driver', + name: 'MongoDB Driver', + version: '1.1.0', + publisher: 'QueryaHub', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '^0.4.7'}, + main: 'index.js', + description: 'Explore MongoDB collections, execute aggregation pipelines, and view BSON documents in an interactive tree view.', + downloadUrl: 'https://cdn.queryahub.com/extensions/mongodb-driver-1.1.0.zip', + sha256Checksum: 'c3d4e5f6a1b20718293a4b5c6d7e8f90123456789abcdef0123456789abcdef2', + homepage: 'https://queryahub.com/drivers/mongodb', + license: 'MIT', + tags: ['database', 'mongodb', 'nosql', 'bson', 'official'], + ), + const ExtensionManifest( + id: 'community.postgres-exporter', + name: 'PostgreSQL Schema Exporter', + version: '0.5.0', + publisher: 'Community', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '^0.4.7'}, + main: 'index.js', + description: 'Export complex PostgreSQL schemas to dbdiagram.io, Mermaid ERD, and DDL scripts with one click.', + downloadUrl: 'https://cdn.queryahub.com/extensions/postgres-exporter-0.5.0.zip', + sha256Checksum: 'd4e5f6a1b2c30718293a4b5c6d7e8f90123456789abcdef0123456789abcdef3', + homepage: 'https://github.com/querya-community/postgres-exporter', + license: 'MIT', + tags: ['postgresql', 'schema', 'export', 'erd', 'mermaid'], + ), + ]; + + @override + Future> getTrending({ExtensionType? type}) async { + await Future.delayed(const Duration(milliseconds: 150)); + return _items.where((item) { + if (type != null && item.type != type) return false; + return true; + }).toList(growable: false); + } + + @override + Future> search(String query, {ExtensionType? type}) async { + await Future.delayed(const Duration(milliseconds: 150)); + final normalized = query.trim().toLowerCase(); + return _items.where((item) { + if (type != null && item.type != type) return false; + if (normalized.isEmpty) return true; + return item.name.toLowerCase().contains(normalized) || + item.id.toLowerCase().contains(normalized) || + item.publisher.toLowerCase().contains(normalized) || + (item.description?.toLowerCase().contains(normalized) ?? false) || + item.tags.any((tag) => tag.toLowerCase().contains(normalized)); + }).toList(growable: false); + } + + @override + Future download(String url, {void Function(double)? onProgress}) async { + for (int i = 1; i <= 10; i++) { + await Future.delayed(const Duration(milliseconds: 80)); + onProgress?.call(i / 10.0); + } + final tmpDir = Directory.systemTemp; + final file = File(p.join(tmpDir.path, 'querya_ext_mock_${DateTime.now().millisecondsSinceEpoch}.zip')); + await file.writeAsString('mock archive payload'); + return file; + } + + @override + Future install( + ExtensionManifest manifest, { + void Function(double)? onProgress, + }) async { + // Simulate download & verification progress + for (int i = 1; i <= 10; i++) { + await Future.delayed(const Duration(milliseconds: 100)); + onProgress?.call(i / 10.0); + } + + // Persist to local extensions directory so LocalExtensionRegistry discovers it + final dir = await ExtensionPaths.extensionsDirectory(); + final extDir = Directory(p.join(dir.path, manifest.id)); + if (!await extDir.exists()) { + await extDir.create(recursive: true); + } + + final manifestFile = File(p.join(extDir.path, 'manifest.json')); + const encoder = JsonEncoder.withIndent(' '); + await manifestFile.writeAsString(encoder.convert(manifest.toJson())); + + // If installing a theme in mock mode, generate a working theme.json so it can be applied in settings + if (manifest.type == ExtensionType.theme) { + final themeFile = File(p.join(extDir.path, 'theme.json')); + final themeContent = _getMockThemeContent(manifest); + await themeFile.writeAsString(themeContent); + } + + // Reload local registry + await LocalExtensionRegistry.instance.reload(); + } + + String _getMockThemeContent(ExtensionManifest manifest) { + if (manifest.id.contains('nord')) { + return '''{ + "name": "Nord Theme", + "type": "dark", + "colors": { + "activityBar.background": "#2e3440", + "statusBar.background": "#2e3440", + "sideBar.background": "#3b4252", + "sideBar.foreground": "#d8dee9", + "tab.activeBackground": "#434c5e", + "panel.background": "#3b4252", + "input.background": "#434c5e", + "editor.background": "#2e3440", + "editor.foreground": "#eceff4", + "editor.selectionBackground": "#434c5e88", + "editorLineNumber.foreground": "#4c566a", + "focusBorder": "#88c0d0" + }, + "tokenColors": [ + { + "name": "Keywords", + "scope": ["keyword", "storage.type"], + "settings": { "foreground": "#81a1c1", "fontStyle": "bold" } + }, + { + "name": "Strings", + "scope": ["string"], + "settings": { "foreground": "#a3be8c" } + }, + { + "name": "Numbers", + "scope": ["constant.numeric"], + "settings": { "foreground": "#b48ead" } + } + ] +}'''; + } + return '''{ + "name": "\${manifest.name}", + "type": "dark", + "colors": { + "activityBar.background": "#050508", + "statusBar.background": "#050508", + "sideBar.background": "#0c0820", + "sideBar.foreground": "#8b7cf8", + "tab.activeBackground": "#14102a", + "panel.background": "#14102a", + "input.background": "#14102a", + "editor.background": "#0a0a14", + "editor.foreground": "#e8f4ff", + "editor.selectionBackground": "#ff2a6d44", + "editorLineNumber.foreground": "#4a3f7a", + "focusBorder": "#00f5ff" + }, + "tokenColors": [ + { + "name": "Keywords", + "scope": ["keyword", "storage.type"], + "settings": { "foreground": "#ff2a6d", "fontStyle": "bold" } + }, + { + "name": "Strings", + "scope": ["string"], + "settings": { "foreground": "#fcee09" } + }, + { + "name": "Functions", + "scope": ["entity.name.function"], + "settings": { "foreground": "#00f5ff" } + } + ] +}'''; + } + + @override + Future uninstall(String extensionId) async { + await Future.delayed(const Duration(milliseconds: 150)); + + // Check local extension registry first + final manifest = LocalExtensionRegistry.instance.manifests + .where((e) => e.id == extensionId) + .firstOrNull; + + if (manifest != null && manifest.installPath != null) { + final extDir = Directory(manifest.installPath!); + if (await extDir.exists()) { + await extDir.delete(recursive: true); + } + } else { + // Fallback: check standard extensions directory by ID + final dir = await ExtensionPaths.extensionsDirectory(); + final extDir = Directory(p.join(dir.path, extensionId)); + if (await extDir.exists()) { + await extDir.delete(recursive: true); + } + } + + await LocalExtensionRegistry.instance.reload(); + } +} diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index eebba457..1daae918 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -370,6 +370,30 @@ class LocalDb { return id; } + /// Atomically updates an existing connection row in SQLite and its secrets in the secure store. + Future updateConnection(ConnectionRow row) async { + if (row.id == null) { + throw ArgumentError('ConnectionRow.id cannot be null when calling updateConnection'); + } + final db = await _open(); + await db.transaction((txn) async { + final count = await txn.update( + 'connections', + row.toPersistenceMap(), + where: 'id = ?', + whereArgs: [row.id], + ); + if (count == 0) { + throw ArgumentError('No connection found with id ${row.id}'); + } + }); + await ConnectionSecretsStore.writeForConnection( + row.id!, + password: row.password, + connectionString: row.connectionString, + ); + } + Future removeConnection(int id) async { await ConnectionSecretsStore.deleteForConnection(id); final db = await _open(); diff --git a/lib/features/connections/connection_url_parser.dart b/lib/features/connections/connection_url_parser.dart new file mode 100644 index 00000000..82ca3723 --- /dev/null +++ b/lib/features/connections/connection_url_parser.dart @@ -0,0 +1,218 @@ +import 'package:querya_desktop/core/storage/local_db.dart'; + +const _supportedSchemes = { + 'postgresql', + 'postgres', + 'mysql', + 'sqlite', + 'mongodb', + 'mongodb+srv', + 'redis', + 'rediss', +}; + +const _validPostgresSslModes = { + 'disable', + 'require', + 'verify-ca', + 'verify-full', +}; + +/// Parses a database connection URL into a [ConnectionRow], or returns an error message. +({ConnectionRow? row, String? error}) parseConnectionUrlInput(String input) { + final trimmed = input.trim(); + if (trimmed.isEmpty) { + return (row: null, error: 'URL/URI is required.'); + } + + final uri = Uri.tryParse(trimmed); + if (uri == null || uri.scheme.isEmpty) { + return (row: null, error: 'Invalid URL/URI format.'); + } + + final scheme = uri.scheme.toLowerCase(); + if (!_supportedSchemes.contains(scheme)) { + return ( + row: null, + error: + 'Unsupported protocol "$scheme". Supported: postgresql, mysql, sqlite, mongodb, redis.', + ); + } + + final sslResult = _resolveSslForScheme(scheme, uri); + if (sslResult.error != null) { + return (row: null, error: sslResult.error); + } + + final row = _buildConnectionRow(trimmed, uri, scheme, sslResult.useSSL); + if (row == null) { + return (row: null, error: 'Failed to parse connection URL.'); + } + return (row: row, error: null); +} + +({bool? useSSL, String? error}) _resolveSslForScheme(String scheme, Uri uri) { + final type = _schemeToType(scheme); + if (type == null) return (useSSL: null, error: null); + + var useSSL = scheme == 'rediss'; + + if (type == 'postgresql') { + final sslMode = uri.queryParameters['sslmode']?.toLowerCase() ?? + uri.queryParameters['ssl']?.toLowerCase(); + if (sslMode != null && sslMode.isNotEmpty) { + if (!_validPostgresSslModes.contains(sslMode)) { + return ( + useSSL: null, + error: + 'Unsupported sslmode "$sslMode" for PostgreSQL. ' + 'Supported: disable, require, verify-ca, verify-full.', + ); + } + useSSL = sslMode != 'disable'; + } + } else if (type != 'sqlite') { + final sslQuery = uri.queryParameters['sslmode'] ?? + uri.queryParameters['ssl']; + if (sslQuery != null) { + final lowerSsl = sslQuery.toLowerCase(); + if (lowerSsl == 'true' || lowerSsl == 'require') { + useSSL = true; + } + } + } + + return (useSSL: useSSL, error: null); +} + +String? _schemeToType(String scheme) { + if (scheme == 'postgresql' || scheme == 'postgres') return 'postgresql'; + if (scheme == 'mysql') return 'mysql'; + if (scheme == 'sqlite') return 'sqlite'; + if (scheme == 'mongodb' || scheme == 'mongodb+srv') return 'mongodb'; + if (scheme == 'redis' || scheme == 'rediss') return 'redis'; + return null; +} + +ConnectionRow? _buildConnectionRow( + String url, + Uri uri, + String scheme, + bool? resolvedUseSSL, +) { + String type; + int? defaultPort; + + if (scheme == 'postgresql' || scheme == 'postgres') { + type = 'postgresql'; + defaultPort = 5432; + } else if (scheme == 'mysql') { + type = 'mysql'; + defaultPort = 3306; + } else if (scheme == 'sqlite') { + type = 'sqlite'; + } else if (scheme == 'mongodb' || scheme == 'mongodb+srv') { + type = 'mongodb'; + defaultPort = 27017; + } else if (scheme == 'redis' || scheme == 'rediss') { + type = 'redis'; + defaultPort = 6379; + } else { + return null; + } + + String? host; + int? port; + String? username; + String? password; + String? databaseName; + String? authSource; + String? connectionString; + var useSSL = resolvedUseSSL ?? (scheme == 'rediss'); + + if (type == 'sqlite') { + String path; + if (url.contains(':memory:')) { + path = ':memory:'; + } else if (url.startsWith('sqlite:///')) { + path = uri.path; + } else if (url.startsWith('sqlite://')) { + path = url.substring(9); + } else if (url.startsWith('sqlite:')) { + path = url.substring(7); + } else { + path = uri.path; + } + host = path; + } else { + host = uri.host.isEmpty ? null : uri.host; + port = uri.hasPort ? uri.port : null; + + if (uri.userInfo.isNotEmpty) { + final parts = uri.userInfo.split(':'); + if (parts.isNotEmpty) { + username = Uri.decodeComponent(parts[0]); + } + if (parts.length > 1) { + password = Uri.decodeComponent(parts.sublist(1).join(':')); + } + } + + databaseName = uri.pathSegments.firstOrNull; + if (databaseName != null && databaseName.isEmpty) { + databaseName = null; + } + + authSource = uri.queryParameters['authSource'] ?? + uri.queryParameters['authsource']; + + if (type == 'postgresql' || type == 'mysql' || type == 'mongodb') { + connectionString = url; + } + } + + final name = _connectionName(type, host, port, databaseName, defaultPort); + + return ConnectionRow( + type: type, + name: name, + host: host, + port: port ?? defaultPort, + username: username, + password: password, + databaseName: databaseName, + authSource: authSource, + useSSL: useSSL, + connectionString: connectionString, + createdAt: DateTime.now().toUtc().toIso8601String(), + ); +} + +String _connectionName( + String type, + String? host, + int? port, + String? databaseName, + int? defaultPort, +) { + if (type == 'sqlite') { + return host == ':memory:' ? 'SQLite (Memory)' : 'SQLite (${host!.split('/').last})'; + } + + final cleanHost = host ?? 'localhost'; + final cleanPort = port ?? defaultPort; + final cleanDb = databaseName ?? ''; + final typeName = switch (type) { + 'postgresql' => 'PostgreSQL', + 'mysql' => 'MySQL', + 'mongodb' => 'MongoDB', + _ => 'Redis', + }; + if (cleanDb.isNotEmpty) { + return '$typeName: $cleanDb'; + } + if (cleanPort != null) { + return '$typeName: $cleanHost:$cleanPort'; + } + return '$typeName: $cleanHost'; +} diff --git a/lib/features/connections/connections_panel_postgres_connection.dart b/lib/features/connections/connections_panel_postgres_connection.dart index 9bc2a359..5021b92a 100644 --- a/lib/features/connections/connections_panel_postgres_connection.dart +++ b/lib/features/connections/connections_panel_postgres_connection.dart @@ -242,12 +242,15 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { material.Padding( padding: const material.EdgeInsets.only( left: 28, top: 4, bottom: 4), - child: material.Text( - 'Error', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, color: theme.colorScheme.destructive), + child: material.Tooltip( + message: _error!, + child: material.Text( + _error!, + overflow: material.TextOverflow.ellipsis, + maxLines: 2, + style: material.TextStyle( + fontSize: 11, color: theme.colorScheme.destructive), + ), ), ), if (_databases.isNotEmpty) diff --git a/lib/features/connections/new_connection_url_dialog.dart b/lib/features/connections/new_connection_url_dialog.dart new file mode 100644 index 00000000..23e60638 --- /dev/null +++ b/lib/features/connections/new_connection_url_dialog.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_url_parser.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Shows a dialog to create a new database connection from a URI. +/// Returns the ConnectionRow or null if cancelled. +Future showNewConnectionUrlDialog(material.BuildContext context) { + return showAppDialog( + context: context, + builder: (context) => material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: WindowLayout.dialogSymmetricInsets(context), + child: const _NewConnectionUrlDialogContent(), + ), + ); +} + +class _NewConnectionUrlDialogContent extends material.StatefulWidget { + const _NewConnectionUrlDialogContent(); + + @override + material.State<_NewConnectionUrlDialogContent> createState() => + _NewConnectionUrlDialogContentState(); +} + +class _NewConnectionUrlDialogContentState + extends material.State<_NewConnectionUrlDialogContent> { + final _urlController = material.TextEditingController(); + String? _validationError; + + @override + void dispose() { + _urlController.dispose(); + super.dispose(); + } + + void _validateAndSubmit() { + final result = parseConnectionUrlInput(_urlController.text); + if (result.error != null) { + setState(() => _validationError = result.error); + return; + } + material.Navigator.of(context).pop(result.row); + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context).colorScheme; + final radius = Theme.of(context).radiusXxl; + return material.Container( + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 500, + minWidth: 380, + ), + decoration: material.BoxDecoration( + color: theme.popover, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: theme.muted), + ), + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(radius), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('New connection from URL').large().semiBold(), + const material.SizedBox(height: 6), + const Text( + 'Create a connection by pasting a database URI (e.g. postgresql://user:pass@host:5432/db).', + ).muted().small(), + const material.SizedBox(height: 16), + material.Container( + decoration: material.BoxDecoration( + color: theme.muted.withValues(alpha: 0.2), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: _validationError != null + ? theme.destructive.withValues(alpha: 0.8) + : theme.border.withValues(alpha: 0.4), + ), + ), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + child: material.Row( + children: [ + material.Icon( + material.Icons.link_rounded, + size: 20, + color: _validationError != null + ? theme.destructive + : theme.mutedForeground, + ), + const material.SizedBox(width: 10), + material.Expanded( + child: TextField( + controller: _urlController, + placeholder: const Text('database://user:pass@host:port/db'), + onSubmitted: (_) => _validateAndSubmit(), + onChanged: (_) { + if (_validationError != null) { + setState(() => _validationError = null); + } + }, + ), + ), + ], + ), + ), + if (_validationError != null) ...[ + const material.SizedBox(height: 8), + Text( + _validationError!, + style: material.TextStyle(color: theme.destructive), + ).small(), + ], + ], + ), + ), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: theme.border.withValues(alpha: 0.3)), + ), + ), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const material.SizedBox(width: 12), + PrimaryButton( + onPressed: _validateAndSubmit, + child: const Text('Create'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart new file mode 100644 index 00000000..e54f2e46 --- /dev/null +++ b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart @@ -0,0 +1,294 @@ +import 'package:flutter/material.dart' as material; +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'; +import 'package:querya_desktop/core/market/marketplace_repository.dart'; +import 'package:querya_desktop/features/extensions/presentation/widgets/extension_card.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +void showExtensionManagerDialog(material.BuildContext context) { + showAppDialog( + context: context, + builder: (ctx) => material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: WindowLayout.dialogSymmetricInsets(ctx), + child: const _ExtensionManagerContent(), + ), + ); +} + +class _ExtensionManagerContent extends material.StatefulWidget { + const _ExtensionManagerContent(); + + @override + material.State<_ExtensionManagerContent> createState() => + _ExtensionManagerContentState(); +} + +class _ExtensionManagerContentState extends material.State<_ExtensionManagerContent> { + int _tabIndex = 0; + List _installed = []; + List _marketplace = []; + bool _loading = true; + final Map _installingProgress = {}; + + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _loadData() async { + setState(() => _loading = true); + await LocalExtensionRegistry.instance.load(); + final installed = LocalExtensionRegistry.instance.manifests; + final market = await MarketplaceRepository.instance.getTrending(); + if (mounted) { + setState(() { + _installed = installed; + _marketplace = market; + _loading = false; + }); + } + } + + Future _onSearchChanged(String query) async { + if (query.trim().isEmpty) { + final market = await MarketplaceRepository.instance.getTrending(); + if (mounted) setState(() => _marketplace = market); + } else { + final market = await MarketplaceRepository.instance.search(query); + if (mounted) setState(() => _marketplace = market); + } + } + + Future _installExtension(ExtensionManifest manifest) async { + setState(() => _installingProgress[manifest.id] = 0.01); + try { + await MarketplaceRepository.instance.install( + manifest, + onProgress: (progress) { + if (mounted) { + setState(() => _installingProgress[manifest.id] = progress); + } + }, + ); + if (mounted) { + setState(() { + _installingProgress.remove(manifest.id); + _installed = LocalExtensionRegistry.instance.manifests; + }); + } + } catch (e) { + if (mounted) { + setState(() => _installingProgress.remove(manifest.id)); + } + } + } + + Future _uninstallExtension(ExtensionManifest manifest) async { + await MarketplaceRepository.instance.uninstall(manifest.id); + if (mounted) { + setState(() { + _installed = LocalExtensionRegistry.instance.manifests; + }); + } + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context).colorScheme; + final radius = Theme.of(context).radiusXxl; + final onPopover = theme.popoverForeground; + + return material.DefaultTextStyle( + style: material.TextStyle(color: onPopover), + child: material.IconTheme( + data: material.IconThemeData(color: onPopover), + child: material.Container( + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 800, + minWidth: 600, + maxHeight: 700, + ), + decoration: material.BoxDecoration( + color: theme.popover, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: theme.border), + ), + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(radius), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.spaceBetween, + crossAxisAlignment: material.CrossAxisAlignment.center, + children: [ + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Extensions').large().semiBold().foreground(), + const material.SizedBox(height: 6), + const Text('Manage local and marketplace extensions') + .muted() + .small(), + ], + ), + ), + const material.SizedBox(width: 16), + PrimaryButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ), + material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 24.0, vertical: 8.0), + child: material.Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _buildTabButton(0, 'Installed', count: _installed.length), + _buildTabButton(1, 'Marketplace'), + _buildTabButton(2, 'Updates'), + ], + ), + ), + material.Divider(height: 1, color: theme.border), + material.Expanded( + child: material.IndexedStack( + index: _tabIndex, + children: [ + _buildInstalledTab(), + _buildMarketplaceTab(), + _buildUpdatesTab(), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + material.Widget _buildTabButton(int index, String label, {int? count}) { + final isSelected = _tabIndex == index; + final displayLabel = count != null ? '$label ($count)' : label; + return SecondaryButton( + onPressed: () => setState(() => _tabIndex = index), + child: material.Text( + displayLabel, + style: material.TextStyle( + color: isSelected ? Theme.of(context).colorScheme.primary : null, + fontWeight: isSelected ? material.FontWeight.w600 : material.FontWeight.w400, + ), + ), + ); + } + + material.Widget _buildInstalledTab() { + if (_loading) { + return const material.Center( + child: material.CircularProgressIndicator(), + ); + } + if (_installed.isEmpty) { + return const material.Center( + child: material.Padding( + padding: material.EdgeInsets.all(32.0), + child: Text('No extensions installed yet. Explore the Marketplace tab to get started!'), + ), + ); + } + return material.ListView.separated( + padding: const material.EdgeInsets.all(24), + itemCount: _installed.length, + separatorBuilder: (_, __) => const material.SizedBox(height: 16), + itemBuilder: (ctx, i) { + final manifest = _installed[i]; + final isInstalling = _installingProgress.containsKey(manifest.id); + final progress = _installingProgress[manifest.id]; + return ExtensionCard( + manifest: manifest, + isInstalled: true, + isInstalling: isInstalling, + installProgress: progress, + onUninstall: () => _uninstallExtension(manifest), + ); + }, + ); + } + + material.Widget _buildMarketplaceTab() { + if (_loading) { + return const material.Center( + child: material.CircularProgressIndicator(), + ); + } + return material.Column( + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 16, 24, 8), + child: TextField( + placeholder: const material.Text('Search extensions by name, tag, or description...'), + onChanged: _onSearchChanged, + features: const [ + InputFeature.leading( + Padding( + padding: material.EdgeInsets.only(right: 8), + child: material.Icon(material.Icons.search_rounded, size: 18), + ), + ), + ], + ), + ), + material.Expanded( + child: _marketplace.isEmpty + ? const material.Center( + child: material.Padding( + padding: material.EdgeInsets.all(32.0), + child: Text('No extensions found matching your search.'), + ), + ) + : material.ListView.separated( + padding: const material.EdgeInsets.all(24), + itemCount: _marketplace.length, + separatorBuilder: (_, __) => const material.SizedBox(height: 16), + itemBuilder: (ctx, i) { + final manifest = _marketplace[i]; + final isInstalled = _installed.any((e) => e.id == manifest.id); + final isInstalling = _installingProgress.containsKey(manifest.id); + final progress = _installingProgress[manifest.id]; + return ExtensionCard( + manifest: manifest, + isInstalled: isInstalled, + isInstalling: isInstalling, + installProgress: progress, + onInstall: () => _installExtension(manifest), + onUninstall: () => _uninstallExtension(manifest), + ); + }, + ), + ), + ], + ); + } + + material.Widget _buildUpdatesTab() { + return const material.Center( + child: material.Padding( + padding: material.EdgeInsets.all(32.0), + child: Text('All installed extensions are up to date!'), + ), + ); + } +} diff --git a/lib/features/extensions/presentation/widgets/extension_card.dart b/lib/features/extensions/presentation/widgets/extension_card.dart new file mode 100644 index 00000000..2d25b488 --- /dev/null +++ b/lib/features/extensions/presentation/widgets/extension_card.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart' as material; +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'; + +class ExtensionCard extends material.StatelessWidget { + const ExtensionCard({ + super.key, + required this.manifest, + required this.isInstalled, + this.hasUpdate = false, + this.isInstalling = false, + this.installProgress, + this.onInstall, + this.onUninstall, + this.onUpdate, + }); + + final ExtensionManifest manifest; + final bool isInstalled; + final bool hasUpdate; + final bool isInstalling; + final double? installProgress; + final material.VoidCallback? onInstall; + final material.VoidCallback? onUninstall; + final material.VoidCallback? onUpdate; + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context).colorScheme; + final radius = Theme.of(context).radiusMd; + + return material.Container( + padding: const material.EdgeInsets.all(16), + decoration: material.BoxDecoration( + color: theme.card, + border: material.Border.all(color: theme.border), + borderRadius: material.BorderRadius.circular(radius), + ), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + _buildIcon(theme, radius), + const material.SizedBox(width: 16), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Row( + children: [ + material.Expanded( + child: Text(manifest.name).large().semiBold(), + ), + ], + ), + const material.SizedBox(height: 4), + material.Row( + children: [ + Text(manifest.publisher).muted().small(), + const material.SizedBox(width: 8), + material.Container( + width: 4, + height: 4, + decoration: material.BoxDecoration( + color: theme.mutedForeground, + shape: material.BoxShape.circle, + ), + ), + const material.SizedBox(width: 8), + Text('v${manifest.version}').muted().small(), + ], + ), + const material.SizedBox(height: 8), + Text( + manifest.description ?? 'No description provided.', + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + ).muted(), + if (manifest.tags.isNotEmpty) ...[ + const material.SizedBox(height: 10), + material.Wrap( + spacing: 6, + runSpacing: 4, + children: manifest.tags + .take(5) + .map((tag) => _buildTagBadge(theme, tag)) + .toList(), + ), + ], + ], + ), + ), + const material.SizedBox(width: 16), + material.Column( + mainAxisAlignment: material.MainAxisAlignment.center, + crossAxisAlignment: material.CrossAxisAlignment.end, + children: [ + if (isInstalled && hasUpdate && !isInstalling) + material.Padding( + padding: const material.EdgeInsets.only(bottom: 8.0), + child: PrimaryButton( + onPressed: onUpdate, + child: const Text('Update'), + ), + ), + if (isInstalling) + material.Column( + crossAxisAlignment: material.CrossAxisAlignment.end, + children: [ + material.SizedBox( + width: 110, + child: material.LinearProgressIndicator( + value: installProgress, + backgroundColor: theme.muted, + color: theme.primary, + minHeight: 6, + borderRadius: material.BorderRadius.circular(3), + ), + ), + const material.SizedBox(height: 6), + Text(installProgress != null + ? 'Installing ${(installProgress! * 100).toInt()}%' + : 'Installing...') + .muted() + .small(), + ], + ) + else if (isInstalled) + SecondaryButton( + onPressed: onUninstall, + child: const Text('Uninstall'), + ) + else + PrimaryButton( + onPressed: onInstall, + child: const Text('Install'), + ), + ], + ), + ], + ), + ); + } + + material.Widget _buildIcon(ColorScheme theme, double radius) { + return material.Container( + width: 48, + height: 48, + decoration: material.BoxDecoration( + color: theme.muted, + borderRadius: material.BorderRadius.circular(radius), + ), + child: material.Icon( + manifest.type == ExtensionType.theme + ? material.Icons.palette_outlined + : material.Icons.extension_rounded, + size: 24, + color: theme.mutedForeground, + ), + ); + } + + material.Widget _buildTagBadge(ColorScheme theme, String tag) { + return material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: material.BoxDecoration( + color: theme.muted, + borderRadius: material.BorderRadius.circular(4), + ), + child: material.Text( + tag, + style: material.TextStyle( + fontSize: 11, + color: theme.mutedForeground, + fontWeight: material.FontWeight.w500, + ), + ), + ); + } +} + diff --git a/lib/features/help/about_dialog.dart b/lib/features/help/about_dialog.dart new file mode 100644 index 00000000..11eef0a7 --- /dev/null +++ b/lib/features/help/about_dialog.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart' as material; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:querya_desktop/core/app/external_link.dart'; +import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Shows the About Querya dialog. +Future showAboutDialog(material.BuildContext context) { + return showAppDialog( + context: context, + builder: (context) => material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: WindowLayout.dialogSymmetricInsets(context), + child: const _AboutDialogContent(), + ), + ); +} + +class _AboutDialogContent extends material.StatefulWidget { + const _AboutDialogContent(); + + @override + material.State<_AboutDialogContent> createState() => + _AboutDialogContentState(); +} + +class _AboutDialogContentState extends material.State<_AboutDialogContent> { + late final Future _packageInfo = PackageInfo.fromPlatform(); + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context).colorScheme; + final radius = Theme.of(context).radiusXxl; + final wb = context.workbench; + + return material.Container( + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 420, + minWidth: 320, + ), + decoration: material.BoxDecoration( + color: theme.popover, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: theme.muted), + ), + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(radius), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 28, 24, 8), + child: material.Column( + children: [ + material.Icon( + material.Icons.search_rounded, + size: 48, + color: wb.accent, + ), + const material.SizedBox(height: 16), + const Text('Querya').large().semiBold(), + const material.SizedBox(height: 8), + FutureBuilder( + future: _packageInfo, + builder: (context, snapshot) { + final version = snapshot.data?.version ?? '…'; + return Text('Version $version').muted().small(); + }, + ), + const material.SizedBox(height: 16), + const Text( + 'A lightweight desktop SQL/NoSQL client.', + ).muted().small(), + const material.SizedBox(height: 12), + const Text( + 'Licensed under the MIT License.', + ).small(), + const material.SizedBox(height: 16), + GhostButton( + onPressed: () => launchRepositoryUrl(), + child: const Text('View repository'), + ), + ], + ), + ), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: theme.border.withValues(alpha: 0.3), + ), + ), + ), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + PrimaryButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// Opens project documentation in the system browser. +Future openQueryaDocumentation() => launchDocumentationUrl(); diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 2e5b0d1c..e34ed870 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -16,6 +16,7 @@ import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; +import 'package:querya_desktop/features/connections/new_connection_url_dialog.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart'; import 'package:querya_desktop/features/main_screen/querya_window_title_bar.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -139,6 +140,15 @@ class _MainScreenState extends State { await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); } + Future _onNewDatabaseConnectionFromUrl() async { + await Future.delayed(const Duration(milliseconds: 100)); + if (!mounted) return; + final row = await showNewConnectionUrlDialog(context); + if (!mounted || row == null) return; + await LocalDb.instance.addConnection(row); + await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + } + @override material.Widget build(material.BuildContext context) { final wb = context.workbench; @@ -154,6 +164,7 @@ class _MainScreenState extends State { builder: (context, workspace, _) { return QueryaWindowTitleBar( onNewDatabaseConnection: _onNewDatabaseConnectionFromMenu, + onNewDatabaseConnectionFromUrl: _onNewDatabaseConnectionFromUrl, activeConnection: workspace.activeConnection, isReadOnly: workspace.isReadOnly, onReadOnlyChanged: () { diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index bba9a8e3..373b2cef 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -5,6 +5,8 @@ import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/connections/driver_manager_dialog.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; +import 'package:querya_desktop/features/extensions/presentation/pages/extension_manager_dialog.dart'; +import 'package:querya_desktop/features/help/about_dialog.dart'; import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -13,6 +15,7 @@ class QueryaWindowTitleBar extends StatelessWidget { const QueryaWindowTitleBar({ super.key, required this.onNewDatabaseConnection, + required this.onNewDatabaseConnectionFromUrl, this.activeConnection, this.onConnect, this.onReconnect, @@ -24,6 +27,7 @@ class QueryaWindowTitleBar extends StatelessWidget { }); final Future Function() onNewDatabaseConnection; + final Future Function() onNewDatabaseConnectionFromUrl; final ConnectionRow? activeConnection; final VoidCallback? onConnect; final VoidCallback? onReconnect; @@ -134,6 +138,15 @@ class QueryaWindowTitleBar extends StatelessWidget { onPressed: (ctx) => showPreferencesDialog(ctx), child: const Text('Preferences…'), ), + const MenuDivider(), + MenuButton( + leading: const material.Icon( + material.Icons.extension_rounded, + size: 18, + ), + onPressed: (ctx) => showExtensionManagerDialog(ctx), + child: const Text('Extensions…'), + ), ], child: const Text('Edit'), ), @@ -152,7 +165,7 @@ class QueryaWindowTitleBar extends StatelessWidget { leading: const material.Icon( material.Icons.link_rounded, size: 18), - onPressed: (_) {}, + onPressed: (_) => onNewDatabaseConnectionFromUrl(), child: const Text('New Connection from URL'), ), MenuButton( @@ -214,9 +227,10 @@ class QueryaWindowTitleBar extends StatelessWidget { MenuButton( subMenu: [ MenuButton( - onPressed: (_) {}, child: const Text('About')), + onPressed: (ctx) => showAboutDialog(ctx), + child: const Text('About')), MenuButton( - onPressed: (_) {}, + onPressed: (_) => openQueryaDocumentation(), child: const Text('Documentation')), ], child: const Text('Help'), diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 4f72544a..a7d5bb84 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; @@ -42,6 +43,9 @@ class _PostgresConnectionFormContentState final _usernameController = material.TextEditingController(text: 'postgres'); final _passwordController = material.TextEditingController(); final _connectionStringController = material.TextEditingController(); + final _sslRootCertController = material.TextEditingController(); + final _sslCertController = material.TextEditingController(); + final _sslKeyController = material.TextEditingController(); bool _useSSL = false; bool _showPassword = false; @@ -64,6 +68,10 @@ class _PostgresConnectionFormContentState ]) { _formValidNotifier.listenTo(c); } + _connectionStringController.addListener(_populateSslFieldsFromUri); + _sslRootCertController.addListener(_syncUriSslParams); + _sslCertController.addListener(_syncUriSslParams); + _sslKeyController.addListener(_syncUriSslParams); _formValidNotifier.seed(); } @@ -82,6 +90,119 @@ class _PostgresConnectionFormContentState return t.startsWith('postgres://') || t.startsWith('postgresql://'); } + void _populateSslFieldsFromUri() { + final uriText = _connectionStringController.text.trim(); + if (uriText.isEmpty) return; + final parsed = Uri.tryParse(uriText); + if (parsed == null) return; + _sslRootCertController.text = parsed.queryParameters['sslrootcert'] ?? ''; + _sslCertController.text = parsed.queryParameters['sslcert'] ?? ''; + _sslKeyController.text = parsed.queryParameters['sslkey'] ?? ''; + } + + void _setOrRemoveSslParam( + Map params, + String key, + material.TextEditingController controller, + ) { + final value = controller.text.trim(); + if (value.isEmpty) { + params.remove(key); + } else { + params[key] = value; + } + } + + void _syncUriSslParams() { + final uriText = _connectionStringController.text.trim(); + if (uriText.isEmpty) return; + final parsed = Uri.tryParse(uriText); + if (parsed == null) return; + final params = Map.from(parsed.queryParameters); + _setOrRemoveSslParam(params, 'sslrootcert', _sslRootCertController); + _setOrRemoveSslParam(params, 'sslcert', _sslCertController); + _setOrRemoveSslParam(params, 'sslkey', _sslKeyController); + final newUri = Uri( + scheme: parsed.scheme, + userInfo: parsed.userInfo.isEmpty ? null : parsed.userInfo, + host: parsed.host, + port: parsed.hasPort ? parsed.port : null, + path: parsed.path.isEmpty ? null : parsed.path, + queryParameters: params.isEmpty ? null : params, + fragment: parsed.fragment.isEmpty ? null : parsed.fragment, + ); + _connectionStringController.text = newUri.toString(); + _formValidNotifier.seed(); + } + + Future _pickCertificateFile( + material.TextEditingController controller, + ) async { + const typeGroup = XTypeGroup( + label: 'PEM files', + extensions: ['pem', 'crt', 'key', 'cer'], + ); + final file = await openFile(acceptedTypeGroups: const [typeGroup]); + if (file == null) return; + controller.text = file.path; + _syncUriSslParams(); + } + + String _buildConnectionUri({ + required String host, + required int port, + String? username, + String? password, + String? database, + String? sslRootCert, + String? sslCert, + String? sslKey, + }) { + final userInfoParts = [ + if (username != null && username.isNotEmpty) Uri.encodeComponent(username), + if (password != null && password.isNotEmpty) Uri.encodeComponent(password), + ]; + final queryParams = { + if (sslRootCert != null && sslRootCert.isNotEmpty) + 'sslrootcert': sslRootCert, + if (sslCert != null && sslCert.isNotEmpty) 'sslcert': sslCert, + if (sslKey != null && sslKey.isNotEmpty) 'sslkey': sslKey, + }; + return Uri( + scheme: 'postgresql', + userInfo: userInfoParts.join(':'), + host: host, + port: port, + path: database == null || database.isEmpty ? '' : '/$database', + queryParameters: queryParams.isEmpty ? null : queryParams, + ).toString(); + } + + String _effectiveConnectionUri() { + final uri = _connectionStringController.text.trim(); + if (uri.isNotEmpty) return uri; + final sslRootCert = _sslRootCertController.text.trim(); + final sslCert = _sslCertController.text.trim(); + final sslKey = _sslKeyController.text.trim(); + if (sslRootCert.isEmpty && sslCert.isEmpty && sslKey.isEmpty) return ''; + return _buildConnectionUri( + host: _hostController.text.trim(), + port: int.tryParse(_portController.text.trim()) ?? 5432, + username: _usernameController.text.trim(), + password: _passwordController.text, + database: _databaseController.text.trim(), + sslRootCert: sslRootCert, + sslCert: sslCert, + sslKey: sslKey, + ); + } + + bool _hasSslCertificateFields() { + return _sslRootCertController.text.trim().isNotEmpty || + _sslCertController.text.trim().isNotEmpty || + _sslKeyController.text.trim().isNotEmpty; + } + void _showTestResult(String result) { _dismissTimer?.cancel(); setState(() { @@ -108,13 +229,14 @@ class _PostgresConnectionFormContentState _testResult = null; }); try { - final uri = _connectionStringController.text.trim(); + final uri = _effectiveConnectionUri(); + final hasUri = uri.isNotEmpty; final conn = PostgresConnection( id: 0, name: _nameController.text.trim().isEmpty ? 'test' : _nameController.text.trim(), - host: uri.isNotEmpty ? 'localhost' : _hostController.text.trim(), + host: hasUri ? 'localhost' : _hostController.text.trim(), port: int.tryParse(_portController.text.trim()) ?? 5432, database: _databaseController.text.trim().isEmpty ? null @@ -124,11 +246,15 @@ class _PostgresConnectionFormContentState : _usernameController.text.trim(), password: _passwordController.text.isEmpty ? null : _passwordController.text, - useSSL: _useSSL, - connectionString: uri.isEmpty ? null : uri, + useSSL: _useSSL || _hasSslCertificateFields(), + connectionString: hasUri ? uri : null, ); - final ok = await conn.testConnection(); - if (mounted) _showTestResult(ok ? 'success' : 'failed'); + final result = await conn.testConnection(); + if (mounted) { + _showTestResult( + result.ok ? 'success' : (result.error ?? 'failed'), + ); + } } catch (e) { if (mounted) _showTestResult('error: $e'); } @@ -140,35 +266,87 @@ class _PostgresConnectionFormContentState final host = _hostController.text.trim(); final port = int.tryParse(_portController.text.trim()) ?? 5432; final database = _databaseController.text.trim(); - final uri = _connectionStringController.text.trim(); + + _syncUriSslParams(); + final effectiveUri = _effectiveConnectionUri(); + final hasSslCerts = _hasSslCertificateFields(); + final effectiveUseSSL = _useSSL || hasSslCerts; + + String? uriHost; + int? uriPort; + if (effectiveUri.isNotEmpty) { + final parsedUri = Uri.tryParse(effectiveUri); + if (parsedUri != null && parsedUri.host.isNotEmpty) { + uriHost = parsedUri.host; + uriPort = parsedUri.hasPort ? parsedUri.port : null; + } + } + + final effectiveHost = uriHost ?? host; + final effectivePort = uriPort ?? port; final displayName = name.isNotEmpty ? name - : (uri.isNotEmpty - ? 'PostgreSQL (URI)' + : (effectiveUri.isNotEmpty + ? 'PostgreSQL: $effectiveHost:$effectivePort' : 'PostgreSQL $host:$port/$database'); final row = ConnectionRow( type: 'postgresql', name: displayName, - host: uri.isNotEmpty ? null : host, - port: uri.isNotEmpty ? null : port, + host: uriHost ?? (effectiveUri.isEmpty ? host : null), + port: uriPort ?? (effectiveUri.isEmpty ? port : null), username: _usernameController.text.trim().isEmpty ? null : _usernameController.text.trim(), password: _passwordController.text.isEmpty ? null : _passwordController.text, databaseName: - uri.isNotEmpty ? null : (database.isEmpty ? null : database), - useSSL: _useSSL, - connectionString: uri.isEmpty ? null : uri, + effectiveUri.isNotEmpty ? null : (database.isEmpty ? null : database), + useSSL: effectiveUseSSL, + connectionString: effectiveUri.isEmpty ? null : effectiveUri, folderId: widget.folderId, createdAt: DateTime.now().toUtc().toIso8601String(), ); material.Navigator.of(context).pop(row); } + material.Widget _buildSslFileField({ + required String label, + required material.TextEditingController controller, + }) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + Text(label).xSmall().muted(), + const Gap(4), + material.Row( + children: [ + material.Expanded( + child: TextField( + key: Key(label), + controller: controller, + placeholder: const Text('/path/to/file.pem'), + onChanged: (_) => _syncUriSslParams(), + ), + ), + const Gap(8), + GhostButton( + onPressed: () => _pickCertificateFile(controller), + child: const Icon(material.Icons.folder_open_rounded), + ), + ], + ), + ], + ); + } + @override void dispose() { _dismissTimer?.cancel(); + _connectionStringController.removeListener(_populateSslFieldsFromUri); + _sslRootCertController.removeListener(_syncUriSslParams); + _sslCertController.removeListener(_syncUriSslParams); + _sslKeyController.removeListener(_syncUriSslParams); for (final c in [ _nameController, _hostController, @@ -187,6 +365,9 @@ class _PostgresConnectionFormContentState _usernameController.dispose(); _passwordController.dispose(); _connectionStringController.dispose(); + _sslRootCertController.dispose(); + _sslCertController.dispose(); + _sslKeyController.dispose(); super.dispose(); } @@ -377,6 +558,32 @@ class _PostgresConnectionFormContentState const Text('Use SSL/TLS').small(), ], ), + if (_useSSL) ...[ + const Gap(16), + const Text('SSL Certificates (optional)') + .small() + .semiBold(), + const Gap(4), + const Text( + 'Root CA, client certificate, and client key are ' + 'appended to the connection URI.', + ).muted().small(), + const Gap(8), + _buildSslFileField( + label: 'Root CA / SSL Root Certificate', + controller: _sslRootCertController, + ), + const Gap(8), + _buildSslFileField( + label: 'SSL Client Certificate', + controller: _sslCertController, + ), + const Gap(8), + _buildSslFileField( + label: 'SSL Client Key', + controller: _sslKeyController, + ), + ], ], ), ), diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index f09d0a3b..85e343b4 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -12,6 +12,7 @@ #include #include #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) bitsdojo_window_linux_registrar = @@ -32,4 +33,7 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) super_native_extensions_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "SuperNativeExtensionsPlugin"); super_native_extensions_plugin_register_with_registrar(super_native_extensions_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 7792787e..f4b41b9b 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST irondash_engine_context refresh_rate super_native_extensions + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index d30bbdad..ff1df342 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,9 +10,11 @@ import device_info_plus import file_selector_macos import flutter_secure_storage_macos import irondash_engine_context +import package_info_plus import refresh_rate import sqflite_darwin import super_native_extensions +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { BitsdojoWindowPlugin.register(with: registry.registrar(forPlugin: "BitsdojoWindowPlugin")) @@ -20,7 +22,9 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) RefreshRatePlugin.register(with: registry.registrar(forPlugin: "RefreshRatePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.yaml b/pubspec.yaml index 380d8795..6fc38a73 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: querya_desktop description: Lightweight desktop SQL/NoSQL client. Flutter (Dart). -version: 0.4.8+ +version: 0.4.9+1 @@ -27,6 +27,9 @@ dependencies: flutter_secure_storage: ^9.2.4 file_selector: ^1.1.0 syntax_highlight: ^0.5.0 + archive: ^4.0.9 + url_launcher: ^6.3.1 + package_info_plus: ^8.3.0 dev_dependencies: flutter_test: diff --git a/test/core/app/app_links_test.dart b/test/core/app/app_links_test.dart new file mode 100644 index 00000000..89618178 --- /dev/null +++ b/test/core/app/app_links_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/app/app_links.dart'; + +void main() { + group('AppLinks', () { + test('repository points to Querya-Desktop GitHub', () { + expect(AppLinks.repository, 'https://github.com/QueryaHub/Querya-Desktop'); + }); + + test('documentation points to docs README on main', () { + expect( + AppLinks.documentation, + 'https://github.com/QueryaHub/Querya-Desktop/blob/main/docs/README.md', + ); + }); + + test('license points to LICENSE file on main', () { + expect( + AppLinks.license, + 'https://github.com/QueryaHub/Querya-Desktop/blob/main/LICENSE', + ); + }); + }); +} diff --git a/test/core/database/mysql_connection_test.dart b/test/core/database/mysql_connection_test.dart index 848a8e08..70f19680 100644 --- a/test/core/database/mysql_connection_test.dart +++ b/test/core/database/mysql_connection_test.dart @@ -39,4 +39,93 @@ void main() { ); }); }); + + group('MysqlConnection when not connected', () { + late MysqlConnection conn; + + setUp(() { + conn = MysqlConnection( + id: 1, + name: 'test', + host: 'localhost', + ); + }); + + test('execute throws StateError', () { + expect( + () => conn.execute('SELECT 1'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to MySQL'), + )), + ); + }); + + test('listDatabases throws StateError', () { + expect( + () => conn.listDatabases(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to MySQL'), + )), + ); + }); + + test('listViews throws StateError', () { + expect( + () => conn.listViews(schema: 'db'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to MySQL'), + )), + ); + }); + + test('listColumnNames throws StateError', () { + expect( + () => conn.listColumnNames(database: 'db', table: 'tbl'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to MySQL'), + )), + ); + }); + + test('listTables throws StateError', () { + expect( + () => conn.listTables(schema: 'db'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to MySQL'), + )), + ); + }); + + test('serverVersion throws StateError', () { + expect( + () => conn.serverVersion(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to MySQL'), + )), + ); + }); + + test('serverStats throws StateError', () { + expect( + () => conn.serverStats(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to MySQL'), + )), + ); + }); + }); } diff --git a/test/core/database/postgres_connection_pool_test.dart b/test/core/database/postgres_connection_pool_test.dart index 0542de50..dff4f3fd 100644 --- a/test/core/database/postgres_connection_pool_test.dart +++ b/test/core/database/postgres_connection_pool_test.dart @@ -339,4 +339,50 @@ void main() { expect(fake.disconnectCount, 1); }); }); + + group('PostgresConnectionPool error wrapping', () { + test('wraps unexpected factory errors in PostgresConnectionException', () async { + Future factory( + ConnectionRow row, { + required String database, + required PgSessionMode mode, + }) async { + throw const FormatException('bad connection string'); + } + + final pool = PostgresConnectionPool(createAndConnect: factory); + await expectLater( + pool.acquire(_row(), database: 'postgres'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Failed to acquire PostgreSQL connection'), + ), + ), + ); + }); + + test('rethrows PostgresConnectionException from factory', () async { + Future factory( + ConnectionRow row, { + required String database, + required PgSessionMode mode, + }) async { + throw PostgresConnectionException('driver refused'); + } + + final pool = PostgresConnectionPool(createAndConnect: factory); + await expectLater( + pool.acquire(_row(), database: 'postgres'), + throwsA( + isA().having( + (e) => e.message, + 'message', + equals('driver refused'), + ), + ), + ); + }); + }); } diff --git a/test/core/database/postgres_connection_test.dart b/test/core/database/postgres_connection_test.dart index bcb68fca..673a87bb 100644 --- a/test/core/database/postgres_connection_test.dart +++ b/test/core/database/postgres_connection_test.dart @@ -433,6 +433,18 @@ void main() { expect(ex.message, 'connection refused'); expect(ex.toString(), 'connection refused'); }); + + test('can store cause and stack trace', () { + final cause = StateError('root'); + final trace = StackTrace.current; + final ex = PostgresConnectionException( + 'connection refused', + cause: cause, + stackTrace: trace, + ); + expect(ex.cause, cause); + expect(ex.stackTrace, trace); + }); }); group('replaceDatabaseInConnectionString', () { @@ -455,4 +467,40 @@ void main() { expect(out, isNot(contains('database=olddb'))); }); }); + + group('PostgresConnection.testConnection', () { + test('returns ok=false and error message when connection fails', () async { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + connectionString: 'postgresql://localhost/db?sslmode=invalid', + ); + final result = await conn.testConnection(); + expect(result.ok, false); + expect(result.error, isNotNull); + expect(result.error, contains('sslmode')); + }); + }); + + group('PostgresConnection.connect', () { + test('throws PostgresConnectionException on invalid sslmode', () async { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + connectionString: 'postgresql://localhost/db?sslmode=invalid', + ); + expect( + conn.connect, + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('sslmode'), + ), + ), + ); + }); + }); } diff --git a/test/core/database/sqlite_connection_test.dart b/test/core/database/sqlite_connection_test.dart index ccf74aee..cd2612f9 100644 --- a/test/core/database/sqlite_connection_test.dart +++ b/test/core/database/sqlite_connection_test.dart @@ -91,6 +91,30 @@ void main() { expect(columns, containsAll(['id', 'name'])); }); + test('executes INSERT, UPDATE, DELETE with RETURNING clause correctly', () async { + await conn.connect(); + + await conn.execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)'); + + // INSERT with RETURNING + final insertRes = await conn.execute("INSERT INTO users (name) VALUES ('Alice') RETURNING id, name"); + expect(insertRes, isNotEmpty); + expect(insertRes.first['id'], 1); + expect(insertRes.first['name'], 'Alice'); + + // UPDATE with RETURNING + final updateRes = await conn.execute("UPDATE users SET name = 'Bob' WHERE id = 1 RETURNING id, name"); + expect(updateRes, isNotEmpty); + expect(updateRes.first['id'], 1); + expect(updateRes.first['name'], 'Bob'); + + // DELETE with RETURNING + final deleteRes = await conn.execute("DELETE FROM users WHERE id = 1 RETURNING id, name"); + expect(deleteRes, isNotEmpty); + expect(deleteRes.first['id'], 1); + expect(deleteRes.first['name'], 'Bob'); + }); + test('throws StateError for modify operations in read-only mode', () async { final roConn = SqliteConnection( id: 2, diff --git a/test/core/market/marketplace_repository_test.dart b/test/core/market/marketplace_repository_test.dart new file mode 100644 index 00000000..32e9292a --- /dev/null +++ b/test/core/market/marketplace_repository_test.dart @@ -0,0 +1,225 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:archive/archive.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:path/path.dart' as p; +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 'package:querya_desktop/core/market/marketplace_repository.dart'; + +void main() { + group('MockMarketplaceRepository', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('querya_market_test_'); + ExtensionPaths.mockExtensionsDirectory = tempDir; + await LocalExtensionRegistry.instance.reload(); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + ExtensionPaths.mockExtensionsDirectory = null; + }); + + test('getTrending returns default seed items', () async { + final repo = MockMarketplaceRepository(); + final trending = await repo.getTrending(); + expect(trending, isNotEmpty); + expect(trending.any((e) => e.id == 'queryahub.clickhouse-driver'), isTrue); + expect(trending.any((e) => e.id == 'queryahub.cyberpunk-neon'), isTrue); + }); + + test('search filters by name, id, description, and tags', () async { + final repo = MockMarketplaceRepository(); + + final byName = await repo.search('ClickHouse'); + expect(byName.length, 1); + expect(byName.first.id, 'queryahub.clickhouse-driver'); + + final byTag = await repo.search('neon'); + expect(byTag.length, 1); + expect(byTag.first.id, 'queryahub.cyberpunk-neon'); + + final empty = await repo.search('nonexistent_extension_query_12345'); + expect(empty, isEmpty); + }); + + test('install writes manifest to disk and reloads LocalExtensionRegistry', () async { + final repo = MockMarketplaceRepository(); + final trending = await repo.getTrending(); + final target = trending.firstWhere((e) => e.id == 'queryahub.clickhouse-driver'); + + final progressValues = []; + 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); + + 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); + }); + + test('install theme creates theme.json in extension directory', () async { + final repo = MockMarketplaceRepository(); + final trending = await repo.getTrending(); + final target = trending.firstWhere((e) => e.id == 'queryahub.cyberpunk-neon'); + + await repo.install(target); + + 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); + expect(await File(p.join(extDir.path, 'theme.json')).exists(), isTrue); + }); + }); + + group('HttpMarketplaceRepository', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('querya_http_market_test_'); + ExtensionPaths.mockExtensionsDirectory = tempDir; + await LocalExtensionRegistry.instance.reload(); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + ExtensionPaths.mockExtensionsDirectory = null; + }); + + test('getTrending fetches from backend API', () async { + final mockClient = MockClient((request) async { + expect(request.url.path, '/api/v1/extensions/trending'); + return http.Response(jsonEncode([ + { + 'id': 'test.extension', + 'name': 'Test Ext', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'theme', + 'engines': {'querya_desktop': '*'}, + } + ]), 200); + }); + + final repo = HttpMarketplaceRepository(client: mockClient); + final trending = await repo.getTrending(); + expect(trending.length, 1); + expect(trending.first.id, 'test.extension'); + }); + + test('search queries backend API', () async { + final mockClient = MockClient((request) async { + expect(request.url.path, '/api/v1/extensions/search'); + expect(request.url.queryParameters['q'], 'sql'); + return http.Response(jsonEncode([ + { + 'id': 'test.sql', + 'name': 'SQL Tools', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'databaseDriver', + 'engines': {'querya_desktop': '*'}, + } + ]), 200); + }); + + final repo = HttpMarketplaceRepository(client: mockClient); + final results = await repo.search('sql'); + expect(results.length, 1); + expect(results.first.name, 'SQL Tools'); + }); + + test('install throws MarketplaceException on SHA256 checksum mismatch', () async { + final archive = Archive(); + archive.addFile(ArchiveFile('test.txt', 4, utf8.encode('good'))); + final zipBytes = ZipEncoder().encode(archive); + + final mockClient = MockClient((request) async { + return http.Response.bytes(zipBytes, 200); + }); + + final repo = HttpMarketplaceRepository(client: mockClient); + const manifest = ExtensionManifest( + id: 'test.sha256', + name: 'SHA256 Test', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.theme, + engines: {'querya_desktop': '*'}, + downloadUrl: 'http://localhost:8000/test.zip', + sha256Checksum: '0000000000000000000000000000000000000000000000000000000000000000', + ); + + expect( + () => repo.install(manifest), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('SHA256 checksum mismatch'), + )), + ); + }); + + test('install prevents Path Traversal during archive unpacking (Issue #242)', () async { + final archive = Archive(); + archive.addFile(ArchiveFile('../evil.txt', 4, utf8.encode('evil'))); + 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.traversal', + name: 'Traversal Test', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.theme, + engines: const {'querya_desktop': '*'}, + downloadUrl: 'http://localhost:8000/evil.zip', + sha256Checksum: expectedSha256, + ); + + expect( + () => repo.install(manifest), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Security violation'), + )), + ); + }); + }); +} + + + diff --git a/test/core/storage/local_db_secrets_test.dart b/test/core/storage/local_db_secrets_test.dart index bee13adf..fdb76f02 100644 --- a/test/core/storage/local_db_secrets_test.dart +++ b/test/core/storage/local_db_secrets_test.dart @@ -116,5 +116,44 @@ void main() { expect(s.password, isNull); expect(s.connectionString, isNull); }); + + test('updateConnection atomically updates SQLite row and secure-store secrets', () async { + const initialRow = ConnectionRow( + type: 'postgres', + name: 'PG_Init', + host: 'localhost', + port: 5432, + username: 'admin', + password: 'old-secret-password', + createdAt: '2026-01-01T00:00:00Z', + ); + final id = await LocalDb.instance.addConnection(initialRow); + + final updatedRow = ConnectionRow( + id: id, + type: 'postgres', + name: 'PG_Updated', + host: 'db.example.com', + port: 5433, + username: 'root', + password: 'new-secret-password', + connectionString: 'postgres://root:new-secret-password@db.example.com:5433/mydb', + createdAt: '2026-01-01T00:00:00Z', + ); + await LocalDb.instance.updateConnection(updatedRow); + + final list = await LocalDb.instance.getConnections(); + final loaded = list.singleWhere((c) => c.id == id); + expect(loaded.name, 'PG_Updated'); + expect(loaded.host, 'db.example.com'); + expect(loaded.port, 5433); + expect(loaded.username, 'root'); + expect(loaded.password, 'new-secret-password'); + expect(loaded.connectionString, 'postgres://root:new-secret-password@db.example.com:5433/mydb'); + + final secrets = await ConnectionSecretsStore.readForConnection(id); + expect(secrets.password, 'new-secret-password'); + expect(secrets.connectionString, 'postgres://root:new-secret-password@db.example.com:5433/mydb'); + }); }); } diff --git a/test/features/connections/connection_url_parser_test.dart b/test/features/connections/connection_url_parser_test.dart new file mode 100644 index 00000000..49c9905f --- /dev/null +++ b/test/features/connections/connection_url_parser_test.dart @@ -0,0 +1,184 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/connections/connection_url_parser.dart'; + +void main() { + group('parseConnectionUrlInput', () { + test('returns error for empty input', () { + final result = parseConnectionUrlInput(' '); + expect(result.row, isNull); + expect(result.error, 'URL/URI is required.'); + }); + + test('returns error for invalid format', () { + final result = parseConnectionUrlInput('not a url'); + expect(result.row, isNull); + expect(result.error, 'Invalid URL/URI format.'); + }); + + test('returns error for unsupported scheme', () { + final result = parseConnectionUrlInput('ftp://localhost/db'); + expect(result.row, isNull); + expect(result.error, contains('Unsupported protocol')); + }); + + test('parses postgresql URL with credentials and database', () { + final result = parseConnectionUrlInput( + 'postgresql://alice:secret@db.example.com:5432/myapp', + ); + expect(result.error, isNull); + final row = result.row!; + expect(row.type, 'postgresql'); + expect(row.name, 'PostgreSQL: myapp'); + expect(row.host, 'db.example.com'); + expect(row.port, 5432); + expect(row.username, 'alice'); + expect(row.password, 'secret'); + expect(row.databaseName, 'myapp'); + expect(row.connectionString, 'postgresql://alice:secret@db.example.com:5432/myapp'); + expect(row.useSSL, false); + }); + + test('parses postgres alias scheme', () { + final result = parseConnectionUrlInput('postgres://localhost/appdb'); + expect(result.error, isNull); + expect(result.row!.type, 'postgresql'); + expect(result.row!.port, 5432); + expect(result.row!.databaseName, 'appdb'); + }); + + test('parses postgresql sslmode=require', () { + final result = parseConnectionUrlInput( + 'postgresql://localhost/postgres?sslmode=require', + ); + expect(result.error, isNull); + expect(result.row!.useSSL, true); + }); + + test('parses postgresql sslmode=verify-full as SSL enabled', () { + final result = parseConnectionUrlInput( + 'postgresql://localhost/postgres?sslmode=verify-full', + ); + expect(result.error, isNull); + expect(result.row!.useSSL, true); + }); + + test('parses postgresql sslmode=verify-ca as SSL enabled', () { + final result = parseConnectionUrlInput( + 'postgresql://localhost/postgres?sslmode=verify-ca', + ); + expect(result.error, isNull); + expect(result.row!.useSSL, true); + }); + + test('parses postgresql sslmode=disable as SSL disabled', () { + final result = parseConnectionUrlInput( + 'postgresql://localhost/postgres?sslmode=disable', + ); + expect(result.error, isNull); + expect(result.row!.useSSL, false); + }); + + test('returns error for postgresql sslmode=prefer', () { + final result = parseConnectionUrlInput( + 'postgresql://localhost/postgres?sslmode=prefer', + ); + expect(result.row, isNull); + expect(result.error, contains('Unsupported sslmode')); + expect(result.error, contains('prefer')); + }); + + test('returns error for invalid postgresql sslmode', () { + final result = parseConnectionUrlInput( + 'postgresql://localhost/postgres?sslmode=invalid', + ); + expect(result.row, isNull); + expect(result.error, contains('Unsupported sslmode')); + }); + + test('parses mysql URL', () { + final result = parseConnectionUrlInput( + 'mysql://root:p%40ss@127.0.0.1:3307/sakila', + ); + expect(result.error, isNull); + final row = result.row!; + expect(row.type, 'mysql'); + expect(row.name, 'MySQL: sakila'); + expect(row.host, '127.0.0.1'); + expect(row.port, 3307); + expect(row.username, 'root'); + expect(row.password, 'p@ss'); + expect(row.connectionString, 'mysql://root:p%40ss@127.0.0.1:3307/sakila'); + }); + + test('parses sqlite file path', () { + final result = parseConnectionUrlInput('sqlite:///tmp/test.db'); + expect(result.error, isNull); + final row = result.row!; + expect(row.type, 'sqlite'); + expect(row.host, '/tmp/test.db'); + expect(row.name, 'SQLite (test.db)'); + }); + + test('parses sqlite in-memory', () { + final result = parseConnectionUrlInput('sqlite:///:memory:'); + expect(result.error, isNull); + expect(result.row!.host, ':memory:'); + expect(result.row!.name, 'SQLite (Memory)'); + }); + + test('parses mongodb URL with authSource', () { + final result = parseConnectionUrlInput( + 'mongodb://admin:pass@mongo.local:27017/app?authSource=admin', + ); + expect(result.error, isNull); + final row = result.row!; + expect(row.type, 'mongodb'); + expect(row.name, 'MongoDB: app'); + expect(row.authSource, 'admin'); + expect(row.connectionString, contains('mongodb://')); + }); + + test('parses mongodb+srv URL', () { + final result = parseConnectionUrlInput( + 'mongodb+srv://user:pass@cluster.example.net/mydb', + ); + expect(result.error, isNull); + expect(result.row!.type, 'mongodb'); + expect(result.row!.host, 'cluster.example.net'); + expect(result.row!.databaseName, 'mydb'); + }); + + test('parses redis URL', () { + final result = parseConnectionUrlInput('redis://:password@localhost:6379'); + expect(result.error, isNull); + final row = result.row!; + expect(row.type, 'redis'); + expect(row.name, 'Redis: localhost:6379'); + expect(row.password, 'password'); + expect(row.connectionString, isNull); + }); + + test('uses default driver port when URI omits port', () { + final result = parseConnectionUrlInput('postgresql://localhost/mydb'); + expect(result.error, isNull); + expect(result.row!.port, 5432); + expect(result.row!.name, 'PostgreSQL: mydb'); + }); + + test('parses rediss URL with SSL enabled', () { + final result = parseConnectionUrlInput('rediss://localhost'); + expect(result.error, isNull); + expect(result.row!.type, 'redis'); + expect(result.row!.useSSL, true); + expect(result.row!.port, 6379); + }); + + test('password with colon is preserved', () { + final result = parseConnectionUrlInput( + 'postgresql://user:p%3Aart@localhost/mydb', + ); + expect(result.error, isNull); + expect(result.row!.password, 'p:art'); + }); + }); +} diff --git a/test/features/connections/new_connection_url_dialog_test.dart b/test/features/connections/new_connection_url_dialog_test.dart new file mode 100644 index 00000000..5af32e03 --- /dev/null +++ b/test/features/connections/new_connection_url_dialog_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/new_connection_url_dialog.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('showNewConnectionUrlDialog', () { + testWidgets('dialog shows title and form controls', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showNewConnectionUrlDialog(context), + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('New connection from URL'), findsOneWidget); + expect(find.text('Create'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + }); + + testWidgets('Cancel closes dialog and returns null', (tester) async { + ConnectionRow? result; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showNewConnectionUrlDialog(context); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(result, isNull); + }); + + testWidgets('empty URL shows validation error', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showNewConnectionUrlDialog(context), + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Create')); + await tester.pumpAndSettle(); + + expect(find.text('URL/URI is required.'), findsOneWidget); + }); + + testWidgets('valid URL returns parsed ConnectionRow', (tester) async { + ConnectionRow? result; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showNewConnectionUrlDialog(context); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byType(TextField), + 'postgresql://user:pass@localhost:5432/mydb', + ); + await tester.tap(find.text('Create')); + await tester.pumpAndSettle(); + + expect(result, isNotNull); + expect(result!.type, 'postgresql'); + expect(result!.databaseName, 'mydb'); + expect(result!.username, 'user'); + expect(result!.password, 'pass'); + }); + }); +} diff --git a/test/features/extensions/extension_manager_test.dart b/test/features/extensions/extension_manager_test.dart new file mode 100644 index 00000000..48ce2bf2 --- /dev/null +++ b/test/features/extensions/extension_manager_test.dart @@ -0,0 +1,134 @@ +import 'dart:io'; +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.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 'package:querya_desktop/core/market/marketplace_repository.dart'; +import 'package:querya_desktop/features/extensions/presentation/pages/extension_manager_dialog.dart'; +import 'package:querya_desktop/features/extensions/presentation/widgets/extension_card.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('ExtensionCard', () { + testWidgets('renders manifest details and tag badges', (tester) async { + const manifest = ExtensionManifest( + id: 'queryahub.clickhouse-driver', + name: 'ClickHouse Driver', + version: '1.0.0', + publisher: 'QueryaHub', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '^0.4.7'}, + description: 'Full support for ClickHouse databases.', + tags: ['database', 'clickhouse', 'olap'], + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: ExtensionCard( + manifest: manifest, + isInstalled: false, + ), + ), + ), + ); + + expect(find.text('ClickHouse Driver'), findsOneWidget); + expect(find.text('QueryaHub'), findsOneWidget); + expect(find.text('v1.0.0'), findsOneWidget); + expect(find.text('Full support for ClickHouse databases.'), findsOneWidget); + expect(find.text('clickhouse'), findsOneWidget); + expect(find.text('Install'), findsOneWidget); + }); + + testWidgets('renders progress bar when isInstalling is true', (tester) async { + const manifest = ExtensionManifest( + id: 'queryahub.clickhouse-driver', + name: 'ClickHouse Driver', + version: '1.0.0', + publisher: 'QueryaHub', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '^0.4.7'}, + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: ExtensionCard( + manifest: manifest, + isInstalled: false, + isInstalling: true, + installProgress: 0.45, + ), + ), + ), + ); + + expect(find.byType(material.LinearProgressIndicator), findsOneWidget); + expect(find.text('Installing 45%'), findsOneWidget); + }); + }); + + group('ExtensionManagerDialog', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('querya_ui_test_'); + ExtensionPaths.mockExtensionsDirectory = tempDir; + await LocalExtensionRegistry.instance.reload(); + MarketplaceRepository.instance = MockMarketplaceRepository(); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + ExtensionPaths.mockExtensionsDirectory = null; + }); + + testWidgets('renders tabs and loads Marketplace items', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (ctx) => material.Scaffold( + body: material.Center( + child: PrimaryButton( + onPressed: () => showExtensionManagerDialog(ctx), + child: const Text('Open Dialog'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + expect(find.text('Extensions'), findsOneWidget); + expect(find.text('Installed (0)'), findsOneWidget); + expect(find.text('Marketplace'), findsOneWidget); + + // Switch to Marketplace tab + await tester.tap(find.text('Marketplace')); + await tester.pumpAndSettle(); + + expect(find.text('ClickHouse Driver'), findsOneWidget); + expect(find.text('Redis Driver'), findsOneWidget); + + // Search filtering + final finder = find.byType(TextField); + expect(finder, findsOneWidget); + + await tester.enterText(finder, 'Nord'); + await tester.pumpAndSettle(); + + expect(find.text('Nord Theme'), findsOneWidget); + expect(find.text('ClickHouse Driver'), findsNothing); + }); + }); +} diff --git a/test/features/help/about_dialog_test.dart b/test/features/help/about_dialog_test.dart new file mode 100644 index 00000000..7465ffa6 --- /dev/null +++ b/test/features/help/about_dialog_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/help/about_dialog.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('showAboutDialog', () { + testWidgets('dialog shows app name, license, and actions', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showAboutDialog(context), + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Querya'), findsOneWidget); + expect(find.textContaining('Version'), findsOneWidget); + expect(find.text('Licensed under the MIT License.'), findsOneWidget); + expect(find.text('View repository'), findsOneWidget); + expect(find.text('Close'), findsOneWidget); + }); + + testWidgets('Close dismisses the dialog', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showAboutDialog(context), + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + + expect(find.text('Querya'), findsNothing); + }); + }); +} diff --git a/test/features/postgresql/postgresql_connection_form_test.dart b/test/features/postgresql/postgresql_connection_form_test.dart index c1589417..adda7db4 100644 --- a/test/features/postgresql/postgresql_connection_form_test.dart +++ b/test/features/postgresql/postgresql_connection_form_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/app_theme.dart'; import 'package:querya_desktop/features/postgresql/postgresql_connection_form.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -88,5 +89,184 @@ void main() { expect(result, isNull); }); + + testWidgets('Save from URI extracts host and port for display', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + ConnectionRow? result; + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showPostgresConnectionForm(context); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byWidgetPredicate( + (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'postgresql://user:pass@host:5432/dbname?sslmode=require', + ), + 'postgresql://u:p@remote.example.com:5433/db', + ); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(result, isNotNull); + expect(result!.host, 'remote.example.com'); + expect(result!.port, 5433); + expect(result!.name, 'PostgreSQL: remote.example.com:5433'); + expect(result!.connectionString, 'postgresql://u:p@remote.example.com:5433/db'); + }); + + testWidgets('SSL certificate path is appended to the URI', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showPostgresConnectionForm(context), + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byWidgetPredicate( + (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'postgresql://user:pass@host:5432/dbname?sslmode=require', + ), + 'postgresql://u:p@remote.example.com:5433/db', + ); + + await tester.scrollUntilVisible( + find.byType(material.Checkbox).first, + 300, + scrollable: find.byType(material.Scrollable).first, + ); + await tester.tap(find.byType(material.Checkbox).first); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.byKey(const Key('Root CA / SSL Root Certificate')), + 300, + scrollable: find.byType(material.Scrollable).first, + ); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('Root CA / SSL Root Certificate')), + '/certs/root.pem', + ); + await tester.pumpAndSettle(); + + final uriField = tester.widget( + find.byWidgetPredicate( + (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'postgresql://user:pass@host:5432/dbname?sslmode=require', + ), + ); + expect(uriField.controller?.text, contains('sslrootcert')); + expect(uriField.controller?.text, contains('root.pem')); + }); + + testWidgets('Save with SSL certs and no URI builds a connection URI', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + ConnectionRow? result; + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showPostgresConnectionForm(context); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byWidgetPredicate( + (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'My PostgreSQL Server', + ), + 'Cert PG', + ); + await tester.enterText( + find.byWidgetPredicate( + (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'localhost', + ).first, + 'pg.example.com', + ); + await tester.enterText( + find.byWidgetPredicate( + (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'postgres', + ).first, + 'appdb', + ); + await tester.enterText( + find.byWidgetPredicate( + (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'postgres', + ).last, + 'admin', + ); + await tester.enterText( + find.byWidgetPredicate( + (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'Password', + ), + 'secret', + ); + + await tester.scrollUntilVisible( + find.byType(material.Checkbox).first, + 300, + scrollable: find.byType(material.Scrollable).first, + ); + await tester.tap(find.byType(material.Checkbox).first); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.byKey(const Key('Root CA / SSL Root Certificate')), + 300, + scrollable: find.byType(material.Scrollable).first, + ); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('Root CA / SSL Root Certificate')), + '/certs/root.pem', + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(result, isNotNull); + expect(result!.connectionString, isNotNull); + expect(result!.connectionString, contains('pg.example.com')); + expect(result!.connectionString, contains('sslrootcert')); + expect(result!.connectionString, contains('root.pem')); + expect(result!.connectionString, contains('admin')); + expect(result!.connectionString, contains('secret')); + expect(result!.useSSL, true); + }); }); } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index eab8f5b5..1bce8ba7 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -12,6 +12,7 @@ #include #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { BitsdojoWindowPluginRegisterWithRegistrar( @@ -26,4 +27,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("RefreshRatePluginCApi")); SuperNativeExtensionsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SuperNativeExtensionsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index bf263147..134fbbb2 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST irondash_engine_context refresh_rate super_native_extensions + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST