Skip to content

Commit c64a4c2

Browse files
Release 0.4.9 (#265)
Release 0.4.9
2 parents 452f99f + 3a997cf commit c64a4c2

41 files changed

Lines changed: 3115 additions & 128 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.4.9] - 2026-07-10
11+
12+
PostgreSQL connection reliability and TLS improvements, plus menu and URI import polish.
13+
14+
### Added
15+
16+
- **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`.
17+
- **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.
18+
- **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.
19+
20+
### Fixed
21+
22+
- **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.
23+
- **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.
24+
- **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.
25+
26+
## [0.4.8] - 2026-07-08
27+
28+
### Fixed
29+
30+
- **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.
31+
- **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.
32+
1033
## [0.4.7-a] - 2026-06-22
1134

1235
### Added

‎docs/planned-0.4.8.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Milestone 0.4.8: Extension Manager UI
2+
3+
**Theme**: Разработка пользовательского интерфейса менеджера расширений (Marketplace Client). Мы создаем вкладку в настройках или отдельное окно, где пользователь сможет просматривать установленные расширения, искать новые в маркетплейсе, скачивать, обновлять и удалять их. Бэкенд маркетплейса пока будет замокан или использоваться статический JSON для тестов.
4+
5+
| ID | Scope | Summary |
6+
|----|-------|---------|
7+
| **UI-EXT-1** | `ui`, `extensions` | **Extension Manager View** — Создать страницу/диалог для менеджера расширений. Вкладки: Installed, Marketplace, Updates. |
8+
| **UI-EXT-2** | `ui`, `extensions` | **Extension Card Component** — Виджет карточки расширения (иконка, название, автор, версия, описание, кнопка Install/Uninstall). |
9+
| **UI-EXT-3** | `core`, `extensions` | **MarketplaceRepository Mock** — Реализовать моковый репозиторий для симуляции запросов к API маркетплейса (пока бэкенд не готов). |
10+
| **UI-EXT-4** | `core`, `extensions` | **Download & Install Flow** — Интеграция UI с процессом скачивания (прогресс-бар), валидации sha256 и распаковки в `~/.querya/extensions/`. |
11+
| **UI-EXT-5** | `core`, `extensions` | **Uninstall & Update** — Механизмы удаления и обновления локальных расширений через UI менеджера. |
12+
13+
## Заметки
14+
Этот релиз фокусируется на пользовательском опыте (UX/UI) взаимодействия с расширениями. Мы не реализуем сам Marketplace API (он будет в 0.5.0), но закладываем сетевой слой клиента (`MarketplaceRepository`) и мокаем данные для того, чтобы UI можно было использовать и тестировать. Под капотом используются механизмы локального обнаружения, заложенные в релизе 0.4.7.

‎lib/core/app/app_links.dart‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/// Canonical external URLs for Querya Desktop.
2+
abstract final class AppLinks {
3+
static const repository =
4+
'https://github.com/QueryaHub/Querya-Desktop';
5+
static const documentation =
6+
'https://github.com/QueryaHub/Querya-Desktop/blob/main/docs/README.md';
7+
static const license =
8+
'https://github.com/QueryaHub/Querya-Desktop/blob/main/LICENSE';
9+
}

‎lib/core/app/external_link.dart‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import 'package:querya_desktop/core/app/app_links.dart';
2+
import 'package:url_launcher/url_launcher.dart';
3+
4+
/// Opens [url] in the system browser.
5+
Future<bool> launchExternalUrl(String url) {
6+
final uri = Uri.parse(url);
7+
return launchUrl(uri, mode: LaunchMode.externalApplication);
8+
}
9+
10+
Future<bool> launchRepositoryUrl() => launchExternalUrl(AppLinks.repository);
11+
12+
Future<bool> launchDocumentationUrl() =>
13+
launchExternalUrl(AppLinks.documentation);

‎lib/core/database/mysql_connection.dart‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,7 @@ class MysqlConnection {
7676
bool get _usesConnectionString =>
7777
connectionString != null && connectionString!.trim().isNotEmpty;
7878

79-
static String _escapeSqlString(String s) {
80-
return s.replaceAll(r'\', r'\\').replaceAll("'", "''");
81-
}
79+
8280

8381
/// MySQL identifier quoting (backticks).
8482
static String quoteIdentifier(String id) {
@@ -284,11 +282,11 @@ class MysqlConnection {
284282
if (!isConnected || _conn == null) {
285283
throw StateError('Not connected to MySQL');
286284
}
287-
final s = _escapeSqlString(schema);
288285
final rs = await execute(
289286
'SELECT TABLE_NAME FROM information_schema.TABLES '
290-
"WHERE TABLE_SCHEMA = '$s' AND TABLE_TYPE = 'VIEW' "
287+
"WHERE TABLE_SCHEMA = :schema AND TABLE_TYPE = 'VIEW' "
291288
'ORDER BY TABLE_NAME',
289+
{'schema': schema},
292290
);
293291
return rs.rows.map((r) => r.colAt(0)!).toList();
294292
}
@@ -301,12 +299,14 @@ class MysqlConnection {
301299
if (!isConnected || _conn == null) {
302300
throw StateError('Not connected to MySQL');
303301
}
304-
final d = _escapeSqlString(database);
305-
final t = _escapeSqlString(table);
306302
final rs = await execute(
307303
'SELECT COLUMN_NAME FROM information_schema.COLUMNS '
308-
"WHERE TABLE_SCHEMA = '$d' AND TABLE_NAME = '$t' "
304+
"WHERE TABLE_SCHEMA = :database AND TABLE_NAME = :table "
309305
'ORDER BY ORDINAL_POSITION',
306+
{
307+
'database': database,
308+
'table': table,
309+
},
310310
);
311311
return rs.rows.map((r) => r.colAt(0)!).toList();
312312
}
@@ -316,11 +316,11 @@ class MysqlConnection {
316316
if (!isConnected || _conn == null) {
317317
throw StateError('Not connected to MySQL');
318318
}
319-
final s = _escapeSqlString(schema);
320319
final rs = await execute(
321320
'SELECT TABLE_NAME FROM information_schema.TABLES '
322-
"WHERE TABLE_SCHEMA = '$s' AND TABLE_TYPE = 'BASE TABLE' "
321+
"WHERE TABLE_SCHEMA = :schema AND TABLE_TYPE = 'BASE TABLE' "
323322
'ORDER BY TABLE_NAME',
323+
{'schema': schema},
324324
);
325325
return rs.rows.map((r) => r.colAt(0)!).toList();
326326
}

‎lib/core/database/postgres_connection.dart‎

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,17 @@ class PostgresConnection {
137137
);
138138
}
139139
_isConnected = true;
140-
} catch (e) {
140+
} catch (e, st) {
141141
_isConnected = false;
142142
_conn = null;
143-
rethrow;
143+
Error.throwWithStackTrace(
144+
PostgresConnectionException(
145+
'Failed to connect to PostgreSQL${name.isNotEmpty ? ' ($name)' : ''}: $e',
146+
cause: e,
147+
stackTrace: st,
148+
),
149+
st,
150+
);
144151
}
145152
}
146153

@@ -174,16 +181,19 @@ class PostgresConnection {
174181
);
175182
}
176183

177-
Future<bool> testConnection() async {
184+
/// Tests connectivity and returns a result with an optional error message.
185+
Future<({bool ok, String? error})> testConnection() async {
178186
try {
179187
await connect();
180188
if (_conn != null) {
181189
await _conn!.execute('SELECT 1');
182-
return true;
190+
return (ok: true, error: null);
183191
}
184-
return false;
185-
} catch (_) {
186-
return false;
192+
return (ok: false, error: 'Connection could not be established.');
193+
} on PostgresConnectionException catch (e) {
194+
return (ok: false, error: e.message);
195+
} catch (e) {
196+
return (ok: false, error: e.toString());
187197
} finally {
188198
await disconnect();
189199
}
@@ -769,8 +779,16 @@ class PostgresSequenceDetails {
769779
}
770780

771781
class PostgresConnectionException implements Exception {
772-
PostgresConnectionException(this.message);
782+
PostgresConnectionException(
783+
this.message, {
784+
this.cause,
785+
this.stackTrace,
786+
});
787+
773788
final String message;
789+
final Object? cause;
790+
final StackTrace? stackTrace;
791+
774792
@override
775793
String toString() => message;
776794
}

‎lib/core/database/postgres_connection_pool.dart‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,23 @@ class PostgresConnectionPool {
8787

8888
_evictIfNeededBeforeNewSlot();
8989

90-
final conn = await createAndConnect(row, database: database, mode: mode);
91-
entry = _PoolEntry(conn)..refs = 1;
92-
_pool[k] = entry;
93-
return PgLease._(this, k, conn);
90+
try {
91+
final conn = await createAndConnect(row, database: database, mode: mode);
92+
entry = _PoolEntry(conn)..refs = 1;
93+
_pool[k] = entry;
94+
return PgLease._(this, k, conn);
95+
} on PostgresConnectionException {
96+
rethrow;
97+
} catch (e, st) {
98+
Error.throwWithStackTrace(
99+
PostgresConnectionException(
100+
'Failed to acquire PostgreSQL connection for database "$database": $e',
101+
cause: e,
102+
stackTrace: st,
103+
),
104+
st,
105+
);
106+
}
94107
}
95108

96109
/// Drops idle LRU slots until there is room for one more key.

‎lib/core/database/sqlite_connection.dart‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,18 +97,21 @@ class SqliteConnection {
9797
.toLowerCase();
9898

9999
// SQLite can execute PRAGMA, SELECT, EXPLAIN statements, which return data
100-
final isQuery = sqlLower.startsWith('select') ||
100+
final isReadOnlyQuery = sqlLower.startsWith('select') ||
101101
sqlLower.startsWith('pragma') ||
102102
sqlLower.startsWith('explain') ||
103103
sqlLower.startsWith('with') ||
104104
sqlLower.startsWith('values');
105105

106-
if (isQuery) {
106+
final hasReturning = RegExp(r'\breturning\b').hasMatch(sqlLower);
107+
108+
if (readOnly && !isReadOnlyQuery) {
109+
throw StateError('Database connection is read-only');
110+
}
111+
112+
if (isReadOnlyQuery || hasReturning) {
107113
return await _db!.rawQuery(sql, arguments);
108114
} else {
109-
if (readOnly) {
110-
throw StateError('Database connection is read-only');
111-
}
112115
await _db!.execute(sql, arguments);
113116
return [];
114117
}

‎lib/core/extensions/models/extension_manifest.dart‎

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import '../../theme/theme_definition.dart';
12
import 'extension_type.dart';
23

34
class ExtensionManifest {
5+
static const typeTheme = ExtensionType.theme;
6+
47
final String id;
58
final String name;
69
final String version;
@@ -11,6 +14,13 @@ class ExtensionManifest {
1114
final String? icon;
1215
final String? description;
1316
final String? installPath;
17+
final String? downloadUrl;
18+
final String? sha256Checksum;
19+
final String? author;
20+
final String? homepage;
21+
final String? license;
22+
final String? preview;
23+
final List<String> tags;
1424

1525
const ExtensionManifest({
1626
required this.id,
@@ -23,20 +33,62 @@ class ExtensionManifest {
2333
this.icon,
2434
this.description,
2535
this.installPath,
36+
this.downloadUrl,
37+
this.sha256Checksum,
38+
this.author,
39+
this.homepage,
40+
this.license,
41+
this.preview,
42+
this.tags = const [],
2643
});
2744

45+
/// Maps a registry [ThemeDefinition] into marketplace field names.
46+
factory ExtensionManifest.fromThemeDefinition(
47+
ThemeDefinition definition, {
48+
String downloadUrl = '',
49+
String sha256Checksum = '',
50+
ExtensionType type = typeTheme,
51+
}) {
52+
final metadata = definition.metadata;
53+
return ExtensionManifest(
54+
id: definition.id,
55+
name: definition.name,
56+
publisher: metadata?.author ?? 'Unknown',
57+
type: type,
58+
version: metadata?.version ?? '0.0.0',
59+
engines: const {'querya_desktop': '^0.4.7'},
60+
downloadUrl: downloadUrl,
61+
sha256Checksum: sha256Checksum.isNotEmpty
62+
? sha256Checksum
63+
: (definition.contentHash ?? ''),
64+
author: metadata?.author,
65+
description: metadata?.description,
66+
homepage: metadata?.homepage,
67+
license: metadata?.license,
68+
preview: metadata?.preview,
69+
tags: metadata?.tags ?? const [],
70+
);
71+
}
72+
2873
factory ExtensionManifest.fromJson(Map<String, dynamic> json, {String? installPath}) {
2974
return ExtensionManifest(
3075
id: json['id'] as String,
3176
name: json['name'] as String,
32-
version: json['version'] as String,
33-
publisher: json['publisher'] as String,
34-
type: ExtensionType.fromString(json['type'] as String),
77+
version: json['version'] as String? ?? '0.0.0',
78+
publisher: json['publisher'] as String? ?? json['author'] as String? ?? 'Unknown',
79+
type: ExtensionType.fromString(json['type'] as String? ?? ''),
3580
engines: Map<String, String>.from(json['engines'] as Map? ?? {}),
3681
main: json['main'] as String?,
3782
icon: json['icon'] as String?,
3883
description: json['description'] as String?,
3984
installPath: installPath,
85+
downloadUrl: json['downloadUrl'] as String?,
86+
sha256Checksum: json['sha256Checksum'] as String? ?? json['sha256'] as String?,
87+
author: json['author'] as String?,
88+
homepage: json['homepage'] as String?,
89+
license: json['license'] as String?,
90+
preview: json['preview'] as String?,
91+
tags: List<String>.from(json['tags'] as List? ?? []),
4092
);
4193
}
4294

@@ -51,6 +103,13 @@ class ExtensionManifest {
51103
if (main != null) 'main': main,
52104
if (icon != null) 'icon': icon,
53105
if (description != null) 'description': description,
106+
if (downloadUrl != null) 'downloadUrl': downloadUrl,
107+
if (sha256Checksum != null) 'sha256Checksum': sha256Checksum,
108+
if (author != null) 'author': author,
109+
if (homepage != null) 'homepage': homepage,
110+
if (license != null) 'license': license,
111+
if (preview != null) 'preview': preview,
112+
if (tags.isNotEmpty) 'tags': tags,
54113
};
55114
}
56115
}

0 commit comments

Comments
 (0)