feat: add mobile gateway pairing guide

This commit is contained in:
Haitao Pan 2026-03-24 15:12:32 +08:00
parent 3bd16e1f89
commit 332ee750b3
10 changed files with 657 additions and 12 deletions

View File

@ -30,6 +30,8 @@
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>XWorkmate uses your local network only when you explicitly connect to a user-configured OpenClaw Gateway on the same network.</string>
<key>NSCameraUsageDescription</key>
<string>XWorkmate uses the camera only when you explicitly scan a gateway pairing QR code.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>

View File

@ -270,7 +270,7 @@ mobile:
description: Mobile Vault server integration section
ui_surface: settings_page
gateway_setup_code:
enabled: false
enabled: true
release_tier: experimental
build_modes: [debug, profile, release]
description: Mobile gateway setup code editor

View File

@ -0,0 +1,516 @@
import 'dart:convert';
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../../i18n/app_language.dart';
import '../../runtime/gateway_runtime.dart';
import '../../theme/app_palette.dart';
import '../../theme/app_theme.dart';
class MobileGatewayPairingGuidePage extends StatelessWidget {
const MobileGatewayPairingGuidePage({
super.key,
required this.supportsQrScan,
required this.onManualInput,
required this.onScannedSetupCode,
});
final bool supportsQrScan;
final VoidCallback onManualInput;
final Future<void> Function(String setupCode) onScannedSetupCode;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
return Scaffold(
backgroundColor: const Color(0xFFF3F1EF),
body: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 0),
child: Row(
children: [
_HeaderCircleButton(
key: const ValueKey('pairing-guide-close-button'),
icon: Icons.close_rounded,
onPressed: () => Navigator.of(context).pop(),
),
Expanded(
child: Center(
child: Text(
'配对网关',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
),
const SizedBox(width: 56),
],
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 28),
child: Column(
children: [
const SizedBox(height: 12),
Container(
width: 118,
height: 118,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 24,
offset: const Offset(0, 12),
),
],
border: Border.all(
color: Colors.black.withValues(alpha: 0.08),
),
),
alignment: Alignment.center,
child: Icon(
Icons.hub_outlined,
size: 56,
color: palette.textPrimary,
),
),
const SizedBox(height: 26),
Text(
'配对你的 OpenClaw 主机',
textAlign: TextAlign.center,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 12),
Text(
'在 Mac、Windows 或云端部署的 OpenClaw 主机上安装 xworkmate然后生成配对二维码或配置码。',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(
color: palette.textSecondary,
height: 1.35,
),
),
const SizedBox(height: 24),
_GuideCard(
key: const ValueKey('pairing-guide-install-card'),
title: '自主安装',
subtitle: '按下面两步在主机上安装 XWorkmate CLI然后生成配对码。',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'1. 安装',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 10),
_CommandBlock(
key: const ValueKey(
'pairing-guide-install-command',
),
command: 'npm install -g xworkmate',
),
const SizedBox(height: 16),
Text(
'2. 配对',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 10),
_CommandBlock(
key: const ValueKey('pairing-guide-pair-command'),
command: 'xworkmate pair',
),
],
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
key: const ValueKey('pairing-guide-scan-button'),
onPressed: () async {
if (!supportsQrScan) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
appText(
'Android 扫码即将支持,当前请先使用手动输入代码。',
'Android QR scanning is coming soon. Use manual code entry for now.',
),
),
),
);
return;
}
final result = await Navigator.of(context)
.push<String>(
MaterialPageRoute<String>(
fullscreenDialog: true,
builder: (_) =>
const MobileGatewayQrScannerPage(),
),
);
if (result == null || !context.mounted) {
return;
}
Navigator.of(context).pop();
await onScannedSetupCode(result);
},
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 18),
backgroundColor: const Color(0xFF151517),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppRadius.button,
),
),
),
child: Text(
'扫描二维码',
style: theme.textTheme.titleMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton(
key: const ValueKey('pairing-guide-manual-button'),
onPressed: () {
Navigator.of(context).pop();
onManualInput();
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 18),
backgroundColor: Colors.white,
foregroundColor: palette.textPrimary,
side: BorderSide(
color: Colors.black.withValues(alpha: 0.08),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppRadius.button,
),
),
),
child: Text(
'手动输入代码',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
),
],
),
),
),
],
),
),
);
}
}
class MobileGatewayQrScannerPage extends StatefulWidget {
const MobileGatewayQrScannerPage({super.key});
@override
State<MobileGatewayQrScannerPage> createState() =>
_MobileGatewayQrScannerPageState();
}
class _MobileGatewayQrScannerPageState
extends State<MobileGatewayQrScannerPage> {
bool _hasHandledDetection = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Positioned.fill(
child: _QrScannerSurface(onCodeDetected: _handleDetectedCode),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_HeaderCircleButton(
key: const ValueKey('pairing-scanner-close-button'),
icon: Icons.close_rounded,
onPressed: () => Navigator.of(context).pop(),
foregroundColor: Colors.white,
backgroundColor: Colors.black.withValues(alpha: 0.28),
),
const Spacer(),
Container(
width: double.infinity,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(AppRadius.dialog),
border: Border.all(
color: Colors.white.withValues(alpha: 0.12),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'扫描配对二维码',
style: theme.textTheme.titleLarge?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
Text(
'将二维码放入取景框内。扫描成功后会自动把配置码带入 Gateway 设置页。',
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.white.withValues(alpha: 0.82),
height: 1.35,
),
),
],
),
),
],
),
),
),
],
),
);
}
void _handleDetectedCode(String raw) {
if (_hasHandledDetection) {
return;
}
final setupCode = resolveGatewaySetupCodeFromScan(raw);
if (setupCode == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
appText('未识别到有效配置码,请重试。', 'No valid setup code found. Try again.'),
),
),
);
return;
}
_hasHandledDetection = true;
Navigator.of(context).pop(setupCode);
}
}
String? resolveGatewaySetupCodeFromScan(String raw) {
final trimmed = raw.trim();
if (trimmed.isEmpty) {
return null;
}
final candidate = _extractSetupCodeFromJsonPayload(trimmed) ?? trimmed;
return decodeGatewaySetupCode(candidate) != null ? candidate : null;
}
String? _extractSetupCodeFromJsonPayload(String raw) {
final normalized = raw.trim();
if (!normalized.startsWith('{')) {
return null;
}
try {
final dynamic decoded = jsonDecode(normalized);
if (decoded is! Map<String, dynamic>) {
return null;
}
final setupCode = decoded['setupCode'];
if (setupCode is! String || setupCode.trim().isEmpty) {
return null;
}
return setupCode.trim();
} catch (_) {
return null;
}
}
class _GuideCard extends StatelessWidget {
const _GuideCard({
super.key,
required this.title,
required this.subtitle,
required this.child,
});
final String title;
final String subtitle;
final Widget child;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(28),
border: Border.all(color: Colors.black.withValues(alpha: 0.08)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(subtitle, style: theme.textTheme.bodyLarge),
const SizedBox(height: 18),
child,
],
),
);
}
}
class _CommandBlock extends StatelessWidget {
const _CommandBlock({super.key, required this.command});
final String command;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final palette = context.palette;
return Container(
padding: const EdgeInsets.fromLTRB(18, 14, 14, 14),
decoration: BoxDecoration(
color: const Color(0xFFF8F6F4),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.black.withValues(alpha: 0.08)),
),
child: Row(
children: [
Expanded(
child: SelectableText(
command,
style: theme.textTheme.titleMedium?.copyWith(
color: palette.textPrimary,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 12),
IconButton(
onPressed: () async {
await Clipboard.setData(ClipboardData(text: command));
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(appText('已复制命令。', 'Command copied.'))),
);
},
icon: const Icon(Icons.content_copy_rounded),
tooltip: appText('复制命令', 'Copy command'),
),
],
),
);
}
}
class _HeaderCircleButton extends StatelessWidget {
const _HeaderCircleButton({
super.key,
required this.icon,
required this.onPressed,
this.foregroundColor,
this.backgroundColor,
});
final IconData icon;
final VoidCallback onPressed;
final Color? foregroundColor;
final Color? backgroundColor;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return SizedBox(
width: 56,
height: 56,
child: DecoratedBox(
decoration: BoxDecoration(
color: backgroundColor ?? Colors.white.withValues(alpha: 0.9),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
),
child: IconButton(
onPressed: onPressed,
icon: Icon(icon),
color: foregroundColor ?? palette.textPrimary,
),
),
);
}
}
class _QrScannerSurface extends StatelessWidget {
const _QrScannerSurface({required this.onCodeDetected});
final ValueChanged<String> onCodeDetected;
@override
Widget build(BuildContext context) {
return MobileScanner(
key: const ValueKey('pairing-guide-ios-scanner'),
onDetect: (capture) {
final code = capture.barcodes
.map((item) => item.rawValue?.trim() ?? '')
.firstWhere((item) => item.isNotEmpty, orElse: () => '');
if (code.isEmpty) {
return;
}
onCodeDetected(code);
},
);
}
}

View File

@ -11,6 +11,7 @@ import '../../runtime/runtime_models.dart';
import '../../theme/app_palette.dart';
import '../../theme/app_theme.dart';
import '../../widgets/detail_drawer.dart';
import 'mobile_gateway_pairing_guide_page.dart';
enum MobileShellTab { assistant, tasks, workspace, secrets, settings }
@ -152,6 +153,54 @@ class _MobileShellState extends State<MobileShell> {
rootLabel: appText('移动端', 'Mobile'),
destination: WorkspaceDestination.settings,
sectionLabel: appText('集成', 'Integrations'),
gatewayProfileIndex: kGatewayRemoteProfileIndex,
prefersGatewaySetupCode: false,
),
);
}
Future<void> _openGatewaySetupCodeEntry({String? prefilledSetupCode}) async {
final setupCode = prefilledSetupCode?.trim() ?? '';
if (setupCode.isNotEmpty) {
final current = widget
.controller
.settingsDraft
.gatewayProfiles[kGatewayRemoteProfileIndex];
await widget.controller.saveSettingsDraft(
widget.controller.settingsDraft.copyWithGatewayProfileAt(
kGatewayRemoteProfileIndex,
current.copyWith(useSetupCode: true, setupCode: setupCode),
),
);
}
widget.controller.openSettings(
detail: SettingsDetailPage.gatewayConnection,
navigationContext: SettingsNavigationContext(
rootLabel: appText('移动端', 'Mobile'),
destination: WorkspaceDestination.settings,
sectionLabel: appText('集成', 'Integrations'),
gatewayProfileIndex: kGatewayRemoteProfileIndex,
prefersGatewaySetupCode: true,
),
);
}
void _showPairingGuidePage() {
unawaited(_showPairingGuidePageFlow());
}
Future<void> _showPairingGuidePageFlow() async {
final supportsQrScan = Theme.of(context).platform == TargetPlatform.iOS;
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
fullscreenDialog: true,
builder: (_) => MobileGatewayPairingGuidePage(
supportsQrScan: supportsQrScan,
onManualInput: () => unawaited(_openGatewaySetupCodeEntry()),
onScannedSetupCode: (setupCode) async {
await _openGatewaySetupCodeEntry(prefilledSetupCode: setupCode);
},
),
),
);
}
@ -172,7 +221,7 @@ class _MobileShellState extends State<MobileShell> {
Navigator.of(sheetContext).pop();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_showConnectSheet();
_showPairingGuidePage();
}
});
},
@ -267,7 +316,7 @@ class _MobileShellState extends State<MobileShell> {
_MobileSafeStrip(
controller: widget.controller,
onOpenSafeSheet: _showMobileSafeSheet,
onOpenGatewayConnect: _showConnectSheet,
onOpenGatewayConnect: _showPairingGuidePage,
),
const SizedBox(height: 10),
Expanded(
@ -491,11 +540,11 @@ class _MobileSafeStrip extends StatelessWidget {
else
FilledButton(
key: const ValueKey('mobile-safe-connect-button'),
onPressed: handlePrimaryConnect,
onPressed: () => unawaited(handlePrimaryConnect()),
child: Text(
controller.canQuickConnectGateway
? appText('快速连接', 'Quick Connect')
: appText('连接 Gateway', 'Connect Gateway'),
: appText('配对网关', 'Pair Gateway'),
),
),
if (hasPendingRun)
@ -678,14 +727,11 @@ class _MobileSafeSheet extends StatelessWidget {
key: const ValueKey(
'mobile-safe-sheet-connect-button',
),
onPressed: handleConnect,
onPressed: () => unawaited(handleConnect()),
child: Text(
controller.canQuickConnectGateway
? appText('快速连接', 'Quick Connect')
: appText(
'打开集成设置',
'Open Integrations',
),
: appText('配对网关', 'Pair Gateway'),
),
),
if (hasPendingRun)

View File

@ -133,6 +133,32 @@ class _SettingsPageState extends State<SettingsPage> {
if (widget.navigationContext != _navigationContext) {
_navigationContext = widget.navigationContext;
}
_applyGatewayNavigationHints();
}
void _applyGatewayNavigationHints() {
final detail = _detail;
final navigationContext = _navigationContext;
if (detail != SettingsDetailPage.gatewayConnection ||
navigationContext == null) {
return;
}
final gatewayProfileIndex = navigationContext.gatewayProfileIndex;
if (gatewayProfileIndex == null) {
return;
}
_selectedGatewayProfileIndex = gatewayProfileIndex.clamp(
0,
kGatewayProfileListLength - 1,
);
}
bool _prefersGatewaySetupCodeForCurrentContext(BuildContext context) {
return resolveUiFeaturePlatformFromContext(context) ==
UiFeaturePlatform.mobile &&
_detail == SettingsDetailPage.gatewayConnection &&
_navigationContext?.prefersGatewaySetupCode == true &&
_selectedGatewayProfileIndex != kGatewayLocalProfileIndex;
}
@override
@ -169,6 +195,7 @@ class _SettingsPageState extends State<SettingsPage> {
_tab = uiFeatures.sanitizeSettingsTab(controller.settingsTab);
_detail = controller.settingsDetail;
_navigationContext = controller.settingsNavigationContext;
_applyGatewayNavigationHints();
final settings = controller.settingsDraft;
final showingDetail = _detail != null;
final showGlobalApplyBar =
@ -1160,9 +1187,13 @@ class _SettingsPageState extends State<SettingsPage> {
resolveUiFeaturePlatformFromContext(context),
);
final setupCodeFeatureEnabled = uiFeatures.supportsGatewaySetupCode;
final forceSetupCodeMode = _prefersGatewaySetupCodeForCurrentContext(
context,
);
final useSetupCode = selectedProfileIndex == kGatewayLocalProfileIndex
? false
: setupCodeFeatureEnabled && gatewayProfile.useSetupCode;
: forceSetupCodeMode ||
(setupCodeFeatureEnabled && gatewayProfile.useSetupCode);
final gatewayTls = gatewayMode == RuntimeConnectionMode.local
? false
: gatewayProfile.tls;
@ -1220,6 +1251,7 @@ class _SettingsPageState extends State<SettingsPage> {
),
const SizedBox(height: 12),
if (selectedProfileIndex != kGatewayLocalProfileIndex &&
!forceSetupCodeMode &&
setupCodeFeatureEnabled) ...[
SectionTabs(
items: [appText('配置码', 'Setup Code'), appText('手动配置', 'Manual')],
@ -1245,6 +1277,7 @@ class _SettingsPageState extends State<SettingsPage> {
TextField(
key: const ValueKey('gateway-setup-code-field'),
controller: _gatewaySetupCodeController,
autofocus: forceSetupCodeMode,
minLines: 4,
maxLines: 6,
decoration: InputDecoration(
@ -3162,9 +3195,13 @@ XWorkmate Privacy Policy
_selectedGatewayProfileIndex,
current,
);
final forceSetupCodeMode =
_navigationContext?.prefersGatewaySetupCode == true &&
_detail == SettingsDetailPage.gatewayConnection &&
_selectedGatewayProfileIndex != kGatewayLocalProfileIndex;
final useSetupCode = mode == RuntimeConnectionMode.local
? false
: current.useSetupCode;
: forceSetupCodeMode || current.useSetupCode;
final tls = mode == RuntimeConnectionMode.local ? false : current.tls;
final parsedPort = int.tryParse(_gatewayPortController.text.trim());
final decoded = useSetupCode

View File

@ -278,6 +278,8 @@ class SettingsNavigationContext {
this.secretsTab,
this.aiGatewayTab,
this.settingsTab,
this.gatewayProfileIndex,
this.prefersGatewaySetupCode,
});
final String rootLabel;
@ -287,6 +289,8 @@ class SettingsNavigationContext {
final SecretsTab? secretsTab;
final AiGatewayTab? aiGatewayTab;
final SettingsTab? settingsTab;
final int? gatewayProfileIndex;
final bool? prefersGatewaySetupCode;
}
enum AccountTab { profile, workspace, sessions }

View File

@ -8,6 +8,7 @@ import Foundation
import device_info_plus
import file_selector_macos
import irondash_engine_context
import mobile_scanner
import package_info_plus
import shared_preferences_foundation
import super_native_extensions
@ -16,6 +17,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin"))
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin"))

View File

@ -380,6 +380,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.17.0"
mobile_scanner:
dependency: "direct main"
description:
name: mobile_scanner
sha256: "0b466a0a8a211b366c2e87f3345715faef9b6011c7147556ad22f37de6ba3173"
url: "https://pub.dev"
source: hosted
version: "6.0.11"
native_toolchain_c:
dependency: transitive
description:

View File

@ -23,6 +23,7 @@ dependencies:
flutter_markdown: ^0.7.7+1
http: ^1.5.0
markdown: ^7.3.0
mobile_scanner: ^6.0.7
package_info_plus: ^8.3.1
path_provider: ^2.1.5
shared_preferences: ^2.5.3

View File

@ -3,6 +3,7 @@ library;
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/app/app_shell.dart';
import 'package:xworkmate/app/ui_feature_manifest.dart';
@ -14,6 +15,34 @@ import 'package:xworkmate/theme/app_theme.dart';
import '../../test_support.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const mobileScannerChannel = MethodChannel(
'dev.steenbakker.mobile_scanner/scanner/method',
);
setUp(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(mobileScannerChannel, (call) async {
return switch (call.method) {
'state' => 1,
'request' => true,
'start' => <Object?, Object?>{
'textureId': 1,
'size': <Object?, Object?>{'width': 1080.0, 'height': 1920.0},
'numberOfCameras': 1,
'currentTorchMode': 0,
},
_ => null,
};
});
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(mobileScannerChannel, null);
});
Future<void> pumpMobileShell(
WidgetTester tester, {
required Widget child,