From 42b7cf19a5322b8f738e4446ba58a394b03292a0 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 17 Oct 2026 12:52:28 +0300 Subject: [PATCH] fix(macos): detect the App Sandbox; the updater refuses to replace a sandboxed app, MCP and extensions say why they cannot work (#1593) --- lib/core/platform/macos_sandbox.dart | 38 ++++++++++++++++ .../installers/update_install_context.dart | 7 +++ .../preferences_extensions_section.dart | 8 ++++ .../settings/preferences_mcp_section.dart | 8 ++++ test/core/platform/macos_sandbox_test.dart | 44 +++++++++++++++++++ .../macos_app_replace_script_test.dart | 12 +++++ .../preferences_extensions_section_test.dart | 32 ++++++++++++++ 7 files changed, 149 insertions(+) create mode 100644 lib/core/platform/macos_sandbox.dart create mode 100644 test/core/platform/macos_sandbox_test.dart diff --git a/lib/core/platform/macos_sandbox.dart b/lib/core/platform/macos_sandbox.dart new file mode 100644 index 00000000..cbdcdeb2 --- /dev/null +++ b/lib/core/platform/macos_sandbox.dart @@ -0,0 +1,38 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart' show visibleForTesting; + +/// Whether Querya Desktop runs inside the macOS App Sandbox (#1593). +/// +/// The release builds are not sandboxed (#1592), but a debug build +/// (`DebugProfile.entitlements`) and any store-style build are. In the +/// sandbox the app cannot replace its own bundle, start extension processes, +/// let an MCP client outside the sandbox read its endpoint file, or read SSH +/// keys and database files it was not handed: those features then fail +/// without saying why, so they check this and say it. +abstract final class MacOsSandbox { + static bool? _override; + + /// Forces [isActive] in tests; `null` goes back to detection. + @visibleForTesting + static set debugOverride(bool? value) => _override = value; + + /// Whether this process is sandboxed (always false off macOS). + static bool get isActive => + _override ?? (Platform.isMacOS && detect(Platform.environment)); + + /// The sandbox sets `APP_SANDBOX_CONTAINER_ID`, and moves `HOME` into the + /// app's container (`~/Library/Containers//Data`). + static bool detect(Map environment) { + final container = environment['APP_SANDBOX_CONTAINER_ID']; + if (container != null && container.isNotEmpty) return true; + final home = environment['HOME'] ?? ''; + return home.contains('/Library/Containers/'); + } + + /// One line for a feature that does not work in the sandbox. + static String unavailable(String feature) => + '$feature is not available while Querya Desktop runs in the macOS App ' + 'Sandbox (a debug or store-style build). The release build from the ' + 'download page is not sandboxed.'; +} diff --git a/lib/core/updater/installers/update_install_context.dart b/lib/core/updater/installers/update_install_context.dart index 0183cbe7..7cf0f622 100644 --- a/lib/core/updater/installers/update_install_context.dart +++ b/lib/core/updater/installers/update_install_context.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/platform/macos_sandbox.dart'; import '../app_updater_service.dart'; @@ -92,6 +93,12 @@ class UpdateInstallContext { /// translocated copy, or a folder this user cannot write (`/Applications` /// for a standard user, a mounted disk image). String? macAppBundleReplaceProblemFor(String bundle) { + // The sandbox cannot write outside its container, so the replace script + // would quit the app and fail (#1593). + if (MacOsSandbox.detect(environment)) { + return 'Querya Desktop is running in the macOS App Sandbox and cannot ' + 'replace itself. Download the new version from the release page.'; + } if (bundle.contains('/AppTranslocation/')) { return 'macOS is running Querya Desktop from a temporary copy, because ' 'it was opened where it was downloaded. Move Querya Desktop to the ' diff --git a/lib/features/settings/preferences_extensions_section.dart b/lib/features/settings/preferences_extensions_section.dart index cc0b8019..89ce9213 100644 --- a/lib/features/settings/preferences_extensions_section.dart +++ b/lib/features/settings/preferences_extensions_section.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/extensions/extension_paths.dart'; import 'package:querya_desktop/core/extensions/local_extension_installer.dart'; import 'package:querya_desktop/core/market/marketplace_repository.dart'; +import 'package:querya_desktop/core/platform/macos_sandbox.dart'; import 'package:querya_desktop/core/platform/open_directory.dart'; import 'package:querya_desktop/features/settings/preferences_controls.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -106,6 +107,13 @@ class PreferencesExtensionsSectionState 'Install a local .zip or .qext package without the Marketplace. ' 'Useful for development and offline environments.', ), + if (MacOsSandbox.isActive) ...[ + const material.SizedBox(height: 8), + PreferencesHint( + key: const material.ValueKey('extensions_sandbox_notice'), + MacOsSandbox.unavailable('Running extensions'), + ), + ], const material.SizedBox(height: 12), material.Wrap( spacing: 8, diff --git a/lib/features/settings/preferences_mcp_section.dart b/lib/features/settings/preferences_mcp_section.dart index 31da9fb7..31f25730 100644 --- a/lib/features/settings/preferences_mcp_section.dart +++ b/lib/features/settings/preferences_mcp_section.dart @@ -6,6 +6,7 @@ import 'package:querya_desktop/core/mcp/mcp_access_store.dart'; import 'package:querya_desktop/core/mcp/mcp_client_config.dart'; import 'package:querya_desktop/core/mcp/mcp_query_service.dart'; import 'package:querya_desktop/core/mcp/mcp_server_controller.dart'; +import 'package:querya_desktop/core/platform/macos_sandbox.dart'; import 'package:querya_desktop/core/security/connection_environment.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_typography.dart'; @@ -177,6 +178,13 @@ class PreferencesMcpSectionState extends material.State { 'read the connections you share below. Read-only: writes are ' 'refused, and no host, user or password is ever sent to the model.', ), + if (MacOsSandbox.isActive) ...[ + const material.SizedBox(height: 8), + PreferencesHint( + key: const material.ValueKey('mcp_sandbox_notice'), + MacOsSandbox.unavailable('The MCP server'), + ), + ], const material.SizedBox(height: 14), if (!_loaded) const material.Padding( diff --git a/test/core/platform/macos_sandbox_test.dart b/test/core/platform/macos_sandbox_test.dart new file mode 100644 index 00000000..96fe5b17 --- /dev/null +++ b/test/core/platform/macos_sandbox_test.dart @@ -0,0 +1,44 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/platform/macos_sandbox.dart'; + +void main() { + group('MacOsSandbox (#1593)', () { + tearDown(() => MacOsSandbox.debugOverride = null); + + test('the container id marks a sandboxed process', () { + expect( + MacOsSandbox.detect(const {'APP_SANDBOX_CONTAINER_ID': 'com.queryahub.querya'}), + isTrue, + ); + }); + + test('a HOME inside ~/Library/Containers marks it too', () { + expect( + MacOsSandbox.detect(const { + 'HOME': '/Users/ann/Library/Containers/com.queryahub.querya/Data', + }), + isTrue, + ); + }); + + test('an ordinary environment is not sandboxed', () { + expect(MacOsSandbox.detect(const {'HOME': '/Users/ann'}), isFalse); + expect(MacOsSandbox.detect(const {'APP_SANDBOX_CONTAINER_ID': ''}), isFalse); + expect(MacOsSandbox.detect(const {}), isFalse); + }); + + test('isActive is false off macOS and can be forced for tests', () { + if (!Platform.isMacOS) expect(MacOsSandbox.isActive, isFalse); + MacOsSandbox.debugOverride = true; + expect(MacOsSandbox.isActive, isTrue); + }); + + test('the message names the feature and the way out', () { + final text = MacOsSandbox.unavailable('The MCP server'); + expect(text, startsWith('The MCP server is not available')); + expect(text, contains('release build')); + }); + }); +} diff --git a/test/core/updater/macos_app_replace_script_test.dart b/test/core/updater/macos_app_replace_script_test.dart index 9948650c..39bfbf57 100644 --- a/test/core/updater/macos_app_replace_script_test.dart +++ b/test/core/updater/macos_app_replace_script_test.dart @@ -162,6 +162,18 @@ void main() { ); }); + test('a sandboxed app cannot replace itself (#1593)', () { + final sandboxed = UpdateInstallContext( + environment: const {'APP_SANDBOX_CONTAINER_ID': 'com.queryahub.querya'}, + resolvedExecutable: '/Applications/Querya.app/Contents/MacOS/q', + isDirectoryWritable: (_) => true, + ); + expect( + sandboxed.macAppBundleReplaceProblemFor('/Applications/Querya.app'), + contains('App Sandbox'), + ); + }); + test('a folder this user cannot write, such as a disk image', () { expect( context(writable: false) diff --git a/test/features/settings/preferences_extensions_section_test.dart b/test/features/settings/preferences_extensions_section_test.dart index 3191d53e..93c152c1 100644 --- a/test/features/settings/preferences_extensions_section_test.dart +++ b/test/features/settings/preferences_extensions_section_test.dart @@ -5,6 +5,7 @@ import 'package:querya_desktop/core/extensions/local_extension_installer.dart'; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/extensions/models/extension_type.dart'; import 'package:querya_desktop/core/market/marketplace_repository.dart'; +import 'package:querya_desktop/core/platform/macos_sandbox.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/features/settings/preferences_extensions_section.dart'; @@ -47,6 +48,37 @@ void main() { expect(find.text('Open extensions folder'), findsOneWidget); }); + testWidgets('says extensions cannot run in the macOS sandbox (#1593)', + (tester) async { + MacOsSandbox.debugOverride = true; + addTearDown(() => MacOsSandbox.debugOverride = null); + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold(body: PreferencesExtensionsSection()), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const material.ValueKey('extensions_sandbox_notice')), + findsOneWidget, + ); + }); + + testWidgets('outside the sandbox there is no notice', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold(body: PreferencesExtensionsSection()), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const material.ValueKey('extensions_sandbox_notice')), + findsNothing, + ); + }); + testWidgets('renders installation error with wb.destructive and error icon (#1651)', (tester) async { final installer = _FakeLocalExtensionInstaller(