From e7b09d235d5f53c989721ff7b8ed8b3e40ed43d8 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 13 Apr 2026 18:02:30 +0800 Subject: [PATCH] feat(settings): refresh account sync, add about metadata, and rewrite task dialog modes --- Makefile | 10 +- .../task-control-plane-unification.md | 4 + ...rkmate-core-module-inventory-2026-04-13.md | 1 + docs/testing/bridge-sync-contract-chain.md | 2 + ...pp_controller_desktop_runtime_helpers.dart | 1 - lib/app/app_metadata.dart | 8 + .../assistant_page_composer_bar.dart | 134 +------- .../assistant_page_task_dialog_controls.dart | 254 +++++++++++++++ .../settings/settings_about_panel.dart | 189 +++++++++++ .../settings/settings_account_panel.dart | 32 +- lib/features/settings/settings_page_core.dart | 134 ++++++++ lib/runtime/account_runtime_client.dart | 10 +- ...ime_controllers_settings_account_impl.dart | 295 +++++++++++------- scripts/package-flutter-mac-app.sh | 6 + .../assistant/assistant_lower_pane_test.dart | 102 +++++- .../settings/settings_about_panel_test.dart | 63 ++++ .../settings/settings_account_panel_test.dart | 50 +++ test/golden/goldens/assistant_lower_pane.png | Bin 37898 -> 38691 bytes .../goldens/settings_about_panel/default.png | Bin 0 -> 12523 bytes .../settings_about_panel_golden_test.dart | 51 +++ test/runtime/bridge_runtime_cleanup_test.dart | 19 ++ ...ime_controllers_settings_account_test.dart | 166 +++++++++- 22 files changed, 1251 insertions(+), 280 deletions(-) create mode 100644 lib/features/assistant/assistant_page_task_dialog_controls.dart create mode 100644 lib/features/settings/settings_about_panel.dart create mode 100644 test/features/settings/settings_about_panel_test.dart create mode 100644 test/golden/goldens/settings_about_panel/default.png create mode 100644 test/golden/settings_about_panel_golden_test.dart diff --git a/Makefile b/Makefile index 9dedac7e..ad5d816c 100644 --- a/Makefile +++ b/Makefile @@ -13,8 +13,12 @@ PUBSPEC_BUILD_ID := $(shell sed -n 's/^build-id:[[:space:]]*//p' pubspec.yaml | APP_VERSION := $(firstword $(subst +, ,$(PUBSPEC_VERSION_LINE))) APP_BUILD_NUMBER_RAW := $(word 2,$(subst +, ,$(PUBSPEC_VERSION_LINE))) APP_BUILD_NUMBER := $(if $(APP_BUILD_NUMBER_RAW),$(APP_BUILD_NUMBER_RAW),1) +APP_BUILD_DATE := $(if $(PUBSPEC_BUILD_DATE),$(PUBSPEC_BUILD_DATE),unknown) +APP_BUILD_COMMIT := $(if $(PUBSPEC_BUILD_ID),$(PUBSPEC_BUILD_ID),unknown) APP_DART_DEFINE_VERSION ?= --dart-define=XWORKMATE_DISPLAY_VERSION=$(APP_VERSION) APP_DART_DEFINE_BUILD ?= --dart-define=XWORKMATE_BUILD_NUMBER=$(APP_BUILD_NUMBER) +APP_DART_DEFINE_BUILD_DATE ?= --dart-define=XWORKMATE_BUILD_DATE=$(APP_BUILD_DATE) +APP_DART_DEFINE_BUILD_COMMIT ?= --dart-define=XWORKMATE_BUILD_COMMIT=$(APP_BUILD_COMMIT) .PHONY: help deps analyze test test-all test-flutter test-golden test-integration test-integration-macos test-patrol test-go test-ci check format run open-macos-xcode sync-version build-linux build-macos build-ios-sim package-deb package-rpm package-linux package-mac install-mac clean build-go-core render-release-docs check-export-compliance test-real-env-login-chain inspect-xworkmate-bridge-service @@ -81,14 +85,14 @@ open-macos-xcode: ## Open the supported macOS Xcode workspace entrypoint open macos/Runner.xcworkspace build-linux: ## Build the Linux app in release mode - $(FLUTTER) build linux --release + $(FLUTTER) build linux --release --build-name=$(APP_VERSION) --build-number=$(APP_BUILD_NUMBER) $(APP_DART_DEFINE_VERSION) $(APP_DART_DEFINE_BUILD) $(APP_DART_DEFINE_BUILD_DATE) $(APP_DART_DEFINE_BUILD_COMMIT) build-macos: ## Build the macOS app in release mode - $(FLUTTER) build macos --release $(APP_STORE_DART_DEFINE) --build-name=$(APP_VERSION) --build-number=$(APP_BUILD_NUMBER) $(APP_DART_DEFINE_VERSION) $(APP_DART_DEFINE_BUILD) + $(FLUTTER) build macos --release $(APP_STORE_DART_DEFINE) --build-name=$(APP_VERSION) --build-number=$(APP_BUILD_NUMBER) $(APP_DART_DEFINE_VERSION) $(APP_DART_DEFINE_BUILD) $(APP_DART_DEFINE_BUILD_DATE) $(APP_DART_DEFINE_BUILD_COMMIT) bash scripts/check-apple-export-compliance.sh build/macos/Build/Products/Release/XWorkmate.app build-ios-sim: ## Build the iOS app for the simulator - $(FLUTTER) build ios --simulator $(APP_STORE_DART_DEFINE) --build-name=$(APP_VERSION) --build-number=$(APP_BUILD_NUMBER) $(APP_DART_DEFINE_VERSION) $(APP_DART_DEFINE_BUILD) + $(FLUTTER) build ios --simulator $(APP_STORE_DART_DEFINE) --build-name=$(APP_VERSION) --build-number=$(APP_BUILD_NUMBER) $(APP_DART_DEFINE_VERSION) $(APP_DART_DEFINE_BUILD) $(APP_DART_DEFINE_BUILD_DATE) $(APP_DART_DEFINE_BUILD_COMMIT) bash scripts/check-apple-export-compliance.sh build/ios/iphonesimulator/Runner.app build-go-core: ## Build the external ACP bridge helper from xworkmate-bridge diff --git a/docs/architecture/task-control-plane-unification.md b/docs/architecture/task-control-plane-unification.md index 0d458bab..414f4e1e 100644 --- a/docs/architecture/task-control-plane-unification.md +++ b/docs/architecture/task-control-plane-unification.md @@ -79,6 +79,10 @@ flowchart TD - `acp.capabilities.providerCatalog` 是 assistant provider picker 的唯一上游真源 - 持久化在线程上的 `providerId` 只表示用户历史选择,不负责反向生成 catalog - provider unavailable 文案与 resolved provider 都来自 `xworkmate.routing.resolve` +- 任务对话模式的 provider 菜单按 execution target 分流: + - `agent` 只展示 bridge-owned provider catalog,即 `codex / opencode / gemini` + - `gateway` 只展示 canonical gateway provider,即 `OpenClaw` +- 这里不保留旧的 provider matrix、preset fallback 或双真源选择路径 ### Gateway Truth diff --git a/docs/architecture/xworkmate-core-module-inventory-2026-04-13.md b/docs/architecture/xworkmate-core-module-inventory-2026-04-13.md index 77ff5ee6..d644048d 100644 --- a/docs/architecture/xworkmate-core-module-inventory-2026-04-13.md +++ b/docs/architecture/xworkmate-core-module-inventory-2026-04-13.md @@ -149,6 +149,7 @@ Status: `Active` 当前 Assistant 事实: - provider catalog 只来自 bridge capabilities,不再恢复任何 preset / backfill / fallback provider truth +- 任务对话模式按 execution target 分流:`智能体` 只提供 `codex / opencode / gemini`,`Gateway` 只提供 `OpenClaw` - task state 仍在 assistant 内被消费,但不再拥有独立 `TasksPage` - skills 数据仍在 assistant 内被消费,但不再拥有独立 `SkillsPage` - assistant focus 只保留仍有真实落点的 `settings / language / theme` diff --git a/docs/testing/bridge-sync-contract-chain.md b/docs/testing/bridge-sync-contract-chain.md index ff313be4..2587e12f 100644 --- a/docs/testing/bridge-sync-contract-chain.md +++ b/docs/testing/bridge-sync-contract-chain.md @@ -17,6 +17,7 @@ and the two key client-side parsing assertions: - `BRIDGE_SERVER_URL` may be retained in account sync metadata, but does not drive runtime endpoint selection - `BRIDGE_AUTH_TOKEN` is written into secure storage +- account sync no longer parses `INTERNAL_SERVICE_TOKEN` as a bridge token fallback ## Sync Chain @@ -81,5 +82,6 @@ flowchart TD - The app-facing managed bridge origin is fixed to `https://xworkmate-bridge.svc.plus`. - `BRIDGE_SERVER_URL`, when present, is metadata only. - `BRIDGE_AUTH_TOKEN` is the only bridge token field used by the sync contract. +- `INTERNAL_SERVICE_TOKEN` is not part of the app-side account sync token contract. - `BRIDGE_AUTH_TOKEN` must never be written into normal settings snapshot, profile JSON, or UI-visible text. - Client requests must assemble the header as `Authorization: Bearer `. diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index b2641790..b9953af3 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -678,7 +678,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController { if (matchesBridgeEndpoint) { final bridgeToken = runtimeEnvironmentValueInternal('BRIDGE_AUTH_TOKEN') ?? - runtimeEnvironmentValueInternal('INTERNAL_SERVICE_TOKEN') ?? (await storeInternal.loadAccountManagedSecret( target: kAccountManagedSecretTargetBridgeAuthToken, ))?.trim(); diff --git a/lib/app/app_metadata.dart b/lib/app/app_metadata.dart index 7575df29..3490f61e 100644 --- a/lib/app/app_metadata.dart +++ b/lib/app/app_metadata.dart @@ -11,4 +11,12 @@ const kAppBuildNumber = String.fromEnvironment( 'XWORKMATE_BUILD_NUMBER', defaultValue: '20260311', ); +const kAppBuildDate = String.fromEnvironment( + 'XWORKMATE_BUILD_DATE', + defaultValue: '2026-03-28', +); +const kAppBuildCommit = String.fromEnvironment( + 'XWORKMATE_BUILD_COMMIT', + defaultValue: 'f153d7b', +); const kAppVersionLabel = 'Version $kAppVersion'; diff --git a/lib/features/assistant/assistant_page_composer_bar.dart b/lib/features/assistant/assistant_page_composer_bar.dart index 18d63bae..9ef8ac20 100644 --- a/lib/features/assistant/assistant_page_composer_bar.dart +++ b/lib/features/assistant/assistant_page_composer_bar.dart @@ -35,6 +35,7 @@ import 'assistant_page_composer_skill_models.dart'; import 'assistant_page_composer_skill_picker.dart'; import 'assistant_page_composer_clipboard.dart'; import 'assistant_page_components_core.dart'; +import 'assistant_page_task_dialog_controls.dart'; class ComposerBarInternal extends StatefulWidget { const ComposerBarInternal({ @@ -310,34 +311,7 @@ class ComposerBarStateInternal extends State { final uiFeatures = controller.featuresFor( resolveUiFeaturePlatformFromContext(context), ); - final visibleExecutionTargets = controller.visibleAssistantExecutionTargets( - uiFeatures.availableExecutionTargets, - ); - final currentExecutionTarget = controller.assistantExecutionTarget; - final executionTarget = - visibleExecutionTargets.contains(currentExecutionTarget) - ? currentExecutionTarget - : (visibleExecutionTargets.isNotEmpty - ? visibleExecutionTargets.first - : currentExecutionTarget); - final compactExecutionTargets = compactAssistantExecutionTargets( - visibleExecutionTargets, - ); - final compactExecutionTarget = collapseAssistantExecutionTargetForDisplay( - executionTarget, - ); final permissionLevel = controller.assistantPermissionLevel; - final availableProviders = controller.assistantProviderCatalog; - final selectedProvider = controller.assistantProviderForSession( - controller.currentSessionKey, - ); - final providerMenuProviders = switch (executionTarget) { - AssistantExecutionTarget.gateway => - selectedProvider.isUnspecified - ? const [] - : [selectedProvider], - AssistantExecutionTarget.agent => availableProviders, - }; final selectedSkills = widget.availableSkills .where((skill) => widget.selectedSkillKeys.contains(skill.key)) .toList(growable: false); @@ -382,110 +356,8 @@ class ComposerBarStateInternal extends State { ), const SizedBox(width: 6), ], - if (compactExecutionTargets.isNotEmpty) ...[ - PopupMenuButton( - key: const Key('assistant-execution-target-button'), - tooltip: appText('任务对话模式', 'Task Dialog Mode'), - onSelected: (value) async { - final resolvedTarget = - resolveAssistantExecutionTargetFromVisibleTargets( - visibleExecutionTargets, - currentTarget: value, - ); - await controller.setAssistantExecutionTarget( - resolvedTarget, - ); - if (mounted) { - setState(() {}); - } - }, - itemBuilder: (context) => compactExecutionTargets - .map( - (value) => PopupMenuItem( - value: value, - key: Key( - 'assistant-execution-target-menu-item-${value.name}', - ), - child: Row( - children: [ - Icon(value.icon, size: 18), - const SizedBox(width: 10), - Expanded(child: Text(value.compactLabel)), - if (value == compactExecutionTarget) - const Icon(Icons.check_rounded, size: 18), - ], - ), - ), - ) - .toList(), - child: ComposerToolbarChipInternal( - icon: compactExecutionTarget.icon, - tooltip: executionTargetTooltipInternal( - compactExecutionTarget, - ), - showChevron: true, - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - ), - ), - const SizedBox(width: 4), - ], - if (providerMenuProviders.isNotEmpty) ...[ - PopupMenuButton( - key: const Key('assistant-provider-button'), - tooltip: appText('智能体 Provider', 'Agent Provider'), - onSelected: (providerId) async { - if (executionTarget.isGateway) { - return; - } - await controller.setAssistantSingleAgentProvider( - controller.resolveAssistantProvider(providerId), - ); - if (mounted) { - setState(() {}); - } - }, - itemBuilder: (context) => providerMenuProviders - .map( - (provider) => PopupMenuItem( - value: provider.providerId, - key: Key( - 'assistant-provider-menu-item-${provider.providerId}', - ), - child: Row( - children: [ - SingleAgentProviderBadgeInternal( - key: Key( - 'assistant-provider-menu-badge-${provider.providerId}', - ), - provider: provider, - ), - const SizedBox(width: 10), - Expanded(child: Text(provider.label)), - if (provider == selectedProvider) - const Icon(Icons.check_rounded, size: 18), - ], - ), - ), - ) - .toList(growable: false), - child: ComposerToolbarChipInternal( - leading: SingleAgentProviderBadgeInternal( - key: const Key('assistant-provider-badge'), - provider: selectedProvider, - ), - tooltip: providerTooltipInternal(selectedProvider), - showChevron: true, - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - ), - ), - const SizedBox(width: 4), - ], + AssistantTaskDialogModeControlsInternal(controller: controller), + const SizedBox(width: 4), if (widget.showModelControl) ...[ widget.modelOptions.isEmpty ? ComposerToolbarChipInternal( diff --git a/lib/features/assistant/assistant_page_task_dialog_controls.dart b/lib/features/assistant/assistant_page_task_dialog_controls.dart new file mode 100644 index 00000000..79dbb4de --- /dev/null +++ b/lib/features/assistant/assistant_page_task_dialog_controls.dart @@ -0,0 +1,254 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../app/app_controller.dart'; +import '../../app/ui_feature_manifest.dart'; +import '../../i18n/app_language.dart'; +import '../../runtime/runtime_models.dart'; +import '../../theme/app_palette.dart'; +import '../../theme/app_theme.dart'; +import 'assistant_page_composer_support.dart'; + +class AssistantTaskDialogModeControlsInternal extends StatelessWidget { + const AssistantTaskDialogModeControlsInternal({ + super.key, + required this.controller, + }); + + final AppController controller; + + @override + Widget build(BuildContext context) { + final uiFeatures = controller.featuresFor( + resolveUiFeaturePlatformFromContext(context), + ); + final visibleExecutionTargets = controller.visibleAssistantExecutionTargets( + uiFeatures.availableExecutionTargets, + ); + if (visibleExecutionTargets.isEmpty) { + return const SizedBox.shrink(); + } + + final currentExecutionTarget = + resolveAssistantExecutionTargetFromVisibleTargets( + visibleExecutionTargets, + currentTarget: controller.assistantExecutionTarget, + ); + final executionTarget = collapseAssistantExecutionTargetForDisplay( + currentExecutionTarget, + ); + final providerMenuProviders = _taskDialogProviderCatalogForTarget( + controller: controller, + executionTarget: executionTarget, + ); + + return Wrap( + spacing: 4, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _TaskDialogExecutionTargetMenuButtonInternal( + controller: controller, + executionTarget: executionTarget, + visibleExecutionTargets: visibleExecutionTargets, + ), + if (providerMenuProviders.isNotEmpty) + _TaskDialogProviderMenuButtonInternal( + controller: controller, + executionTarget: executionTarget, + selectedProvider: controller.assistantProviderForSession( + controller.currentSessionKey, + ), + providers: providerMenuProviders, + ), + ], + ); + } +} + +List _taskDialogProviderCatalogForTarget({ + required AppController controller, + required AssistantExecutionTarget executionTarget, +}) { + if (executionTarget.isGateway) { + return [ + controller.assistantProviderForSession(controller.currentSessionKey), + ]; + } + return controller.assistantProviderCatalog; +} + +class _TaskDialogExecutionTargetMenuButtonInternal extends StatelessWidget { + const _TaskDialogExecutionTargetMenuButtonInternal({ + required this.controller, + required this.executionTarget, + required this.visibleExecutionTargets, + }); + + final AppController controller; + final AssistantExecutionTarget executionTarget; + final List visibleExecutionTargets; + + @override + Widget build(BuildContext context) { + final compactExecutionTargets = compactAssistantExecutionTargets( + visibleExecutionTargets, + ); + final palette = context.palette; + final selectedLabel = executionTarget.label; + + return PopupMenuButton( + key: const Key('assistant-execution-target-button'), + tooltip: appText('任务对话模式', 'Task Dialog Mode'), + onSelected: (value) { + unawaited(_handleExecutionTargetSelected(value)); + }, + itemBuilder: (context) => compactExecutionTargets + .map( + (value) => PopupMenuItem( + value: value, + key: Key('assistant-execution-target-menu-item-${value.name}'), + child: Row( + children: [ + Icon(value.icon, size: 18), + const SizedBox(width: 10), + Expanded(child: Text(value.label)), + if (value == executionTarget) + const Icon(Icons.check_rounded, size: 18), + ], + ), + ), + ) + .toList(growable: false), + child: _TaskDialogSelectorChipInternal( + leading: Icon(executionTarget.icon, size: 14, color: palette.textMuted), + label: selectedLabel, + tooltip: appText('任务对话模式', 'Task Dialog Mode'), + ), + ); + } + + Future _handleExecutionTargetSelected( + AssistantExecutionTarget value, + ) async { + final resolvedTarget = resolveAssistantExecutionTargetFromVisibleTargets( + visibleExecutionTargets, + currentTarget: value, + ); + await controller.setAssistantExecutionTarget(resolvedTarget); + } +} + +class _TaskDialogProviderMenuButtonInternal extends StatelessWidget { + const _TaskDialogProviderMenuButtonInternal({ + required this.controller, + required this.executionTarget, + required this.selectedProvider, + required this.providers, + }); + + final AppController controller; + final AssistantExecutionTarget executionTarget; + final SingleAgentProvider selectedProvider; + final List providers; + + @override + Widget build(BuildContext context) { + final displayProvider = selectedProvider.isUnspecified + ? SingleAgentProvider.openclaw + : selectedProvider; + + return PopupMenuButton( + key: const Key('assistant-provider-button'), + tooltip: appText('智能体 Provider', 'Agent Provider'), + onSelected: (provider) { + unawaited(_handleProviderSelected(provider)); + }, + itemBuilder: (context) => providers + .map( + (provider) => PopupMenuItem( + value: provider, + key: Key('assistant-provider-menu-item-${provider.providerId}'), + child: Row( + children: [ + SingleAgentProviderBadgeInternal( + key: Key( + 'assistant-provider-menu-badge-${provider.providerId}', + ), + provider: provider, + ), + const SizedBox(width: 10), + Expanded(child: Text(provider.label)), + if (provider == displayProvider) + const Icon(Icons.check_rounded, size: 18), + ], + ), + ), + ) + .toList(growable: false), + child: _TaskDialogSelectorChipInternal( + leading: SingleAgentProviderBadgeInternal( + key: const Key('assistant-provider-badge'), + provider: displayProvider, + ), + label: displayProvider.label, + tooltip: appText('智能体 Provider', 'Agent Provider'), + ), + ); + } + + Future _handleProviderSelected(SingleAgentProvider provider) async { + if (executionTarget.isGateway) { + return; + } + await controller.setAssistantSingleAgentProvider(provider); + } +} + +class _TaskDialogSelectorChipInternal extends StatelessWidget { + const _TaskDialogSelectorChipInternal({ + required this.leading, + required this.label, + required this.tooltip, + }); + + final Widget leading; + final String label; + final String tooltip; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + final theme = Theme.of(context); + + return Tooltip( + message: tooltip, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xs, + vertical: 5, + ), + decoration: BoxDecoration( + color: palette.surfaceSecondary, + borderRadius: BorderRadius.circular(AppRadius.chip), + border: Border.all(color: palette.strokeSoft), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + leading, + const SizedBox(width: 6), + Text(label, style: theme.textTheme.labelMedium), + const SizedBox(width: 2), + Icon( + Icons.keyboard_arrow_down_rounded, + size: 14, + color: palette.textMuted, + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/settings/settings_about_panel.dart b/lib/features/settings/settings_about_panel.dart new file mode 100644 index 00000000..4571daf7 --- /dev/null +++ b/lib/features/settings/settings_about_panel.dart @@ -0,0 +1,189 @@ +import 'package:flutter/material.dart'; + +import '../../i18n/app_language.dart'; + +@immutable +class SettingsAboutSnapshot { + const SettingsAboutSnapshot({ + required this.appVersion, + required this.appBuildNumber, + required this.appBuildDate, + required this.appCommit, + required this.bridgeEndpoint, + required this.bridgeStatus, + required this.bridgeVersion, + required this.bridgeBuildDate, + required this.bridgeCommit, + required this.bridgeImage, + }); + + final String appVersion; + final String appBuildNumber; + final String appBuildDate; + final String appCommit; + final String bridgeEndpoint; + final String bridgeStatus; + final String bridgeVersion; + final String bridgeBuildDate; + final String bridgeCommit; + final String bridgeImage; + + const SettingsAboutSnapshot.defaults() + : appVersion = '', + appBuildNumber = '', + appBuildDate = '', + appCommit = '', + bridgeEndpoint = '', + bridgeStatus = '', + bridgeVersion = '', + bridgeBuildDate = '', + bridgeCommit = '', + bridgeImage = ''; +} + +class SettingsAboutPanel extends StatelessWidget { + const SettingsAboutPanel({ + super.key, + required this.snapshot, + required this.busy, + required this.onRefresh, + }); + + final SettingsAboutSnapshot snapshot; + final bool busy; + final Future Function() onRefresh; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText('关于', 'About'), + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + appText( + '直接查看当前应用构建与 Bridge 运行时版本信息,便于排查发布与联调问题。', + 'Review the current app build and bridge runtime information in one place.', + ), + ), + ], + ), + ), + const SizedBox(width: 16), + FilledButton.tonal( + key: const ValueKey('settings-about-refresh-button'), + onPressed: busy ? null : () => onRefresh(), + child: busy + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + key: const ValueKey( + 'settings-about-refresh-progress', + ), + strokeWidth: 2, + ), + ), + const SizedBox(width: 8), + Text(appText('刷新中', 'Refreshing')), + ], + ) + : Text(appText('刷新版本信息', 'Refresh Build Info')), + ), + ], + ), + const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText('应用构建', 'App Build'), + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 8), + Text( + '${appText('Version', 'Version')}: ${_displayValue(snapshot.appVersion)}', + key: const ValueKey('settings-about-app-version'), + ), + const SizedBox(height: 6), + Text( + '${appText('Build Number', 'Build Number')}: ${_displayValue(snapshot.appBuildNumber)}', + key: const ValueKey('settings-about-app-build-number'), + ), + const SizedBox(height: 6), + Text( + '${appText('Build Date', 'Build Date')}: ${_displayValue(snapshot.appBuildDate)}', + key: const ValueKey('settings-about-app-build-date'), + ), + const SizedBox(height: 6), + Text( + 'Commit: ${_displayValue(snapshot.appCommit)}', + key: const ValueKey('settings-about-app-commit'), + ), + const SizedBox(height: 18), + Text( + appText('Bridge 运行时', 'Bridge Runtime'), + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 8), + Text( + '${appText('Endpoint', 'Endpoint')}: ${_displayValue(snapshot.bridgeEndpoint)}', + key: const ValueKey('settings-about-bridge-endpoint'), + ), + const SizedBox(height: 6), + Text( + '${appText('Status', 'Status')}: ${_displayValue(snapshot.bridgeStatus)}', + key: const ValueKey('settings-about-bridge-status'), + ), + const SizedBox(height: 6), + Text( + '${appText('Version', 'Version')}: ${_displayValue(snapshot.bridgeVersion)}', + key: const ValueKey('settings-about-bridge-version'), + ), + const SizedBox(height: 6), + Text( + '${appText('Build Date', 'Build Date')}: ${_displayValue(snapshot.bridgeBuildDate)}', + key: const ValueKey('settings-about-bridge-build-date'), + ), + const SizedBox(height: 6), + Text( + 'Commit: ${_displayValue(snapshot.bridgeCommit)}', + key: const ValueKey('settings-about-bridge-commit'), + ), + const SizedBox(height: 6), + Text( + '${appText('Image', 'Image')}: ${_displayValue(snapshot.bridgeImage)}', + key: const ValueKey('settings-about-bridge-image'), + ), + ], + ), + ), + ], + ); + } +} + +String _displayValue(String value) { + return value.trim().isEmpty ? appText('不可用', 'Unavailable') : value.trim(); +} diff --git a/lib/features/settings/settings_account_panel.dart b/lib/features/settings/settings_account_panel.dart index 1f504352..d7f94a68 100644 --- a/lib/features/settings/settings_account_panel.dart +++ b/lib/features/settings/settings_account_panel.dart @@ -10,6 +10,7 @@ class SettingsAccountPanel extends StatelessWidget { required this.accountSession, required this.accountState, required this.accountBusy, + this.accountStatus = '', required this.accountSignedIn, required this.accountMfaRequired, required this.accountBaseUrlController, @@ -28,6 +29,7 @@ class SettingsAccountPanel extends StatelessWidget { final AccountSessionSummary? accountSession; final AccountSyncState? accountState; final bool accountBusy; + final String accountStatus; final bool accountSignedIn; final bool accountMfaRequired; final TextEditingController accountBaseUrlController; @@ -68,6 +70,7 @@ class SettingsAccountPanel extends StatelessWidget { accountSession: accountSession, accountState: accountState, accountBusy: accountBusy, + accountStatus: accountStatus, onSync: onSync, onLogout: onLogout, ); @@ -284,6 +287,7 @@ class _SignedInAccountPanel extends StatelessWidget { required this.accountSession, required this.accountState, required this.accountBusy, + required this.accountStatus, required this.onSync, required this.onLogout, }); @@ -292,6 +296,7 @@ class _SignedInAccountPanel extends StatelessWidget { final AccountSessionSummary? accountSession; final AccountSyncState? accountState; final bool accountBusy; + final String accountStatus; final Future Function() onSync; final Future Function() onLogout; @@ -316,6 +321,11 @@ class _SignedInAccountPanel extends StatelessWidget { final syncMessage = accountState?.syncMessage.trim().isNotEmpty == true ? accountState!.syncMessage.trim() : appText('尚未同步远端配置', 'Remote config not synced yet'); + final effectiveSyncState = accountBusy ? 'syncing' : syncState; + final effectiveSyncMessage = + accountBusy && accountStatus.trim().isNotEmpty + ? accountStatus.trim() + : syncMessage; final mfaEnabled = accountSession?.totpEnabled == true || accountSession?.mfaEnabled == true; @@ -342,7 +352,7 @@ class _SignedInAccountPanel extends StatelessWidget { ), const SizedBox(height: 8), Text( - '${appText('同步状态', 'Sync Status')}: $syncState · $syncMessage', + '${appText('同步状态', 'Sync Status')}: $effectiveSyncState · $effectiveSyncMessage', key: const ValueKey('settings-account-sync-status'), style: Theme.of(context).textTheme.bodySmall, ), @@ -413,7 +423,25 @@ class _SignedInAccountPanel extends StatelessWidget { FilledButton.tonal( key: const ValueKey('settings-account-sync-button'), onPressed: accountBusy ? null : () => onSync(), - child: Text(appText('重新同步', 'Sync Again')), + child: accountBusy + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + key: const ValueKey( + 'settings-account-sync-progress', + ), + strokeWidth: 2, + ), + ), + const SizedBox(width: 8), + Text(appText('同步中', 'Syncing')), + ], + ) + : Text(appText('重新同步', 'Sync Again')), ), TextButton( key: const ValueKey('settings-account-logout-button'), diff --git a/lib/features/settings/settings_page_core.dart b/lib/features/settings/settings_page_core.dart index b9718d26..a6e79983 100644 --- a/lib/features/settings/settings_page_core.dart +++ b/lib/features/settings/settings_page_core.dart @@ -1,6 +1,11 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + import 'package:flutter/material.dart'; import '../../app/app_controller.dart'; +import '../../app/app_metadata.dart'; import '../../app/workspace_navigation.dart'; import '../../i18n/app_language.dart'; import '../../models/app_models.dart'; @@ -9,6 +14,7 @@ import '../../runtime/runtime_models.dart'; import '../../widgets/settings_page_shell.dart'; import '../../widgets/surface_card.dart'; import 'settings_account_panel.dart'; +import 'settings_about_panel.dart'; class SettingsPage extends StatefulWidget { const SettingsPage({ @@ -34,6 +40,8 @@ class _SettingsPageState extends State { late final TextEditingController _accountIdentifierController; late final TextEditingController _accountPasswordController; late final TextEditingController _accountMfaCodeController; + SettingsAboutSnapshot _aboutSnapshot = const SettingsAboutSnapshot.defaults(); + bool _aboutBusy = false; String _lastSavedAccountBaseUrl = ''; String _lastSavedAccountIdentifier = ''; @@ -51,6 +59,7 @@ class _SettingsPageState extends State { ); _accountPasswordController = TextEditingController(); _accountMfaCodeController = TextEditingController(); + unawaited(_refreshAboutSnapshot()); } @override @@ -108,6 +117,7 @@ class _SettingsPageState extends State { baseUrl: _accountBaseUrlController.text.trim(), ); await _refreshBridgeCapabilities(); + await _refreshAboutSnapshot(); } Future _verifyAccountMfa(SettingsSnapshot settings) async { @@ -150,6 +160,87 @@ class _SettingsPageState extends State { await widget.controller.settingsController.logoutAccount(); _accountPasswordController.clear(); _accountMfaCodeController.clear(); + await _refreshAboutSnapshot(); + } + + Future _refreshAboutSnapshot() async { + if (!mounted) { + return; + } + setState(() { + _aboutBusy = true; + }); + final snapshot = await _loadAboutSnapshot(); + if (!mounted) { + return; + } + setState(() { + _aboutSnapshot = snapshot; + _aboutBusy = false; + }); + } + + Future _loadAboutSnapshot() async { + final bridgeMetadata = await _loadBridgeMetadata(); + return SettingsAboutSnapshot( + appVersion: kAppVersion, + appBuildNumber: kAppBuildNumber, + appBuildDate: kAppBuildDate, + appCommit: kAppBuildCommit, + bridgeEndpoint: kManagedBridgeServerUrl, + bridgeStatus: _stringValue(bridgeMetadata['status']), + bridgeVersion: _resolveBridgeVersion(bridgeMetadata), + bridgeBuildDate: _resolveBridgeBuildDate(bridgeMetadata), + bridgeCommit: _stringValue(bridgeMetadata['commit']), + bridgeImage: _stringValue(bridgeMetadata['image']), + ); + } + + Future> _loadBridgeMetadata() async { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 4); + try { + final request = await client + .getUrl(Uri.parse('$kManagedBridgeServerUrl/api/ping')) + .timeout(const Duration(seconds: 4)); + request.headers.set(HttpHeaders.acceptHeader, 'application/json'); + final response = await request.close().timeout(const Duration(seconds: 4)); + final body = await utf8 + .decodeStream(response) + .timeout(const Duration(seconds: 4)); + if (response.statusCode < 200 || response.statusCode >= 300) { + return { + 'status': 'error', + 'version': '', + 'commit': '', + 'image': '', + 'buildDate': '', + }; + } + final decoded = jsonDecode(body); + if (decoded is Map) { + return decoded; + } + if (decoded is Map) { + return decoded.cast(); + } + } catch (_) { + return const { + 'status': 'unavailable', + 'version': '', + 'commit': '', + 'image': '', + 'buildDate': '', + }; + } finally { + client.close(force: true); + } + return const { + 'status': 'unavailable', + 'version': '', + 'commit': '', + 'image': '', + 'buildDate': '', + }; } @override @@ -165,6 +256,7 @@ class _SettingsPageState extends State { _syncAccountControllers(currentSettings); final accountState = controller.settingsController.accountSyncState; final accountBusy = controller.settingsController.accountBusy; + final accountStatus = controller.settingsController.accountStatus; final accountSignedIn = controller.settingsController.accountSignedIn; final accountMfaRequired = controller.settingsController.accountMfaRequired; @@ -201,6 +293,7 @@ class _SettingsPageState extends State { accountSession: accountSession, accountState: accountState, accountBusy: accountBusy, + accountStatus: accountStatus, accountSignedIn: accountSignedIn, accountMfaRequired: accountMfaRequired, accountBaseUrlController: _accountBaseUrlController, @@ -216,9 +309,50 @@ class _SettingsPageState extends State { onLogout: _logoutAccount, ), ), + const SizedBox(height: 24), + SurfaceCard( + key: const ValueKey('settings-about-panel-card'), + child: SettingsAboutPanel( + snapshot: _aboutSnapshot, + busy: _aboutBusy, + onRefresh: _refreshAboutSnapshot, + ), + ), ], ); }, ); } } + +String _stringValue(Object? value) { + return value == null ? '' : value.toString().trim(); +} + +String _resolveBridgeVersion(Map payload) { + final explicit = _stringValue(payload['version']); + if (explicit.isNotEmpty) { + return explicit; + } + final tag = _stringValue(payload['tag']); + if (tag.isNotEmpty) { + return tag; + } + return ''; +} + +String _resolveBridgeBuildDate(Map payload) { + final candidates = [ + payload['buildDate'], + payload['build-date'], + payload['builtAt'], + payload['build_at'], + ]; + for (final candidate in candidates) { + final value = _stringValue(candidate); + if (value.isNotEmpty) { + return value; + } + } + return ''; +} diff --git a/lib/runtime/account_runtime_client.dart b/lib/runtime/account_runtime_client.dart index b6614ff3..d70d6623 100644 --- a/lib/runtime/account_runtime_client.dart +++ b/lib/runtime/account_runtime_client.dart @@ -153,13 +153,17 @@ class AccountRuntimeClient { } Future loadSession({required String token}) async { - final payload = await _requestJson( + final payload = await loadProfile(token: token); + final user = _asMap(payload['user']); + return _accountSessionSummaryFromUserJson(user); + } + + Future> loadProfile({required String token}) { + return _requestJson( method: 'GET', path: '/api/auth/session', bearerToken: token, ); - final user = _asMap(payload['user']); - return _accountSessionSummaryFromUserJson(user); } Future createBridgeBootstrapTicket({ diff --git a/lib/runtime/runtime_controllers_settings_account_impl.dart b/lib/runtime/runtime_controllers_settings_account_impl.dart index 8494dc3b..4d41b427 100644 --- a/lib/runtime/runtime_controllers_settings_account_impl.dart +++ b/lib/runtime/runtime_controllers_settings_account_impl.dart @@ -147,6 +147,7 @@ Future completeAccountSignInSettingsInternal( baseUrl: baseUrl, bridgeTokenOverride: _resolveBridgeAuthorizationToken(payload), bridgeServerUrlOverride: _resolveBridgeServerUrl(payload), + profilePayloadOverride: payload, quiet: true, ); await controller.reloadDerivedStateInternal(); @@ -179,7 +180,8 @@ Future restoreAccountSessionSettingsInternal( try { final client = controller.buildAccountClient(normalizedBaseUrl); - final session = await client.loadSession(token: token); + final payload = await client.loadProfile(token: token); + final session = _accountSessionSummaryFromUserPayload(_asMap(payload['user'])); await controller.storeInternal.saveAccountSessionSummary(session); if (session.userId.trim().isNotEmpty) { await controller.storeInternal.saveAccountSessionUserId(session.userId); @@ -198,6 +200,7 @@ Future restoreAccountSessionSettingsInternal( await syncAccountSettingsInternal( controller, baseUrl: normalizedBaseUrl, + profilePayloadOverride: payload, quiet: true, ); } on AccountRuntimeException catch (error) { @@ -225,28 +228,21 @@ Future syncAccountSettingsInternal( bool quiet = false, String bridgeTokenOverride = '', String bridgeServerUrlOverride = '', + Map profilePayloadOverride = const {}, }) async { + final normalizedBaseUrl = normalizeAccountBaseUrlSettingsInternal( + baseUrl, + fallback: controller.snapshotInternal.accountBaseUrl, + ); final sessionToken = (await controller.storeInternal.loadAccountSessionToken())?.trim() ?? ''; if (sessionToken.isEmpty) { - final nextState = AccountSyncState.defaults().copyWith( - syncState: 'blocked', - syncMessage: 'Account session is unavailable', - lastSyncAtMs: DateTime.now().millisecondsSinceEpoch, - lastSyncError: 'Account session is unavailable', - profileScope: 'bridge', - ); - await _persistAccountSyncStateInternal(controller, nextState); - const result = AccountSyncResult( + return _persistAccountSyncFailureInternal( + controller, state: 'blocked', message: 'Account session is unavailable', + quiet: quiet, ); - controller.accountStatusInternal = result.message; - if (!quiet) { - controller.accountBusyInternal = false; - controller.notifyListeners(); - } - return result; } if (!quiet) { @@ -255,100 +251,125 @@ Future syncAccountSettingsInternal( controller.notifyListeners(); } - final bridgeToken = bridgeTokenOverride.trim().isNotEmpty - ? bridgeTokenOverride.trim() - : ((await controller.storeInternal.loadAccountManagedSecret( - target: kAccountManagedSecretTargetBridgeAuthToken, - ))?.trim() ?? - ''); - if (bridgeToken.isEmpty) { - const result = AccountSyncResult( - state: 'blocked', - message: 'Bridge authorization is unavailable', - ); - await _persistAccountSyncStateInternal( + try { + Map profilePayload = profilePayloadOverride; + if (profilePayload.isEmpty) { + if (normalizedBaseUrl.isEmpty) { + return _persistAccountSyncFailureInternal( + controller, + state: 'blocked', + message: 'Account base URL is required', + quiet: quiet, + ); + } + final client = controller.buildAccountClient(normalizedBaseUrl); + profilePayload = await client.loadProfile(token: sessionToken); + } + await _persistAccountSessionSummaryFromProfilePayloadInternal( controller, - AccountSyncState.defaults().copyWith( - syncState: result.state, - syncMessage: result.message, - lastSyncAtMs: DateTime.now().millisecondsSinceEpoch, - lastSyncError: result.message, - profileScope: 'bridge', + profilePayload, + ); + + final profileBridgeToken = _resolveBridgeAuthorizationToken(profilePayload); + final bridgeToken = bridgeTokenOverride.trim().isNotEmpty + ? bridgeTokenOverride.trim() + : profileBridgeToken.trim().isNotEmpty + ? profileBridgeToken.trim() + : ((await controller.storeInternal.loadAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + ))?.trim() ?? + ''); + if (bridgeToken.isEmpty) { + return _persistAccountSyncFailureInternal( + controller, + state: 'blocked', + message: 'Bridge authorization is unavailable', + quiet: quiet, + ); + } + + await controller.storeInternal.saveAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + value: bridgeToken, + ); + final resolvedBridgeServerUrl = _resolveCurrentBridgeServerUrl( + controller, + bridgeServerUrlOverride: bridgeServerUrlOverride.trim().isNotEmpty + ? bridgeServerUrlOverride + : _resolveBridgeServerUrl(profilePayload), + ); + await controller.storeInternal.clearAccountManagedSecret( + target: kAccountManagedSecretTargetAIGatewayAccessToken, + ); + await controller.storeInternal.clearAccountManagedSecret( + target: kAccountManagedSecretTargetOllamaCloudApiKey, + ); + + final nextState = AccountSyncState.defaults().copyWith( + syncedDefaults: AccountRemoteProfile.defaults().copyWith( + bridgeServerUrl: resolvedBridgeServerUrl, + ), + syncState: 'ready', + syncMessage: 'Bridge access synced', + lastSyncAtMs: DateTime.now().millisecondsSinceEpoch, + lastSyncSource: resolvedBridgeServerUrl, + lastSyncError: '', + profileScope: 'bridge', + tokenConfigured: const AccountTokenConfigured( + bridge: true, + vault: false, + apisix: false, ), ); - controller.accountStatusInternal = result.message; + await controller.storeInternal.saveAccountSyncState(nextState); + final currentSettings = controller.snapshotInternal; + final currentModeConfig = currentSettings.acpBridgeServerModeConfig; + final nextModeConfig = currentModeConfig.copyWith( + cloudSynced: currentModeConfig.cloudSynced.copyWith( + accountBaseUrl: '', + accountIdentifier: '', + lastSyncAt: nextState.lastSyncAtMs, + remoteServerSummary: currentModeConfig.cloudSynced.remoteServerSummary + .copyWith( + endpoint: resolvedBridgeServerUrl, + hasAdvancedOverrides: false, + ), + ), + ); + final sanitizedSettings = _sanitizeBridgeOnlyAccountSyncSettings( + currentSettings.copyWith(acpBridgeServerModeConfig: nextModeConfig), + ); + if (sanitizedSettings.toJsonString() != currentSettings.toJsonString()) { + await controller.saveSnapshot(sanitizedSettings); + } + await controller.reloadDerivedStateInternal(); + final email = controller.accountSessionInternal?.email.trim() ?? ''; + controller.accountStatusInternal = email.isEmpty + ? 'Signed in' + : 'Signed in as $email'; if (!quiet) { controller.accountBusyInternal = false; controller.notifyListeners(); } - return result; + return const AccountSyncResult( + state: 'ready', + message: 'Bridge access synced', + ); + } on AccountRuntimeException catch (error) { + return _persistAccountSyncFailureInternal( + controller, + state: 'error', + message: error.message, + quiet: quiet, + ); + } catch (error) { + return _persistAccountSyncFailureInternal( + controller, + state: 'error', + message: error.toString(), + quiet: quiet, + ); } - - await controller.storeInternal.saveAccountManagedSecret( - target: kAccountManagedSecretTargetBridgeAuthToken, - value: bridgeToken, - ); - final resolvedBridgeServerUrl = _resolveCurrentBridgeServerUrl( - controller, - bridgeServerUrlOverride: bridgeServerUrlOverride, - ); - await controller.storeInternal.clearAccountManagedSecret( - target: kAccountManagedSecretTargetAIGatewayAccessToken, - ); - await controller.storeInternal.clearAccountManagedSecret( - target: kAccountManagedSecretTargetOllamaCloudApiKey, - ); - - final nextState = AccountSyncState.defaults().copyWith( - syncedDefaults: AccountRemoteProfile.defaults().copyWith( - bridgeServerUrl: resolvedBridgeServerUrl, - ), - syncState: 'ready', - syncMessage: 'Bridge access synced', - lastSyncAtMs: DateTime.now().millisecondsSinceEpoch, - lastSyncSource: resolvedBridgeServerUrl, - lastSyncError: '', - profileScope: 'bridge', - tokenConfigured: const AccountTokenConfigured( - bridge: true, - vault: false, - apisix: false, - ), - ); - await controller.storeInternal.saveAccountSyncState(nextState); - final currentSettings = controller.snapshotInternal; - final currentModeConfig = currentSettings.acpBridgeServerModeConfig; - final nextModeConfig = currentModeConfig.copyWith( - cloudSynced: currentModeConfig.cloudSynced.copyWith( - accountBaseUrl: '', - accountIdentifier: '', - lastSyncAt: nextState.lastSyncAtMs, - remoteServerSummary: currentModeConfig.cloudSynced.remoteServerSummary - .copyWith( - endpoint: resolvedBridgeServerUrl, - hasAdvancedOverrides: false, - ), - ), - ); - final sanitizedSettings = _sanitizeBridgeOnlyAccountSyncSettings( - currentSettings.copyWith(acpBridgeServerModeConfig: nextModeConfig), - ); - if (sanitizedSettings.toJsonString() != currentSettings.toJsonString()) { - await controller.saveSnapshot(sanitizedSettings); - } - await controller.reloadDerivedStateInternal(); - final email = controller.accountSessionInternal?.email.trim() ?? ''; - controller.accountStatusInternal = email.isEmpty - ? 'Signed in' - : 'Signed in as $email'; - if (!quiet) { - controller.accountBusyInternal = false; - controller.notifyListeners(); - } - return const AccountSyncResult( - state: 'ready', - message: 'Bridge access synced', - ); } Future logoutAccountSettingsInternal( @@ -456,22 +477,64 @@ SettingsSnapshot _sanitizeBridgeOnlyAccountSyncSettings( ); } +Future _persistAccountSessionSummaryFromProfilePayloadInternal( + SettingsController controller, + Map payload, +) async { + final user = _asMap(payload['user']); + if (user.isEmpty) { + return; + } + final summary = _accountSessionSummaryFromUserPayload(user); + final hasSessionDetails = + summary.userId.trim().isNotEmpty || + summary.email.trim().isNotEmpty || + summary.name.trim().isNotEmpty || + summary.role.trim().isNotEmpty; + if (!hasSessionDetails) { + return; + } + await controller.storeInternal.saveAccountSessionSummary(summary); + if (summary.userId.trim().isNotEmpty) { + await controller.storeInternal.saveAccountSessionUserId(summary.userId); + } + final identifier = summary.email.trim().isNotEmpty + ? summary.email.trim() + : (await controller.storeInternal.loadAccountSessionIdentifier()) + ?.trim() ?? + controller.snapshotInternal.accountUsername.trim(); + if (identifier.isNotEmpty) { + await controller.storeInternal.saveAccountSessionIdentifier(identifier); + } +} + +Future _persistAccountSyncFailureInternal( + SettingsController controller, { + required String state, + required String message, + required bool quiet, +}) async { + await _persistAccountSyncStateInternal( + controller, + AccountSyncState.defaults().copyWith( + syncState: state, + syncMessage: message, + lastSyncAtMs: DateTime.now().millisecondsSinceEpoch, + lastSyncError: message, + profileScope: 'bridge', + ), + ); + controller.accountStatusInternal = message; + if (!quiet) { + controller.accountBusyInternal = false; + controller.notifyListeners(); + } + return AccountSyncResult(state: state, message: message); +} + String _resolveBridgeAuthorizationToken(Map payload) { final explicit = _stringValue(payload['BRIDGE_AUTH_TOKEN']); - if (explicit.isNotEmpty) { - return explicit; - } - final uppercaseInternalServiceToken = _stringValue( - payload['INTERNAL_SERVICE_TOKEN'], - ); - if (uppercaseInternalServiceToken.isNotEmpty) { - return uppercaseInternalServiceToken; - } - final internalServiceToken = _stringValue(payload['internalServiceToken']); - if (internalServiceToken.isNotEmpty) { - return internalServiceToken; - } - return ''; + return explicit; } String _resolveBridgeServerUrl(Map payload) { diff --git a/scripts/package-flutter-mac-app.sh b/scripts/package-flutter-mac-app.sh index 33d2de9e..8c0f700f 100755 --- a/scripts/package-flutter-mac-app.sh +++ b/scripts/package-flutter-mac-app.sh @@ -50,12 +50,16 @@ if [[ -z "$VERSION_LINE" ]]; then echo "Unable to read version from $PUBSPEC_PATH" >&2 exit 1 fi +BUILD_DATE_LINE="$(sed -n 's/^build-date:[[:space:]]*//p' "$PUBSPEC_PATH" | head -n 1)" +BUILD_ID_LINE="$(sed -n 's/^build-id:[[:space:]]*//p' "$PUBSPEC_PATH" | head -n 1)" APP_VERSION="${VERSION_LINE%%+*}" APP_BUILD="${VERSION_LINE#*+}" if [[ "$APP_BUILD" == "$VERSION_LINE" ]]; then APP_BUILD="1" fi +APP_BUILD_DATE="${BUILD_DATE_LINE:-unknown}" +APP_BUILD_COMMIT="${BUILD_ID_LINE:-unknown}" BUILD_APP_PATH="$APP_DIR/build/macos/Build/Products/$PRODUCTS_DIR_NAME/$APP_NAME.app" DIST_APP_PATH="$DIST_DIR/$APP_NAME.app" @@ -77,6 +81,8 @@ BUILD_ARGS=( --build-number="$APP_BUILD" --dart-define="XWORKMATE_DISPLAY_VERSION=$APP_VERSION" --dart-define="XWORKMATE_BUILD_NUMBER=$APP_BUILD" + --dart-define="XWORKMATE_BUILD_DATE=$APP_BUILD_DATE" + --dart-define="XWORKMATE_BUILD_COMMIT=$APP_BUILD_COMMIT" "$APP_STORE_DEFINE" ) diff --git a/test/features/assistant/assistant_lower_pane_test.dart b/test/features/assistant/assistant_lower_pane_test.dart index 8d2dbbc2..a665d02d 100644 --- a/test/features/assistant/assistant_lower_pane_test.dart +++ b/test/features/assistant/assistant_lower_pane_test.dart @@ -10,7 +10,7 @@ import 'package:xworkmate/widgets/surface_card.dart'; void main() { group('AssistantLowerPaneInternal', () { - testWidgets('shows agent and gateway task dialog modes', (tester) async { + testWidgets('shows mode-specific provider catalogs', (tester) async { final controller = AppController( initialBridgeProviderCatalog: const [ SingleAgentProvider.codex, @@ -27,28 +27,51 @@ void main() { ); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('assistant-provider-button'))); + await tester.pumpAndSettle(); + expect( - find.byKey(const Key('assistant-provider-button')), + find.byKey(const Key('assistant-provider-menu-item-codex')), + findsOneWidget, + ); + expect( + find.byKey(const Key('assistant-provider-menu-item-opencode')), + findsOneWidget, + ); + expect( + find.byKey(const Key('assistant-provider-menu-item-gemini')), findsOneWidget, ); - await tester.tap( - find.byKey(const Key('assistant-execution-target-button')), + find.byKey(const Key('assistant-provider-menu-item-codex')), ); await tester.pumpAndSettle(); expect( - find.byKey(const Key('assistant-execution-target-menu-item-agent')), - findsOneWidget, - ); - expect( - find.byKey(const Key('assistant-execution-target-menu-item-gateway')), + find.byKey(const Key('assistant-execution-target-button')), findsOneWidget, ); - await tester.tap( - find.byKey(const Key('assistant-execution-target-menu-item-gateway')), + final gatewayThread = controller + .requireTaskThreadForSessionInternal('session-1') + .copyWith( + executionBinding: ExecutionBinding( + executionMode: threadExecutionModeFromAssistantExecutionTarget( + AssistantExecutionTarget.gateway, + ), + executorId: SingleAgentProvider.openclaw.providerId, + providerId: SingleAgentProvider.openclaw.providerId, + endpointId: '', + executionModeSource: ThreadSelectionSource.explicit, + providerSource: ThreadSelectionSource.explicit, + ), + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + controller.taskThreadRepositoryInternal.replace( + gatewayThread, + persist: false, ); + controller.notifyListeners(); await tester.pumpAndSettle(); expect(controller.assistantExecutionTarget.name, 'gateway'); @@ -64,18 +87,43 @@ void main() { find.byKey(const Key('assistant-provider-menu-item-openclaw')), findsOneWidget, ); + expect( + find.byKey(const Key('assistant-provider-menu-item-codex')), + findsNothing, + ); + expect( + find.byKey(const Key('assistant-provider-menu-item-opencode')), + findsNothing, + ); + expect( + find.byKey(const Key('assistant-provider-menu-item-gemini')), + findsNothing, + ); await tester.tap( find.byKey(const Key('assistant-provider-menu-item-openclaw')), ); await tester.pumpAndSettle(); - await tester.tap( - find.byKey(const Key('assistant-execution-target-button')), - ); - await tester.pumpAndSettle(); - await tester.tap( - find.byKey(const Key('assistant-execution-target-menu-item-agent')), + final agentThread = controller + .requireTaskThreadForSessionInternal('session-1') + .copyWith( + executionBinding: ExecutionBinding( + executionMode: threadExecutionModeFromAssistantExecutionTarget( + AssistantExecutionTarget.agent, + ), + executorId: SingleAgentProvider.codex.providerId, + providerId: SingleAgentProvider.codex.providerId, + endpointId: '', + executionModeSource: ThreadSelectionSource.explicit, + providerSource: ThreadSelectionSource.explicit, + ), + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + controller.taskThreadRepositoryInternal.replace( + agentThread, + persist: false, ); + controller.notifyListeners(); await tester.pumpAndSettle(); expect(controller.assistantExecutionTarget.name, 'agent'); @@ -83,6 +131,26 @@ void main() { find.byKey(const Key('assistant-provider-button')), findsOneWidget, ); + + await tester.tap(find.byKey(const Key('assistant-provider-button'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('assistant-provider-menu-item-codex')), + findsOneWidget, + ); + expect( + find.byKey(const Key('assistant-provider-menu-item-openclaw')), + findsNothing, + ); + expect( + find.byKey(const Key('assistant-provider-menu-item-opencode')), + findsOneWidget, + ); + expect( + find.byKey(const Key('assistant-provider-menu-item-gemini')), + findsOneWidget, + ); }); testWidgets('shows assistant providers and allows switching provider', ( diff --git a/test/features/settings/settings_about_panel_test.dart b/test/features/settings/settings_about_panel_test.dart new file mode 100644 index 00000000..11476471 --- /dev/null +++ b/test/features/settings/settings_about_panel_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/features/settings/settings_about_panel.dart'; +import 'package:xworkmate/theme/app_theme.dart'; +import 'package:xworkmate/widgets/surface_card.dart'; + +void main() { + testWidgets('renders app and bridge build metadata clearly', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Material( + child: Center( + child: SizedBox( + width: 1100, + child: SurfaceCard( + child: SettingsAboutPanel( + snapshot: const SettingsAboutSnapshot( + appVersion: '1.0.0-beta.2', + appBuildNumber: '4', + appBuildDate: '2026-03-28', + appCommit: 'f153d7b', + bridgeEndpoint: 'https://xworkmate-bridge.svc.plus', + bridgeStatus: 'ok', + bridgeVersion: '991ecb0ae2f270cdf6cc7bd456d4391cce664ae2', + bridgeBuildDate: '2026-04-13T09:00:00Z', + bridgeCommit: '991ecb0ae2f270cdf6cc7bd456d4391cce664ae2', + bridgeImage: + 'ghcr.io/x-evor/xworkmate-bridge:991ecb0ae2f270cdf6cc7bd456d4391cce664ae2', + ), + busy: false, + onRefresh: () async {}, + ), + ), + ), + ), + ), + ), + ); + + expect(find.text('关于'), findsOneWidget); + expect( + find.byKey(const ValueKey('settings-about-app-version')), + findsOneWidget, + ); + expect( + find.textContaining('Version: 1.0.0-beta.2'), + findsOneWidget, + ); + expect( + find.textContaining('Build Date: 2026-03-28'), + findsOneWidget, + ); + expect( + find.textContaining('Commit: 991ecb0ae2f270cdf6cc7bd456d4391cce664ae2'), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('settings-about-refresh-button')), + findsOneWidget, + ); + }); +} diff --git a/test/features/settings/settings_account_panel_test.dart b/test/features/settings/settings_account_panel_test.dart index 17dac265..6a837b2e 100644 --- a/test/features/settings/settings_account_panel_test.dart +++ b/test/features/settings/settings_account_panel_test.dart @@ -216,6 +216,56 @@ void main() { findsNothing, ); }); + + testWidgets('shows live syncing feedback while resync is running', ( + tester, + ) async { + final controllers = _TestControllers(); + addTearDown(controllers.dispose); + + await tester.pumpWidget( + _buildTestApp( + child: SettingsAccountPanel( + settings: SettingsSnapshot.defaults().copyWith( + accountBaseUrl: 'https://accounts.svc.plus', + accountUsername: 'review@svc.plus', + ), + accountSession: const AccountSessionSummary( + userId: 'u-1', + email: 'review@svc.plus', + name: 'Review User', + role: 'operator', + mfaEnabled: true, + ), + accountState: AccountSyncState.defaults().copyWith( + syncState: 'ready', + syncMessage: 'Bridge access synced', + profileScope: 'bridge', + ), + accountBusy: true, + accountStatus: 'Syncing bridge access...', + accountSignedIn: true, + accountMfaRequired: false, + accountBaseUrlController: controllers.baseUrl, + accountIdentifierController: controllers.identifier, + accountPasswordController: controllers.password, + accountMfaCodeController: controllers.mfaCode, + onSaveAccountProfile: () async {}, + onLogin: () async {}, + onVerifyMfa: () async {}, + onCancelMfa: () async {}, + onSync: () async {}, + onLogout: () async {}, + ), + ), + ); + + expect(find.textContaining('Syncing bridge access...'), findsOneWidget); + final syncButton = tester.widget( + find.byKey(const ValueKey('settings-account-sync-button')), + ); + expect(syncButton.onPressed, isNull); + }); }); } diff --git a/test/golden/goldens/assistant_lower_pane.png b/test/golden/goldens/assistant_lower_pane.png index 234954a9e4ac47fe6f7157f56eff93c88e39c122..d22941211cc6a6286426d497f9b31bd98e4ed535 100644 GIT binary patch literal 38691 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 literal 37898 zcmeFa2UL^U8Yr3>+gK187@Cw39EvE2gc7<^r3#320;2Q|p*MBJ&}C>!7l?>J=)F2L z=~Y5UdI?2(CvV3&bMDE{th?^I>%H~PTkmE$n!&xl{q?&4-+%`ya%30iFTh|hvitJV z>M+<9a~SNj&`)QAf@5tU?^U^sGYE zt@?z0834}z@$nm7do_M!d)SMtV&sh@6N^lD+2T2TxtrrV;~V(i<`q**zapoM0tEsy zwM>QRw2PY>E($eceCA&rIluq2w^j;mM z2v-k`53|zkWpf|Hz;X37sJkYIo7T$c2I8?+;Sli{wsp0PV_PeEYpL%K^ zeNjKh%x-c2{K0xHZYKV*ooM;D2X!yc(C=5*Kj|OcQJ#)Zv2X+!unC4gq=mqDgN2`E z!t|r7S5xtdo8AGANzV6rrw6VUb?ZO)Fp+VJ4Ab{U$*g;n(o~+a!L3hv7#?200$~bl zj=iULiUT8y+u=>vaVdRjL!~ZHZ+1X-)oL7UhFo3KK<+8jyNPZ-U>f$_ zH<56eJ$^M#@HwoDL55c2nRe+D4Fe07Uqq@f$0=0^#kZx1FTu#y2A=t=TX{JP1z>qL zK4H+^9|EkjwjbM#$m%jNLXHuAX%h_hS}aHkso89|YwYw{whE6m%|oUcd#fwt3EEB# zMOD!jwX^hfp}dI@)tM!FUt5Y1GWV<7tgbZi+~;hFs#1fuo47JTAb%YhtM(ME-YLqF zQA(F0pLr*~HQ3pxShJrQJ}kh(GFAot(h2{luNo;jHxx#2JB-31!)A98Sz|JrKX)*i zzd?C3D4VA9C~;y}!dT24h(`RMpGS7RtYkZlbSh}LHBe_EQQ;7{wDi(y$m2eNIgg{V z#S7lZ$N5xdpAijTxJ*uL@1N}zw5?b5p5daHTI-V9dvmUVj)f($%7L}o9{(t97JmBM z#7+$};oD41>S{TEn6oQCqUp<#!*nF|0b7L5rZ)nIXV%f?z%))gp=LjN72n&W^3#9f z2fpKT1H%1ZHT;EQyoP?&ozVEX_TT9#UPb6csxu!Ay%N*5s-Jj5U(`V!*(2KVx}FXK zf%Y?-41EWsnb5VKT)Gt-_CDRP;S1!xqf)2Hz)V)VR?cMJg3y)d+)@~LC(~UyT(eU`tx6B#>Si2iRzB5 z?*AOI@FIuc9dyx~6Bt0y!Iaw7)Z(&njUPyN8{L+YrcY$+u)mjP%ojYw)|lGcX(WtK z;hCxKtg=fsE}QQXKx{XvigS|ggBeBj(^>3JBpo)Vhw{Z=u^6~SM6cb#$GEI4cgf_t z1KQb$l~SNM@J!VVrG#ux91>nzHmNlb2ery$oXMxm_8l9vuDW)!v1soP)PSZ>FYTQ0XRGUTO(t zSJu!C7P8D>BF?m9Zhf)}oGdK+>M8hrzObb8a3#~xo{MopK2W=}4haf|XR&_glQw6& z-bME4tC#W^%;(F+6*4vw5Wo*%o#G)l+8cAT^yGF3)twLbHx7}rnT}*(Nr|L4KvIV# zoTZO;3DrBH=EcLk()9l5@7VR5I}hp4m;2;-k4sAR&O(6u+3Z{o_rErEi5+a{<>se} z*1LpYNm*6w(`98zX$DSQDwqPYhkIm7nqJ`AcPeNR81r?}o(EPT$3i`?rxG7a6+Co= ztoLjEDjM!Dv9Z@_rxZT{bo6%YY+;ps&lCBg{{4xG>k1hZ6b@T0ZfcID<`E0lzcrm9 z1>m=_`(o8c@VFW0i&$J*+TSsgAnduV#rPsvFrON?Z?BG3T^;w?`trfMX25lsFDZa9 z>w8>eKIGDsM`i)^xY^-AeKNJqLRxw{eiC@L&zL@$_-mNL{1X;*X}PS^oBKG3FyR{v z7d9BES#a=2qs{C_&@_ggx72%YZhP+@0QWG*bM$!AKyZG5JC$utzG-73t@ZVRx=K

w(oYMqDcG7KaC9PpypGic?aM#0DlQX#6nF z*C~^QtkdUsW9?6?oB(*1wxQ+iKKgLH`n$Wy5F7S0$S(@_zbYtA|LA7Id3fLFr<`4{ z&(K5Q5Ad>(ji^=&c0Gvsk?j5n7>w6@YO8=wKV>o(^1f~M4C=mJBIHlCMxP=plASKs z2Wc9~$B&V%r#1l41$2)b0gdMaaBy;%B-H7RNTogVJP#)U1cTLjqv5$qQ1oC-U$6Jr z&&{?gQd-QjMci#F&OQw#@awh2gORz(2%c(Z3l_3*kG)nFGF8cwkS6v&eZmC58LBoy zWWI5BGPHU-h=vR$UQwvhl^R$0?a{i5-9ZqMO=}R=27@ ztZ6uI<-kx`IIy#%Q*pNMK=}X7!EUsSl5p%f9zs*I|K;7Qb?xI_a>DK7otk4@>h{s& z@#BrorWiwlY@w&;i_m4`?Z(t7Cc+VU81=9_#;p9bLPqx%|N8=QQv;Br) zZI`i(ZKzIl9Sg9lI-L1S+MK89SJGuSy+d?x%;)G@x>$#VFp=}+KNn!bs&gnFLxn}r z6zk!(Cj-nE+?<8bz?$W%ABxdZZlnkE{GQ-oK zIptb7LH6qy2>&dvl_M*Ci(esIO);c8dj8;NjMw2%T4YKyr(<=N7KE8!*D6tSDu-X& z1t-65FMqZX*|hIi9tBzQa85OS(JIZsc9O4n_2#}{%INS_XX~Azt6`5k4tFF(b*(SL z88Z1(@CT(OtsvA66$cbIhySrFDJ`ANUtV3Gg-=~=ecPlSXg)mKBg+NhBhxGSI?HUn zuGd-~?{BG@FfnL{@&QlTax8>h3Nz^wn7pK@uE1*O)P$b({{`1yY55!H^ zUM}~szmnv&hADO0Fpb1s^$?odUIN+dKMF%2a&zq5tDvaOPW@rzLNipb)Cy1=Q5FAIp7%pKgS?;Psl(?>rh1k{XTY|iEY1sF8Z?^|Oj&`xL>&lan1_?dT z_ycC9Tg9%zUG{b$G*D@6%XLqS&X*Qzj>mumyW{i)S>;gY^DJaV{U1=`W`VG}#ukK( z3a5IkZxX2=HHtvP^(#?l>0OGYK~$`cZ=`cn0ntr6eu5JeR+$G=pB3-=VBMu3Rs7+~&LL@sLzJw_T6E{wlsVsptS47l(l$Q6~nk3QoSn83y&i zgS_QdD=CR?s#}@uMW{_ek0&z&cQlX0e;D)B8&vv)AIL;UndKA~!gHp&Ewk6UyWuTs zExRD$8CpJ%jHK`0AtzS^2*#_YMY7CI4M(!fP?>fw_MtyuWHb0Jv(;MGY_hv|W}f9J zax(gwS}}5yqB(-ob6u$?AM;|pY�QY=M2H}XB(KU&OGJMhV~BkavhDdzaH4pmj% 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 literal 0 HcmV?d00001 diff --git a/test/golden/settings_about_panel_golden_test.dart b/test/golden/settings_about_panel_golden_test.dart new file mode 100644 index 00000000..b7c90a97 --- /dev/null +++ b/test/golden/settings_about_panel_golden_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/features/settings/settings_about_panel.dart'; +import 'package:xworkmate/theme/app_theme.dart'; +import 'package:xworkmate/widgets/surface_card.dart'; + +void main() { + testWidgets('settings about panel matches baseline', (tester) async { + await tester.binding.setSurfaceSize(const Size(1280, 780)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Material( + child: Center( + child: SizedBox( + width: 1100, + child: SurfaceCard( + child: SettingsAboutPanel( + snapshot: const SettingsAboutSnapshot( + appVersion: '1.0.0-beta.2', + appBuildNumber: '4', + appBuildDate: '2026-03-28', + appCommit: 'f153d7b', + bridgeEndpoint: 'https://xworkmate-bridge.svc.plus', + bridgeStatus: 'ok', + bridgeVersion: '991ecb0ae2f270cdf6cc7bd456d4391cce664ae2', + bridgeBuildDate: '', + bridgeCommit: + '991ecb0ae2f270cdf6cc7bd456d4391cce664ae2', + bridgeImage: + 'ghcr.io/x-evor/xworkmate-bridge:991ecb0ae2f270cdf6cc7bd456d4391cce664ae2', + ), + busy: false, + onRefresh: () async {}, + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byType(SettingsAboutPanel), + matchesGoldenFile('goldens/settings_about_panel/default.png'), + ); + }); +} diff --git a/test/runtime/bridge_runtime_cleanup_test.dart b/test/runtime/bridge_runtime_cleanup_test.dart index dc50c551..94c14105 100644 --- a/test/runtime/bridge_runtime_cleanup_test.dart +++ b/test/runtime/bridge_runtime_cleanup_test.dart @@ -118,6 +118,25 @@ void main() { }, ); + test( + 'ignores legacy INTERNAL_SERVICE_TOKEN for managed bridge auth resolution', + () async { + final controller = AppController( + environmentOverride: const { + 'INTERNAL_SERVICE_TOKEN': 'legacy-bridge-token', + }, + ); + addTearDown(controller.dispose); + + final bridgeHeader = await controller + .resolveGatewayAcpAuthorizationHeaderInternal( + Uri.parse('https://xworkmate-bridge.svc.plus/acp/rpc'), + ); + + expect(bridgeHeader, isNull); + }, + ); + test( 'runtime coordinator only exposes remote and offline gateway modes', () { diff --git a/test/runtime/runtime_controllers_settings_account_test.dart b/test/runtime/runtime_controllers_settings_account_test.dart index a14446d6..e1ffb7f0 100644 --- a/test/runtime/runtime_controllers_settings_account_test.dart +++ b/test/runtime/runtime_controllers_settings_account_test.dart @@ -34,7 +34,14 @@ void main() { ); await store.saveAccountSessionToken('session-token'); - final controller = SettingsController(store); + final client = _FakeAccountRuntimeClient( + loginPayload: const {}, + profilePayload: const {}, + ); + final controller = SettingsController( + store, + accountClientFactory: (_) => client, + ); addTearDown(controller.dispose); await controller.initialize(); @@ -56,12 +63,11 @@ void main() { 'Bridge authorization is unavailable', ); expect(controller.accountStatus, 'Bridge authorization is unavailable'); + expect(client.loadProfileCallCount, 1); }, ); - test( - 'login sync stores the current bridge contract from login payload', - () async { + test('login sync stores BRIDGE_AUTH_TOKEN from login payload', () async { final storeRoot = await Directory.systemTemp.createTemp( 'xworkmate-account-sync-uppercase-token-', ); @@ -89,7 +95,7 @@ void main() { accountClientFactory: (_) => _FakeAccountRuntimeClient( loginPayload: { 'token': 'session-token', - 'INTERNAL_SERVICE_TOKEN': 'bridge-token-from-login', + 'BRIDGE_AUTH_TOKEN': 'bridge-token-from-login', 'BRIDGE_SERVER_URL': 'https://xworkmate-bridge-alt.svc.plus', 'user': { 'id': 'user-1', @@ -131,6 +137,65 @@ void main() { }, ); + test( + 'login sync ignores legacy INTERNAL_SERVICE_TOKEN fallback', + () async { + final storeRoot = await Directory.systemTemp.createTemp( + 'xworkmate-account-sync-legacy-token-', + ); + addTearDown(() async { + if (await storeRoot.exists()) { + await storeRoot.delete(recursive: true); + } + }); + + final store = SecureConfigStore( + secretRootPathResolver: () async => '${storeRoot.path}/secrets', + appDataRootPathResolver: () async => '${storeRoot.path}/app-data', + supportRootPathResolver: () async => '${storeRoot.path}/support', + enableSecureStorage: false, + ); + await store.initialize(); + await store.saveSettingsSnapshot( + SettingsSnapshot.defaults().copyWith( + accountBaseUrl: 'https://accounts.svc.plus', + ), + ); + + final controller = SettingsController( + store, + accountClientFactory: (_) => _FakeAccountRuntimeClient( + loginPayload: { + 'token': 'session-token', + 'INTERNAL_SERVICE_TOKEN': 'legacy-bridge-token', + 'BRIDGE_SERVER_URL': 'https://xworkmate-bridge-alt.svc.plus', + 'user': { + 'id': 'user-1', + 'email': 'review@svc.plus', + }, + }, + ), + ); + addTearDown(controller.dispose); + await controller.initialize(); + + await controller.loginAccount( + baseUrl: 'https://accounts.svc.plus', + identifier: 'review@svc.plus', + password: 'password', + ); + + expect(controller.accountSyncState, isNotNull); + expect(controller.accountSyncState!.syncState, 'blocked'); + expect( + await store.loadAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + ), + isNull, + ); + }, + ); + test('syncAccountSettings pins the managed bridge cloud entry', () async { final storeRoot = await Directory.systemTemp.createTemp( 'xworkmate-account-managed-bridge-', @@ -160,7 +225,14 @@ void main() { value: 'bridge-token', ); - final controller = SettingsController(store); + final client = _FakeAccountRuntimeClient( + loginPayload: const {}, + profilePayload: const {}, + ); + final controller = SettingsController( + store, + accountClientFactory: (_) => client, + ); addTearDown(controller.dispose); await controller.initialize(); @@ -183,8 +255,77 @@ void main() { .endpoint, kManagedBridgeServerUrl, ); + expect(client.loadProfileCallCount, 1); }); + test( + 'syncAccountSettings refreshes managed bridge contract from protected account profile', + () async { + final storeRoot = await Directory.systemTemp.createTemp( + 'xworkmate-account-managed-bridge-refresh-', + ); + addTearDown(() async { + if (await storeRoot.exists()) { + await storeRoot.delete(recursive: true); + } + }); + + final store = SecureConfigStore( + secretRootPathResolver: () async => '${storeRoot.path}/secrets', + appDataRootPathResolver: () async => '${storeRoot.path}/app-data', + supportRootPathResolver: () async => '${storeRoot.path}/support', + enableSecureStorage: false, + ); + await store.initialize(); + await store.saveSettingsSnapshot( + SettingsSnapshot.defaults().copyWith( + accountBaseUrl: 'https://accounts.svc.plus', + accountUsername: 'review@svc.plus', + ), + ); + await store.saveAccountSessionToken('session-token'); + await store.saveAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + value: 'stale-bridge-token', + ); + + final client = _FakeAccountRuntimeClient( + loginPayload: const {}, + profilePayload: { + 'BRIDGE_AUTH_TOKEN': 'fresh-bridge-token', + 'BRIDGE_SERVER_URL': 'https://xworkmate-bridge-new.svc.plus', + 'user': { + 'id': 'user-1', + 'email': 'review@svc.plus', + }, + }, + ); + final controller = SettingsController( + store, + accountClientFactory: (_) => client, + ); + addTearDown(controller.dispose); + await controller.initialize(); + + final result = await controller.syncAccountSettings( + baseUrl: 'https://accounts.svc.plus', + ); + + expect(result.state, 'ready'); + expect(client.loadProfileCallCount, 1); + expect( + await store.loadAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + ), + 'fresh-bridge-token', + ); + expect( + controller.accountSyncState!.syncedDefaults.bridgeServerUrl, + 'https://xworkmate-bridge-new.svc.plus', + ); + }, + ); + test( 'does not recover bridge sync state from stale cloud-synced snapshot state', () async { @@ -243,10 +384,15 @@ void main() { } class _FakeAccountRuntimeClient extends AccountRuntimeClient { - _FakeAccountRuntimeClient({required this.loginPayload}) + _FakeAccountRuntimeClient({ + required this.loginPayload, + this.profilePayload = const {}, + }) : super(baseUrl: 'https://accounts.svc.plus'); final Map loginPayload; + final Map profilePayload; + int loadProfileCallCount = 0; @override Future> login({ @@ -255,4 +401,10 @@ class _FakeAccountRuntimeClient extends AccountRuntimeClient { }) async { return loginPayload; } + + @override + Future> loadProfile({required String token}) async { + loadProfileCallCount += 1; + return profilePayload; + } }