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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions lib/core/platform/macos_sandbox.dart
Original file line number Diff line number Diff line change
@@ -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/<bundle id>/Data`).
static bool detect(Map<String, String> 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.';
}
7 changes: 7 additions & 0 deletions lib/core/updater/installers/update_install_context.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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 '
Expand Down
8 changes: 8 additions & 0 deletions lib/features/settings/preferences_extensions_section.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions lib/features/settings/preferences_mcp_section.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -177,6 +178,13 @@ class PreferencesMcpSectionState extends material.State<PreferencesMcpSection> {
'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(
Expand Down
44 changes: 44 additions & 0 deletions test/core/platform/macos_sandbox_test.dart
Original file line number Diff line number Diff line change
@@ -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'));
});
});
}
12 changes: 12 additions & 0 deletions test/core/updater/macos_app_replace_script_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions test/features/settings/preferences_extensions_section_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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(
Expand Down
Loading