From 7c67223ea05857bad77d7863f6b84cf45b349819 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 06:21:11 +0300 Subject: [PATCH 01/14] fix(theme): default remote install to kDebugMode for localhost policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #399 — release builds no longer allow localhost/private IPs via ThemeRemoteInstallService constructor default. --- .../theme/theme_remote_install_service.dart | 3 ++- .../theme_remote_install_service_test.dart | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/lib/core/theme/theme_remote_install_service.dart b/lib/core/theme/theme_remote_install_service.dart index 061c27c3..fa958c89 100644 --- a/lib/core/theme/theme_remote_install_service.dart +++ b/lib/core/theme/theme_remote_install_service.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; @@ -27,7 +28,7 @@ class ThemeRemoteInstallService { this._registry, { Future Function(Uri uri)? httpGet, Duration timeout = const Duration(seconds: 30), - bool allowLocalhostInDebug = true, + bool allowLocalhostInDebug = kDebugMode, }) : _httpGet = httpGet ?? _defaultHttpGet, _timeout = timeout, _allowLocalhostInDebug = allowLocalhostInDebug; diff --git a/test/core/theme/theme_remote_install_service_test.dart b/test/core/theme/theme_remote_install_service_test.dart index 04adc8b6..ad80b0cb 100644 --- a/test/core/theme/theme_remote_install_service_test.dart +++ b/test/core/theme/theme_remote_install_service_test.dart @@ -164,6 +164,32 @@ void main() { expect(result, isA()); }); + test('rejects localhost URLs when release policy is enforced', () async { + final service = ThemeRemoteInstallService( + registry, + allowLocalhostInDebug: false, + ); + final result = await service.installFromUrl( + 'https://127.0.0.1/theme.json', + ); + expect(result, isA()); + expect( + (result as ThemeDefinitionImportFailure).message, + contains('Only public HTTPS theme URLs are allowed'), + ); + }); + + test('rejects private IPv4 URLs when release policy is enforced', () async { + final service = ThemeRemoteInstallService( + registry, + allowLocalhostInDebug: false, + ); + final result = await service.installFromUrl( + 'https://192.168.1.10/theme.json', + ); + expect(result, isA()); + }); + test('reuses existing file when remote content hash matches', () async { final raw = await File('test/fixtures/themes/querya_custom_dark.json') .readAsString(); From 6a9c89ea103b4af56f8e587071d47c29821eba02 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 06:21:32 +0300 Subject: [PATCH 02/14] fix(security): use p.isWithin for archive extraction bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #401 — shared archive_path_guard replaces startsWith checks in marketplace, sideload, and updater extract paths. --- .../extensions/local_extension_installer.dart | 7 ++- .../market/http_marketplace_repository.dart | 5 +- lib/core/security/archive_path_guard.dart | 18 +++++++ .../installers/update_install_utils.dart | 5 +- .../security/archive_path_guard_test.dart | 47 +++++++++++++++++++ 5 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 lib/core/security/archive_path_guard.dart create mode 100644 test/core/security/archive_path_guard_test.dart diff --git a/lib/core/extensions/local_extension_installer.dart b/lib/core/extensions/local_extension_installer.dart index 557ede90..f413494c 100644 --- a/lib/core/extensions/local_extension_installer.dart +++ b/lib/core/extensions/local_extension_installer.dart @@ -10,6 +10,7 @@ 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/sandbox/sandbox_policy.dart'; import 'package:querya_desktop/core/market/marketplace_repository.dart'; +import 'package:querya_desktop/core/security/archive_path_guard.dart'; /// Installs an extension package from a local `.zip` / `.qext` archive (issue #316). /// @@ -216,9 +217,7 @@ class LocalExtensionInstaller { } if (filename.isEmpty || filename == '/') continue; - if (filename.contains('..') || - filename.startsWith('/') || - filename.startsWith('\\')) { + if (!isArchiveEntryNameSafe(filename)) { throw MarketplaceException( 'Security violation: Path traversal detected in archive entry ' '"${file.name}"', @@ -226,7 +225,7 @@ class LocalExtensionInstaller { } final targetPath = p.normalize(p.join(destPath, filename)); - if (!targetPath.startsWith(destPath)) { + if (!isArchiveExtractPathWithinRoot(destPath, targetPath)) { throw MarketplaceException( 'Security violation: Extraction path out of bounds "${file.name}"', ); diff --git a/lib/core/market/http_marketplace_repository.dart b/lib/core/market/http_marketplace_repository.dart index 1d1a5caf..c7d293cd 100644 --- a/lib/core/market/http_marketplace_repository.dart +++ b/lib/core/market/http_marketplace_repository.dart @@ -11,6 +11,7 @@ import 'package:querya_desktop/core/extensions/sandbox/sandbox_policy.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/security/archive_path_guard.dart'; import 'marketplace_repository.dart'; /// HTTP implementation of [MarketplaceRepository] connecting to MarketApi backend. @@ -152,12 +153,12 @@ class HttpMarketplaceRepository implements MarketplaceRepository { for (final file in archive) { final filename = file.name; // Check for Path Traversal attempts - if (filename.contains('..') || filename.startsWith('/') || filename.startsWith('\\')) { + if (!isArchiveEntryNameSafe(filename)) { throw MarketplaceException('Security violation: Path traversal detected in archive entry "$filename"'); } final targetPath = p.normalize(p.join(extDirPath, filename)); - if (!targetPath.startsWith(extDirPath)) { + if (!isArchiveExtractPathWithinRoot(extDirPath, targetPath)) { throw MarketplaceException('Security violation: Extraction path out of bounds "$filename"'); } diff --git a/lib/core/security/archive_path_guard.dart b/lib/core/security/archive_path_guard.dart new file mode 100644 index 00000000..97006c62 --- /dev/null +++ b/lib/core/security/archive_path_guard.dart @@ -0,0 +1,18 @@ +import 'package:path/path.dart' as p; + +/// Returns true when [targetPath] equals [rootPath] or lies inside it. +/// +/// Prefer over [String.startsWith] so sibling prefixes (e.g. `/tmp/abc` vs +/// `/tmp/abcd`) cannot bypass extraction bounds. +bool isArchiveExtractPathWithinRoot(String rootPath, String targetPath) { + final root = p.normalize(rootPath); + final target = p.normalize(targetPath); + return p.equals(root, target) || p.isWithin(root, target); +} + +/// Rejects archive entry names that attempt absolute paths or traversal. +bool isArchiveEntryNameSafe(String entryName) { + if (entryName.contains('..')) return false; + if (entryName.startsWith('/') || entryName.startsWith('\\')) return false; + return true; +} diff --git a/lib/core/updater/installers/update_install_utils.dart b/lib/core/updater/installers/update_install_utils.dart index 7ef528ce..a5a769f9 100644 --- a/lib/core/updater/installers/update_install_utils.dart +++ b/lib/core/updater/installers/update_install_utils.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:archive/archive.dart'; import 'package:path/path.dart' as p; +import '../../security/archive_path_guard.dart'; import '../app_updater_service.dart'; /// Safely extracts a zip archive into [destinationDir]. @@ -21,14 +22,14 @@ Future extractZipSecurely({ for (final entry in archive) { final name = entry.name; - if (name.contains('..') || name.startsWith('/') || name.startsWith('\\')) { + if (!isArchiveEntryNameSafe(name)) { throw AppUpdaterException( 'Security violation: path traversal in archive entry "$name"', ); } final targetPath = p.normalize(p.join(root, name)); - if (!targetPath.startsWith(root)) { + if (!isArchiveExtractPathWithinRoot(root, targetPath)) { throw AppUpdaterException( 'Security violation: extraction path out of bounds "$name"', ); diff --git a/test/core/security/archive_path_guard_test.dart b/test/core/security/archive_path_guard_test.dart new file mode 100644 index 00000000..01f64c7f --- /dev/null +++ b/test/core/security/archive_path_guard_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/security/archive_path_guard.dart'; + +void main() { + group('isArchiveExtractPathWithinRoot', () { + test('allows paths inside root', () { + expect( + isArchiveExtractPathWithinRoot('/tmp/ext', '/tmp/ext/file.txt'), + isTrue, + ); + }); + + test('allows root path itself', () { + expect( + isArchiveExtractPathWithinRoot('/tmp/ext', '/tmp/ext'), + isTrue, + ); + }); + + test('rejects sibling prefix paths (startsWith false positive)', () { + expect( + isArchiveExtractPathWithinRoot('/tmp/abc', '/tmp/abcd/evil.txt'), + isFalse, + ); + }); + + test('rejects paths outside root', () { + expect( + isArchiveExtractPathWithinRoot('/tmp/ext', '/tmp/other/file.txt'), + isFalse, + ); + }); + }); + + group('isArchiveEntryNameSafe', () { + test('rejects traversal and absolute names', () { + expect(isArchiveEntryNameSafe('../evil.txt'), isFalse); + expect(isArchiveEntryNameSafe('/etc/passwd'), isFalse); + expect(isArchiveEntryNameSafe(r'\windows\system32'), isFalse); + }); + + test('allows relative safe names', () { + expect(isArchiveEntryNameSafe('manifest.json'), isTrue); + expect(isArchiveEntryNameSafe('bin/driver'), isTrue); + }); + }); +} From d245e7c0df6085b531aaae127abdbd3f93211b17 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 06:22:16 +0300 Subject: [PATCH 03/14] fix(marketplace): require SHA256 checksum before HttpMarketplace install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #396 — marketplace install fails closed when manifest checksum is missing or empty, matching the updater security model. --- .../market/http_marketplace_repository.dart | 25 +++++--- .../market/marketplace_repository_test.dart | 61 +++++++++++++++++++ 2 files changed, 76 insertions(+), 10 deletions(-) diff --git a/lib/core/market/http_marketplace_repository.dart b/lib/core/market/http_marketplace_repository.dart index 1d1a5caf..f85a65ca 100644 --- a/lib/core/market/http_marketplace_repository.dart +++ b/lib/core/market/http_marketplace_repository.dart @@ -124,21 +124,26 @@ class HttpMarketplaceRepository implements MarketplaceRepository { 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.', - ); - } + final expectedSha256 = manifest.sha256Checksum?.trim().toLowerCase(); + if (expectedSha256 == null || expectedSha256.isEmpty) { + throw MarketplaceException( + 'Extension manifest is missing SHA256 checksum for "${manifest.id}". ' + 'Installation aborted.', + ); + } + + final bytes = await archiveFile.readAsBytes(); + final actualSha256 = sha256.convert(bytes).toString().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(); diff --git a/test/core/market/marketplace_repository_test.dart b/test/core/market/marketplace_repository_test.dart index db9e14d2..e3aeeda1 100644 --- a/test/core/market/marketplace_repository_test.dart +++ b/test/core/market/marketplace_repository_test.dart @@ -231,6 +231,67 @@ void main() { ); }); + test('install aborts when SHA256 checksum is missing', () 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.no-sha256', + name: 'No SHA256', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.theme, + engines: {'querya_desktop': '*'}, + downloadUrl: 'http://localhost:8000/test.zip', + ); + + expect( + () => repo.install(manifest), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('missing SHA256 checksum'), + )), + ); + }); + + test('install aborts when SHA256 checksum is empty', () 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.empty-sha256', + name: 'Empty SHA256', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.theme, + engines: {'querya_desktop': '*'}, + downloadUrl: 'http://localhost:8000/test.zip', + sha256Checksum: ' ', + ); + + expect( + () => repo.install(manifest), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('missing SHA256 checksum'), + )), + ); + }); + test('install prevents Path Traversal during archive unpacking (Issue #242)', () async { final archive = Archive(); archive.addFile(ArchiveFile('../evil.txt', 4, utf8.encode('evil'))); From e7372ab177282d659299d11aa68902e37a01d7fb Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 06:23:00 +0300 Subject: [PATCH 04/14] fix(marketplace): restrict extension download URLs (HTTPS allowlist) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #397 — validate API base and download URLs against trusted hosts and block cleartext/private targets in release builds. --- .../market/http_marketplace_repository.dart | 39 +++++++- .../market/marketplace_download_policy.dart | 86 ++++++++++++++++++ .../marketplace_download_policy_test.dart | 90 +++++++++++++++++++ .../market/marketplace_repository_test.dart | 29 ++++++ 4 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 lib/core/market/marketplace_download_policy.dart create mode 100644 test/core/market/marketplace_download_policy_test.dart diff --git a/lib/core/market/http_marketplace_repository.dart b/lib/core/market/http_marketplace_repository.dart index 9c0cf2cf..ee4c29d7 100644 --- a/lib/core/market/http_marketplace_repository.dart +++ b/lib/core/market/http_marketplace_repository.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:archive/archive.dart'; import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:querya_desktop/core/extensions/extension_support.dart'; @@ -12,6 +13,7 @@ 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/security/archive_path_guard.dart'; +import 'marketplace_download_policy.dart'; import 'marketplace_repository.dart'; /// HTTP implementation of [MarketplaceRepository] connecting to MarketApi backend. @@ -22,13 +24,46 @@ class HttpMarketplaceRepository implements MarketplaceRepository { HttpMarketplaceRepository({ this.baseUrl = 'http://localhost:8000/api/v1', http.Client? client, - }) : _client = client ?? http.Client(); + Iterable extraTrustedDownloadHosts = const [], + bool allowLocalhostInDebug = kDebugMode, + }) : _client = client ?? http.Client(), + _allowLocalhostInDebug = allowLocalhostInDebug, + _trustedDownloadHosts = MarketplaceDownloadPolicy.trustedHostsFor( + apiBaseUrl: baseUrl, + extraTrustedHosts: extraTrustedDownloadHosts, + ) { + _validateApiBaseUrl(); + } final String baseUrl; final http.Client _client; + final bool _allowLocalhostInDebug; + final Set _trustedDownloadHosts; + + void _validateApiBaseUrl() { + if (!MarketplaceDownloadPolicy.isAllowedApiBaseUrl( + baseUrl, + allowLocalhostInDebug: _allowLocalhostInDebug, + )) { + throw MarketplaceException( + 'Marketplace API base URL is not allowed: $baseUrl', + ); + } + } + + void _validateDownloadUrl(Uri uri) { + if (!MarketplaceDownloadPolicy.isAllowedDownloadUrl( + uri, + trustedHosts: _trustedDownloadHosts, + allowLocalhostInDebug: _allowLocalhostInDebug, + )) { + throw MarketplaceException('Download URL is not allowed: $uri'); + } + } @override Future> getTrending({ExtensionType? type}) async { + _validateApiBaseUrl(); final uri = Uri.parse('$baseUrl/extensions/trending').replace( queryParameters: type != null ? {'type': type.value} : null, ); @@ -42,6 +77,7 @@ class HttpMarketplaceRepository implements MarketplaceRepository { @override Future> search(String query, {ExtensionType? type}) async { + _validateApiBaseUrl(); final uri = Uri.parse('$baseUrl/extensions/search').replace( queryParameters: { 'q': query.trim(), @@ -62,6 +98,7 @@ class HttpMarketplaceRepository implements MarketplaceRepository { if (uri == null) { throw MarketplaceException('Invalid download URL: $url'); } + _validateDownloadUrl(uri); final request = http.Request('GET', uri); final response = await _client.send(request).timeout(const Duration(seconds: 30)); diff --git a/lib/core/market/marketplace_download_policy.dart b/lib/core/market/marketplace_download_policy.dart new file mode 100644 index 00000000..be5edb04 --- /dev/null +++ b/lib/core/market/marketplace_download_policy.dart @@ -0,0 +1,86 @@ +import 'package:flutter/foundation.dart'; + +import '../theme/theme_remote_install_policy.dart'; + +/// HTTPS and host allowlist rules for marketplace API and artifact downloads. +abstract final class MarketplaceDownloadPolicy { + /// Hosts permitted for extension archive downloads (API host + extras). + static Set trustedHostsFor({ + required String apiBaseUrl, + Iterable extraTrustedHosts = const [], + }) { + final hosts = {}; + final apiHost = Uri.tryParse(apiBaseUrl.trim())?.host.toLowerCase(); + if (apiHost != null && apiHost.isNotEmpty) { + hosts.add(apiHost); + } + for (final host in extraTrustedHosts) { + final normalized = host.trim().toLowerCase(); + if (normalized.isNotEmpty) { + hosts.add(normalized); + } + } + return hosts; + } + + /// Whether [baseUrl] may be used for MarketApi REST calls. + static bool isAllowedApiBaseUrl( + String baseUrl, { + bool allowLocalhostInDebug = kDebugMode, + }) { + final uri = Uri.tryParse(baseUrl.trim()); + if (uri == null || !uri.hasAuthority || uri.host.isEmpty) { + return false; + } + + if (uri.scheme == 'https') { + return ThemeRemoteInstallPolicy.isAllowedUrl( + uri, + allowLocalhostInDebug: allowLocalhostInDebug, + ); + } + + if (allowLocalhostInDebug && uri.scheme == 'http') { + return _isDebugLocalHttpHost(uri.host); + } + + return false; + } + + /// Whether [uri] may be used to download an extension archive. + static bool isAllowedDownloadUrl( + Uri uri, { + required Set trustedHosts, + bool allowLocalhostInDebug = kDebugMode, + }) { + if (!uri.hasAuthority || uri.host.isEmpty) { + return false; + } + + final host = uri.host.toLowerCase(); + if (!trustedHosts.contains(host)) { + return false; + } + + if (uri.scheme == 'https') { + return ThemeRemoteInstallPolicy.isAllowedUrl( + uri, + allowLocalhostInDebug: allowLocalhostInDebug, + ); + } + + if (allowLocalhostInDebug && uri.scheme == 'http') { + return _isDebugLocalHttpHost(host); + } + + return false; + } + + static bool _isDebugLocalHttpHost(String host) { + final probe = Uri.parse('https://$host/'); + return ThemeRemoteInstallPolicy.isAllowedUrl( + probe, + allowLocalhostInDebug: true, + ); + } +} diff --git a/test/core/market/marketplace_download_policy_test.dart b/test/core/market/marketplace_download_policy_test.dart new file mode 100644 index 00000000..547e7490 --- /dev/null +++ b/test/core/market/marketplace_download_policy_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/market/marketplace_download_policy.dart'; + +void main() { + group('MarketplaceDownloadPolicy', () { + test('trustedHostsFor includes API host and extras', () { + expect( + MarketplaceDownloadPolicy.trustedHostsFor( + apiBaseUrl: 'https://api.example.com/api/v1', + extraTrustedHosts: ['cdn.example.com'], + ), + {'api.example.com', 'cdn.example.com'}, + ); + }); + + test('isAllowedApiBaseUrl allows public https API', () { + expect( + MarketplaceDownloadPolicy.isAllowedApiBaseUrl( + 'https://api.example.com/api/v1', + allowLocalhostInDebug: false, + ), + isTrue, + ); + }); + + test('isAllowedApiBaseUrl rejects cleartext in release mode', () { + expect( + MarketplaceDownloadPolicy.isAllowedApiBaseUrl( + 'http://localhost:8000/api/v1', + allowLocalhostInDebug: false, + ), + isFalse, + ); + }); + + test('isAllowedApiBaseUrl allows localhost http in debug mode', () { + expect( + MarketplaceDownloadPolicy.isAllowedApiBaseUrl( + 'http://localhost:8000/api/v1', + allowLocalhostInDebug: true, + ), + isTrue, + ); + }); + + test('isAllowedDownloadUrl rejects untrusted host', () { + expect( + MarketplaceDownloadPolicy.isAllowedDownloadUrl( + Uri.parse('https://evil.example.com/pkg.zip'), + trustedHosts: {'api.example.com'}, + allowLocalhostInDebug: false, + ), + isFalse, + ); + }); + + test('isAllowedDownloadUrl rejects private IPs in release mode', () { + expect( + MarketplaceDownloadPolicy.isAllowedDownloadUrl( + Uri.parse('https://192.168.1.10/pkg.zip'), + trustedHosts: {'192.168.1.10'}, + allowLocalhostInDebug: false, + ), + isFalse, + ); + }); + + test('isAllowedDownloadUrl rejects file scheme', () { + expect( + MarketplaceDownloadPolicy.isAllowedDownloadUrl( + Uri.parse('file:///etc/passwd'), + trustedHosts: {'localhost'}, + allowLocalhostInDebug: true, + ), + isFalse, + ); + }); + + test('isAllowedDownloadUrl allows trusted public https host', () { + expect( + MarketplaceDownloadPolicy.isAllowedDownloadUrl( + Uri.parse('https://cdn.example.com/pkg.zip'), + trustedHosts: {'cdn.example.com'}, + allowLocalhostInDebug: false, + ), + isTrue, + ); + }); + }); +} diff --git a/test/core/market/marketplace_repository_test.dart b/test/core/market/marketplace_repository_test.dart index e3aeeda1..4e23cfd5 100644 --- a/test/core/market/marketplace_repository_test.dart +++ b/test/core/market/marketplace_repository_test.dart @@ -355,6 +355,35 @@ void main() { )), ); }); + + test('download rejects disallowed URLs when release policy is enforced', + () async { + final repo = HttpMarketplaceRepository( + baseUrl: 'https://cdn.example.com/api/v1', + extraTrustedDownloadHosts: ['cdn.example.com'], + allowLocalhostInDebug: false, + client: MockClient((request) async => http.Response('', 200)), + ); + + expect( + () => repo.download('http://cdn.example.com/test.zip'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Download URL is not allowed'), + )), + ); + + expect( + () => repo.download('https://127.0.0.1/test.zip'), + throwsA(isA()), + ); + + expect( + () => repo.download('file:///tmp/test.zip'), + throwsA(isA()), + ); + }); }); } From 1d96aa32afeccc03bd74fbb741bb84376f543275 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 06:44:55 +0300 Subject: [PATCH 05/14] fix(security): add SafeZipExtractor limits for archive installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #398 — shared zip bomb bounds for marketplace, sideload, and updater paths; document default limits in docs/security.md. --- docs/security.md | 14 ++ .../extensions/local_extension_installer.dart | 15 +- .../market/http_marketplace_repository.dart | 17 ++- lib/core/security/safe_zip_extractor.dart | 134 +++++++++++++++++ .../installers/update_install_utils.dart | 9 +- .../security/safe_zip_extractor_test.dart | 136 ++++++++++++++++++ 6 files changed, 318 insertions(+), 7 deletions(-) create mode 100644 lib/core/security/safe_zip_extractor.dart create mode 100644 test/core/security/safe_zip_extractor_test.dart diff --git a/docs/security.md b/docs/security.md index 9b9d44a7..545fbabf 100644 --- a/docs/security.md +++ b/docs/security.md @@ -21,3 +21,17 @@ On upgrade from older databases, existing plaintext secrets in SQLite are **migr ## Tests Automated tests use an **in-memory** secrets backend (see `test/flutter_test_config.dart`) so CI does not require a desktop keyring. + +## Archive install limits (extensions and updates) + +Marketplace downloads, local extension sideload (`.zip` / `.qext`), and in-app updater extraction use `SafeZipExtractor` (`lib/core/security/safe_zip_extractor.dart`) with shared default limits: + +| Limit | Default | +|-------|---------| +| Max compressed archive size | 100 MiB | +| Max total uncompressed size | 500 MiB | +| Max entries | 10 000 | +| Max single entry uncompressed size | 100 MiB | +| Max compression ratio (uncompressed ÷ compressed) | 100:1 | + +Archives exceeding these bounds fail closed before files are written to disk. Path traversal checks remain in `archive_path_guard.dart`. diff --git a/lib/core/extensions/local_extension_installer.dart b/lib/core/extensions/local_extension_installer.dart index f413494c..3296763f 100644 --- a/lib/core/extensions/local_extension_installer.dart +++ b/lib/core/extensions/local_extension_installer.dart @@ -11,6 +11,7 @@ import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_policy.dart'; import 'package:querya_desktop/core/market/marketplace_repository.dart'; import 'package:querya_desktop/core/security/archive_path_guard.dart'; +import 'package:querya_desktop/core/security/safe_zip_extractor.dart'; /// Installs an extension package from a local `.zip` / `.qext` archive (issue #316). /// @@ -43,7 +44,12 @@ class LocalExtensionInstaller { } onProgress?.call(0.1); - final bytes = await archiveFile.readAsBytes(); + late final List bytes; + try { + bytes = await SafeZipExtractor.readBoundedBytes(archiveFile); + } on SafeZipException catch (error) { + throw MarketplaceException(error.message); + } if (expectedSha256 != null && expectedSha256.trim().isNotEmpty) { final actual = sha256.convert(bytes).toString().toLowerCase(); @@ -57,7 +63,12 @@ class LocalExtensionInstaller { } onProgress?.call(0.25); - final archive = ZipDecoder().decodeBytes(bytes); + late final Archive archive; + try { + archive = SafeZipExtractor.decodeBytes(bytes); + } on SafeZipException catch (error) { + throw MarketplaceException(error.message); + } if (archive.isEmpty) { throw MarketplaceException('Extension archive is empty.'); } diff --git a/lib/core/market/http_marketplace_repository.dart b/lib/core/market/http_marketplace_repository.dart index ee4c29d7..11578fb7 100644 --- a/lib/core/market/http_marketplace_repository.dart +++ b/lib/core/market/http_marketplace_repository.dart @@ -13,6 +13,7 @@ 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/security/archive_path_guard.dart'; +import 'package:querya_desktop/core/security/safe_zip_extractor.dart'; import 'marketplace_download_policy.dart'; import 'marketplace_repository.dart'; @@ -170,7 +171,12 @@ class HttpMarketplaceRepository implements MarketplaceRepository { ); } - final bytes = await archiveFile.readAsBytes(); + late final List bytes; + try { + bytes = await SafeZipExtractor.readBoundedBytes(archiveFile); + } on SafeZipException catch (error) { + throw MarketplaceException(error.message); + } final actualSha256 = sha256.convert(bytes).toString().toLowerCase(); if (actualSha256 != expectedSha256) { throw MarketplaceException( @@ -181,8 +187,13 @@ class HttpMarketplaceRepository implements MarketplaceRepository { onProgress?.call(0.85); - // Step 3: Safe Archive Extraction (Preventing Path Traversal / Zip Bomb - Issue #242) - final archive = ZipDecoder().decodeBytes(bytes); + // Step 3: Safe Archive Extraction (path traversal + zip bomb limits) + final Archive archive; + try { + archive = SafeZipExtractor.decodeBytes(bytes); + } on SafeZipException catch (error) { + throw MarketplaceException(error.message); + } final dir = await ExtensionPaths.extensionsDirectory(); final extDir = Directory(p.join(dir.path, manifest.id)); diff --git a/lib/core/security/safe_zip_extractor.dart b/lib/core/security/safe_zip_extractor.dart new file mode 100644 index 00000000..0ff52fad --- /dev/null +++ b/lib/core/security/safe_zip_extractor.dart @@ -0,0 +1,134 @@ +import 'dart:io'; + +import 'package:archive/archive.dart'; + +/// Bounds for zip decode/extract to mitigate zip bombs and memory exhaustion. +class ZipDecodeLimits { + const ZipDecodeLimits({ + required this.maxCompressedBytes, + required this.maxTotalUncompressedBytes, + required this.maxEntryCount, + required this.maxEntryUncompressedBytes, + required this.maxCompressionRatio, + }); + + final int maxCompressedBytes; + final int maxTotalUncompressedBytes; + final int maxEntryCount; + final int maxEntryUncompressedBytes; + final double maxCompressionRatio; + + /// Default limits for marketplace, sideload, and updater archives. + static const ZipDecodeLimits standard = ZipDecodeLimits( + maxCompressedBytes: 100 * 1024 * 1024, + maxTotalUncompressedBytes: 500 * 1024 * 1024, + maxEntryCount: 10000, + maxEntryUncompressedBytes: 100 * 1024 * 1024, + maxCompressionRatio: 100, + ); +} + +/// Thrown when an archive exceeds [ZipDecodeLimits]. +class SafeZipException implements Exception { + SafeZipException(this.message); + + final String message; + + @override + String toString() => 'SafeZipException: $message'; +} + +/// Bounded zip decode used by marketplace, sideload, and updater paths. +abstract final class SafeZipExtractor { + static Future> readBoundedBytes( + File file, { + ZipDecodeLimits limits = ZipDecodeLimits.standard, + }) async { + final length = await file.length(); + if (length > limits.maxCompressedBytes) { + throw SafeZipException( + 'Archive exceeds maximum compressed size ' + '(${limits.maxCompressedBytes} bytes).', + ); + } + return file.readAsBytes(); + } + + static Archive decodeBytes( + List bytes, { + ZipDecodeLimits limits = ZipDecodeLimits.standard, + }) { + if (bytes.length > limits.maxCompressedBytes) { + throw SafeZipException( + 'Archive exceeds maximum compressed size ' + '(${limits.maxCompressedBytes} bytes).', + ); + } + + final Archive archive; + try { + archive = ZipDecoder().decodeBytes(bytes); + } on Object catch (error) { + throw SafeZipException('Failed to decode zip archive: $error'); + } + + _validateArchive( + archive, + compressedBytes: bytes.length, + limits: limits, + ); + return archive; + } + + static Future readAndDecodeFile( + File file, { + ZipDecodeLimits limits = ZipDecodeLimits.standard, + }) async { + final bytes = await readBoundedBytes(file, limits: limits); + return decodeBytes(bytes, limits: limits); + } + + static void _validateArchive( + Archive archive, { + required int compressedBytes, + required ZipDecodeLimits limits, + }) { + if (archive.length > limits.maxEntryCount) { + throw SafeZipException( + 'Archive contains too many entries (${archive.length}; ' + 'max ${limits.maxEntryCount}).', + ); + } + + var totalUncompressed = 0; + for (final entry in archive) { + if (!entry.isFile) continue; + + final size = entry.size; + if (size > limits.maxEntryUncompressedBytes) { + throw SafeZipException( + 'Archive entry "${entry.name}" exceeds maximum uncompressed size ' + '($size bytes; max ${limits.maxEntryUncompressedBytes}).', + ); + } + + totalUncompressed += size; + if (totalUncompressed > limits.maxTotalUncompressedBytes) { + throw SafeZipException( + 'Archive exceeds maximum total uncompressed size ' + '(max ${limits.maxTotalUncompressedBytes} bytes).', + ); + } + } + + if (compressedBytes > 0 && totalUncompressed > 0) { + final ratio = totalUncompressed / compressedBytes; + if (ratio > limits.maxCompressionRatio) { + throw SafeZipException( + 'Archive compression ratio is too high ' + '(${ratio.toStringAsFixed(1)}:1; max ${limits.maxCompressionRatio}:1).', + ); + } + } + } +} diff --git a/lib/core/updater/installers/update_install_utils.dart b/lib/core/updater/installers/update_install_utils.dart index a5a769f9..6755191e 100644 --- a/lib/core/updater/installers/update_install_utils.dart +++ b/lib/core/updater/installers/update_install_utils.dart @@ -4,6 +4,7 @@ import 'package:archive/archive.dart'; import 'package:path/path.dart' as p; import '../../security/archive_path_guard.dart'; +import '../../security/safe_zip_extractor.dart'; import '../app_updater_service.dart'; /// Safely extracts a zip archive into [destinationDir]. @@ -16,8 +17,12 @@ Future extractZipSecurely({ } await destinationDir.create(recursive: true); - final bytes = await zipFile.readAsBytes(); - final archive = ZipDecoder().decodeBytes(bytes); + final Archive archive; + try { + archive = await SafeZipExtractor.readAndDecodeFile(zipFile); + } on SafeZipException catch (error) { + throw AppUpdaterException(error.message); + } final root = p.normalize(destinationDir.path); for (final entry in archive) { diff --git a/test/core/security/safe_zip_extractor_test.dart b/test/core/security/safe_zip_extractor_test.dart new file mode 100644 index 00000000..56fab3e7 --- /dev/null +++ b/test/core/security/safe_zip_extractor_test.dart @@ -0,0 +1,136 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/security/safe_zip_extractor.dart'; + +const _tightLimits = ZipDecodeLimits( + maxCompressedBytes: 4096, + maxTotalUncompressedBytes: 8192, + maxEntryCount: 5, + maxEntryUncompressedBytes: 4096, + maxCompressionRatio: 10, +); + +Archive _singleFileArchive(String name, List content) { + return Archive()..addFile(ArchiveFile(name, content.length, content)); +} + +Future _writeZip(Directory dir, Archive archive, String name) async { + final bytes = ZipEncoder().encode(archive)!; + final file = File(p.join(dir.path, name)); + await file.writeAsBytes(bytes); + return file; +} + +void main() { + group('SafeZipExtractor', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('querya_safe_zip_'); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('decodes a small valid archive', () { + final archive = _singleFileArchive('hello.txt', utf8.encode('hello')); + final zipBytes = ZipEncoder().encode(archive)!; + + final decoded = SafeZipExtractor.decodeBytes(zipBytes, limits: _tightLimits); + expect(decoded.length, 1); + expect(decoded.first.name, 'hello.txt'); + }); + + test('rejects archives exceeding max compressed bytes', () async { + final file = File(p.join(tempDir.path, 'oversize.zip')); + await file.writeAsBytes(List.filled(5000, 1)); + + expect( + () => SafeZipExtractor.readBoundedBytes(file, limits: _tightLimits), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('maximum compressed size'), + )), + ); + }); + + test('rejects archives with too many entries', () { + final archive = Archive(); + for (var i = 0; i < 6; i++) { + archive.addFile(ArchiveFile('file$i.txt', 1, [i])); + } + final zipBytes = ZipEncoder().encode(archive)!; + + expect( + () => SafeZipExtractor.decodeBytes(zipBytes, limits: _tightLimits), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('too many entries'), + )), + ); + }); + + test('rejects archives exceeding total uncompressed size', () { + const limits = ZipDecodeLimits( + maxCompressedBytes: 4096, + maxTotalUncompressedBytes: 6000, + maxEntryCount: 5, + maxEntryUncompressedBytes: 5000, + maxCompressionRatio: 100, + ); + final archive = Archive() + ..addFile(ArchiveFile('a.bin', 4000, List.filled(4000, 1))) + ..addFile(ArchiveFile('b.bin', 4000, List.filled(4000, 2))); + final zipBytes = ZipEncoder().encode(archive)!; + + expect( + () => SafeZipExtractor.decodeBytes(zipBytes, limits: limits), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('total uncompressed size'), + )), + ); + }); + + test('rejects high compression ratio zip bombs', () { + const limits = ZipDecodeLimits( + maxCompressedBytes: 4096, + maxTotalUncompressedBytes: 8192, + maxEntryCount: 5, + maxEntryUncompressedBytes: 10000, + maxCompressionRatio: 10, + ); + final payload = List.filled(5000, 0); + final archive = _singleFileArchive('bomb.bin', payload); + final zipBytes = ZipEncoder().encode(archive)!; + + expect( + () => SafeZipExtractor.decodeBytes(zipBytes, limits: limits), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('compression ratio'), + )), + ); + }); + + test('readAndDecodeFile reads bounded archives from disk', () async { + final archive = _singleFileArchive('ok.txt', utf8.encode('ok')); + final zipFile = await _writeZip(tempDir, archive, 'ok.zip'); + + final decoded = + await SafeZipExtractor.readAndDecodeFile(zipFile, limits: _tightLimits); + expect(decoded.first.name, 'ok.txt'); + }); + }); +} From efe300b7cc763dff9769abe498ee225033aad25d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 06:45:32 +0300 Subject: [PATCH 06/14] fix(theme): require SHA256 checksum for remote theme install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #400 — remote theme URLs fail closed without checksum; document policy in docs/security.md. --- docs/security.md | 4 +++ .../theme/theme_remote_install_service.dart | 8 +++++- .../theme_remote_install_service_test.dart | 28 ++++++++++++++++++- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/docs/security.md b/docs/security.md index 9b9d44a7..82754881 100644 --- a/docs/security.md +++ b/docs/security.md @@ -21,3 +21,7 @@ On upgrade from older databases, existing plaintext secrets in SQLite are **migr ## Tests Automated tests use an **in-memory** secrets backend (see `test/flutter_test_config.dart`) so CI does not require a desktop keyring. + +## Remote theme install integrity + +`ThemeRemoteInstallService` requires a **SHA256 checksum** for every remote theme URL install. Pass `sha256Checksum` to the API or append `?sha256=` to the theme URL. Installs without a checksum fail closed before download. This matches the marketplace extension install policy (#396). diff --git a/lib/core/theme/theme_remote_install_service.dart b/lib/core/theme/theme_remote_install_service.dart index fa958c89..7b50bde6 100644 --- a/lib/core/theme/theme_remote_install_service.dart +++ b/lib/core/theme/theme_remote_install_service.dart @@ -75,6 +75,12 @@ class ThemeRemoteInstallService { final expectedChecksum = _normalizeSha256( sha256Checksum ?? uri.queryParameters['sha256'], ); + if (expectedChecksum == null) { + return const ThemeDefinitionImportFailure( + 'SHA256 checksum is required for remote theme install. ' + 'Add ?sha256= to the URL or pass sha256Checksum.', + ); + } File? tempFile; try { @@ -92,7 +98,7 @@ class ThemeRemoteInstallService { } final actualChecksum = sha256.convert(utf8.encode(body)).toString(); - if (expectedChecksum != null && expectedChecksum != actualChecksum) { + if (expectedChecksum != actualChecksum) { return const ThemeDefinitionImportFailure( 'Checksum mismatch. Theme was not installed.', ); diff --git a/test/core/theme/theme_remote_install_service_test.dart b/test/core/theme/theme_remote_install_service_test.dart index ad80b0cb..8f10ac5a 100644 --- a/test/core/theme/theme_remote_install_service_test.dart +++ b/test/core/theme/theme_remote_install_service_test.dart @@ -1,5 +1,7 @@ +import 'dart:convert'; import 'dart:io'; +import 'package:crypto/crypto.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; @@ -26,6 +28,8 @@ Future _fixtureAssetLoader(String assetPath) async { return File(p.join('test/fixtures/themes', fileName)).readAsString(); } +String _sha256Hex(String body) => sha256.convert(utf8.encode(body)).toString(); + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -100,6 +104,7 @@ void main() { final result = await service.installFromUrl( 'https://cdn.example.com/themes/querya_custom_dark.json', + sha256Checksum: _sha256Hex(raw), ); expect(result, isA()); @@ -136,18 +141,37 @@ void main() { expect(await ExtensionPaths.mockExtensionsDirectory!.list().length, 0); }); + test('rejects install when SHA256 checksum is missing', () async { + final service = ThemeRemoteInstallService( + registry, + allowLocalhostInDebug: false, + ); + + final result = await service.installFromUrl( + 'https://cdn.example.com/themes/querya_custom_dark.json', + ); + + expect(result, isA()); + expect( + (result as ThemeDefinitionImportFailure).message, + contains('SHA256 checksum is required'), + ); + }); + test('rejects invalid JSON without writing to themes folder', () async { + const brokenBody = '{ not valid json'; final service = ThemeRemoteInstallService( registry, allowLocalhostInDebug: false, httpGet: (_) async => const RemoteThemeHttpResponse( statusCode: 200, - body: '{ not valid json', + body: brokenBody, ), ); final result = await service.installFromUrl( 'https://cdn.example.com/themes/broken.json', + sha256Checksum: _sha256Hex(brokenBody), ); expect(result, isA()); @@ -219,6 +243,7 @@ void main() { final result = await service.installFromUrl( 'https://cdn.example.com/themes/querya_custom_dark.json', + sha256Checksum: _sha256Hex(raw), ); expect(result, isA()); @@ -235,6 +260,7 @@ void main() { final result = await controller.importRegistryThemeFromUrl( 'https://cdn.example.com/themes/querya_custom_dark.json', + sha256Checksum: _sha256Hex(raw), remoteInstallService: ThemeRemoteInstallService( registry, allowLocalhostInDebug: false, From 8913d6983c297277f891e5d28d75d227b5fd73d7 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 06:47:50 +0300 Subject: [PATCH 07/14] chore(test): remove unnecessary null assertions in safe zip tests --- test/core/security/safe_zip_extractor_test.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/core/security/safe_zip_extractor_test.dart b/test/core/security/safe_zip_extractor_test.dart index 56fab3e7..283a4f63 100644 --- a/test/core/security/safe_zip_extractor_test.dart +++ b/test/core/security/safe_zip_extractor_test.dart @@ -19,7 +19,7 @@ Archive _singleFileArchive(String name, List content) { } Future _writeZip(Directory dir, Archive archive, String name) async { - final bytes = ZipEncoder().encode(archive)!; + final bytes = ZipEncoder().encode(archive); final file = File(p.join(dir.path, name)); await file.writeAsBytes(bytes); return file; @@ -41,7 +41,7 @@ void main() { test('decodes a small valid archive', () { final archive = _singleFileArchive('hello.txt', utf8.encode('hello')); - final zipBytes = ZipEncoder().encode(archive)!; + final zipBytes = ZipEncoder().encode(archive); final decoded = SafeZipExtractor.decodeBytes(zipBytes, limits: _tightLimits); expect(decoded.length, 1); @@ -67,7 +67,7 @@ void main() { for (var i = 0; i < 6; i++) { archive.addFile(ArchiveFile('file$i.txt', 1, [i])); } - final zipBytes = ZipEncoder().encode(archive)!; + final zipBytes = ZipEncoder().encode(archive); expect( () => SafeZipExtractor.decodeBytes(zipBytes, limits: _tightLimits), @@ -90,7 +90,7 @@ void main() { final archive = Archive() ..addFile(ArchiveFile('a.bin', 4000, List.filled(4000, 1))) ..addFile(ArchiveFile('b.bin', 4000, List.filled(4000, 2))); - final zipBytes = ZipEncoder().encode(archive)!; + final zipBytes = ZipEncoder().encode(archive); expect( () => SafeZipExtractor.decodeBytes(zipBytes, limits: limits), @@ -112,7 +112,7 @@ void main() { ); final payload = List.filled(5000, 0); final archive = _singleFileArchive('bomb.bin', payload); - final zipBytes = ZipEncoder().encode(archive)!; + final zipBytes = ZipEncoder().encode(archive); expect( () => SafeZipExtractor.decodeBytes(zipBytes, limits: limits), From 276865c6c238399fdbe58220ddc3cca5354af81d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 11:19:30 +0300 Subject: [PATCH 08/14] fix(sandbox): require consent before unsandboxed driver launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #395 — fail-closed when OS sandbox wrapper unavailable; user must confirm via dialog on Linux (no bwrap) and Windows until AppContainer. --- docs/security.md | 13 ++ .../extensions/extension_driver_session.dart | 46 +++++- .../extensions/rpc/plugin_rpc_bridge.dart | 2 + .../sandbox/sandbox_os_isolation.dart | 58 +++++++ .../sandbox/sandbox_process_runner.dart | 22 ++- .../unsandboxed_launch_consent_gate.dart | 21 +++ .../unsandboxed_driver_consent_dialog.dart | 52 ++++++ lib/features/main_screen/main_screen.dart | 12 ++ .../sandbox/sandbox_os_isolation_test.dart | 48 ++++++ .../sandbox/sandbox_process_runner_test.dart | 54 +++++++ ...0\265\320\271\321\201\320\276\320\262.txt" | 148 ++++++++++++++++++ 11 files changed, 470 insertions(+), 6 deletions(-) create mode 100644 lib/core/extensions/sandbox/sandbox_os_isolation.dart create mode 100644 lib/core/extensions/sandbox/unsandboxed_launch_consent_gate.dart create mode 100644 lib/features/extensions/presentation/widgets/unsandboxed_driver_consent_dialog.dart create mode 100644 test/core/extensions/sandbox/sandbox_os_isolation_test.dart create mode 100644 "\320\220\320\275\320\260\320\273\320\270\320\267 Fluid Morphing \320\230\320\275\321\202\320\265\321\200\321\204\320\265\320\271\321\201\320\276\320\262.txt" diff --git a/docs/security.md b/docs/security.md index 580174e1..32f46518 100644 --- a/docs/security.md +++ b/docs/security.md @@ -37,3 +37,16 @@ Marketplace downloads, local extension sideload (`.zip` / `.qext`), and in-app u Archives exceeding these bounds fail closed before files are written to disk. Path traversal checks remain in `archive_path_guard.dart`. +## Extension driver OS sandbox + +Process-sandbox database drivers launch inside OS-level isolation when available: + +| Platform | Wrapper | When unavailable | +|----------|---------|------------------| +| Linux | `bwrap` (bubblewrap) | User must confirm **Run without OS sandbox** | +| macOS | `sandbox-exec` (Seatbelt) | N/A — always wrapped | +| Windows | AppContainer (planned) | User must confirm until native helper ships | + +Querya refuses **silent** unsandboxed launch. `SandboxProcessRunner` throws `SandboxOsIsolationUnavailableException` until the user approves via the consent dialog registered from the main window. + +**Linux:** install `bubblewrap` and ensure unprivileged user namespaces are enabled if you want OS sandbox without manual confirmation. \ No newline at end of file diff --git a/lib/core/extensions/extension_driver_session.dart b/lib/core/extensions/extension_driver_session.dart index 31f46f13..2c22e87f 100644 --- a/lib/core/extensions/extension_driver_session.dart +++ b/lib/core/extensions/extension_driver_session.dart @@ -11,6 +11,8 @@ import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/extensions/models/extension_object_metadata.dart'; import 'package:querya_desktop/core/extensions/models/extension_server_stats.dart'; import 'package:querya_desktop/core/extensions/rpc/plugin_rpc_bridge.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_os_isolation.dart'; +import 'package:querya_desktop/core/extensions/sandbox/unsandboxed_launch_consent_gate.dart'; import 'package:querya_desktop/core/sdui/sdui_tree_schema.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -124,18 +126,52 @@ class ExtensionDriverSession { } final bridge = bridgeFactory?.call() ?? PluginRpcBridge(); - await bridge.start( + await _startBridgeWithConsent( + bridge: bridge, manifest: manifest, pluginExecutable: executable, extensionRoot: root, - handshakeParams: { - 'queryaVersion': '2.0.0', - 'pluginId': manifest.id, - }, ); return bridge; } + Future _startBridgeWithConsent({ + required PluginRpcBridge bridge, + required ExtensionManifest manifest, + required String pluginExecutable, + required String extensionRoot, + }) async { + const handshakeParams = { + 'queryaVersion': '2.0.0', + }; + + try { + await bridge.start( + manifest: manifest, + pluginExecutable: pluginExecutable, + extensionRoot: extensionRoot, + handshakeParams: { + ...handshakeParams, + 'pluginId': manifest.id, + }, + ); + } on SandboxOsIsolationUnavailableException catch (error) { + final approved = + await UnsandboxedLaunchConsentGate.instance.request(error); + if (!approved) throw error; + await bridge.start( + manifest: manifest, + pluginExecutable: pluginExecutable, + extensionRoot: extensionRoot, + allowUnsandboxedLaunch: true, + handshakeParams: { + ...handshakeParams, + 'pluginId': manifest.id, + }, + ); + } + } + Future _injectAndConnect( PluginRpcBridge bridge, { required int connectionId, diff --git a/lib/core/extensions/rpc/plugin_rpc_bridge.dart b/lib/core/extensions/rpc/plugin_rpc_bridge.dart index 89153785..9e528bb3 100644 --- a/lib/core/extensions/rpc/plugin_rpc_bridge.dart +++ b/lib/core/extensions/rpc/plugin_rpc_bridge.dart @@ -62,6 +62,7 @@ class PluginRpcBridge { String? extensionRoot, Map? environment, Map? handshakeParams, + bool allowUnsandboxedLaunch = false, }) async { if (_started) { throw StateError('PluginRpcBridge already started'); @@ -77,6 +78,7 @@ class PluginRpcBridge { extensionRoot: extensionRoot ?? manifest.installPath, capabilities: capabilities, environment: environment, + allowUnsandboxedLaunch: allowUnsandboxedLaunch, ); _handle = handle; diff --git a/lib/core/extensions/sandbox/sandbox_os_isolation.dart b/lib/core/extensions/sandbox/sandbox_os_isolation.dart new file mode 100644 index 00000000..53d381ee --- /dev/null +++ b/lib/core/extensions/sandbox/sandbox_os_isolation.dart @@ -0,0 +1,58 @@ +import 'sandbox_launch_command.dart'; + +/// Thrown when a process-sandbox driver would launch without OS-level isolation. +class SandboxOsIsolationUnavailableException implements Exception { + const SandboxOsIsolationUnavailableException({ + required this.platform, + required this.message, + this.installHint, + }); + + final String platform; + final String message; + final String? installHint; + + @override + String toString() => 'SandboxOsIsolationUnavailableException: $message'; +} + +/// Describes why OS sandboxing is unavailable for a launch command. +abstract final class SandboxOsIsolation { + static SandboxOsIsolationUnavailableException? exceptionForLaunchCommand( + SandboxLaunchCommand command, + ) { + if (command.usesOsSandbox) return null; + + switch (command.platform) { + case 'linux': + return const SandboxOsIsolationUnavailableException( + platform: 'linux', + message: + 'OS sandbox (bubblewrap) is not available on this Linux system.', + installHint: + 'Install bubblewrap (bwrap) from your distribution and ensure ' + 'unprivileged user namespaces are enabled, or confirm below to ' + 'run the driver without OS sandbox.', + ); + case 'windows': + return const SandboxOsIsolationUnavailableException( + platform: 'windows', + message: + 'Native OS sandbox is not yet available for extension drivers ' + 'on Windows.', + installHint: + 'Drivers run with soft isolation only until AppContainer support ' + 'lands. Confirm below only if you trust this extension.', + ); + default: + return SandboxOsIsolationUnavailableException( + platform: command.platform, + message: + 'OS sandbox is not available for extension drivers on ' + '${command.platform}.', + installHint: + 'Confirm below only if you trust this extension package.', + ); + } + } +} diff --git a/lib/core/extensions/sandbox/sandbox_process_runner.dart b/lib/core/extensions/sandbox/sandbox_process_runner.dart index 09d86146..e793eac1 100644 --- a/lib/core/extensions/sandbox/sandbox_process_runner.dart +++ b/lib/core/extensions/sandbox/sandbox_process_runner.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_launch_command.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_os_isolation.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_scratch_directory.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_secret_guard.dart'; @@ -100,6 +101,9 @@ class SandboxProcessRunner { /// Spawns [pluginExecutable] inside the OS sandbox for [pluginId]. /// + /// When OS sandboxing is unavailable, launch fails unless + /// [allowUnsandboxedLaunch] is true (requires explicit user consent in UI). + /// /// Credentials must never be passed via [pluginArguments] or [environment]; /// use [SandboxCredentialsInjector] over Stdio JSON-RPC instead. Future start({ @@ -109,6 +113,7 @@ class SandboxProcessRunner { String? extensionRoot, SandboxCapabilities? capabilities, Map? environment, + bool allowUnsandboxedLaunch = false, }) async { SandboxSecretGuard.assertNoSecrets( arguments: pluginArguments, @@ -125,7 +130,8 @@ class SandboxProcessRunner { if (bwrapAvailable == null && !usesBwrap) { debugPrint( 'SandboxProcessRunner: bubblewrap unavailable or cannot set up user ' - 'namespaces on this system; launching $pluginId without OS sandbox.', + 'namespaces on this system; $pluginId requires consent to launch ' + 'without OS sandbox.', ); } final command = SandboxLaunchCommand.build( @@ -138,6 +144,20 @@ class SandboxProcessRunner { bwrapAvailable: usesBwrap, ); + final isolationIssue = + SandboxOsIsolation.exceptionForLaunchCommand(command); + if (isolationIssue != null && !allowUnsandboxedLaunch) { + await scratch.delete(); + throw isolationIssue; + } + + if (isolationIssue != null) { + debugPrint( + 'SandboxProcessRunner: launching $pluginId without OS sandbox after ' + 'explicit consent (${command.platform}).', + ); + } + // Never forward parent secrets via environment. Only pass an explicit map // (credentials go through Stdio JSON-RPC — Block E §5). final sanitizedEnv = { diff --git a/lib/core/extensions/sandbox/unsandboxed_launch_consent_gate.dart b/lib/core/extensions/sandbox/unsandboxed_launch_consent_gate.dart new file mode 100644 index 00000000..4bd2df05 --- /dev/null +++ b/lib/core/extensions/sandbox/unsandboxed_launch_consent_gate.dart @@ -0,0 +1,21 @@ +import 'sandbox_os_isolation.dart'; + +typedef UnsandboxedLaunchConsentHandler = Future Function( + SandboxOsIsolationUnavailableException details, +); + +/// App-level hook for explicit user consent before unsandboxed driver launch. +class UnsandboxedLaunchConsentGate { + UnsandboxedLaunchConsentGate._(); + + static final UnsandboxedLaunchConsentGate instance = + UnsandboxedLaunchConsentGate._(); + + UnsandboxedLaunchConsentHandler? handler; + + Future request(SandboxOsIsolationUnavailableException details) async { + final callback = handler; + if (callback == null) return false; + return callback(details); + } +} diff --git a/lib/features/extensions/presentation/widgets/unsandboxed_driver_consent_dialog.dart b/lib/features/extensions/presentation/widgets/unsandboxed_driver_consent_dialog.dart new file mode 100644 index 00000000..dbba764d --- /dev/null +++ b/lib/features/extensions/presentation/widgets/unsandboxed_driver_consent_dialog.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_os_isolation.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +Future showUnsandboxedDriverConsentDialog( + material.BuildContext context, + SandboxOsIsolationUnavailableException details, +) async { + final approved = await showAppDialog( + context: context, + builder: (dialogContext) => material.AlertDialog( + title: const material.Text('Run driver without OS sandbox?'), + content: material.SizedBox( + width: 440, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Text(details.message), + if (details.installHint != null) ...[ + const material.SizedBox(height: 12), + material.Text( + details.installHint!, + style: material.TextStyle( + fontSize: 13, + color: Theme.of(dialogContext).colorScheme.mutedForeground, + ), + ), + ], + const material.SizedBox(height: 12), + const material.Text( + 'The driver process may access your user session (files, network) ' + 'beyond the extension manifest policy. Only continue if you trust ' + 'this extension package.', + ), + ], + ), + ), + actions: [ + OutlineButton( + onPressed: () => material.Navigator.pop(dialogContext, false), + child: const material.Text('Cancel'), + ), + PrimaryButton( + onPressed: () => material.Navigator.pop(dialogContext, true), + child: const material.Text('Run without OS sandbox'), + ), + ], + ), + ); + return approved == true; +} diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 307fd937..f24f201e 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -13,6 +13,8 @@ import 'package:querya_desktop/core/storage/app_settings.dart'; 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/core/extensions/sandbox/unsandboxed_launch_consent_gate.dart'; +import 'package:querya_desktop/features/extensions/presentation/widgets/unsandboxed_driver_consent_dialog.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'; @@ -39,8 +41,18 @@ class _MainScreenState extends State { final ValueNotifier _workspace = ValueNotifier(MainScreenWorkspaceState.empty); + @override + void initState() { + super.initState(); + UnsandboxedLaunchConsentGate.instance.handler = (details) { + if (!mounted) return Future.value(false); + return showUnsandboxedDriverConsentDialog(context, details); + }; + } + @override void dispose() { + UnsandboxedLaunchConsentGate.instance.handler = null; _workspace.dispose(); super.dispose(); } diff --git a/test/core/extensions/sandbox/sandbox_os_isolation_test.dart b/test/core/extensions/sandbox/sandbox_os_isolation_test.dart new file mode 100644 index 00000000..b0c7f401 --- /dev/null +++ b/test/core/extensions/sandbox/sandbox_os_isolation_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_launch_command.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_os_isolation.dart'; + +void main() { + group('SandboxOsIsolation', () { + test('returns null when launch command uses OS sandbox', () { + final command = SandboxLaunchCommand.build( + pluginExecutable: '/bin/driver', + scratchPath: '/tmp/s', + platformOverride: 'linux', + bwrapAvailable: true, + ); + + expect( + SandboxOsIsolation.exceptionForLaunchCommand(command), + isNull, + ); + }); + + test('returns linux message when bwrap unavailable', () { + final command = SandboxLaunchCommand.build( + pluginExecutable: '/bin/driver', + scratchPath: '/tmp/s', + platformOverride: 'linux', + bwrapAvailable: false, + ); + + final error = SandboxOsIsolation.exceptionForLaunchCommand(command); + expect(error, isNotNull); + expect(error!.platform, 'linux'); + expect(error.message, contains('bubblewrap')); + }); + + test('returns windows message for soft isolation path', () { + final command = SandboxLaunchCommand.build( + pluginExecutable: r'C:\driver.exe', + scratchPath: r'C:\tmp\s', + platformOverride: 'windows', + ); + + final error = SandboxOsIsolation.exceptionForLaunchCommand(command); + expect(error, isNotNull); + expect(error!.platform, 'windows'); + expect(error.message, contains('Windows')); + }); + }); +} diff --git a/test/core/extensions/sandbox/sandbox_process_runner_test.dart b/test/core/extensions/sandbox/sandbox_process_runner_test.dart index b2211b71..3b94223c 100644 --- a/test/core/extensions/sandbox/sandbox_process_runner_test.dart +++ b/test/core/extensions/sandbox/sandbox_process_runner_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_launch_command.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_os_isolation.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_process_runner.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_scratch_directory.dart'; @@ -259,6 +260,58 @@ void main() { await handle.dispose(); }); + test('refuses unsandboxed launch without explicit consent', () async { + final runner = SandboxProcessRunner( + platformOverride: 'linux', + bwrapAvailable: false, + scratchBaseDirectory: tempBase, + processStarter: ( + exe, + args, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) async => + _FakeProcess(), + ); + + await expectLater( + () => runner.start( + pluginId: 'test.driver', + pluginExecutable: '/bin/driver', + ), + throwsA(isA()), + ); + }); + + test('allows unsandboxed launch when consent flag is set', () async { + final runner = SandboxProcessRunner( + platformOverride: 'windows', + scratchBaseDirectory: tempBase, + processStarter: ( + exe, + args, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) async => + _FakeProcess(), + ); + + final handle = await runner.start( + pluginId: 'win.driver', + pluginExecutable: 'driver.exe', + allowUnsandboxedLaunch: true, + ); + + expect(handle.launchCommand.usesOsSandbox, isFalse); + await handle.dispose(); + }); + test('start deletes scratch when process spawn fails', () async { Directory? observedScratch; final runner = SandboxProcessRunner( @@ -282,6 +335,7 @@ void main() { () => runner.start( pluginId: 'fail.driver', pluginExecutable: 'driver.exe', + allowUnsandboxedLaunch: true, ), throwsA(isA()), ); diff --git "a/\320\220\320\275\320\260\320\273\320\270\320\267 Fluid Morphing \320\230\320\275\321\202\320\265\321\200\321\204\320\265\320\271\321\201\320\276\320\262.txt" "b/\320\220\320\275\320\260\320\273\320\270\320\267 Fluid Morphing \320\230\320\275\321\202\320\265\321\200\321\204\320\265\320\271\321\201\320\276\320\262.txt" new file mode 100644 index 00000000..a416b964 --- /dev/null +++ "b/\320\220\320\275\320\260\320\273\320\270\320\267 Fluid Morphing \320\230\320\275\321\202\320\265\321\200\321\204\320\265\320\271\321\201\320\276\320\262.txt" @@ -0,0 +1,148 @@ +Архитектура и реализация Fluid Morphing интерфейсов: от концептуальной физики до системной интеграции Flutter и Rust в настольных приложениях баз данных +Эволюция пользовательских интерфейсов в программном обеспечении отражает непрерывное стремление инженеров и дизайнеров сократить когнитивную дистанцию между человеком и вычислительной системой. Исторически цифровые интерфейсы строились на парадигме дискретных, мгновенных переключений состояний, что было обусловлено аппаратными ограничениями. Однако по мере роста вычислительных мощностей индустрия переходит к непрерывным, физически обоснованным моделям взаимодействия. В авангарде этого перехода находится концепция «Fluid, morphing interface» (текучего, морфирующего интерфейса). Данное исследование представляет собой исчерпывающий анализ принципов построения таких интерфейсов, математических и архитектурных требований к их реализации, специфики применения в кроссплатформенной экосистеме Flutter, а также стратегической необходимости использования системных языков программирования, таких как Rust, для обеспечения бескомпромиссной производительности в ресурсоемких настольных приложениях — в частности, в контексте клиента баз данных Querya Desktop. +Концептуальные основы и фундаментальные требования к Fluid Interfaces +Концепция «Fluid Interfaces» (текучих интерфейсов) приобрела статус индустриального стандарта и была формализована в рамках знаковой презентации Apple на конференции WWDC 2018 («Designing Fluid Interfaces», Session 803)1. Основная философская и когнитивная предпосылка этой парадигмы заключается в том, что интерфейс должен ощущаться не как механизм, которым нужно управлять, а как естественное, бесшовное продолжение человеческого разума и физического тела1. +Когда интерфейс ведет себя в строгом соответствии с ментальной моделью физического мира, когнитивная нагрузка на пользователя радикально снижается6. В реальном мире объекты не телепортируются в пространстве, не останавливаются мгновенно при потере контакта и обладают характеристиками массы, инерции, трения и упругости8. Симуляция этих характеристик в цифровой среде требует соблюдения трех фундаментальных архитектурных требований: мгновенного отклика (Responsive), прерываемости (Interruptible) и перенаправляемости (Redirectable)5. +Мгновенный отклик и прямое манипулирование (Responsiveness & Direct Manipulation) +В физическом мире реакция на прикосновение к объекту происходит мгновенно. В цифровой среде появление даже микроскопической задержки (latency) разрушает когнитивную иллюзию прямого манипулирования (direct manipulation)8. Отклик является фундаментом, на котором строятся все остальные взаимодействия. +Интерфейс обязан реагировать на событие первоначального касания или нажатия (pointer-down), а не дожидаться момента отпускания манипулятора (pointer-up или click)8. Ожидание полного цикла клика для визуализации обратной связи делает систему субъективно "мертвой". Отслеживание движений пальца или курсора должно происходить в строгом соотношении 1:1, при этом критически важно сохранять изначальное смещение (grab offset) между точкой касания и геометрическим центром объекта6. Принудительное примагничивание центра элемента к курсору в момент захвата нарушает пространственную целостность восприятия. +Архитектурно это требует захвата указателя (pointer capture), чтобы отслеживание продолжалось даже в том случае, если курсор выходит за пределы изначальных границ элемента8. Любые искусственные таймеры, функции антидребезга (debounce) или задержки перед началом визуального отклика строго недопустимы, поскольку они создают барьер между намерением пользователя и реакцией системы5. +Абсолютная прерываемость (Interruptibility) +Прерываемость является наиболее сложным в реализации, но критически важным требованием. Человеческая мысль и физический жест происходят параллельно; намерение может измениться в любую долю секунды5. Традиционная анимация, основанная на жестко заданном времени (например, CSS-переходы transition или @keyframes), концептуально непригодна для текучих интерфейсов, так как она не позволяет бесшовно перехватить объект в полете6. +Если пользователь смахивает панель, а затем, осознав ошибку, пытается поймать ее на середине экрана, система обязана: +1. Мгновенно остановить текущую анимацию без визуальных скачков. +2. Считать текущее визуальное значение с экрана (presentation layer), а не конечное логическое значение. +3. Передать управление обратно жесту пользователя, сохранив при этом остаточную скорость объекта6. +Блокировка пользовательского ввода (input lockouts) во время транзитных состояний интерфейса является антипаттерном. Интерфейс должен оставаться функциональным и готовым к взаимодействию даже во время выполнения самых сложных анимационных последовательностей5. +Перенаправляемость и сохранение импульса (Redirectability) +Перенаправляемость описывает способность системы плавно менять конечную цель движения «на лету» без визуальных разрывов (seams) и скачков8. Если в процессе анимации закрытия модального окна поступает команда на его повторное открытие, система не должна мгновенно инвертировать вектор скорости (что вызовет эффект удара о невидимую стену — "brick wall effect"). Вместо этого вычисляется новая траектория, которая органично сливается с текущим вектором движения путем смешивания скоростей (velocity blending)8. +Физика взаимодействия: математика пружин и кинематика UI +Для реализации вышеописанных требований индустрия интерфейсов полностью отказывается от традиционных кривых плавности (easing curves), таких как ease-in или ease-out, в пользу систем, основанных на симуляции физики пружин (spring physics). +Отказ от концепции «продолжительности» +В парадигме текучих интерфейсов разработчики не оперируют фиксированным временем анимации (duration). Теоретически, физическая пружина никогда не останавливается полностью, а лишь асимптотически приближается к состоянию покоя. Принудительное навязывание продолжительности ломает физическую достоверность движения5. Вместо этого поведение системы описывается двумя ключевыми параметрами: коэффициентом затухания (Damping Ratio) и временем реакции (Response)8. + + +Параметр физической модели + Математическое описание и влияние на UX + Рекомендованные значения + Damping Ratio (Коэффициент затухания) + Определяет уровень трения в системе и контролирует осцилляцию (перелет). Значение означает критическое затухание (critically damped) — система достигает цели максимально быстро без отскока. Значения создают упругость и осцилляцию (bounciness). + + для большинства стандартных перемещений; для инерционных бросков, шторок и элементов, несущих физический импульс5. + Response (Время реакции) + Определяет жесткость пружины, выраженную через примерное время в секундах, необходимое для завершения основного пути движения. Меньшие значения делают интерфейс более "жестким" и резким. + + секунды для свайпов и открытия модальных окон, для свободного перемещения элементов (например, Picture-in-Picture)6. + Передача скорости (Velocity Handoff) +Точка, в которой заканчивается жест пользователя (pointer release) и начинается программная анимация, называется швом (seam). Для создания иллюзии непрерывности анимация должна унаследовать точную скорость жеста в момент отпускания8. В математическом выражении, начальная скорость пружины нормализуется относительно оставшегося расстояния до цели: + + +где — относительная скорость, передаваемая в алгоритм пружины, а — физическая скорость курсора или пальца в момент отпускания экрана (вычисляемая на основе истории последних событий pointermove)5. Если палец двигался со скоростью 50 пикселей в секунду, а до цели осталось 100 пикселей, начальная скорость пружины составит 8. +Проекция импульса (Momentum Projection) +При инерционном смахивании (flick) интерфейс должен вычислить, где объект остановился бы естественным образом под воздействием трения, и лишь затем выбрать ближайшую логическую точку привязки (snap target) для завершения движения6. Инженеры Apple используют для этого формулу экспоненциального затухания, которая отличается от классической ньютоновской кинематики: + + +где — начальная скорость жеста (initial velocity), а — коэффициент замедления (deceleration rate, часто принимаемый за константу для нормального прокручивания)3. Вычисленная проекция прибавляется к текущей позиции объекта, после чего система находит ближайшую валидную позицию (например, угол экрана для видеоплеера или край страницы) и перенаправляет пружину в эту точку3. +Декомпозиция сложного двумерного движения (например, перемещения плавающего окна) требует независимого расчета пружин по осям X и Y. Использование единой пружины для 2D-вектора приводит к десинхронизации, если начальные скорости по осям различаются8. +Анатомия Morphing-интерфейсов +В то время как принципы Fluid касаются пространственной динамики и кинематики, Morphing (морфинг) описывает плавную трансформацию геометрической формы, структуры, цвета или свойств объекта при переходе между логическими состояниями. В пользовательских интерфейсах морфинг чаще всего применяется к интерактивным элементам: плавающая кнопка действия (FAB) трансформируется в полноэкранное диалоговое окно, иконка «гамбургер» превращается в крестик закрытия, а карточка данных бесшовно раскрывается в детальное представление12. +Математическая и алгоритмическая сложность морфинга заключается в интерполяции между двумя геометрически различными фигурами (например, превращение звезды в круг или треугольника в квадрат). Прямолинейная интерполяция вершин одного контура в вершины другого неизбежно приводит к визуальным артефактам, таким как самопересечение формы (self-intersection) или неестественное вращение фигуры вокруг своей оси15. +В масштабных приложениях качественный морфинг достигается за счет сложных подготовительных алгоритмов: +1. Выравнивание контрольных точек (Point Alignment): Перед началом анимации алгоритм вычисляет оптимальное смещение (minimum shift) индексов массивов контрольных точек обеих фигур. Цель состоит в минимизации суммарного вектора перемещения всех точек. Это предотвращает "скручивание" фигуры во время трансформации15. +2. Унификация контуров (Contour Normalization): Обе фигуры должны быть приведены к одинаковому количеству сегментов (кривых Безье или дискретных точек). Прямые линии фигуры-источника динамически делятся на множество мелких кривых Безье, чтобы соответствовать сложным изгибам фигуры-цели. Однако топологические ограничения сохраняются: алгоритмы на базе метрик контуров могут трансформировать формы только с равным количеством замкнутых контуров (нельзя напрямую морфировать сплошной квадрат в фигуру с отверстием, например, букву "О")16. +3. Сохранение вершинной симметрии: При трансформации фигур с малым количеством углов (например, треугольник в прямоугольник) математическая интерполяция должна сохранять угловые вершины неподвижными, изгибая лишь необходимые грани (например, самая длинная грань треугольника изгибается, формируя две стороны прямоугольника). Использование сотен контрольных точек в таких случаях неэффективно и вызывает визуальные искажения ("дрожание" прямых линий)15. +Архитектура и реализация во фреймворке Flutter +Фреймворк Flutter, благодаря своему высокопроизводительному движку рендеринга (Skia и новое поколение Impeller), декларативной природе дерева виджетов и прямому доступу к низкоуровневым графическим примитивам, является превосходной платформой для создания текучих морфирующих интерфейсов. Анимации во Flutter жестко привязаны к частоте обновления экрана (Vsync), что позволяет напрямую управлять физическими параметрами на уровне каждого кадра7. +Инфраструктура виджетов и специализированные пакеты +Для решения задач морфинга в экосистеме Flutter разработаны продвинутые библиотеки, абстрагирующие сложную математику кривых Безье от разработчика прикладного слоя. +1. Пакет morphable_shape +Архитектура самого Flutter предоставляет базовый класс ShapeBorder, который накладывает жесткие ограничения: невозможно независимо стилизовать каждую сторону фигуры (как это реализовано в CSS) или плавно интерполировать сложные многоугольники18. Пакет morphable_shape полностью переписывает механизм работы с формами15. Особенности реализации: +* Динамические радиусы и процентные величины: Пакет интегрирует модуль dimension, позволяя задавать радиусы скругления и размеры не только в абсолютных пикселях, но и в процентах от размера родительского контейнера (например, DynamicRadius.elliptical(60.toPXLength, 10.toPercentLength)). Система автоматически масштабирует смежные радиусы, предотвращая их перекрытие19. +* Алгоритм симметричного морфинга: В отличие от базовых подходов, алгоритм морфинга здесь учитывает симметрию фигур. Он вычисляет контрольные точки лишь однажды, кэшируя результаты (если размер контейнера неизменен). Это позволяет изменять толщину границ, градиентную заливку и геометрическую форму без постоянного пересчета кривых Безье на каждом кадре15. +* Сериализация (JSON): Все объекты форм поддерживают методы toJson() и десериализацию. Это позволяет масштабным приложениям хранить описания сложных UI-состояний в базах данных или конфигурационных файлах и динамически загружать их18. +2. Пакет path_morph +Утилита, сфокусированная исключительно на интерполяции низкоуровневых объектов Path (путей)16. Алгоритм берет исходный путь и путь назначения, семплирует их через системный класс PathMetric (разбивая путь на равные отрезки) и математически сдвигает точки одного пути к другому15. Этот пакет требует от инженера ручного контроля над стейтом через AnimationController и setState, обновляя координаты массива shiftedPoints16. Как упоминалось ранее, его фундаментальным ограничением является требование равного количества контуров в обеих фигурах16. +3. Пакет flutter_morphing_icons +Инструмент прикладного уровня, предназначенный для трансформации иконок. Пакет реализует плавные переходы между состояниями (States) с применением встроенных кривых замедления (easing curves) и пространственных матричных трансформаций14. Он предоставляет абстракции, такие как MorphingAnimationConfig.scale или MorphingAnimationConfig.rotate, позволяя комбинировать кросс-фейд (исчезновение) с изменением масштаба и угла поворота, что создает иллюзию морфинга без тяжелых вычислений контуров векторной графики14. +Продвинутые техники: CustomPainter и фрагментные шейдеры +В высоконагруженных и визуально сложных приложениях стандартного дерева виджетов бывает недостаточно. Для создания органического морфинга (например, эффекта сливающихся капель жидкости — metaballs) математического смещения вершин фигур не хватает. +Для достижения таких эффектов инженеры Flutter используют CustomPainter в связке с фрагментными шейдерами (Fragment Shaders). С версии 3.7 Flutter поддерживает выполнение пользовательских шейдеров, написанных на языке GLSL и скомпилированных в SPIR-V17. Вместо просчета геометрии на CPU, шейдер выполняется параллельно на тысячах ядер GPU. Пространство описывается через функции расстояния со знаком (SDF - Signed Distance Field), а морфинг двух объектов реализуется с помощью математической функции сглаженного минимума (smooth polynomial minimum). Этот подход обеспечивает идеальную текучесть жидкости (fluid liquid effects) при практически нулевой нагрузке на центральный процессор17. +Уязвимости, узкие места и проблемы производительности (Jank) +Несмотря на высочайшие эстетические и когнитивные преимущества, внедрение Fluid Morphing интерфейсов привносит значительные архитектурные риски. Попытки реализовать подобные системы без глубокого понимания цикла рендеринга приводят к катастрофическому падению производительности. +Вычислительные затраты CPU и потеря кадров +Анимации, основанные на физике пружин и пересчете точек Безье для морфинга, требуют непрерывных вычислений на каждом кадре (16.6 мс для 60 FPS или 8.3 мс для 120 FPS дисплеев). Если эти вычисления происходят в главном потоке пользовательского интерфейса (UI Isolate во Flutter), они напрямую конкурируют за процессорное время с бизнес-логикой приложения17. Превышение временного лимита вызывает пропуск кадра (Jank), что мгновенно разрушает иллюзию физической достоверности и плавности8. Использование AnimatedBuilder с частым вызовом setState может приводить к инвалидации избыточно больших сегментов дерева виджетов, вызывая каскадные перерисовки15. +Управление слоями отрисовки (RepaintBoundary) +При морфинге формы контейнера система рендеринга Flutter по умолчанию перерисовывает не только саму форму, но и ее фон, тени, а также все дочерние элементы. Неконтролируемая перерисовка (repainting) сложных иерархий перегружает этап растрирования графического конвейера. Инженерным решением является изоляция морфирующего виджета с помощью RepaintBoundary, что принудительно выделяет элемент в отдельный слой (layer) кэширования17. Однако избыточное использование слоев приводит к исчерпанию оперативной памяти (особенно видеопамяти), что требует тонкой балансировки. +Конфликт с обработкой больших наборов данных +Наиболее уязвимым местом архитектуры текучих интерфейсов является их несовместимость с синхронной обработкой больших объемов данных. Например, в приложениях, работающих с тяжелыми аналитическими выборками, стандартный виджет DataTable во Flutter пытается вычислить внутренние размеры и отрендерить все переданные строки одновременно. При загрузке крупного датасета (large dataset) это полностью блокирует UI-поток, делая интерфейс "мертвым" и неспособным реагировать на жесты пользователя до завершения компоновки23. Отложенная загрузка (lazy loading) и разбиение вычислений на чанки частично решают проблему, но не устраняют корневой конфликт управляемых языков программирования. +Императив применения Rust в контексте Querya Desktop +Глубокий анализ ограничений производительности подводит нас к необходимости пересмотра архитектуры масштабных продуктов, оперирующих тяжелыми данными. Показательным примером применения системного подхода является проект Querya Desktop — разрабатываемый Владимиром Козловым (ZhuchkaTriplesix / QueryaHub) кроссплатформенный клиент для управления реляционными (SQL) и нереляционными (NoSQL) базами данных25. +Особенности доменной области (Database Client) +Querya Desktop позиционируется как легковесный клиент с фокусом на чистый интерфейс, работающий по принципу "local-first" (офлайн-обработка без зависимости от внешних облачных серверов)25. Специфика любого продвинутого клиента баз данных (подобного API Dash в сфере API-клиентов28) заключается в необходимости извлечения, парсинга, криптографической верификации и визуализации колоссальных объемов информации: многомегабайтных JSON-документов, десятков тысяч строк транзакционных таблиц и потоков бинарных данных28. +Конфликт сборщика мусора и текучего UX +Использование чистого Dart во Flutter для обработки таких массивов данных фатально для концепции Fluid UI. Dart работает в виртуальной машине со сборщиком мусора (Garbage Collector). Десериализация ответа СУБД (например, PostgreSQL или MongoDB) объемом 50 МБ непосредственно в объекты Dart приведет к мгновенной массовой аллокации сотен тысяч объектов в памяти. +Это неизбежно провоцирует остановки "Stop-The-World" (GC Pauses). Когда сборщик мусора приостанавливает выполнение изолята для сканирования и очистки кучи, UI-поток замирает. В этот момент любая физическая анимация пружины, перенаправление смахивания модального окна или морфинг FAB-кнопки будут заморожены30. Восприятие интерфейса как "продолжения разума" рушится. Кроме того, библиотеки для подключения к БД на чистом Dart исторически уступают в стабильности и скорости нативным драйверам. +Роль Rust и механизмов FFI +Внедрение системного языка Rust в архитектуру Querya Desktop не является избыточным усложнением (overengineering), а представляет собой жесткую техническую необходимость для защиты изолята пользовательского интерфейса. Rust компилируется в нативный машинный код, гарантирует безопасность памяти на этапе компиляции без использования сборщика мусора и обладает непревзойденной экосистемой высокопроизводительных I/O библиотек (Tokio, SQLx)30. +Архитектурное распределение обязанностей в такой системе выглядит следующим образом: + + +Уровень архитектуры + Технология + Ответственность и механизмы + Frontend (UI / UX Layer) + Flutter & Dart + Обработка Pointer Events, расчет физики пружин, морфинг форм (morphable_shape), рендеринг графики (Impeller). Задача — поддержание стабильных 120 FPS без блокировок7. + Bridge (Слой связи) + flutter_rust_bridge / rinf + Foreign Function Interface (FFI). Обеспечивает асинхронный вызов функций и потоковую передачу данных между языковыми средами33. + Backend (Core Engine) + Rust Core + Многопоточная обработка соединений (TCP/TLS), парсинг бинарных протоколов СУБД, фильтрация данных, криптография (интеграция опыта разработки OxyJWT25). + Технологии интеграции: flutter_rust_bridge vs rinf +Интеграция достигается посредством кодогенераторов, автоматизирующих создание безопасных привязок (bindings). +flutter_rust_bridge: Основное преимущество данного фреймворка в контексте десктопного приложения — поддержка абстракций с нулевым копированием (zero-copy)32. Когда ядро Rust получает из базы данных бинарный массив, оно может передать указатель на эту область памяти во Flutter. Вектор Vec из Rust проецируется во Flutter как Uint8List. Dart не копирует эти мегабайты в свою кучу, что полностью спасает Garbage Collector от перегрузки31. Кроме того, вызовы асинхронны: async fn в Rust выполняется в собственном пуле потоков операционной системы, оставляя UI-поток абсолютно свободным для рендеринга текучих анимаций32. +rinf: Является альтернативным архитектурным подходом. Если flutter_rust_bridge стремится скрыть границу между языками, заставляя функции Rust выглядеть как нативные функции Dart, то rinf использует явную систему обмена сообщениями (message passing). Этот подход избегает создания скрытых фоновых потоков моста, предоставляя разработчику тотальный контроль над асинхронными задачами (сотни тысяч конкурентных I/O операций), что делает архитектуру потоков данных более предсказуемой и прозрачной34. +Автор Querya Desktop, Владимир Козлов, имеет обширный опыт в Systems R&D, построении высококонкурентных архитектур и интеграции Rust с другими языками (проекты OxyJWT и высокоскоростной веб-фреймворк OxyRoute, обходящий GIL в Python)25. Использование ядра на Rust в Querya Desktop является логической кульминацией этого инженерного подхода: перенос "горячего пути" (hot path) вычислений в нативный код для максимизации пропускной способности при сохранении феноменального пользовательского опыта25. +Выводы и инженерные перспективы +Синтез данных о дизайне физических интерфейсов, графическом рендеринге и системном программировании позволяет сформулировать ключевые архитектурные выводы: +1. Конфликт парадигм и путь к симбиозу: Существует фундаментальный технический конфликт между потребностью человеческого восприятия в мгновенном, текучем интерфейсе (требующем жесткого окна в 8-16 миллисекунд на кадр) и необходимостью обработки масштабных массивов данных. Morphing-анимации, базирующиеся на пересчете сотен точек кривых Безье, многократно усиливают эту уязвимость. Без строгой архитектурной сегрегации эти два процесса — вычисления и рендеринг — взаимно уничтожают производительность системы. +2. Детерминизм против сборки мусора: Применение принципов Fluid UI в кроссплатформенной среде неизбежно обнажает ограничения управляемых языков при работе с тяжелым вводом-выводом. Визуальная отзывчивость не может быть гарантирована, если система подвержена недетерминированным паузам сборщика мусора. Паттерн "Flutter для UI + Rust для бизнес-логики" перестал быть просто экспериментальной оптимизацией. Для десктопных приложений корпоративного и профессионального класса (клиенты СУБД, IDE, тяжелые API-инструменты) этот симбиоз является технологическим императивом, обеспечивающим выживаемость концепции Fluid UI. +3. Эволюция программного обеспечения B2B и Developer Tools: Исторически инструменты для управления базами данных или серверами проектировались как утилитарные, статичные интерфейсы с таблицами, где задержки отклика считались приемлемой нормой. Внедрение Fluid Morphing интерфейсов в инструменты уровня Querya Desktop знаменует тектонический сдвиг. Информационные массивы перестают восприниматься как абстрактный текст; они обретают "физические" свойства, становясь осязаемыми объектами, которые можно свайпать, перенаправлять и интуитивно трансформировать. +Проектирование текучих морфирующих интерфейсов — это не поверхностная задача UI-дизайна. Это глубокая системная инженерная проблема, требующая бесшовной синхронизации между физикой анимации, механизмами аппаратного рендеринга и низкоуровневым управлением распределением памяти. Лишь интеграция мощных декларативных UI-фреймворков с бескомпромиссными системными языками способна обеспечить опыт взаимодействия, при котором граница между инструментом и человеческим разумом окончательно стирается. +Источники +1. Apple: “Designing Fluid Interfaces” – how to delight your mobile app users - 3Advance, https://objective-williamson3780.on.getshifter.io/posts/apple-designing-fluid-interfaces-techniques-to-delight-your-mobile-app-users/ +2. Natural gestures and animations inspired by Apple's WWDC18 talk "Designing Fluid Interfaces" - GitHub, https://github.com/nathangitter/fluid-interfaces +3. 803_Designing Fluid Interfaces-V4c_D_Final.key - Apple, https://devstreaming-cdn.apple.com/videos/wwdc/2018/803lpnlacvg2jsndx/803/803_designing_fluid_interfaces.pdf?dl=1 +4. Interfaces should be about feeling | by Nate Smith - UX Collective, https://uxdesign.cc/interfaces-should-be-about-feeling-11bda14ec20 +5. Building Fluid Interfaces. How to create natural gestures and… | by Nathan Gitter - Medium, https://medium.com/@nathangitter/building-fluid-interfaces-ios-swift-9732bb934bf5 +6. Build Fluid Interfaces with Skills for Design Engineers, https://surfskills.surf/s/emilkowalski/skills/apple-design +7. Animation & Transition | flutter package, https://flutter.ducafecat.com/en/pubs/animation-transition-packages +8. skills/skills/apple-design/SKILL.md at main · emilkowalski/skills - GitHub, https://github.com/emilkowalski/skills/blob/main/skills/apple-design/SKILL.md +9. Fluid Interfaces. At WWDC 2018 Apple introduced some… | by Mats Hauge - UX Collective, https://uxdesign.cc/fluid-interfaces-8302c95939fb +10. Making iOS animations feel just right | by Jille van der Weerd - Prototypr, https://blog.prototypr.io/making-animations-feel-just-right-673d797692c1 +11. Gestures in Fluid Interfaces — On Intent and Projection | by Christian Schnorr | iOS App Development | Medium, https://medium.com/ios-os-x-development/gestures-in-fluid-interfaces-on-intent-and-projection-36d158db7395 +12. Fluid Morph Background - Vengeance UI, https://www.vengenceui.com/components/fluid-morph-bg +13. Morphing Controls design pattern - UI-Patterns.com, https://ui-patterns.com/patterns/morphing-controls +14. flutter_morphing_icons | Flutter package - Pub.dev, https://pub.dev/packages/flutter_morphing_icons +15. Creating morphable shapes in Flutter - Wenkai Fan - Medium, https://kevinvan.medium.com/creating-morphable-shapes-in-flutter-f17bcfecb0ed +16. GitHub - ikbendewilliam/path_morph_for_flutter: This is a Flutter package that lets you smoothly morph one path into another., https://github.com/ikbendewilliam/path_morph_for_flutter +17. Effective Animations in Flutter - Plague Fox, https://plugfox.dev/effective-animations-in-flutter/ +18. Design Shapes in Flutter — Introduction to the Morphable Shape Package | by Wenkai Fan, https://itnext.io/design-shapes-in-flutter-introduction-to-the-morphable-shape-package-30e0d33c60a7 +19. morphable_shape | Flutter package - Pub.dev, https://pub.dev/packages/morphable_shape +20. morphable_shape 0.0.9 | Flutter package - pub.dev, https://pub.dev/packages/morphable_shape/versions/0.0.9 +21. path_morph - Flutter package in Animation & Transition category, https://fluttergems.dev/packages/path_morph/ +22. path_morph package - All Versions - Pub.dev, https://pub.dev/packages/path_morph/versions +23. How to Find Performance and Bottleneck Issues in Flutter Apps? - % - Mobikul, https://mobikul.com/find-performance-and-bottleneck-issue-in-flutter-apps/ +24. How can I optimize DataTable in Flutter for large datasets, similar to ListView.builder?, https://www.reddit.com/r/FlutterDev/comments/1k0w9ye/how_can_i_optimize_datatable_in_flutter_for_large/ +25. Eva Rei ZhuchkaTriplesix - GitHub, https://github.com/ZhuchkaTriplesix +26. Приложение «Querya - App Store - Apple, https://apps.apple.com/ru/app/querya/id6756846513 +27. Querya - App Store - Apple, https://apps.apple.com/it/app/querya/id6756846513 +28. GitHub - api-evangelist/api-dash: API Dash is a beautiful AI-powered open-source cross-platform API client built with Flutter, available on Desktop (macOS, Windows, Linux) and Mobile., https://github.com/api-evangelist/api-dash +29. Introducing API Dash - An open-source cross-platform API Client built using Flutter (https://github.com/foss42/api-dash) : r/FlutterDev - Reddit, https://www.reddit.com/r/FlutterDev/comments/123p41l/introducing_api_dash_an_opensource_crossplatform/ +30. Rust + Flutter: How to Build Fast, Safe, Cross-Platform Mobile Apps - DEV Community, https://dev.to/abibeh/rust-flutter-how-to-build-fast-safe-cross-platform-mobile-apps-ika +31. Show HN: Rust GUI Library via Flutter | Hacker News, https://news.ycombinator.com/item?id=41213711 +32. flutter_rust_bridge - crates.io: Rust Package Registry, https://crates.io/crates/flutter_rust_bridge/1.10.0 +33. Flutter Rust Bridge download | SourceForge.net, https://sourceforge.net/projects/flutter-rust-bridge.mirror/ +34. Rinf vs `flutter_rust_bridge` · cunarist rinf · Discussion #195 - GitHub, https://github.com/cunarist/rinf/discussions/195 +35. Flutter plus Rust is the same stack I'm using for my music app. Thanks to the aw... | Hacker News, https://news.ycombinator.com/item?id=41749424 \ No newline at end of file From a3f58dbc00597f4018ca8bded76602d899e00977 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 11:19:36 +0300 Subject: [PATCH 09/14] chore: remove accidental local analysis file from branch --- ...0\265\320\271\321\201\320\276\320\262.txt" | 148 ------------------ 1 file changed, 148 deletions(-) delete mode 100644 "\320\220\320\275\320\260\320\273\320\270\320\267 Fluid Morphing \320\230\320\275\321\202\320\265\321\200\321\204\320\265\320\271\321\201\320\276\320\262.txt" diff --git "a/\320\220\320\275\320\260\320\273\320\270\320\267 Fluid Morphing \320\230\320\275\321\202\320\265\321\200\321\204\320\265\320\271\321\201\320\276\320\262.txt" "b/\320\220\320\275\320\260\320\273\320\270\320\267 Fluid Morphing \320\230\320\275\321\202\320\265\321\200\321\204\320\265\320\271\321\201\320\276\320\262.txt" deleted file mode 100644 index a416b964..00000000 --- "a/\320\220\320\275\320\260\320\273\320\270\320\267 Fluid Morphing \320\230\320\275\321\202\320\265\321\200\321\204\320\265\320\271\321\201\320\276\320\262.txt" +++ /dev/null @@ -1,148 +0,0 @@ -Архитектура и реализация Fluid Morphing интерфейсов: от концептуальной физики до системной интеграции Flutter и Rust в настольных приложениях баз данных -Эволюция пользовательских интерфейсов в программном обеспечении отражает непрерывное стремление инженеров и дизайнеров сократить когнитивную дистанцию между человеком и вычислительной системой. Исторически цифровые интерфейсы строились на парадигме дискретных, мгновенных переключений состояний, что было обусловлено аппаратными ограничениями. Однако по мере роста вычислительных мощностей индустрия переходит к непрерывным, физически обоснованным моделям взаимодействия. В авангарде этого перехода находится концепция «Fluid, morphing interface» (текучего, морфирующего интерфейса). Данное исследование представляет собой исчерпывающий анализ принципов построения таких интерфейсов, математических и архитектурных требований к их реализации, специфики применения в кроссплатформенной экосистеме Flutter, а также стратегической необходимости использования системных языков программирования, таких как Rust, для обеспечения бескомпромиссной производительности в ресурсоемких настольных приложениях — в частности, в контексте клиента баз данных Querya Desktop. -Концептуальные основы и фундаментальные требования к Fluid Interfaces -Концепция «Fluid Interfaces» (текучих интерфейсов) приобрела статус индустриального стандарта и была формализована в рамках знаковой презентации Apple на конференции WWDC 2018 («Designing Fluid Interfaces», Session 803)1. Основная философская и когнитивная предпосылка этой парадигмы заключается в том, что интерфейс должен ощущаться не как механизм, которым нужно управлять, а как естественное, бесшовное продолжение человеческого разума и физического тела1. -Когда интерфейс ведет себя в строгом соответствии с ментальной моделью физического мира, когнитивная нагрузка на пользователя радикально снижается6. В реальном мире объекты не телепортируются в пространстве, не останавливаются мгновенно при потере контакта и обладают характеристиками массы, инерции, трения и упругости8. Симуляция этих характеристик в цифровой среде требует соблюдения трех фундаментальных архитектурных требований: мгновенного отклика (Responsive), прерываемости (Interruptible) и перенаправляемости (Redirectable)5. -Мгновенный отклик и прямое манипулирование (Responsiveness & Direct Manipulation) -В физическом мире реакция на прикосновение к объекту происходит мгновенно. В цифровой среде появление даже микроскопической задержки (latency) разрушает когнитивную иллюзию прямого манипулирования (direct manipulation)8. Отклик является фундаментом, на котором строятся все остальные взаимодействия. -Интерфейс обязан реагировать на событие первоначального касания или нажатия (pointer-down), а не дожидаться момента отпускания манипулятора (pointer-up или click)8. Ожидание полного цикла клика для визуализации обратной связи делает систему субъективно "мертвой". Отслеживание движений пальца или курсора должно происходить в строгом соотношении 1:1, при этом критически важно сохранять изначальное смещение (grab offset) между точкой касания и геометрическим центром объекта6. Принудительное примагничивание центра элемента к курсору в момент захвата нарушает пространственную целостность восприятия. -Архитектурно это требует захвата указателя (pointer capture), чтобы отслеживание продолжалось даже в том случае, если курсор выходит за пределы изначальных границ элемента8. Любые искусственные таймеры, функции антидребезга (debounce) или задержки перед началом визуального отклика строго недопустимы, поскольку они создают барьер между намерением пользователя и реакцией системы5. -Абсолютная прерываемость (Interruptibility) -Прерываемость является наиболее сложным в реализации, но критически важным требованием. Человеческая мысль и физический жест происходят параллельно; намерение может измениться в любую долю секунды5. Традиционная анимация, основанная на жестко заданном времени (например, CSS-переходы transition или @keyframes), концептуально непригодна для текучих интерфейсов, так как она не позволяет бесшовно перехватить объект в полете6. -Если пользователь смахивает панель, а затем, осознав ошибку, пытается поймать ее на середине экрана, система обязана: -1. Мгновенно остановить текущую анимацию без визуальных скачков. -2. Считать текущее визуальное значение с экрана (presentation layer), а не конечное логическое значение. -3. Передать управление обратно жесту пользователя, сохранив при этом остаточную скорость объекта6. -Блокировка пользовательского ввода (input lockouts) во время транзитных состояний интерфейса является антипаттерном. Интерфейс должен оставаться функциональным и готовым к взаимодействию даже во время выполнения самых сложных анимационных последовательностей5. -Перенаправляемость и сохранение импульса (Redirectability) -Перенаправляемость описывает способность системы плавно менять конечную цель движения «на лету» без визуальных разрывов (seams) и скачков8. Если в процессе анимации закрытия модального окна поступает команда на его повторное открытие, система не должна мгновенно инвертировать вектор скорости (что вызовет эффект удара о невидимую стену — "brick wall effect"). Вместо этого вычисляется новая траектория, которая органично сливается с текущим вектором движения путем смешивания скоростей (velocity blending)8. -Физика взаимодействия: математика пружин и кинематика UI -Для реализации вышеописанных требований индустрия интерфейсов полностью отказывается от традиционных кривых плавности (easing curves), таких как ease-in или ease-out, в пользу систем, основанных на симуляции физики пружин (spring physics). -Отказ от концепции «продолжительности» -В парадигме текучих интерфейсов разработчики не оперируют фиксированным временем анимации (duration). Теоретически, физическая пружина никогда не останавливается полностью, а лишь асимптотически приближается к состоянию покоя. Принудительное навязывание продолжительности ломает физическую достоверность движения5. Вместо этого поведение системы описывается двумя ключевыми параметрами: коэффициентом затухания (Damping Ratio) и временем реакции (Response)8. - - -Параметр физической модели - Математическое описание и влияние на UX - Рекомендованные значения - Damping Ratio (Коэффициент затухания) - Определяет уровень трения в системе и контролирует осцилляцию (перелет). Значение означает критическое затухание (critically damped) — система достигает цели максимально быстро без отскока. Значения создают упругость и осцилляцию (bounciness). - - для большинства стандартных перемещений; для инерционных бросков, шторок и элементов, несущих физический импульс5. - Response (Время реакции) - Определяет жесткость пружины, выраженную через примерное время в секундах, необходимое для завершения основного пути движения. Меньшие значения делают интерфейс более "жестким" и резким. - - секунды для свайпов и открытия модальных окон, для свободного перемещения элементов (например, Picture-in-Picture)6. - Передача скорости (Velocity Handoff) -Точка, в которой заканчивается жест пользователя (pointer release) и начинается программная анимация, называется швом (seam). Для создания иллюзии непрерывности анимация должна унаследовать точную скорость жеста в момент отпускания8. В математическом выражении, начальная скорость пружины нормализуется относительно оставшегося расстояния до цели: - - -где — относительная скорость, передаваемая в алгоритм пружины, а — физическая скорость курсора или пальца в момент отпускания экрана (вычисляемая на основе истории последних событий pointermove)5. Если палец двигался со скоростью 50 пикселей в секунду, а до цели осталось 100 пикселей, начальная скорость пружины составит 8. -Проекция импульса (Momentum Projection) -При инерционном смахивании (flick) интерфейс должен вычислить, где объект остановился бы естественным образом под воздействием трения, и лишь затем выбрать ближайшую логическую точку привязки (snap target) для завершения движения6. Инженеры Apple используют для этого формулу экспоненциального затухания, которая отличается от классической ньютоновской кинематики: - - -где — начальная скорость жеста (initial velocity), а — коэффициент замедления (deceleration rate, часто принимаемый за константу для нормального прокручивания)3. Вычисленная проекция прибавляется к текущей позиции объекта, после чего система находит ближайшую валидную позицию (например, угол экрана для видеоплеера или край страницы) и перенаправляет пружину в эту точку3. -Декомпозиция сложного двумерного движения (например, перемещения плавающего окна) требует независимого расчета пружин по осям X и Y. Использование единой пружины для 2D-вектора приводит к десинхронизации, если начальные скорости по осям различаются8. -Анатомия Morphing-интерфейсов -В то время как принципы Fluid касаются пространственной динамики и кинематики, Morphing (морфинг) описывает плавную трансформацию геометрической формы, структуры, цвета или свойств объекта при переходе между логическими состояниями. В пользовательских интерфейсах морфинг чаще всего применяется к интерактивным элементам: плавающая кнопка действия (FAB) трансформируется в полноэкранное диалоговое окно, иконка «гамбургер» превращается в крестик закрытия, а карточка данных бесшовно раскрывается в детальное представление12. -Математическая и алгоритмическая сложность морфинга заключается в интерполяции между двумя геометрически различными фигурами (например, превращение звезды в круг или треугольника в квадрат). Прямолинейная интерполяция вершин одного контура в вершины другого неизбежно приводит к визуальным артефактам, таким как самопересечение формы (self-intersection) или неестественное вращение фигуры вокруг своей оси15. -В масштабных приложениях качественный морфинг достигается за счет сложных подготовительных алгоритмов: -1. Выравнивание контрольных точек (Point Alignment): Перед началом анимации алгоритм вычисляет оптимальное смещение (minimum shift) индексов массивов контрольных точек обеих фигур. Цель состоит в минимизации суммарного вектора перемещения всех точек. Это предотвращает "скручивание" фигуры во время трансформации15. -2. Унификация контуров (Contour Normalization): Обе фигуры должны быть приведены к одинаковому количеству сегментов (кривых Безье или дискретных точек). Прямые линии фигуры-источника динамически делятся на множество мелких кривых Безье, чтобы соответствовать сложным изгибам фигуры-цели. Однако топологические ограничения сохраняются: алгоритмы на базе метрик контуров могут трансформировать формы только с равным количеством замкнутых контуров (нельзя напрямую морфировать сплошной квадрат в фигуру с отверстием, например, букву "О")16. -3. Сохранение вершинной симметрии: При трансформации фигур с малым количеством углов (например, треугольник в прямоугольник) математическая интерполяция должна сохранять угловые вершины неподвижными, изгибая лишь необходимые грани (например, самая длинная грань треугольника изгибается, формируя две стороны прямоугольника). Использование сотен контрольных точек в таких случаях неэффективно и вызывает визуальные искажения ("дрожание" прямых линий)15. -Архитектура и реализация во фреймворке Flutter -Фреймворк Flutter, благодаря своему высокопроизводительному движку рендеринга (Skia и новое поколение Impeller), декларативной природе дерева виджетов и прямому доступу к низкоуровневым графическим примитивам, является превосходной платформой для создания текучих морфирующих интерфейсов. Анимации во Flutter жестко привязаны к частоте обновления экрана (Vsync), что позволяет напрямую управлять физическими параметрами на уровне каждого кадра7. -Инфраструктура виджетов и специализированные пакеты -Для решения задач морфинга в экосистеме Flutter разработаны продвинутые библиотеки, абстрагирующие сложную математику кривых Безье от разработчика прикладного слоя. -1. Пакет morphable_shape -Архитектура самого Flutter предоставляет базовый класс ShapeBorder, который накладывает жесткие ограничения: невозможно независимо стилизовать каждую сторону фигуры (как это реализовано в CSS) или плавно интерполировать сложные многоугольники18. Пакет morphable_shape полностью переписывает механизм работы с формами15. Особенности реализации: -* Динамические радиусы и процентные величины: Пакет интегрирует модуль dimension, позволяя задавать радиусы скругления и размеры не только в абсолютных пикселях, но и в процентах от размера родительского контейнера (например, DynamicRadius.elliptical(60.toPXLength, 10.toPercentLength)). Система автоматически масштабирует смежные радиусы, предотвращая их перекрытие19. -* Алгоритм симметричного морфинга: В отличие от базовых подходов, алгоритм морфинга здесь учитывает симметрию фигур. Он вычисляет контрольные точки лишь однажды, кэшируя результаты (если размер контейнера неизменен). Это позволяет изменять толщину границ, градиентную заливку и геометрическую форму без постоянного пересчета кривых Безье на каждом кадре15. -* Сериализация (JSON): Все объекты форм поддерживают методы toJson() и десериализацию. Это позволяет масштабным приложениям хранить описания сложных UI-состояний в базах данных или конфигурационных файлах и динамически загружать их18. -2. Пакет path_morph -Утилита, сфокусированная исключительно на интерполяции низкоуровневых объектов Path (путей)16. Алгоритм берет исходный путь и путь назначения, семплирует их через системный класс PathMetric (разбивая путь на равные отрезки) и математически сдвигает точки одного пути к другому15. Этот пакет требует от инженера ручного контроля над стейтом через AnimationController и setState, обновляя координаты массива shiftedPoints16. Как упоминалось ранее, его фундаментальным ограничением является требование равного количества контуров в обеих фигурах16. -3. Пакет flutter_morphing_icons -Инструмент прикладного уровня, предназначенный для трансформации иконок. Пакет реализует плавные переходы между состояниями (States) с применением встроенных кривых замедления (easing curves) и пространственных матричных трансформаций14. Он предоставляет абстракции, такие как MorphingAnimationConfig.scale или MorphingAnimationConfig.rotate, позволяя комбинировать кросс-фейд (исчезновение) с изменением масштаба и угла поворота, что создает иллюзию морфинга без тяжелых вычислений контуров векторной графики14. -Продвинутые техники: CustomPainter и фрагментные шейдеры -В высоконагруженных и визуально сложных приложениях стандартного дерева виджетов бывает недостаточно. Для создания органического морфинга (например, эффекта сливающихся капель жидкости — metaballs) математического смещения вершин фигур не хватает. -Для достижения таких эффектов инженеры Flutter используют CustomPainter в связке с фрагментными шейдерами (Fragment Shaders). С версии 3.7 Flutter поддерживает выполнение пользовательских шейдеров, написанных на языке GLSL и скомпилированных в SPIR-V17. Вместо просчета геометрии на CPU, шейдер выполняется параллельно на тысячах ядер GPU. Пространство описывается через функции расстояния со знаком (SDF - Signed Distance Field), а морфинг двух объектов реализуется с помощью математической функции сглаженного минимума (smooth polynomial minimum). Этот подход обеспечивает идеальную текучесть жидкости (fluid liquid effects) при практически нулевой нагрузке на центральный процессор17. -Уязвимости, узкие места и проблемы производительности (Jank) -Несмотря на высочайшие эстетические и когнитивные преимущества, внедрение Fluid Morphing интерфейсов привносит значительные архитектурные риски. Попытки реализовать подобные системы без глубокого понимания цикла рендеринга приводят к катастрофическому падению производительности. -Вычислительные затраты CPU и потеря кадров -Анимации, основанные на физике пружин и пересчете точек Безье для морфинга, требуют непрерывных вычислений на каждом кадре (16.6 мс для 60 FPS или 8.3 мс для 120 FPS дисплеев). Если эти вычисления происходят в главном потоке пользовательского интерфейса (UI Isolate во Flutter), они напрямую конкурируют за процессорное время с бизнес-логикой приложения17. Превышение временного лимита вызывает пропуск кадра (Jank), что мгновенно разрушает иллюзию физической достоверности и плавности8. Использование AnimatedBuilder с частым вызовом setState может приводить к инвалидации избыточно больших сегментов дерева виджетов, вызывая каскадные перерисовки15. -Управление слоями отрисовки (RepaintBoundary) -При морфинге формы контейнера система рендеринга Flutter по умолчанию перерисовывает не только саму форму, но и ее фон, тени, а также все дочерние элементы. Неконтролируемая перерисовка (repainting) сложных иерархий перегружает этап растрирования графического конвейера. Инженерным решением является изоляция морфирующего виджета с помощью RepaintBoundary, что принудительно выделяет элемент в отдельный слой (layer) кэширования17. Однако избыточное использование слоев приводит к исчерпанию оперативной памяти (особенно видеопамяти), что требует тонкой балансировки. -Конфликт с обработкой больших наборов данных -Наиболее уязвимым местом архитектуры текучих интерфейсов является их несовместимость с синхронной обработкой больших объемов данных. Например, в приложениях, работающих с тяжелыми аналитическими выборками, стандартный виджет DataTable во Flutter пытается вычислить внутренние размеры и отрендерить все переданные строки одновременно. При загрузке крупного датасета (large dataset) это полностью блокирует UI-поток, делая интерфейс "мертвым" и неспособным реагировать на жесты пользователя до завершения компоновки23. Отложенная загрузка (lazy loading) и разбиение вычислений на чанки частично решают проблему, но не устраняют корневой конфликт управляемых языков программирования. -Императив применения Rust в контексте Querya Desktop -Глубокий анализ ограничений производительности подводит нас к необходимости пересмотра архитектуры масштабных продуктов, оперирующих тяжелыми данными. Показательным примером применения системного подхода является проект Querya Desktop — разрабатываемый Владимиром Козловым (ZhuchkaTriplesix / QueryaHub) кроссплатформенный клиент для управления реляционными (SQL) и нереляционными (NoSQL) базами данных25. -Особенности доменной области (Database Client) -Querya Desktop позиционируется как легковесный клиент с фокусом на чистый интерфейс, работающий по принципу "local-first" (офлайн-обработка без зависимости от внешних облачных серверов)25. Специфика любого продвинутого клиента баз данных (подобного API Dash в сфере API-клиентов28) заключается в необходимости извлечения, парсинга, криптографической верификации и визуализации колоссальных объемов информации: многомегабайтных JSON-документов, десятков тысяч строк транзакционных таблиц и потоков бинарных данных28. -Конфликт сборщика мусора и текучего UX -Использование чистого Dart во Flutter для обработки таких массивов данных фатально для концепции Fluid UI. Dart работает в виртуальной машине со сборщиком мусора (Garbage Collector). Десериализация ответа СУБД (например, PostgreSQL или MongoDB) объемом 50 МБ непосредственно в объекты Dart приведет к мгновенной массовой аллокации сотен тысяч объектов в памяти. -Это неизбежно провоцирует остановки "Stop-The-World" (GC Pauses). Когда сборщик мусора приостанавливает выполнение изолята для сканирования и очистки кучи, UI-поток замирает. В этот момент любая физическая анимация пружины, перенаправление смахивания модального окна или морфинг FAB-кнопки будут заморожены30. Восприятие интерфейса как "продолжения разума" рушится. Кроме того, библиотеки для подключения к БД на чистом Dart исторически уступают в стабильности и скорости нативным драйверам. -Роль Rust и механизмов FFI -Внедрение системного языка Rust в архитектуру Querya Desktop не является избыточным усложнением (overengineering), а представляет собой жесткую техническую необходимость для защиты изолята пользовательского интерфейса. Rust компилируется в нативный машинный код, гарантирует безопасность памяти на этапе компиляции без использования сборщика мусора и обладает непревзойденной экосистемой высокопроизводительных I/O библиотек (Tokio, SQLx)30. -Архитектурное распределение обязанностей в такой системе выглядит следующим образом: - - -Уровень архитектуры - Технология - Ответственность и механизмы - Frontend (UI / UX Layer) - Flutter & Dart - Обработка Pointer Events, расчет физики пружин, морфинг форм (morphable_shape), рендеринг графики (Impeller). Задача — поддержание стабильных 120 FPS без блокировок7. - Bridge (Слой связи) - flutter_rust_bridge / rinf - Foreign Function Interface (FFI). Обеспечивает асинхронный вызов функций и потоковую передачу данных между языковыми средами33. - Backend (Core Engine) - Rust Core - Многопоточная обработка соединений (TCP/TLS), парсинг бинарных протоколов СУБД, фильтрация данных, криптография (интеграция опыта разработки OxyJWT25). - Технологии интеграции: flutter_rust_bridge vs rinf -Интеграция достигается посредством кодогенераторов, автоматизирующих создание безопасных привязок (bindings). -flutter_rust_bridge: Основное преимущество данного фреймворка в контексте десктопного приложения — поддержка абстракций с нулевым копированием (zero-copy)32. Когда ядро Rust получает из базы данных бинарный массив, оно может передать указатель на эту область памяти во Flutter. Вектор Vec из Rust проецируется во Flutter как Uint8List. Dart не копирует эти мегабайты в свою кучу, что полностью спасает Garbage Collector от перегрузки31. Кроме того, вызовы асинхронны: async fn в Rust выполняется в собственном пуле потоков операционной системы, оставляя UI-поток абсолютно свободным для рендеринга текучих анимаций32. -rinf: Является альтернативным архитектурным подходом. Если flutter_rust_bridge стремится скрыть границу между языками, заставляя функции Rust выглядеть как нативные функции Dart, то rinf использует явную систему обмена сообщениями (message passing). Этот подход избегает создания скрытых фоновых потоков моста, предоставляя разработчику тотальный контроль над асинхронными задачами (сотни тысяч конкурентных I/O операций), что делает архитектуру потоков данных более предсказуемой и прозрачной34. -Автор Querya Desktop, Владимир Козлов, имеет обширный опыт в Systems R&D, построении высококонкурентных архитектур и интеграции Rust с другими языками (проекты OxyJWT и высокоскоростной веб-фреймворк OxyRoute, обходящий GIL в Python)25. Использование ядра на Rust в Querya Desktop является логической кульминацией этого инженерного подхода: перенос "горячего пути" (hot path) вычислений в нативный код для максимизации пропускной способности при сохранении феноменального пользовательского опыта25. -Выводы и инженерные перспективы -Синтез данных о дизайне физических интерфейсов, графическом рендеринге и системном программировании позволяет сформулировать ключевые архитектурные выводы: -1. Конфликт парадигм и путь к симбиозу: Существует фундаментальный технический конфликт между потребностью человеческого восприятия в мгновенном, текучем интерфейсе (требующем жесткого окна в 8-16 миллисекунд на кадр) и необходимостью обработки масштабных массивов данных. Morphing-анимации, базирующиеся на пересчете сотен точек кривых Безье, многократно усиливают эту уязвимость. Без строгой архитектурной сегрегации эти два процесса — вычисления и рендеринг — взаимно уничтожают производительность системы. -2. Детерминизм против сборки мусора: Применение принципов Fluid UI в кроссплатформенной среде неизбежно обнажает ограничения управляемых языков при работе с тяжелым вводом-выводом. Визуальная отзывчивость не может быть гарантирована, если система подвержена недетерминированным паузам сборщика мусора. Паттерн "Flutter для UI + Rust для бизнес-логики" перестал быть просто экспериментальной оптимизацией. Для десктопных приложений корпоративного и профессионального класса (клиенты СУБД, IDE, тяжелые API-инструменты) этот симбиоз является технологическим императивом, обеспечивающим выживаемость концепции Fluid UI. -3. Эволюция программного обеспечения B2B и Developer Tools: Исторически инструменты для управления базами данных или серверами проектировались как утилитарные, статичные интерфейсы с таблицами, где задержки отклика считались приемлемой нормой. Внедрение Fluid Morphing интерфейсов в инструменты уровня Querya Desktop знаменует тектонический сдвиг. Информационные массивы перестают восприниматься как абстрактный текст; они обретают "физические" свойства, становясь осязаемыми объектами, которые можно свайпать, перенаправлять и интуитивно трансформировать. -Проектирование текучих морфирующих интерфейсов — это не поверхностная задача UI-дизайна. Это глубокая системная инженерная проблема, требующая бесшовной синхронизации между физикой анимации, механизмами аппаратного рендеринга и низкоуровневым управлением распределением памяти. Лишь интеграция мощных декларативных UI-фреймворков с бескомпромиссными системными языками способна обеспечить опыт взаимодействия, при котором граница между инструментом и человеческим разумом окончательно стирается. -Источники -1. Apple: “Designing Fluid Interfaces” – how to delight your mobile app users - 3Advance, https://objective-williamson3780.on.getshifter.io/posts/apple-designing-fluid-interfaces-techniques-to-delight-your-mobile-app-users/ -2. Natural gestures and animations inspired by Apple's WWDC18 talk "Designing Fluid Interfaces" - GitHub, https://github.com/nathangitter/fluid-interfaces -3. 803_Designing Fluid Interfaces-V4c_D_Final.key - Apple, https://devstreaming-cdn.apple.com/videos/wwdc/2018/803lpnlacvg2jsndx/803/803_designing_fluid_interfaces.pdf?dl=1 -4. Interfaces should be about feeling | by Nate Smith - UX Collective, https://uxdesign.cc/interfaces-should-be-about-feeling-11bda14ec20 -5. Building Fluid Interfaces. How to create natural gestures and… | by Nathan Gitter - Medium, https://medium.com/@nathangitter/building-fluid-interfaces-ios-swift-9732bb934bf5 -6. Build Fluid Interfaces with Skills for Design Engineers, https://surfskills.surf/s/emilkowalski/skills/apple-design -7. Animation & Transition | flutter package, https://flutter.ducafecat.com/en/pubs/animation-transition-packages -8. skills/skills/apple-design/SKILL.md at main · emilkowalski/skills - GitHub, https://github.com/emilkowalski/skills/blob/main/skills/apple-design/SKILL.md -9. Fluid Interfaces. At WWDC 2018 Apple introduced some… | by Mats Hauge - UX Collective, https://uxdesign.cc/fluid-interfaces-8302c95939fb -10. Making iOS animations feel just right | by Jille van der Weerd - Prototypr, https://blog.prototypr.io/making-animations-feel-just-right-673d797692c1 -11. Gestures in Fluid Interfaces — On Intent and Projection | by Christian Schnorr | iOS App Development | Medium, https://medium.com/ios-os-x-development/gestures-in-fluid-interfaces-on-intent-and-projection-36d158db7395 -12. Fluid Morph Background - Vengeance UI, https://www.vengenceui.com/components/fluid-morph-bg -13. Morphing Controls design pattern - UI-Patterns.com, https://ui-patterns.com/patterns/morphing-controls -14. flutter_morphing_icons | Flutter package - Pub.dev, https://pub.dev/packages/flutter_morphing_icons -15. Creating morphable shapes in Flutter - Wenkai Fan - Medium, https://kevinvan.medium.com/creating-morphable-shapes-in-flutter-f17bcfecb0ed -16. GitHub - ikbendewilliam/path_morph_for_flutter: This is a Flutter package that lets you smoothly morph one path into another., https://github.com/ikbendewilliam/path_morph_for_flutter -17. Effective Animations in Flutter - Plague Fox, https://plugfox.dev/effective-animations-in-flutter/ -18. Design Shapes in Flutter — Introduction to the Morphable Shape Package | by Wenkai Fan, https://itnext.io/design-shapes-in-flutter-introduction-to-the-morphable-shape-package-30e0d33c60a7 -19. morphable_shape | Flutter package - Pub.dev, https://pub.dev/packages/morphable_shape -20. morphable_shape 0.0.9 | Flutter package - pub.dev, https://pub.dev/packages/morphable_shape/versions/0.0.9 -21. path_morph - Flutter package in Animation & Transition category, https://fluttergems.dev/packages/path_morph/ -22. path_morph package - All Versions - Pub.dev, https://pub.dev/packages/path_morph/versions -23. How to Find Performance and Bottleneck Issues in Flutter Apps? - % - Mobikul, https://mobikul.com/find-performance-and-bottleneck-issue-in-flutter-apps/ -24. How can I optimize DataTable in Flutter for large datasets, similar to ListView.builder?, https://www.reddit.com/r/FlutterDev/comments/1k0w9ye/how_can_i_optimize_datatable_in_flutter_for_large/ -25. Eva Rei ZhuchkaTriplesix - GitHub, https://github.com/ZhuchkaTriplesix -26. Приложение «Querya - App Store - Apple, https://apps.apple.com/ru/app/querya/id6756846513 -27. Querya - App Store - Apple, https://apps.apple.com/it/app/querya/id6756846513 -28. GitHub - api-evangelist/api-dash: API Dash is a beautiful AI-powered open-source cross-platform API client built with Flutter, available on Desktop (macOS, Windows, Linux) and Mobile., https://github.com/api-evangelist/api-dash -29. Introducing API Dash - An open-source cross-platform API Client built using Flutter (https://github.com/foss42/api-dash) : r/FlutterDev - Reddit, https://www.reddit.com/r/FlutterDev/comments/123p41l/introducing_api_dash_an_opensource_crossplatform/ -30. Rust + Flutter: How to Build Fast, Safe, Cross-Platform Mobile Apps - DEV Community, https://dev.to/abibeh/rust-flutter-how-to-build-fast-safe-cross-platform-mobile-apps-ika -31. Show HN: Rust GUI Library via Flutter | Hacker News, https://news.ycombinator.com/item?id=41213711 -32. flutter_rust_bridge - crates.io: Rust Package Registry, https://crates.io/crates/flutter_rust_bridge/1.10.0 -33. Flutter Rust Bridge download | SourceForge.net, https://sourceforge.net/projects/flutter-rust-bridge.mirror/ -34. Rinf vs `flutter_rust_bridge` · cunarist rinf · Discussion #195 - GitHub, https://github.com/cunarist/rinf/discussions/195 -35. Flutter plus Rust is the same stack I'm using for my music app. Thanks to the aw... | Hacker News, https://news.ycombinator.com/item?id=41749424 \ No newline at end of file From 93f5cb52b4c400c542ae38c35dcd149ecb972b95 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 11:39:59 +0300 Subject: [PATCH 10/14] test(sandbox): pass allowUnsandboxedLaunch in plugin RPC bridge tests Windows platformOverride in unit tests simulates soft isolation; consent flag is required after #395 fail-closed behavior. --- .../rpc/plugin_rpc_bridge_test.dart | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/test/core/extensions/rpc/plugin_rpc_bridge_test.dart b/test/core/extensions/rpc/plugin_rpc_bridge_test.dart index 6d5f73e9..c7bee5e8 100644 --- a/test/core/extensions/rpc/plugin_rpc_bridge_test.dart +++ b/test/core/extensions/rpc/plugin_rpc_bridge_test.dart @@ -150,6 +150,7 @@ void main() { final handshake = await bridge.start( manifest: testManifest, pluginExecutable: '/opt/driver', + allowUnsandboxedLaunch: true, ); expect(handshake, isA()); expect((handshake as Map)['protocolVersion'], '1.0'); @@ -190,7 +191,11 @@ void main() { ); await expectLater( - bridge.start(manifest: testManifest, pluginExecutable: '/opt/driver'), + bridge.start( + manifest: testManifest, + pluginExecutable: '/opt/driver', + allowUnsandboxedLaunch: true, + ), throwsA(isA()), ); expect(bridge.isStarted, isFalse); @@ -234,7 +239,11 @@ void main() { requestTimeout: const Duration(seconds: 5), ); - await bridge.start(manifest: testManifest, pluginExecutable: '/opt/driver'); + await bridge.start( + manifest: testManifest, + pluginExecutable: '/opt/driver', + allowUnsandboxedLaunch: true, + ); final pending = bridge.connect({'host': 'x'}); await Future.delayed(const Duration(milliseconds: 20)); process.completeExit(1); @@ -296,7 +305,11 @@ void main() { enableStderrPipe: false, ); - await bridge.start(manifest: testManifest, pluginExecutable: '/opt/driver'); + await bridge.start( + manifest: testManifest, + pluginExecutable: '/opt/driver', + allowUnsandboxedLaunch: true, + ); await bridge.shutdown(); expect(shutdownReceived, isTrue); From bd8acf1e37a6fc25c15aefb43ff5091e5b952344 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 11:42:19 +0300 Subject: [PATCH 11/14] style(sandbox): use rethrow when consent is denied --- lib/core/extensions/extension_driver_session.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/core/extensions/extension_driver_session.dart b/lib/core/extensions/extension_driver_session.dart index 2c22e87f..ee8a3d7a 100644 --- a/lib/core/extensions/extension_driver_session.dart +++ b/lib/core/extensions/extension_driver_session.dart @@ -158,7 +158,7 @@ class ExtensionDriverSession { } on SandboxOsIsolationUnavailableException catch (error) { final approved = await UnsandboxedLaunchConsentGate.instance.request(error); - if (!approved) throw error; + if (!approved) rethrow; await bridge.start( manifest: manifest, pluginExecutable: pluginExecutable, From b3f2685c21c4db62e4f8d22ab5e36ea71859b5b3 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 11:22:19 +0300 Subject: [PATCH 12/14] fix(marketplace): add sideload security notice and optional SHA256 field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #402 — extension sideload dialog warns about unverified installs and accepts optional checksum before LocalExtensionInstaller runs. --- docs/security.md | 6 +- .../pages/extension_manager_dialog.dart | 17 ++- .../widgets/extension_sideload_dialog.dart | 128 ++++++++++++++++++ 3 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 lib/features/extensions/presentation/widgets/extension_sideload_dialog.dart diff --git a/docs/security.md b/docs/security.md index 32f46518..8480bf26 100644 --- a/docs/security.md +++ b/docs/security.md @@ -49,4 +49,8 @@ Process-sandbox database drivers launch inside OS-level isolation when available Querya refuses **silent** unsandboxed launch. `SandboxProcessRunner` throws `SandboxOsIsolationUnavailableException` until the user approves via the consent dialog registered from the main window. -**Linux:** install `bubblewrap` and ensure unprivileged user namespaces are enabled if you want OS sandbox without manual confirmation. \ No newline at end of file +**Linux:** install `bubblewrap` and ensure unprivileged user namespaces are enabled if you want OS sandbox without manual confirmation. + +## Local extension sideload (`.zip` / `.qext`) + +Installing from a local file does **not** verify integrity unless you paste an optional **SHA-256 checksum** in the install dialog. Marketplace installs always require a manifest checksum (#396). Sideload is intended for trusted local packages and development builds. diff --git a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart index 89bcc724..2fce9aca 100644 --- a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart +++ b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart @@ -7,6 +7,7 @@ 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/features/extensions/presentation/widgets/extension_sideload_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; void showExtensionManagerDialog(material.BuildContext context) { @@ -124,7 +125,18 @@ class _ExtensionManagerContentState ], ); if (file == null) return; - await LocalExtensionInstaller().installFromPath(file.path); + + if (!mounted) return; + final request = await showExtensionSideloadDialog( + context, + archivePath: file.path, + ); + if (request == null) return; + + await LocalExtensionInstaller().installFromPath( + request.archivePath, + expectedSha256: request.sha256Checksum, + ); await LocalExtensionRegistry.instance.reload(); if (!mounted) return; setState(() { @@ -269,7 +281,8 @@ class _ExtensionManagerContentState const material.SizedBox(width: 12), material.Expanded( child: const Text( - 'Install a local .zip or .qext package without the Marketplace.', + 'Install a local .zip or .qext package. Integrity verification ' + 'is optional — paste SHA-256 if the publisher provides one.', ).muted().small(), ), ], diff --git a/lib/features/extensions/presentation/widgets/extension_sideload_dialog.dart b/lib/features/extensions/presentation/widgets/extension_sideload_dialog.dart new file mode 100644 index 00000000..dfb84ff7 --- /dev/null +++ b/lib/features/extensions/presentation/widgets/extension_sideload_dialog.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart' as material; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +class ExtensionSideloadRequest { + const ExtensionSideloadRequest({ + required this.archivePath, + this.sha256Checksum, + }); + + final String archivePath; + final String? sha256Checksum; +} + +Future showExtensionSideloadDialog( + material.BuildContext context, { + required String archivePath, +}) async { + return showAppDialog( + context: context, + builder: (dialogContext) => _ExtensionSideloadDialog( + archivePath: archivePath, + ), + ); +} + +class _ExtensionSideloadDialog extends material.StatefulWidget { + const _ExtensionSideloadDialog({required this.archivePath}); + + final String archivePath; + + @override + material.State<_ExtensionSideloadDialog> createState() => + _ExtensionSideloadDialogState(); +} + +class _ExtensionSideloadDialogState + extends material.State<_ExtensionSideloadDialog> { + final _checksumController = material.TextEditingController(); + + @override + void dispose() { + _checksumController.dispose(); + super.dispose(); + } + + void _submit() { + final checksum = _checksumController.text.trim(); + material.Navigator.pop( + context, + ExtensionSideloadRequest( + archivePath: widget.archivePath, + sha256Checksum: checksum.isEmpty ? null : checksum, + ), + ); + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final fileName = p.basename(widget.archivePath); + + return material.AlertDialog( + title: const material.Text('Install local extension'), + content: material.SizedBox( + width: 440, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Text('File: $fileName'), + const material.SizedBox(height: 12), + material.Container( + width: double.infinity, + padding: const material.EdgeInsets.all(12), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.35), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all(color: cs.border), + ), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Icon( + material.Icons.warning_amber_rounded, + size: 18, + color: cs.mutedForeground, + ), + const material.SizedBox(width: 10), + material.Expanded( + child: material.Text( + 'Local packages are not verified unless you provide a ' + 'SHA-256 checksum. Only install archives from sources you ' + 'trust.', + style: material.TextStyle( + fontSize: 13, + color: cs.mutedForeground, + ), + ), + ), + ], + ), + ), + const material.SizedBox(height: 12), + material.TextField( + controller: _checksumController, + decoration: const material.InputDecoration( + labelText: 'SHA-256 checksum (optional)', + hintText: '64-character hex digest from publisher', + ), + autocorrect: false, + ), + ], + ), + ), + actions: [ + OutlineButton( + onPressed: () => material.Navigator.pop(context), + child: const material.Text('Cancel'), + ), + PrimaryButton( + onPressed: _submit, + child: const material.Text('Install'), + ), + ], + ); + } +} From fdae2c1cb848b866a5b6281a2e8aafb1be311cf3 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 11:52:32 +0300 Subject: [PATCH 13/14] packaging(linux): add rpm, Flatpak, and AUR (#386) Extend Release CI with .rpm and .flatpak artifacts reusing the Flutter linux bundle; add build scripts, Flatpak manifest, RPM spec, and AUR PKGBUILD with updater/docs policy for managed installs. Closes #386 --- .github/workflows/release.yml | 27 +++++++- .gitignore | 2 + CHANGELOG.md | 4 ++ docs/packaging.md | 37 ++++++++++- docs/release-checklist.md | 2 +- docs/tags-and-releases.md | 6 +- packaging/linux/aur/PKGBUILD | 39 ++++++++++++ packaging/linux/aur/README.md | 21 +++++++ packaging/linux/aur/querya_desktop.desktop | 10 +++ packaging/linux/aur/querya_desktop.png | Bin 0 -> 36406 bytes .../com.queryahub.querya_desktop.desktop | 9 +++ .../flatpak/com.queryahub.querya_desktop.yml | 29 +++++++++ packaging/linux/querya-desktop.spec | 44 +++++++++++++ packaging/linux/querya_desktop.desktop | 10 +++ scripts/linux/build_deb.sh | 16 +---- scripts/linux/build_flatpak.sh | 58 ++++++++++++++++++ scripts/linux/build_rpm.sh | 56 +++++++++++++++++ 17 files changed, 351 insertions(+), 19 deletions(-) create mode 100644 packaging/linux/aur/PKGBUILD create mode 100644 packaging/linux/aur/README.md create mode 100644 packaging/linux/aur/querya_desktop.desktop create mode 100644 packaging/linux/aur/querya_desktop.png create mode 100644 packaging/linux/flatpak/com.queryahub.querya_desktop.desktop create mode 100644 packaging/linux/flatpak/com.queryahub.querya_desktop.yml create mode 100644 packaging/linux/querya-desktop.spec create mode 100644 packaging/linux/querya_desktop.desktop create mode 100755 scripts/linux/build_flatpak.sh create mode 100755 scripts/linux/build_rpm.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0107c26b..29f36850 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -146,6 +146,21 @@ jobs: - name: Build Linux .deb (Debian/Ubuntu) run: ./scripts/linux/build_deb.sh + - name: Build Linux .rpm (Fedora/RHEL) + run: | + sudo apt-get install -y rpm + ./scripts/linux/build_rpm.sh + + - name: Install Flatpak toolchain + run: | + sudo apt-get install -y flatpak flatpak-builder + flatpak remote-add --if-not-exists --system flathub \ + https://flathub.org/repo/flathub.flatpakrepo + flatpak install -y --system flathub org.gnome.Platform//48 org.gnome.Sdk//48 + + - name: Build Linux Flatpak (installable) + run: ./scripts/linux/build_flatpak.sh + - uses: actions/upload-artifact@v4 with: name: bundle-linux @@ -153,6 +168,8 @@ jobs: Querya-Desktop-${{ steps.version.outputs.version }}-linux.zip Querya-Desktop-${{ steps.version.outputs.version }}-linux.AppImage Querya-Desktop-${{ steps.version.outputs.version }}-linux.deb + Querya-Desktop-${{ steps.version.outputs.version }}-linux.rpm + Querya-Desktop-${{ steps.version.outputs.version }}-linux.flatpak if-no-files-found: error build-macos: @@ -266,11 +283,13 @@ jobs: run: | set -euo pipefail mkdir -p dist - find artifacts \( -name '*.zip' -o -name '*.AppImage' -o -name '*.deb' -o -name '*-windows-setup.exe' \) \ + find artifacts \( -name '*.zip' -o -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' -o -name '*.flatpak' -o -name '*-windows-setup.exe' \) \ -exec mv -t dist/ {} + test "$(find dist -name '*.zip' | wc -l)" -eq 3 test "$(find dist -name '*.AppImage' | wc -l)" -eq 1 test "$(find dist -name '*.deb' | wc -l)" -eq 1 + test "$(find dist -name '*.rpm' | wc -l)" -eq 1 + test "$(find dist -name '*.flatpak' | wc -l)" -eq 1 test "$(find dist -name '*-windows-setup.exe' | wc -l)" -eq 1 cd dist sha256sum *.* | tee SHA256SUMS.txt @@ -296,9 +315,11 @@ jobs: echo "#### Installable" echo "- **Linux AppImage**: \`Querya-Desktop-${VERSION}-linux.AppImage\` (\`chmod +x\` then run)" echo "- **Linux .deb** (Debian/Ubuntu): \`Querya-Desktop-${VERSION}-linux.deb\` — \`sudo apt install ./Querya-Desktop-${VERSION}-linux.deb\`" + echo "- **Linux .rpm** (Fedora/RHEL): \`Querya-Desktop-${VERSION}-linux.rpm\` — \`sudo dnf install ./Querya-Desktop-${VERSION}-linux.rpm\`" + echo "- **Linux Flatpak**: \`Querya-Desktop-${VERSION}-linux.flatpak\` — \`flatpak install --user ./Querya-Desktop-${VERSION}-linux.flatpak\`" echo "- **Windows setup**: \`Querya-Desktop-${VERSION}-windows-setup.exe\` (Inno Setup)" echo "" - echo "RPM / Flatpak / AUR — follow-ups on #386." + echo "**Arch (AUR):** PKGBUILD in \`packaging/linux/aur/\` (community-maintained)." echo "" echo "Verify checksums: \`SHA256SUMS.txt\`" echo "" @@ -326,6 +347,8 @@ jobs: files: | dist/*.zip dist/*.deb + dist/*.rpm + dist/*.flatpak dist/*.AppImage dist/*-windows-setup.exe dist/SHA256SUMS.txt diff --git a/.gitignore b/.gitignore index 025ddbec..9668440b 100644 --- a/.gitignore +++ b/.gitignore @@ -45,5 +45,7 @@ docker/sqlite/data/ # Local packaging outputs Querya-Desktop-*.deb +Querya-Desktop-*.rpm +Querya-Desktop-*.flatpak Querya-Desktop-*.AppImage Querya-Desktop-*.zip diff --git a/CHANGELOG.md b/CHANGELOG.md index d210b917..4a4b8477 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Linux distro packaging (#386)** — `.rpm`, Flatpak (`.flatpak` bundle + manifest), and AUR PKGBUILD; Release CI publishes rpm + Flatpak alongside existing `.deb` / AppImage — see [packaging.md](docs/packaging.md). + ## [0.4.11] - 2026-07-27 Universal UI standard for drivers/extensions, shell UX hardening, Fluid QueryaMotion morphing, virtual grid/pool reliability, performance follow-ups, and dual-channel packaging (portable + installable). diff --git a/docs/packaging.md b/docs/packaging.md index 3f041d99..0c0fb260 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -6,7 +6,7 @@ Querya Desktop ships (and will ship) two download channels. See epic | Channel | Typical artifact | Profile data | |---------|------------------|--------------| | **Portable** | `Querya-Desktop-{ver}-{os}.zip` (Flutter bundle) | OS app-support by default; optional sidecar — see below | -| **Installable** | AppImage, Windows setup, deb/rpm/Flatpak (planned) | Normal OS locations | +| **Installable** | AppImage, Windows setup, `.deb`, `.rpm`, Flatpak | Normal OS locations | ## Portable profile (`QueryaData`) @@ -54,3 +54,38 @@ Legacy `com.example.*` support directories are migrated once into the new paths - `lib/core/storage/app_data_root.dart` — portable detection, support-dir redirect, id migration - Updater packaging context: `lib/core/updater/installers/update_install_context.dart` - Release workflow: `.github/workflows/release.yml` + +## Linux distro packages + +All installable Linux formats reuse the same Flutter `build/linux/x64/release/bundle` +payload as the portable zip and AppImage. + +| Format | Build script | Install | +|--------|--------------|---------| +| `.deb` | [`scripts/linux/build_deb.sh`](../scripts/linux/build_deb.sh) | `sudo apt install ./Querya-Desktop-{ver}-linux.deb` | +| `.rpm` | [`scripts/linux/build_rpm.sh`](../scripts/linux/build_rpm.sh) | `sudo dnf install ./Querya-Desktop-{ver}-linux.rpm` | +| Flatpak | [`scripts/linux/build_flatpak.sh`](../scripts/linux/build_flatpak.sh) | `flatpak install --user ./Querya-Desktop-{ver}-linux.flatpak` | +| AUR | [`packaging/linux/aur/`](../packaging/linux/aur/) | Community PKGBUILD (Release zip under `/opt`) | + +**Runtime dependencies (deb/rpm):** GTK 3, libsecret, GLib; app indicator recommended for tray. + +### Updater policy + +| Install type | In-app updater | +|--------------|----------------| +| Portable zip / AppImage | Downloads matching release asset (zip or AppImage) | +| `.deb` / `.rpm` | Use **apt** / **dnf** (or distro equivalent); in-app install is not offered | +| Flatpak | Use **`flatpak update`** (`FLATPAK_ID` / managed runtime — see `update_install_context.dart`) | +| Snap | Use **`snap refresh`** when/if a Snap build ships | + +### Flatpak sandbox notes + +The bundled Flatpak grants network, home, and D-Bus access to the freedesktop +secrets service (libsecret). Some database setups (Unix sockets outside `$HOME`, +custom TLS stores) may need extra permissions, e.g.: + +```bash +flatpak override --user com.queryahub.querya_desktop --filesystem=/var/run/postgresql:ro +``` + +Flathub submission can reuse [`packaging/linux/flatpak/com.queryahub.querya_desktop.yml`](../packaging/linux/flatpak/com.queryahub.querya_desktop.yml). diff --git a/docs/release-checklist.md b/docs/release-checklist.md index a5491cef..4e38e2b9 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -70,7 +70,7 @@ Verify the 0.4.4 motion tokens, smooth animations, and high refresh rate support - [x] After merge to `main`, confirm any **Auto Version Bump** still yields a **0.4.11+…** product version (do not ship as 0.4.12). - [x] **Tag** `0.4.11` is placed on the **commit that includes all fixes** you want in binaries (a tag does not auto-include later commits; see [CONTRIBUTING.md](../CONTRIBUTING.md)). - [x] Run the **Release** workflow from GitHub Actions (see [tags-and-releases.md](tags-and-releases.md)). -- [ ] Verify **portable** zips (`*-linux.zip`, `*-windows.zip`, `*-macos.zip`), **installable** artifacts (`*.AppImage`, `*.deb`, `*-windows-setup.exe`), and `SHA256SUMS.txt` on the GitHub Release. +- [ ] Verify **portable** zips (`*-linux.zip`, `*-windows.zip`, `*-macos.zip`), **installable** artifacts (`*.AppImage`, `*.deb`, `*.rpm`, `*.flatpak`, `*-windows-setup.exe`), and `SHA256SUMS.txt` on the GitHub Release. ## Docs diff --git a/docs/tags-and-releases.md b/docs/tags-and-releases.md index 1907b485..2d9b0621 100644 --- a/docs/tags-and-releases.md +++ b/docs/tags-and-releases.md @@ -43,13 +43,17 @@ |----------|--------| | `Querya-Desktop-X.Y.Z-linux.AppImage` | [`scripts/linux/build_appimage.sh`](../scripts/linux/build_appimage.sh) (`chmod +x` then run) | | `Querya-Desktop-X.Y.Z-linux.deb` | Debian/Ubuntu — [`scripts/linux/build_deb.sh`](../scripts/linux/build_deb.sh) | +| `Querya-Desktop-X.Y.Z-linux.rpm` | Fedora/RHEL — [`scripts/linux/build_rpm.sh`](../scripts/linux/build_rpm.sh) | +| `Querya-Desktop-X.Y.Z-linux.flatpak` | Distro-agnostic — [`scripts/linux/build_flatpak.sh`](../scripts/linux/build_flatpak.sh) | | `Querya-Desktop-X.Y.Z-windows-setup.exe` | Inno Setup (`packaging/windows/querya.iss`) | ```bash sudo apt install ./Querya-Desktop-X.Y.Z-linux.deb +sudo dnf install ./Querya-Desktop-X.Y.Z-linux.rpm +flatpak install --user ./Querya-Desktop-X.Y.Z-linux.flatpak ``` -RPM / Flatpak / AUR: [#386](https://github.com/QueryaHub/Querya-Desktop/issues/386) / epic [#379](https://github.com/QueryaHub/Querya-Desktop/issues/379). +**Arch (AUR):** community PKGBUILD — [`packaging/linux/aur/`](../packaging/linux/aur/) (installs the Release portable zip under `/opt`). ## Changelog в GitHub Release diff --git a/packaging/linux/aur/PKGBUILD b/packaging/linux/aur/PKGBUILD new file mode 100644 index 00000000..35ef2b6f --- /dev/null +++ b/packaging/linux/aur/PKGBUILD @@ -0,0 +1,39 @@ +# Maintainer: QueryaHub +# AUR package — installs the official Release portable Linux zip under /opt. +# Bump pkgver/pkgrel when a new GitHub Release is published. +pkgname=querya-desktop +pkgver=0.4.11 +pkgrel=1 +pkgdesc="Multi-database desktop client (PostgreSQL, MySQL, Redis, MongoDB, SQLite)" +arch=('x86_64') +url="https://github.com/QueryaHub/Querya-Desktop" +license=('MIT') +depends=('gtk3' 'libsecret' 'glib2') +optdepends=( + 'libappindicator-gtk3: system tray / app indicator' +) +source=( + "Querya-Desktop-${pkgver}-linux.zip::https://github.com/QueryaHub/Querya-Desktop/releases/download/${pkgver}/Querya-Desktop-${pkgver}-linux.zip" + "querya_desktop.desktop" +) +sha256sums=('SKIP' 'SKIP') + +prepare() { + bsdtar -xf "$srcdir/Querya-Desktop-${pkgver}-linux.zip" -C "$srcdir" +} + +package() { + install -dm755 "$pkgdir/opt/querya-desktop" + cp -a "$srcdir"/{querya_desktop,lib,data} "$pkgdir/opt/querya-desktop/" + + install -dm755 "$pkgdir/usr/bin" + ln -s /opt/querya-desktop/querya_desktop "$pkgdir/usr/bin/querya_desktop" + + install -dm755 "$pkgdir/usr/share/applications" + install -Dm644 "$srcdir/querya_desktop.desktop" \ + "$pkgdir/usr/share/applications/querya_desktop.desktop" + + install -dm755 "$pkgdir/usr/share/icons/hicolor/512x512/apps" + install -Dm644 "$srcdir/querya_desktop.png" \ + "$pkgdir/usr/share/icons/hicolor/512x512/apps/querya_desktop.png" +} diff --git a/packaging/linux/aur/README.md b/packaging/linux/aur/README.md new file mode 100644 index 00000000..fdcfdd8b --- /dev/null +++ b/packaging/linux/aur/README.md @@ -0,0 +1,21 @@ +# Arch Linux (AUR) + +Community packaging for Arch-based distros. The PKGBUILD installs the official +**portable Linux zip** from GitHub Releases under `/opt/querya-desktop`. + +## Before publishing to AUR + +1. Copy `PKGBUILD`, `querya_desktop.desktop`, and `querya_desktop.png` into a clean build directory. +2. Bump `pkgver` / `pkgrel` to match the GitHub Release tag and AUR revision. +3. Run `makepkg -si` locally and smoke-launch `querya_desktop`. +4. Generate `.SRCINFO`: `makepkg --printsrcinfo > .SRCINFO` +5. Push to your AUR repo (e.g. `querya-desktop`). + +`querya_desktop.desktop` and the 512×512 icon are the same assets used by +`.deb` / `.rpm` packaging (`packaging/linux/querya_desktop.desktop` and +`macos/Runner/Assets.xcassets/.../app_icon_512.png`). + +## Updates + +Prefer **`pacman -Syu`** / AUR helper updates over the in-app zip/AppImage +updater when running this package build. diff --git a/packaging/linux/aur/querya_desktop.desktop b/packaging/linux/aur/querya_desktop.desktop new file mode 100644 index 00000000..fc46a01b --- /dev/null +++ b/packaging/linux/aur/querya_desktop.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=Querya Desktop +Comment=Multi-database desktop client +Exec=querya_desktop +Icon=querya_desktop +Categories=Development;Database; +Terminal=false +StartupWMClass=querya_desktop +X-Querya-AppId=com.queryahub.querya_desktop diff --git a/packaging/linux/aur/querya_desktop.png b/packaging/linux/aur/querya_desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..326c0e72c9d820600887813b3b98d0dd69c5d4e8 GIT binary patch literal 36406 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 literal 0 HcmV?d00001 diff --git a/packaging/linux/flatpak/com.queryahub.querya_desktop.desktop b/packaging/linux/flatpak/com.queryahub.querya_desktop.desktop new file mode 100644 index 00000000..da264a91 --- /dev/null +++ b/packaging/linux/flatpak/com.queryahub.querya_desktop.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=Querya Desktop +Comment=Multi-database desktop client +Exec=querya_desktop +Icon=com.queryahub.querya_desktop +Categories=Development;Database; +Terminal=false +StartupWMClass=querya_desktop diff --git a/packaging/linux/flatpak/com.queryahub.querya_desktop.yml b/packaging/linux/flatpak/com.queryahub.querya_desktop.yml new file mode 100644 index 00000000..50cdaa9d --- /dev/null +++ b/packaging/linux/flatpak/com.queryahub.querya_desktop.yml @@ -0,0 +1,29 @@ +# Flatpak manifest for Querya Desktop (Flutter GTK bundle). +# Built by scripts/linux/build_flatpak.sh — bundle/ is copied next to this file at build time. +app-id: com.queryahub.querya_desktop +runtime: org.gnome.Platform +runtime-version: '48' +sdk: org.gnome.Sdk +command: querya_desktop + +finish-args: + - --share=network + - --share=ipc + - --socket=fallback-x11 + - --socket=wayland + - --device=dri + - --filesystem=home + - --talk-name=org.freedesktop.secrets + +modules: + - name: querya-desktop + buildsystem: simple + build-commands: + - install -Dm755 querya_desktop /app/bin/querya_desktop + - cp -a lib /app/ + - cp -a data /app/ + - install -Dm644 com.queryahub.querya_desktop.desktop /app/share/applications/com.queryahub.querya_desktop.desktop + - install -Dm644 com.queryahub.querya_desktop.png /app/share/icons/hicolor/512x512/apps/com.queryahub.querya_desktop.png + sources: + - type: dir + path: bundle diff --git a/packaging/linux/querya-desktop.spec b/packaging/linux/querya-desktop.spec new file mode 100644 index 00000000..fa7d1be9 --- /dev/null +++ b/packaging/linux/querya-desktop.spec @@ -0,0 +1,44 @@ +# RPM spec for Querya Desktop (built via scripts/linux/build_rpm.sh). +# Macros querya_bundle, querya_icon, querya_desktop_file, and version are passed on the command line. + +Name: querya-desktop +Version: %{version} +Release: 1%{?dist} +Summary: Querya Desktop database client +License: MIT +URL: https://github.com/QueryaHub/Querya-Desktop +BuildArch: x86_64 +Requires: gtk3 >= 3.22 +Requires: libsecret >= 0.18 +Requires: glib2 >= 2.56 +Recommends: libappindicator-gtk3 + +%description +Multi-database desktop client for PostgreSQL, MySQL, Redis, MongoDB, +SQLite, and sandboxed drivers. + +%prep +# Pre-built Flutter bundle; no source compile in this packaging path. + +%build +# no-op + +%install +rm -rf %{buildroot} +mkdir -p %{buildroot}/opt/querya-desktop +cp -a %{querya_bundle}/. %{buildroot}/opt/querya-desktop/ +chmod +x %{buildroot}/opt/querya-desktop/querya_desktop +mkdir -p %{buildroot}/usr/bin +ln -s /opt/querya-desktop/querya_desktop %{buildroot}/usr/bin/querya_desktop +mkdir -p %{buildroot}/usr/share/applications +mkdir -p %{buildroot}/usr/share/icons/hicolor/512x512/apps +install -m 644 %{querya_desktop_file} %{buildroot}/usr/share/applications/querya_desktop.desktop +install -m 644 %{querya_icon} %{buildroot}/usr/share/icons/hicolor/512x512/apps/querya_desktop.png + +%files +/opt/querya-desktop +/usr/bin/querya_desktop +/usr/share/applications/querya_desktop.desktop +/usr/share/icons/hicolor/512x512/apps/querya_desktop.png + +%changelog diff --git a/packaging/linux/querya_desktop.desktop b/packaging/linux/querya_desktop.desktop new file mode 100644 index 00000000..fc46a01b --- /dev/null +++ b/packaging/linux/querya_desktop.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=Querya Desktop +Comment=Multi-database desktop client +Exec=querya_desktop +Icon=querya_desktop +Categories=Development;Database; +Terminal=false +StartupWMClass=querya_desktop +X-Querya-AppId=com.queryahub.querya_desktop diff --git a/scripts/linux/build_deb.sh b/scripts/linux/build_deb.sh index ce39bb2e..07299dc2 100755 --- a/scripts/linux/build_deb.sh +++ b/scripts/linux/build_deb.sh @@ -8,8 +8,6 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" BUNDLE="${1:-$ROOT/build/linux/x64/release/bundle}" BINARY_NAME="querya_desktop" -# Keep in sync with linux/CMakeLists.txt APPLICATION_ID (and #385 when merged). -APP_ID="${QUERYA_LINUX_APP_ID:-com.queryahub.querya_desktop}" ICON_SRC="$ROOT/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png" if [[ ! -d "$BUNDLE" ]]; then @@ -51,18 +49,8 @@ if [[ -f "$ICON_SRC" ]]; then cp "$ICON_SRC" "$PKG/usr/share/icons/hicolor/512x512/apps/${BINARY_NAME}.png" fi -cat > "$PKG/usr/share/applications/${BINARY_NAME}.desktop" <&2 + exit 1 +fi +if [[ ! -x "$BUNDLE/$BINARY_NAME" ]]; then + echo "error: missing executable: $BUNDLE/$BINARY_NAME" >&2 + exit 1 +fi +if ! command -v flatpak-builder >/dev/null 2>&1; then + echo "error: flatpak-builder is required" >&2 + exit 1 +fi + +VERSION="$(grep '^version:' "$ROOT/pubspec.yaml" | sed 's/^version: //; s/+.*//')" +OUT="${2:-$ROOT/Querya-Desktop-${VERSION}-linux.flatpak}" + +WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/querya-flatpak.XXXXXX")" +cleanup() { rm -rf "$WORKDIR"; } +trap cleanup EXIT + +STAGING="$WORKDIR/staging" +BUILD_DIR="$WORKDIR/build" +REPO="$WORKDIR/repo" +STATE="$WORKDIR/state" + +mkdir -p "$STAGING/bundle" +cp -a "$BUNDLE"/. "$STAGING/bundle/" +cp "$MANIFEST_SRC" "$STAGING/${APP_ID}.yml" +cp "$DESKTOP_SRC" "$STAGING/bundle/${APP_ID}.desktop" +cp "$ICON_SRC" "$STAGING/bundle/${APP_ID}.png" + +flatpak-builder \ + --force-clean \ + --repo="$REPO" \ + --state-dir="$STATE" \ + "$BUILD_DIR" \ + "$STAGING/${APP_ID}.yml" + +flatpak build-bundle "$REPO" "$OUT" "$APP_ID" +echo "Wrote $OUT" diff --git a/scripts/linux/build_rpm.sh b/scripts/linux/build_rpm.sh new file mode 100755 index 00000000..3457c72d --- /dev/null +++ b/scripts/linux/build_rpm.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Build Querya-Desktop-*.rpm from a Flutter linux release bundle (Fedora/RHEL/openSUSE). +# +# Usage: +# ./scripts/linux/build_rpm.sh [bundle_dir] [output_rpm] +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +BUNDLE="${1:-$ROOT/build/linux/x64/release/bundle}" +BINARY_NAME="querya_desktop" +ICON_SRC="$ROOT/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png" +DESKTOP_FILE="$ROOT/packaging/linux/querya_desktop.desktop" +SPEC="$ROOT/packaging/linux/querya-desktop.spec" + +if [[ ! -d "$BUNDLE" ]]; then + echo "error: Flutter linux bundle not found: $BUNDLE" >&2 + exit 1 +fi +if [[ ! -x "$BUNDLE/$BINARY_NAME" ]]; then + echo "error: missing executable: $BUNDLE/$BINARY_NAME" >&2 + exit 1 +fi +if [[ ! -f "$DESKTOP_FILE" ]]; then + echo "error: desktop file not found: $DESKTOP_FILE" >&2 + exit 1 +fi +if ! command -v rpmbuild >/dev/null 2>&1; then + echo "error: rpmbuild is required (dnf install rpm-build / apt install rpm)" >&2 + exit 1 +fi + +VERSION="$(grep '^version:' "$ROOT/pubspec.yaml" | sed 's/^version: //; s/+.*//')" +OUT="${2:-$ROOT/Querya-Desktop-${VERSION}-linux.rpm}" + +WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/querya-rpm.XXXXXX")" +cleanup() { rm -rf "$WORKDIR"; } +trap cleanup EXIT + +RPM_TOP="$WORKDIR/rpm" +mkdir -p "$RPM_TOP"/{BUILD,RPMS,SOURCES,SPECS,SRPMS,BUILDROOT} + +rpmbuild -bb "$SPEC" \ + --define "_topdir $RPM_TOP" \ + --define "version $VERSION" \ + --define "querya_bundle $BUNDLE" \ + --define "querya_icon $ICON_SRC" \ + --define "querya_desktop_file $DESKTOP_FILE" + +BUILT="$(find "$RPM_TOP/RPMS" -name 'querya-desktop-*.rpm' | head -n 1)" +if [[ -z "$BUILT" || ! -f "$BUILT" ]]; then + echo "error: rpmbuild did not produce an RPM under $RPM_TOP/RPMS" >&2 + exit 1 +fi + +cp "$BUILT" "$OUT" +echo "Wrote $OUT" From cdb78b0d3e24ad8d13e180027030d14a796f8f5f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 12:02:22 +0300 Subject: [PATCH 14/14] chore(release): prepare pre-release 0.4.11-a MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security review (#395–#402) and Linux rpm/Flatpak/AUR (#386); bump pubspec to 0.4.11-a and add CHANGELOG section for Release CI. --- CHANGELOG.md | 15 +++++++++++++++ docs/roadmap.md | 4 +++- packaging/linux/aur/PKGBUILD | 2 +- pubspec.yaml | 2 +- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a4b8477..b358b175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.11-a] - 2026-07-27 + +Post-0.4.11 patch: security review hardening (#395–#402) and remaining Linux distro packages (#386). + ### Added - **Linux distro packaging (#386)** — `.rpm`, Flatpak (`.flatpak` bundle + manifest), and AUR PKGBUILD; Release CI publishes rpm + Flatpak alongside existing `.deb` / AppImage — see [packaging.md](docs/packaging.md). +### Security + +- **Theme remote install localhost (#399)** — `ThemeRemoteInstallService` defaults `allowLocalhostInDebug` to `kDebugMode`. +- **Archive path guard (#401)** — zip extraction uses `p.isWithin()` bounds checks (`archive_path_guard.dart`). +- **Marketplace SHA256 (#396)** — `HttpMarketplaceRepository` requires manifest checksum before install. +- **Marketplace download URLs (#397)** — HTTPS allowlist / SSRF policy (`MarketplaceDownloadPolicy`). +- **Safe zip extraction (#398)** — shared zip-bomb limits via `SafeZipExtractor` (extensions, updater, themes). +- **Remote theme SHA256 (#400)** — remote theme install requires checksum when provided by metadata. +- **Sandbox OS consent (#395)** — fail-closed unsandboxed driver launch without OS wrapper (bubblewrap / consent dialog). +- **Sideload integrity UX (#402)** — local `.zip`/`.qext` install dialog with security notice and optional SHA256. + ## [0.4.11] - 2026-07-27 Universal UI standard for drivers/extensions, shell UX hardening, Fluid QueryaMotion morphing, virtual grid/pool reliability, performance follow-ups, and dual-channel packaging (portable + installable). diff --git a/docs/roadmap.md b/docs/roadmap.md index 84938bb0..e1d4c610 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -3,6 +3,7 @@ Living document for planned work. Not a commitment order; adjust as priorities change. **GitHub Latest Release:** [0.4.11](https://github.com/QueryaHub/Querya-Desktop/releases/tag/0.4.11) (2026-07-27). +**Next patch:** **0.4.11-a** — security review (#395–#402) + Linux rpm/Flatpak/AUR (#386); tag pending. **Next product release:** **0.5.0** — live Marketplace download and install — see below. ## Theme system @@ -24,7 +25,8 @@ Living document for planned work. Not a commitment order; adjust as priorities c - **Shipped in 0.4.8:** Extension Manager UI (+ mock Marketplace) — [planned-0.4.8.md](planned-0.4.8.md). - **Shipped in 0.4.9:** PostgreSQL SSL & connection reliability — see [CHANGELOG.md](../CHANGELOG.md). - **Shipped in 0.4.10:** Sandboxed extension runtime (Block E), Plugin RPC bridge (Block C), SDUI form/tree builders, local `.zip`/`.qext` install, Registration/Activation for external database drivers (e.g. ClickHouse), in-app updater — see [CHANGELOG.md](../CHANGELOG.md). -- **Shipped in 0.4.11 (Latest):** Universal UI / SDUI RPC expand, ExtensionTableView, universal export, MySQL/SQLite parity, shell UX (#339), Fluid QueryaMotion (#342) + perf follow-ups (#356), grid/pool/timeout fixes, dual-channel packaging (portable zip + AppImage / `.deb` / Windows setup) — [CHANGELOG.md](../CHANGELOG.md) `[0.4.11]`, [packaging.md](packaging.md), epic [#379](https://github.com/QueryaHub/Querya-Desktop/issues/379). +- **Shipped in 0.4.11:** Universal UI / SDUI RPC expand, ExtensionTableView, universal export, MySQL/SQLite parity, shell UX (#339), Fluid QueryaMotion (#342) + perf follow-ups (#356), grid/pool/timeout fixes, dual-channel packaging (portable zip + AppImage / `.deb` / Windows setup) — [CHANGELOG.md](../CHANGELOG.md) `[0.4.11]`, [packaging.md](packaging.md), epic [#379](https://github.com/QueryaHub/Querya-Desktop/issues/379). +- **Pending 0.4.11-a:** security hardening (#395–#402), Linux `.rpm` / Flatpak / AUR (#386) — [CHANGELOG.md](../CHANGELOG.md) `[0.4.11-a]`. - **Planned 0.5.0:** Marketplace Launch — live download, `sha256` validation, install themes (and later DB drivers) from the network. ## Query history and favorites diff --git a/packaging/linux/aur/PKGBUILD b/packaging/linux/aur/PKGBUILD index 35ef2b6f..f789bcdd 100644 --- a/packaging/linux/aur/PKGBUILD +++ b/packaging/linux/aur/PKGBUILD @@ -2,7 +2,7 @@ # AUR package — installs the official Release portable Linux zip under /opt. # Bump pkgver/pkgrel when a new GitHub Release is published. pkgname=querya-desktop -pkgver=0.4.11 +pkgver=0.4.11-a pkgrel=1 pkgdesc="Multi-database desktop client (PostgreSQL, MySQL, Redis, MongoDB, SQLite)" arch=('x86_64') diff --git a/pubspec.yaml b/pubspec.yaml index 76bdd6b6..20394a8d 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.11+2 +version: 0.4.11-a