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 234954a9..d2294121 100644 Binary files a/test/golden/goldens/assistant_lower_pane.png and b/test/golden/goldens/assistant_lower_pane.png differ diff --git a/test/golden/goldens/settings_about_panel/default.png b/test/golden/goldens/settings_about_panel/default.png new file mode 100644 index 00000000..73c07dfb Binary files /dev/null and b/test/golden/goldens/settings_about_panel/default.png differ 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; + } }