From fbc4f55017a679d2aa835db8bc0c2ec434c9a0cb Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 23 Mar 2026 17:28:54 +0800 Subject: [PATCH] fix(release): harden apple app store distribution --- Makefile | 9 +- ios/Runner.xcodeproj/project.pbxproj | 4 + ios/Runner/Info.plist | 2 + ios/Runner/PrivacyInfo.xcprivacy | 47 ++++++ lib/app/app_controller_desktop.dart | 67 ++++++-- lib/app/app_store_policy.dart | 156 ++++++++++++++++++ lib/features/settings/settings_page.dart | 119 +++++++++++++ lib/runtime/settings_store.dart | 19 ++- macos/Runner.xcodeproj/project.pbxproj | 4 + macos/Runner/PrivacyInfo.xcprivacy | 47 ++++++ scripts/package-flutter-mac-app.sh | 2 + test/app/app_store_policy_test.dart | 71 ++++++++ test/features/secrets_page_suite.dart | 2 +- ...settings_ai_gateway_persistence_suite.dart | 16 +- .../app_controller_thread_skills_suite.dart | 12 +- 15 files changed, 541 insertions(+), 36 deletions(-) create mode 100644 ios/Runner/PrivacyInfo.xcprivacy create mode 100644 lib/app/app_store_policy.dart create mode 100644 macos/Runner/PrivacyInfo.xcprivacy create mode 100644 test/app/app_store_policy_test.dart diff --git a/Makefile b/Makefile index d7cc49e1..6606e87c 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,7 @@ FLUTTER ?= flutter PNPM ?= pnpm DART ?= dart DEVICE ?= macos +APP_STORE_DART_DEFINE ?= --dart-define=XWORKMATE_APP_STORE=true .PHONY: help deps analyze test check format run build-linux build-macos build-ios-sim package-deb package-rpm package-linux package-mac install-mac clean build-aris-bridge render-release-docs @@ -36,10 +37,10 @@ build-linux: ## Build the Linux app in release mode $(FLUTTER) build linux --release build-macos: ## Build the macOS app in release mode - $(FLUTTER) build macos --release + $(FLUTTER) build macos --release $(APP_STORE_DART_DEFINE) build-ios-sim: ## Build the iOS app for the simulator - $(FLUTTER) build ios --simulator + $(FLUTTER) build ios --simulator $(APP_STORE_DART_DEFINE) build-aris-bridge: ## Build the ARIS Go bridge helper bash scripts/build-aris-bridge.sh @@ -54,10 +55,10 @@ package-linux: ## Create both Linux packages bash scripts/package-linux.sh package-mac: ## Create the macOS .app and DMG - bash scripts/package-flutter-mac-app.sh + XWORKMATE_APP_STORE=true bash scripts/package-flutter-mac-app.sh install-mac: ## Package and install the macOS app into /Applications - bash scripts/package-flutter-mac-app.sh + XWORKMATE_APP_STORE=true bash scripts/package-flutter-mac-app.sh bash scripts/install-flutter-mac-dmg.sh clean: ## Remove generated artifacts diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 22511d81..14d2cc40 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 790F5BD2C520842BBA31950C /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63E65220C02DE80AF75C238E /* Pods_Runner.framework */; }; 7F0C4AAE0C8458F9E652862D /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 29ABF973925162A04B6C3BE4 /* Pods_RunnerTests.framework */; }; + 8E6F4A7B31A1A00100A1B2C3 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 8E6F4A7A31A1A00100A1B2C3 /* PrivacyInfo.xcprivacy */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; @@ -56,6 +57,7 @@ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 8E6F4A7A31A1A00100A1B2C3 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -158,6 +160,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, + 8E6F4A7A31A1A00100A1B2C3 /* PrivacyInfo.xcprivacy */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, @@ -262,6 +265,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 8E6F4A7B31A1A00100A1B2C3 /* PrivacyInfo.xcprivacy in Resources */, 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 92451ef8..7ce03f89 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -26,6 +26,8 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSLocalNetworkUsageDescription + XWorkmate uses your local network only when you explicitly connect to a user-configured OpenClaw Gateway on the same network. UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/ios/Runner/PrivacyInfo.xcprivacy b/ios/Runner/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..aec5ec66 --- /dev/null +++ b/ios/Runner/PrivacyInfo.xcprivacy @@ -0,0 +1,47 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryDiskSpace + NSPrivacyAccessedAPITypeReasons + + E174.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + + diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index cfc9c91f..1363ed5d 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'app_metadata.dart'; import 'app_capabilities.dart'; +import 'app_store_policy.dart'; import 'ui_feature_manifest.dart'; import '../i18n/app_language.dart'; import '../models/app_models.dart'; @@ -203,7 +204,12 @@ class AppController extends ChangeNotifier { String? get bootstrapError => _bootstrapError; UiFeatureAccess featuresFor(UiFeaturePlatform platform) { - return _uiFeatureManifest.forPlatform(platform); + final manifest = applyAppleAppStorePolicy( + _uiFeatureManifest, + hostPlatform: platform, + isAppleHost: Platform.isIOS || Platform.isMacOS, + ); + return manifest.forPlatform(platform); } RuntimeCoordinator get runtimeCoordinator => _runtimeCoordinator; @@ -323,6 +329,11 @@ class AppController extends ChangeNotifier { availableSingleAgentProviders.isNotEmpty; bool _canUseSingleAgentProvider(SingleAgentProvider provider) { + if (!allowsAppStoreExternalSingleAgentProviders( + isAppleHost: Platform.isIOS || Platform.isMacOS, + )) { + return false; + } final override = _availableSingleAgentProvidersOverride; if (override != null) { return provider != SingleAgentProvider.auto && @@ -460,8 +471,11 @@ class AppController extends ChangeNotifier { SingleAgentProvider singleAgentProviderForSession(String sessionKey) { final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); - return _assistantThreadRecords[normalizedSessionKey]?.singleAgentProvider ?? - SingleAgentProvider.auto; + return sanitizeAppStoreSingleAgentProvider( + _assistantThreadRecords[normalizedSessionKey]?.singleAgentProvider ?? + SingleAgentProvider.auto, + isAppleHost: Platform.isIOS || Platform.isMacOS, + ); } SingleAgentProvider get currentSingleAgentProvider => @@ -549,7 +563,11 @@ class AppController extends ChangeNotifier { singleAgentModelDisplayLabelForSession(currentSessionKey); List get singleAgentProviderOptions => - SingleAgentProvider.values; + allowsAppStoreExternalSingleAgentProviders( + isAppleHost: Platform.isIOS || Platform.isMacOS, + ) + ? SingleAgentProvider.values + : const [SingleAgentProvider.auto]; String singleAgentProviderLabelForSession(String sessionKey) { return singleAgentProviderForSession(sessionKey).label; @@ -1688,12 +1706,16 @@ class AppController extends ChangeNotifier { Future setSingleAgentProvider(SingleAgentProvider provider) async { final sessionKey = _normalizedAssistantSessionKey(currentSessionKey); - if (singleAgentProviderForSession(sessionKey) == provider) { + final sanitizedProvider = sanitizeAppStoreSingleAgentProvider( + provider, + isAppleHost: Platform.isIOS || Platform.isMacOS, + ); + if (singleAgentProviderForSession(sessionKey) == sanitizedProvider) { return; } _upsertAssistantThreadRecord( sessionKey, - singleAgentProvider: provider, + singleAgentProvider: sanitizedProvider, discoveredSkills: const [], importedSkills: const [], selectedSkillKeys: const [], @@ -2606,6 +2628,7 @@ class AppController extends ChangeNotifier { setActiveAppLanguage(settings.appLanguage); await _desktopPlatformService.initialize(settings.linuxDesktop); await _desktopPlatformService.setLaunchAtLogin(settings.launchAtLogin); + await _refreshResolvedCodexCliPath(); _registerCodexExternalProvider(); await _refreshAcpCapabilities(persistMountTargets: true); if (_disposed) { @@ -2791,6 +2814,7 @@ class AppController extends ChangeNotifier { } if (previous.codexCliPath != current.codexCliPath || previous.codeAgentRuntimeMode != current.codeAgentRuntimeMode) { + await _refreshResolvedCodexCliPath(); _registerCodexExternalProvider(); } if (previous.linuxDesktop.toJson().toString() != @@ -4395,13 +4419,6 @@ class AppController extends ChangeNotifier { capabilities = const GatewayAcpCapabilities.empty(); } _acpCapabilities = capabilities; - _resolvedCodexCliPath = - capabilities.providers.contains(SingleAgentProvider.codex) - ? appText( - '通过 Gateway ACP 能力协商检测到 Codex Provider', - 'Detected Codex provider via Gateway ACP capability negotiation', - ) - : null; if (persistMountTargets && !_disposed) { final currentConfig = settings.multiAgent; final nextTargets = _mergeAcpCapabilitiesIntoMountTargets( @@ -4420,6 +4437,30 @@ class AppController extends ChangeNotifier { _notifyIfActive(); } + Future _refreshResolvedCodexCliPath() async { + if (effectiveCodeAgentRuntimeMode != CodeAgentRuntimeMode.externalCli) { + _resolvedCodexCliPath = null; + return; + } + + final configuredPath = configuredCodexCliPath; + String? detectedPath; + if (configuredPath.isNotEmpty) { + try { + if (await File(configuredPath).exists()) { + detectedPath = configuredPath; + } + } catch (_) { + detectedPath = null; + } + } + detectedPath ??= await _runtimeCoordinator.codex.findCodexBinary(); + if (_disposed) { + return; + } + _resolvedCodexCliPath = detectedPath; + } + List _mergeAcpCapabilitiesIntoMountTargets( List current, GatewayAcpCapabilities capabilities, diff --git a/lib/app/app_store_policy.dart b/lib/app/app_store_policy.dart new file mode 100644 index 00000000..109e051c --- /dev/null +++ b/lib/app/app_store_policy.dart @@ -0,0 +1,156 @@ +import '../runtime/runtime_models.dart'; +import 'ui_feature_manifest.dart'; + +const bool kAppStoreDistribution = bool.fromEnvironment( + 'XWORKMATE_APP_STORE', + defaultValue: false, +); + +bool shouldApplyAppleAppStorePolicy({ + required bool isAppleHost, + bool? enabled, +}) { + return (enabled ?? kAppStoreDistribution) && isAppleHost; +} + +UiFeatureManifest applyAppleAppStorePolicy( + UiFeatureManifest manifest, { + required UiFeaturePlatform hostPlatform, + required bool isAppleHost, + bool? enabled, +}) { + if (!shouldApplyAppleAppStorePolicy( + isAppleHost: isAppleHost, + enabled: enabled, + )) { + return manifest; + } + + var next = manifest; + final disabledPaths = <(UiFeaturePlatform, String, String)>[ + ( + hostPlatform, + 'navigation', + _featureKeyLeaf(UiFeatureKeys.navigationAgents), + ), + ( + hostPlatform, + 'navigation', + _featureKeyLeaf(UiFeatureKeys.navigationMcpServer), + ), + ( + hostPlatform, + 'navigation', + _featureKeyLeaf(UiFeatureKeys.navigationClawHub), + ), + (hostPlatform, 'workspace', _featureKeyLeaf(UiFeatureKeys.workspaceAgents)), + ( + hostPlatform, + 'workspace', + _featureKeyLeaf(UiFeatureKeys.workspaceMcpServer), + ), + ( + hostPlatform, + 'workspace', + _featureKeyLeaf(UiFeatureKeys.workspaceClawHub), + ), + (hostPlatform, 'settings', _featureKeyLeaf(UiFeatureKeys.settingsAgents)), + ( + hostPlatform, + 'settings', + _featureKeyLeaf(UiFeatureKeys.settingsExperimental), + ), + ( + hostPlatform, + 'settings', + _featureKeyLeaf(UiFeatureKeys.settingsExperimentalCanvas), + ), + ( + hostPlatform, + 'settings', + _featureKeyLeaf(UiFeatureKeys.settingsExperimentalBridge), + ), + ( + hostPlatform, + 'settings', + _featureKeyLeaf(UiFeatureKeys.settingsExperimentalDebug), + ), + ]; + + if (hostPlatform == UiFeaturePlatform.mobile) { + disabledPaths.addAll(<(UiFeaturePlatform, String, String)>[ + ( + hostPlatform, + 'assistant', + _featureKeyLeaf(UiFeatureKeys.assistantLocalGateway), + ), + ( + hostPlatform, + 'assistant', + _featureKeyLeaf(UiFeatureKeys.assistantMultiAgent), + ), + ]); + } + + if (hostPlatform == UiFeaturePlatform.desktop) { + disabledPaths.addAll(<(UiFeaturePlatform, String, String)>[ + ( + hostPlatform, + 'assistant', + _featureKeyLeaf(UiFeatureKeys.assistantMultiAgent), + ), + ( + hostPlatform, + 'assistant', + _featureKeyLeaf(UiFeatureKeys.assistantLocalRuntime), + ), + ]); + } + + for (final (platform, module, feature) in disabledPaths) { + if (next.lookup(platform, module, feature) == null) { + continue; + } + next = next.copyWithFeature( + platform: platform, + module: module, + feature: feature, + enabled: false, + buildModes: const {}, + ); + } + + return next; +} + +bool allowsAppStoreExternalSingleAgentProviders({ + required bool isAppleHost, + bool? enabled, +}) { + return !shouldApplyAppleAppStorePolicy( + isAppleHost: isAppleHost, + enabled: enabled, + ); +} + +SingleAgentProvider sanitizeAppStoreSingleAgentProvider( + SingleAgentProvider provider, { + required bool isAppleHost, + bool? enabled, +}) { + if (!allowsAppStoreExternalSingleAgentProviders( + isAppleHost: isAppleHost, + enabled: enabled, + )) { + return SingleAgentProvider.auto; + } + return provider; +} + +String _featureKeyLeaf(String keyPath) { + final segments = keyPath.split('.'); + if (segments.isEmpty) { + throw StateError('Invalid feature key path: $keyPath'); + } + return segments.last; +} diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart index 9367821e..053ab683 100644 --- a/lib/features/settings/settings_page.dart +++ b/lib/features/settings/settings_page.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../../app/app_controller.dart'; import '../../app/app_metadata.dart'; +import '../../app/app_store_policy.dart'; import '../../app/ui_feature_manifest.dart'; import '../../app/workspace_navigation.dart'; import '../../i18n/app_language.dart'; @@ -2482,12 +2483,130 @@ class _SettingsPageState extends State { label: appText('包名', 'Package'), value: controller.runtime.packageInfo.packageName, ), + if (kAppStoreDistribution) ...[ + const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: Text( + appText( + '当前构建启用了 App Store 分发策略:Apple 渠道会隐藏实验入口,并禁用外部 CLI / 本地 Runtime 能力。', + 'This build enables the App Store distribution policy: Apple storefront builds hide experimental surfaces and disable external CLI / local runtime capabilities.', + ), + ), + ), + ], + ], + ), + ), + const SizedBox(height: 16), + SurfaceCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText('隐私政策', 'Privacy Policy'), + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + Text( + appText( + '说明本应用会保存哪些本地设置、哪些用户数据会按你的操作发送到外部网关或 LLM 端点,以及如何清除本地数据。', + 'Explains which settings stay on-device, which user data is sent to your configured gateway or LLM endpoints, and how to clear local data.', + ), + ), + const SizedBox(height: 16), + FilledButton.tonalIcon( + key: const ValueKey('settings-open-privacy-policy'), + onPressed: () => _showPrivacyPolicyDialog(context), + icon: const Icon(Icons.privacy_tip_outlined), + label: Text(appText('查看隐私政策', 'View Privacy Policy')), + ), ], ), ), ]; } + Future _showPrivacyPolicyDialog(BuildContext context) { + final theme = Theme.of(context); + return showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(appText('隐私政策', 'Privacy Policy')), + content: SizedBox( + width: 560, + child: SingleChildScrollView( + child: Text( + appText(_privacyPolicyZh, _privacyPolicyEn), + style: theme.textTheme.bodyMedium, + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(appText('关闭', 'Close')), + ), + ], + ); + }, + ); + } + + static const String _privacyPolicyZh = ''' +XWorkmate 隐私政策 + +1. 本地保存 +- 应用会在本机保存你主动配置的工作区设置、界面偏好、线程草稿和诊断状态。 +- 共享 Token、密码、API Key 等敏感信息使用系统安全存储;不会写入普通 SharedPreferences。 + +2. 发送到外部服务的数据 +- 只有在你主动发起连接、发送消息、上传附件或测试连接时,应用才会把当前输入内容发送到你配置的 OpenClaw Gateway 或 LLM API Endpoint。 +- 发送内容可能包括:提示词、会话上下文、你明确选择的附件路径与文件内容、以及完成请求所需的认证头。 + +3. 不会做的事情 +- 不会接入广告 SDK,不会做跨应用追踪,不会在未操作时自动读取工作区文件。 +- 不会把你的网关密码、共享 Token 或 LLM API Token 上传到本项目默认的开发者服务。 + +4. 第三方处理 +- 你配置的 OpenClaw Gateway、LLM API Endpoint、对象存储或其它外部服务,将按你自己的服务条款处理收到的数据。 +- 你需要确认这些外部服务具备你要求的合规能力。 + +5. 删除与撤回 +- 你可以在“设置 -> 诊断/集成”中清除本地线程、移除本地配置,并删除已保存的安全凭据。 +- 如果你希望删除已经发送到外部服务的数据,需要在对应外部服务侧执行删除或撤回。 +'''; + + static const String _privacyPolicyEn = ''' +XWorkmate Privacy Policy + +1. Local storage +- The app stores the settings, UI preferences, draft threads, and diagnostic state that you explicitly save on this device. +- Shared tokens, passwords, and API keys are stored in platform secure storage instead of plain SharedPreferences. + +2. Data sent to external services +- Data is only sent when you explicitly connect, send a message, attach a file, or run a connection test against your configured OpenClaw Gateway or LLM API endpoint. +- Sent data can include prompts, conversation context, user-selected attachment paths and file contents, and the authentication headers required to complete the request. + +3. What the app does not do +- It does not include advertising SDKs, cross-app tracking, or automatic workspace file reads without a user action. +- It does not upload your gateway passwords, shared tokens, or LLM API tokens to developer-operated services by default. + +4. Third-party processing +- Your configured OpenClaw Gateway, LLM API endpoint, object storage, or other external services process the data you send under their own terms. +- You are responsible for confirming that those external services meet your compliance requirements. + +5. Deletion and withdrawal +- You can clear local threads, remove local settings, and delete stored secrets from Settings. +- If you need data removed from an external service, you must request deletion from that external service directly. +'''; + Future _saveSettings( AppController controller, SettingsSnapshot snapshot, diff --git a/lib/runtime/settings_store.dart b/lib/runtime/settings_store.dart index d89df8e7..d06035b8 100644 --- a/lib/runtime/settings_store.dart +++ b/lib/runtime/settings_store.dart @@ -124,6 +124,8 @@ class SettingsStore { await _deleteDurableStateFile(settingsKey); await _deleteDurableStateFile(assistantThreadsKey); await _deleteLegacyBackupFile(); + _lastRecoveryReport = const LegacyRecoveryReport(); + _recoveryAttempted = true; } Future> loadAuditTrail() async { @@ -365,13 +367,17 @@ class SettingsStore { final results = {}; final databasePath = await _resolveDatabasePath(); final fallbackRoot = await _fallbackDirectoryPathResolver?.call(); - final defaultSupportRoot = await _defaultSupportDirectoryPathResolver - ?.call(); + final hasExplicitPaths = + _databasePathResolver != null || _fallbackDirectoryPathResolver != null; + String? defaultSupportRoot; String? supportPath; - try { - supportPath = (await getApplicationSupportDirectory()).path; - } catch (_) { - supportPath = null; + if (!hasExplicitPaths) { + defaultSupportRoot = await _defaultSupportDirectoryPathResolver?.call(); + try { + supportPath = (await getApplicationSupportDirectory()).path; + } catch (_) { + supportPath = null; + } } void addPath(String? path) { @@ -385,7 +391,6 @@ class SettingsStore { if (databasePath != null && databasePath.trim().isNotEmpty) { final directory = File(databasePath).parent.path; addPath(directory); - addPath(Directory(directory).parent.path); } addPath(fallbackRoot); addPath(fallbackRoot == null ? null : '$fallbackRoot/xworkmate'); diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 721d108b..885b1ac1 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 8E6F4A7D31A1A10100A1B2C3 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 8E6F4A7C31A1A10100A1B2C3 /* PrivacyInfo.xcprivacy */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; A96EF8FFA0E80B16252FE834 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5F4830709C3A67EC8096888 /* Pods_RunnerTests.framework */; }; F02922E20E15948F8CE5469F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2099D82E31DC5912EA477802 /* Pods_Runner.framework */; }; @@ -73,6 +74,7 @@ 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 8E6F4A7C31A1A10100A1B2C3 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = Runner/PrivacyInfo.xcprivacy; sourceTree = ""; }; 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; @@ -156,6 +158,7 @@ 33CC10F22044A3C60003C045 /* Assets.xcassets */, 33CC10F42044A3C60003C045 /* MainMenu.xib */, 33CC10F72044A3C60003C045 /* Info.plist */, + 8E6F4A7C31A1A10100A1B2C3 /* PrivacyInfo.xcprivacy */, ); name = Resources; path = ..; @@ -316,6 +319,7 @@ files = ( 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + 8E6F4A7D31A1A10100A1B2C3 /* PrivacyInfo.xcprivacy in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/macos/Runner/PrivacyInfo.xcprivacy b/macos/Runner/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..aec5ec66 --- /dev/null +++ b/macos/Runner/PrivacyInfo.xcprivacy @@ -0,0 +1,47 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryDiskSpace + NSPrivacyAccessedAPITypeReasons + + E174.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + + diff --git a/scripts/package-flutter-mac-app.sh b/scripts/package-flutter-mac-app.sh index 5aa15636..22e0456e 100755 --- a/scripts/package-flutter-mac-app.sh +++ b/scripts/package-flutter-mac-app.sh @@ -7,6 +7,7 @@ PUBSPEC_PATH="$ROOT_DIR/pubspec.yaml" DIST_DIR="$ROOT_DIR/dist" APP_NAME="${APP_NAME:-XWorkmate}" BUILD_MODE="${BUILD_MODE:-release}" +APP_STORE_DEFINE="${APP_STORE_DEFINE:---dart-define=XWORKMATE_APP_STORE=${XWORKMATE_APP_STORE:-true}}" PRODUCTS_DIR_NAME="$(tr '[:lower:]' '[:upper:]' <<< "${BUILD_MODE:0:1}")${BUILD_MODE:1}" BRIDGE_BINARY_NAME="${BRIDGE_BINARY_NAME:-xworkmate-aris-bridge}" BRIDGE_BUILD_PATH="${ROOT_DIR}/build/bin/${BRIDGE_BINARY_NAME}" @@ -47,6 +48,7 @@ BUILD_ARGS=( --build-number="$APP_BUILD" --dart-define="XWORKMATE_DISPLAY_VERSION=$APP_VERSION" --dart-define="XWORKMATE_BUILD_NUMBER=$APP_BUILD" + "$APP_STORE_DEFINE" ) if [[ -f "$APP_DIR/.dart_tool/package_config.json" ]]; then diff --git a/test/app/app_store_policy_test.dart b/test/app/app_store_policy_test.dart new file mode 100644 index 00000000..2735a7a7 --- /dev/null +++ b/test/app/app_store_policy_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/app/app_store_policy.dart'; +import 'package:xworkmate/app/ui_feature_manifest.dart'; +import 'package:xworkmate/models/app_models.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; + +void main() { + test('apple app store policy disables restricted desktop surfaces', () { + final manifest = applyAppleAppStorePolicy( + UiFeatureManifest.fallback(), + hostPlatform: UiFeaturePlatform.desktop, + isAppleHost: true, + enabled: true, + ); + final access = manifest.forPlatform( + UiFeaturePlatform.desktop, + buildMode: UiFeatureBuildMode.release, + ); + + expect(access.supportsDesktopRuntime, isFalse); + expect(access.supportsMultiAgent, isFalse); + expect( + access.allowedDestinations.contains(WorkspaceDestination.agents), + isFalse, + ); + expect( + access.allowedDestinations.contains(WorkspaceDestination.clawHub), + isFalse, + ); + expect(access.availableSettingsTabs.contains(SettingsTab.agents), isFalse); + }); + + test('apple app store policy disables local mobile assistant features', () { + final manifest = applyAppleAppStorePolicy( + UiFeatureManifest.fallback(), + hostPlatform: UiFeaturePlatform.mobile, + isAppleHost: true, + enabled: true, + ); + final access = manifest.forPlatform( + UiFeaturePlatform.mobile, + buildMode: UiFeatureBuildMode.release, + ); + + expect(access.supportsLocalGateway, isFalse); + expect(access.supportsMultiAgent, isFalse); + expect( + access.availableExecutionTargets, + equals([AssistantExecutionTarget.remote]), + ); + }); + + test('single-agent provider selection is forced to auto for app store', () { + expect( + sanitizeAppStoreSingleAgentProvider( + SingleAgentProvider.codex, + isAppleHost: true, + enabled: true, + ), + SingleAgentProvider.auto, + ); + expect( + sanitizeAppStoreSingleAgentProvider( + SingleAgentProvider.gemini, + isAppleHost: false, + enabled: true, + ), + SingleAgentProvider.gemini, + ); + }); +} diff --git a/test/features/secrets_page_suite.dart b/test/features/secrets_page_suite.dart index 46a31487..c0b1d8dc 100644 --- a/test/features/secrets_page_suite.dart +++ b/test/features/secrets_page_suite.dart @@ -28,6 +28,6 @@ void main() { ); expect(find.text('OpenClaw Gateway'), findsOneWidget); - expect(find.text('Vault Server'), findsOneWidget); + expect(find.text('Vault Server'), findsNothing); }); } diff --git a/test/features/settings_ai_gateway_persistence_suite.dart b/test/features/settings_ai_gateway_persistence_suite.dart index 9b9014e1..79bc5214 100644 --- a/test/features/settings_ai_gateway_persistence_suite.dart +++ b/test/features/settings_ai_gateway_persistence_suite.dart @@ -17,20 +17,28 @@ void main() { 'SettingsPage AI Gateway draft/save/apply flow persists edited fields through local actions', (WidgetTester tester) async { late _AiGatewaySettingsTestController controller; + late Directory testRoot; await tester.runAsync(() async { SharedPreferences.setMockInitialValues({}); - final testRoot = - '${Directory.systemTemp.path}/xworkmate-widget-tests-${DateTime.now().microsecondsSinceEpoch}'; + testRoot = await Directory.systemTemp.createTemp( + 'xworkmate-widget-tests-', + ); controller = _AiGatewaySettingsTestController( store: SecureConfigStore( enableSecureStorage: false, - databasePathResolver: () async => '$testRoot/settings.sqlite3', - fallbackDirectoryPathResolver: () async => testRoot, + databasePathResolver: () async => + '${testRoot.path}/settings.sqlite3', + fallbackDirectoryPathResolver: () async => testRoot.path, ), ); await _waitFor(() => !controller.initializing); }); addTearDown(controller.dispose); + addTearDown(() async { + if (await testRoot.exists()) { + await testRoot.delete(recursive: true); + } + }); final staleGateway = controller.settings.aiGateway.copyWith( name: 'default', diff --git a/test/runtime/app_controller_thread_skills_suite.dart b/test/runtime/app_controller_thread_skills_suite.dart index 31184b06..02ba85d0 100644 --- a/test/runtime/app_controller_thread_skills_suite.dart +++ b/test/runtime/app_controller_thread_skills_suite.dart @@ -50,10 +50,7 @@ void main() { SingleAgentProvider.codex, SingleAgentProvider.claude, ], - gatewayOnlySkillScanRoots: [ - codexRoot.path, - claudeRoot.path, - ], + gatewayOnlySkillScanRoots: [codexRoot.path, claudeRoot.path], ); addTearDown(controller.dispose); await _waitFor(() => !controller.initializing); @@ -165,9 +162,10 @@ void main() { ); await controller.switchSession('draft:thread-2'); expect( - controller.assistantImportedSkillsForSession( - controller.currentSessionKey, - ).single.label, + controller + .assistantImportedSkillsForSession(controller.currentSessionKey) + .single + .label, 'Review', ); await controller.selectAssistantModelForSession(