openclaw-app 1.2.1 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
+ import { fileURLToPath } from "url";
3
4
  /**
4
5
  * OpenClaw App Channel Plugin
5
6
  *
@@ -337,7 +338,23 @@ const RECONNECT_DELAY = 5000;
337
338
  const PING_INTERVAL = 30_000; // 30s keepalive — prevents DO hibernation
338
339
  const DEFAULT_ACCOUNT_ID = "default";
339
340
  const CHANNEL_ID = "openclaw-app";
340
- const PLUGIN_VERSION = "1.1.9";
341
+
342
+ function resolvePluginVersion(): string {
343
+ try {
344
+ const pluginDir = path.dirname(fileURLToPath(import.meta.url));
345
+ const pluginMetaPath = path.join(pluginDir, "openclaw.plugin.json");
346
+ const raw = fs.readFileSync(pluginMetaPath, "utf-8");
347
+ const parsed = JSON.parse(raw) as { version?: unknown };
348
+ const version = typeof parsed.version === "string" ? parsed.version.trim() : "";
349
+ if (version) return version;
350
+ } catch (_) {
351
+ // Fallback handled below.
352
+ }
353
+ const envVersion = (process.env.OPENCLAW_APP_PLUGIN_VERSION ?? "").trim();
354
+ return envVersion || "unknown";
355
+ }
356
+
357
+ const PLUGIN_VERSION = resolvePluginVersion();
341
358
  /** Fixed chatSessionKey that routes cron messages to the App inbox UI */
342
359
  const INBOX_CHAT_SESSION_KEY = "inbox";
343
360
 
@@ -828,7 +845,7 @@ async function handleRelayMessage(ctx: any, accountId: string, state: RelayState
828
845
 
829
846
  if (state.relayToken) {
830
847
  const peerMac = msg.mac as string | undefined;
831
- const peerTs = msg.ts as number | undefined;
848
+ const peerTs = parseHandshakeTs(msg.ts);
832
849
  const version = msg.v as string | undefined;
833
850
 
834
851
  if (!peerMac || peerTs == null) {
@@ -843,6 +860,20 @@ async function handleRelayMessage(ctx: any, accountId: string, state: RelayState
843
860
  ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake timestamp out of window, dropping`);
844
861
  return;
845
862
  }
863
+ const sessionForMac = sessionKey ?? "";
864
+ const macOk = await verifyHandshakeMac(
865
+ state.relayToken,
866
+ "app",
867
+ sessionForMac,
868
+ peerPubKey,
869
+ peerTs,
870
+ peerMac,
871
+ version
872
+ );
873
+ if (!macOk) {
874
+ ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake MAC verification failed`);
875
+ return;
876
+ }
846
877
  }
847
878
 
848
879
  // V2: Derive persistent shared secret and store it
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-app",
3
3
  "name": "OpenClaw App",
4
- "version": "1.2.1",
4
+ "version": "1.2.2",
5
5
  "description": "Mobile app channel for OpenClaw — chat via the OpenClaw App app through a Cloudflare Worker relay.",
6
6
  "channels": [
7
7
  "openclaw-app"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-app",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "description": "OpenClaw App channel plugin — relay bridge for the OpenClaw App app",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -0,0 +1,839 @@
1
+ import 'dart:async';
2
+ import 'dart:math';
3
+ import 'package:flutter/material.dart';
4
+ import 'package:flutter/services.dart';
5
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
6
+ import 'package:go_router/go_router.dart';
7
+ import 'package:shared_preferences/shared_preferences.dart';
8
+ import 'package:url_launcher/url_launcher.dart';
9
+ import 'package:openclaw_mobile/core/gateway/connection_state.dart' as gateway;
10
+ import 'package:openclaw_mobile/core/gateway/gateway_client.dart';
11
+ import 'package:openclaw_mobile/core/storage/preferences.dart';
12
+ import 'package:openclaw_mobile/features/settings/settings_page.dart';
13
+ import 'package:openclaw_mobile/features/settings/settings_provider.dart';
14
+ import 'package:openclaw_mobile/router.dart';
15
+ import 'package:openclaw_mobile/shared/constants.dart';
16
+ import 'package:openclaw_mobile/shared/widgets/connect_failure_dialog.dart';
17
+ import 'package:openclaw_mobile/theme.dart';
18
+ import 'package:openclaw_mobile/l10n/app_localizations.dart';
19
+
20
+ /// Pairing status
21
+ enum PairingStatus {
22
+ initial,
23
+ connecting,
24
+ connected,
25
+ pairingRequested,
26
+ paired,
27
+ error,
28
+ }
29
+
30
+ /// Pairing state data
31
+ class PairingState {
32
+ final PairingStatus status;
33
+ final String gatewayUrl;
34
+
35
+ /// App-generated room id for relay; shown in install prompt and used when connecting.
36
+ final String? roomId;
37
+ final String? errorMessage;
38
+
39
+ const PairingState({
40
+ this.status = PairingStatus.initial,
41
+ this.gatewayUrl = '',
42
+ this.roomId,
43
+ this.errorMessage,
44
+ });
45
+
46
+ PairingState copyWith({
47
+ PairingStatus? status,
48
+ String? gatewayUrl,
49
+ String? roomId,
50
+ String? errorMessage,
51
+ }) {
52
+ return PairingState(
53
+ status: status ?? this.status,
54
+ gatewayUrl: gatewayUrl ?? this.gatewayUrl,
55
+ roomId: roomId ?? this.roomId,
56
+ errorMessage: errorMessage,
57
+ );
58
+ }
59
+ }
60
+
61
+ /// Pairing state notifier
62
+ class PairingNotifier extends StateNotifier<PairingState> {
63
+ final GatewayClient _gatewayClient;
64
+ final Ref _ref;
65
+ StreamSubscription<gateway.ConnectionState>? _connectionSubscription;
66
+
67
+ PairingNotifier(this._gatewayClient, this._ref)
68
+ : super(const PairingState()) {
69
+ _init();
70
+ }
71
+
72
+ Future<void> _init() async {
73
+ await loadConfig();
74
+
75
+ _connectionSubscription =
76
+ _gatewayClient.connectionState.listen((connState) {
77
+ debugPrint('PairingNotifier: connState=$connState');
78
+ if (connState == gateway.ConnectionState.connected) {
79
+ debugPrint('PairingNotifier: relay connected, marking as paired');
80
+ state = state.copyWith(status: PairingStatus.paired);
81
+ // Persist so next launch skips the pairing page.
82
+ SharedPreferences.getInstance()
83
+ .then((p) => p.setBool(AppConstants.prefHasPaired, true));
84
+ } else if (connState == gateway.ConnectionState.error) {
85
+ state = state.copyWith(
86
+ status: PairingStatus.error,
87
+ errorMessage: 'Connection failed',
88
+ );
89
+ } else if (connState == gateway.ConnectionState.connecting ||
90
+ connState == gateway.ConnectionState.reconnecting) {
91
+ state = state.copyWith(status: PairingStatus.connecting);
92
+ } else if (connState == gateway.ConnectionState.disconnected) {
93
+ if (state.status == PairingStatus.connecting ||
94
+ state.status == PairingStatus.connected ||
95
+ state.status == PairingStatus.pairingRequested) {
96
+ state = state.copyWith(
97
+ status: PairingStatus.error,
98
+ errorMessage: '连接已断开,请检查 Gateway 地址是否正确',
99
+ );
100
+ } else if (state.status != PairingStatus.paired) {
101
+ state = state.copyWith(status: PairingStatus.initial);
102
+ }
103
+ }
104
+ });
105
+ }
106
+
107
+ Future<void> loadConfig() async {
108
+ final prefs = await SharedPreferences.getInstance();
109
+ final saved = prefs.getString(AppConstants.prefRelayUrl);
110
+ final relayUrl = (saved?.isNotEmpty == true)
111
+ ? saved!
112
+ : PreferencesManager.defaultRelayUrl;
113
+
114
+ final cleanUrl = relayUrl.contains('#')
115
+ ? relayUrl.substring(0, relayUrl.indexOf('#')).trim()
116
+ : relayUrl;
117
+
118
+ // Room id is generated once per device and never overwritten.
119
+ String? roomId = prefs.getString(AppConstants.prefRelayRoomId);
120
+ if (roomId == null || roomId.isEmpty) {
121
+ roomId = _generateRoomId();
122
+ await prefs.setString(AppConstants.prefRelayRoomId, roomId);
123
+ // Sync to AppSettingsProvider so the settings page reflects the new value.
124
+ _ref.read(appSettingsProvider.notifier).setRelayRoomId(roomId);
125
+ }
126
+
127
+ state = state.copyWith(gatewayUrl: cleanUrl, roomId: roomId);
128
+ }
129
+
130
+ /// Generates a hex-segment room id, e.g. `3a8f2c1b-9e4d7a6f-1c5b8e2d`.
131
+ static String _generateRoomId() {
132
+ final r = Random.secure();
133
+ String seg(int n) =>
134
+ List.generate(n, (_) => r.nextInt(16).toRadixString(16)).join();
135
+ return '${seg(8)}-${seg(8)}-${seg(8)}';
136
+ }
137
+
138
+ /// Check if already connected and mark as paired
139
+ bool checkAlreadyConnected() {
140
+ if (_gatewayClient.state == gateway.ConnectionState.connected) {
141
+ state = state.copyWith(status: PairingStatus.paired);
142
+ return true;
143
+ }
144
+ return false;
145
+ }
146
+
147
+ void setGatewayUrl(String url) {
148
+ state = state.copyWith(gatewayUrl: url);
149
+ }
150
+
151
+ static const _connectTimeoutSecs = 8;
152
+
153
+ Future<void> connect() async {
154
+ // Update UI immediately before any async work
155
+ state = state.copyWith(
156
+ status: PairingStatus.connecting,
157
+ errorMessage: null,
158
+ );
159
+
160
+ final prefs = await SharedPreferences.getInstance();
161
+ final savedUrl = prefs.getString(AppConstants.prefRelayUrl);
162
+ // Fall back to built-in default so first-run works without manual setup
163
+ final relayUrl = (savedUrl?.isNotEmpty == true)
164
+ ? savedUrl!
165
+ : (state.gatewayUrl.isNotEmpty
166
+ ? state.gatewayUrl
167
+ : PreferencesManager.defaultRelayUrl);
168
+ final relayToken = prefs.getString(AppConstants.prefRelayToken);
169
+ // Room id is always pre-generated in loadConfig; fall back to state value.
170
+ final relayRoomId = prefs.getString(AppConstants.prefRelayRoomId) ??
171
+ state.roomId ??
172
+ 'default';
173
+
174
+ state = state.copyWith(gatewayUrl: relayUrl);
175
+
176
+ try {
177
+ await _gatewayClient
178
+ .connectToRelay(
179
+ relayUrl: relayUrl,
180
+ roomId: relayRoomId,
181
+ token: relayToken,
182
+ )
183
+ .timeout(
184
+ const Duration(seconds: _connectTimeoutSecs),
185
+ onTimeout: () {
186
+ state = state.copyWith(
187
+ status: PairingStatus.error,
188
+ errorMessage: '连接超时,请确认你是否已完成上方提示的操作:\n1. 安装插件\n2. 重启 Gateway',
189
+ );
190
+ },
191
+ );
192
+ } catch (e) {
193
+ state = state.copyWith(
194
+ status: PairingStatus.error,
195
+ errorMessage: '连接失败,请确认你是否已完成上方提示的操作:\n1. 安装插件\n2. 重启 Gateway',
196
+ );
197
+ }
198
+ }
199
+
200
+ Future<void> startPairing() async {
201
+ state = state.copyWith(status: PairingStatus.paired);
202
+ }
203
+
204
+ Future<void> disconnect() async {
205
+ await _gatewayClient.disconnect();
206
+ state = state.copyWith(
207
+ status: PairingStatus.initial,
208
+ errorMessage: null,
209
+ );
210
+ }
211
+
212
+ @override
213
+ void dispose() {
214
+ _connectionSubscription?.cancel();
215
+ super.dispose();
216
+ }
217
+ }
218
+
219
+ /// GatewayClient Provider
220
+ final gatewayClientProvider = Provider<GatewayClient>((ref) {
221
+ final client = GatewayClient.instance;
222
+ client.initialize();
223
+ ref.onDispose(() => client.dispose());
224
+ return client;
225
+ });
226
+
227
+ /// Pairing state Provider
228
+ final pairingProvider =
229
+ StateNotifierProvider<PairingNotifier, PairingState>((ref) {
230
+ final gatewayClient = ref.watch(gatewayClientProvider);
231
+ return PairingNotifier(gatewayClient, ref);
232
+ });
233
+
234
+ /// Pairing page — redesigned UI inspired by paring.html
235
+ class PairingPage extends ConsumerStatefulWidget {
236
+ /// When true (navigated from Settings), skip the auto-jump-to-chat logic.
237
+ final bool fromSettings;
238
+
239
+ const PairingPage({super.key, this.fromSettings = false});
240
+
241
+ @override
242
+ ConsumerState<PairingPage> createState() => _PairingPageState();
243
+ }
244
+
245
+ class _PairingPageState extends ConsumerState<PairingPage> {
246
+ bool _copied = false;
247
+ int _heroTapCount = 0;
248
+ DateTime? _lastHeroTapTime;
249
+
250
+ void _handleHeroTap() {
251
+ final now = DateTime.now();
252
+ if (_lastHeroTapTime == null ||
253
+ now.difference(_lastHeroTapTime!) > const Duration(seconds: 1)) {
254
+ _heroTapCount = 1;
255
+ } else {
256
+ _heroTapCount++;
257
+ }
258
+ _lastHeroTapTime = now;
259
+
260
+ if (_heroTapCount >= 5) {
261
+ _heroTapCount = 0;
262
+ if (widget.fromSettings) {
263
+ Navigator.of(context).pop();
264
+ } else {
265
+ context.go(AppRoutes.chat);
266
+ }
267
+ }
268
+ }
269
+
270
+ String _buildInstallCommands(String? roomId) {
271
+ final id = roomId ?? '';
272
+ return '''
273
+ Please complete the following steps in order:
274
+
275
+ 1. Run `openclaw plugins install openclaw-app`
276
+
277
+ 2. When the first step is completed, run `openclaw config set channels.openclaw-app.accounts.default.roomId "$id"`
278
+
279
+ 3. Then run `openclaw config set channels.openclaw-app.accounts.default.relayUrl "wss://openclaw.rewen.org"`
280
+
281
+ 4. Then run `openclaw config set channels.openclaw-app.accounts.default.enabled true`
282
+
283
+ 5. After completing steps 1–4, reply exactly:
284
+ "Task complete. Room ID has been set to <the id you just set>.Please wait about 30 seconds, then proactively ask me whether the restart has finished. Once I confirm it is done, you can return to the OpenClaw app to complete the connection."
285
+
286
+ 6. After sending the reply above, run `openclaw gateway restart`
287
+ ''';
288
+ }
289
+
290
+ @override
291
+ void initState() {
292
+ super.initState();
293
+ WidgetsBinding.instance.addPostFrameCallback((_) {
294
+ // Already connected in this session (e.g. navigated back from chat).
295
+ if (!widget.fromSettings) {
296
+ if (ref.read(pairingProvider.notifier).checkAlreadyConnected()) {
297
+ if (mounted) context.go(AppRoutes.chat);
298
+ return;
299
+ }
300
+ }
301
+ ref.read(pairingProvider.notifier).loadConfig();
302
+ });
303
+ }
304
+
305
+ @override
306
+ void dispose() {
307
+ super.dispose();
308
+ }
309
+
310
+ void _openSettings() {
311
+ Navigator.of(context).push(
312
+ MaterialPageRoute(builder: (_) => const SettingsPage()),
313
+ );
314
+ }
315
+
316
+ Future<void> _copyCommands(BuildContext context) async {
317
+ final roomId = ref.read(pairingProvider).roomId ?? '';
318
+ await Clipboard.setData(ClipboardData(text: _buildInstallCommands(roomId)));
319
+ if (!mounted) return;
320
+ setState(() => _copied = true);
321
+ await Future.delayed(const Duration(seconds: 2));
322
+ if (mounted) setState(() => _copied = false);
323
+ }
324
+
325
+ // Light: #F4F5F7 — Tailwind gray-100 like
326
+ // Dark: AppTheme.darkBg (#1e293b in HTML roughly maps to existing AppTheme background)
327
+ @override
328
+ Widget build(BuildContext context) {
329
+ final pairingState = ref.watch(pairingProvider);
330
+ final theme = Theme.of(context);
331
+ final isDark = theme.brightness == Brightness.dark;
332
+
333
+ ref.listen<PairingState>(pairingProvider, (prev, next) {
334
+ if (prev?.status != PairingStatus.paired &&
335
+ next.status == PairingStatus.paired) {
336
+ if (widget.fromSettings) {
337
+ if (context.mounted) Navigator.of(context).pop();
338
+ } else {
339
+ context.go(AppRoutes.chat);
340
+ }
341
+ }
342
+ if (prev?.status != PairingStatus.error &&
343
+ next.status == PairingStatus.error &&
344
+ next.errorMessage != null) {
345
+ unawaited(showConnectFailureDialog(context));
346
+ }
347
+ });
348
+
349
+ return Scaffold(
350
+ backgroundColor: isDark ? AppTheme.darkBg : const Color(0xFFF4F5F7),
351
+ extendBodyBehindAppBar: true,
352
+ appBar: AppBar(
353
+ backgroundColor: Colors.transparent,
354
+ elevation: 0,
355
+ scrolledUnderElevation: 0,
356
+ forceMaterialTransparency: true,
357
+ iconTheme: IconThemeData(
358
+ color: isDark ? Colors.white : AppTheme.lightTextPrimary),
359
+ automaticallyImplyLeading: false,
360
+ leading: widget.fromSettings
361
+ ? IconButton(
362
+ icon: const Icon(Icons.arrow_back_rounded),
363
+ onPressed: () => Navigator.of(context).pop(),
364
+ )
365
+ : null,
366
+ actions: widget.fromSettings
367
+ ? null
368
+ : [
369
+ IconButton(
370
+ tooltip: AppLocalizations.of(context)!.settings,
371
+ icon: const Icon(Icons.settings_outlined),
372
+ onPressed: _openSettings,
373
+ ),
374
+ ],
375
+ ),
376
+ body: SafeArea(
377
+ bottom: false,
378
+ child: Center(
379
+ child: ConstrainedBox(
380
+ // constrain max width like the app-frame
381
+ constraints: const BoxConstraints(maxWidth: 600),
382
+ child: _buildMainView(theme, isDark, pairingState),
383
+ ),
384
+ ),
385
+ ),
386
+ );
387
+ }
388
+
389
+ // ================================================================
390
+ // Main view (initial / error state)
391
+ // ================================================================
392
+
393
+ // Remove explicit height variable since it floats in the layout now.
394
+ Widget _buildMainView(ThemeData theme, bool isDark, PairingState pState) {
395
+ return LayoutBuilder(
396
+ builder: (context, constraints) {
397
+ return SingleChildScrollView(
398
+ padding: const EdgeInsets.fromLTRB(12, 0, 12, 40),
399
+ child: ConstrainedBox(
400
+ constraints: BoxConstraints(minHeight: constraints.maxHeight - 32),
401
+ child: Column(
402
+ mainAxisAlignment: MainAxisAlignment.center,
403
+ mainAxisSize: MainAxisSize.min,
404
+ children: [
405
+ _buildHeroSection(),
406
+ _buildTitleSection(theme, isDark),
407
+ const SizedBox(height: 32),
408
+ _buildInstructionCard(theme, isDark, pState),
409
+ const SizedBox(height: 32),
410
+ _buildConnectArea(theme, isDark, pState),
411
+ const SizedBox(
412
+ height: 64), // extra bottom space for layout bump
413
+ ],
414
+ ),
415
+ ),
416
+ );
417
+ },
418
+ );
419
+ }
420
+
421
+ Widget _buildHeroSection() {
422
+ return GestureDetector(
423
+ onTap: _handleHeroTap,
424
+ child: Container(
425
+ margin: const EdgeInsets.only(top: 8, bottom: 12),
426
+ width: 110,
427
+ height: 110,
428
+ alignment: Alignment.center,
429
+ child: Image.asset(
430
+ AppConstants.logoAsset,
431
+ width: 96,
432
+ height: 96,
433
+ fit: BoxFit.contain,
434
+ ),
435
+ ),
436
+ );
437
+ }
438
+
439
+ Widget _buildTitleSection(ThemeData theme, bool isDark) {
440
+ return Column(
441
+ children: [
442
+ Text(
443
+ AppLocalizations.of(context)!.connectToServer,
444
+ style: theme.textTheme.headlineSmall?.copyWith(
445
+ fontWeight: FontWeight.w700,
446
+ fontSize: 28,
447
+ letterSpacing: -0.5,
448
+ color:
449
+ isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
450
+ ),
451
+ textAlign: TextAlign.center,
452
+ ),
453
+ const SizedBox(height: 8),
454
+ Text(
455
+ AppLocalizations.of(context)!.followStepsToPair,
456
+ style: theme.textTheme.bodyMedium?.copyWith(
457
+ fontSize: 16,
458
+ color:
459
+ isDark ? AppTheme.darkTextSecondary : const Color(0xFF6B7280),
460
+ ),
461
+ textAlign: TextAlign.center,
462
+ ),
463
+ ],
464
+ );
465
+ }
466
+
467
+ Widget _buildInstructionCard(
468
+ ThemeData theme, bool isDark, PairingState pairingState) {
469
+ final cardBg = isDark ? AppTheme.darkSurface : Colors.white;
470
+ final codeBg =
471
+ isDark ? AppTheme.darkSurfaceElevated : const Color(0xFFF8FAFC);
472
+ final borderColor = isDark ? AppTheme.darkBorder : const Color(0xFFE2E8F0);
473
+
474
+ return Container(
475
+ decoration: BoxDecoration(
476
+ color: cardBg,
477
+ borderRadius: BorderRadius.circular(24),
478
+ border: isDark ? Border.all(color: AppTheme.darkBorderStrong) : null,
479
+ boxShadow: isDark
480
+ ? null
481
+ : [
482
+ BoxShadow(
483
+ color: Colors.black.withValues(alpha: 0.05),
484
+ blurRadius: 6,
485
+ offset: const Offset(0, 4),
486
+ ),
487
+ BoxShadow(
488
+ color: Colors.black.withValues(alpha: 0.05),
489
+ blurRadius: 25,
490
+ offset: const Offset(0, 20),
491
+ ),
492
+ ],
493
+ ),
494
+ padding: const EdgeInsets.all(16),
495
+ child: Column(
496
+ crossAxisAlignment: CrossAxisAlignment.start,
497
+ children: [
498
+ Text(
499
+ AppLocalizations.of(context)!.tellYourAgent,
500
+ style: theme.textTheme.titleMedium?.copyWith(
501
+ fontSize: 18,
502
+ fontWeight: FontWeight.w600,
503
+ color:
504
+ isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
505
+ ),
506
+ ),
507
+ const SizedBox(height: 8),
508
+ Text(
509
+ AppLocalizations.of(context)!.sendInstructionToOpenClaw,
510
+ style: theme.textTheme.bodyMedium?.copyWith(
511
+ fontSize: 14,
512
+ color: isDark
513
+ ? AppTheme.darkTextSecondary
514
+ : AppTheme.lightTextSecondary,
515
+ height: 1.4,
516
+ ),
517
+ ),
518
+ const SizedBox(height: 16),
519
+ // Code block container
520
+ Container(
521
+ width: double.infinity,
522
+ decoration: BoxDecoration(
523
+ color: codeBg,
524
+ borderRadius: BorderRadius.circular(16),
525
+ border: Border.all(color: borderColor),
526
+ ),
527
+ child: Column(
528
+ crossAxisAlignment: CrossAxisAlignment.stretch,
529
+ children: [
530
+ // Header of code block
531
+ Container(
532
+ padding:
533
+ const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
534
+ decoration: BoxDecoration(
535
+ border: Border(
536
+ bottom: BorderSide(color: borderColor),
537
+ ),
538
+ color: isDark
539
+ ? Colors.white.withValues(alpha: 0.05)
540
+ : Colors.white.withValues(alpha: 0.5),
541
+ ),
542
+ child: Row(
543
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
544
+ children: [
545
+ Text(
546
+ 'INSTRUCTIONS',
547
+ style: TextStyle(
548
+ fontSize: 12,
549
+ fontWeight: FontWeight.w600,
550
+ color: isDark
551
+ ? AppTheme.darkTextSecondary
552
+ : const Color(0xFF6B7280),
553
+ letterSpacing: 0.5,
554
+ ),
555
+ ),
556
+ GestureDetector(
557
+ onTap: () => _copyCommands(context),
558
+ child: AnimatedContainer(
559
+ duration: const Duration(milliseconds: 200),
560
+ padding: const EdgeInsets.symmetric(
561
+ horizontal: 12, vertical: 6),
562
+ decoration: BoxDecoration(
563
+ color: _copied
564
+ ? const Color(0xFF10B981)
565
+ : const Color(0xFF0EA5E9),
566
+ border: Border.all(
567
+ color: _copied
568
+ ? const Color(0xFF10B981)
569
+ : const Color(0xFF0EA5E9)),
570
+ borderRadius: BorderRadius.circular(20),
571
+ boxShadow: [
572
+ BoxShadow(
573
+ color: const Color(0xFF0EA5E9)
574
+ .withValues(alpha: 0.2),
575
+ blurRadius: 4,
576
+ offset: const Offset(0, 2),
577
+ ),
578
+ ],
579
+ ),
580
+ child: Row(
581
+ mainAxisSize: MainAxisSize.min,
582
+ children: [
583
+ Icon(
584
+ _copied
585
+ ? Icons.check_rounded
586
+ : Icons.copy_rounded,
587
+ size: 14,
588
+ color: Colors.white,
589
+ ),
590
+ const SizedBox(width: 6),
591
+ Text(
592
+ _copied ? 'Copied!' : 'Copy',
593
+ style: const TextStyle(
594
+ fontSize: 13,
595
+ fontWeight: FontWeight.w600,
596
+ color: Colors.white,
597
+ ),
598
+ ),
599
+ ],
600
+ ),
601
+ ),
602
+ ),
603
+ ],
604
+ ),
605
+ ),
606
+ // Code content area
607
+ Container(
608
+ constraints: const BoxConstraints(maxHeight: 110),
609
+ padding:
610
+ const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
611
+ child: SingleChildScrollView(
612
+ child: Text(
613
+ _buildInstallCommands(pairingState.roomId),
614
+ style: TextStyle(
615
+ fontFamily: 'monospace',
616
+ fontSize: 12.0,
617
+ color: isDark
618
+ ? const Color(0xFFCBD5E1)
619
+ : const Color(0xFF334155),
620
+ height: 1.15,
621
+ letterSpacing: -0.5,
622
+ ),
623
+ ),
624
+ ),
625
+ ),
626
+ ],
627
+ ),
628
+ ),
629
+ ],
630
+ ),
631
+ );
632
+ }
633
+
634
+ Widget _buildErrorBanner(ThemeData theme, String messageKey) {
635
+ String msg = messageKey;
636
+ if (messageKey.contains('断开')) {
637
+ msg = AppLocalizations.of(context)!.connectionDisconnectedCheckAddress;
638
+ } else if (messageKey.contains('超时')) {
639
+ msg = AppLocalizations.of(context)!.connectTimeoutAction;
640
+ } else if (messageKey.contains('失败')) {
641
+ msg = AppLocalizations.of(context)!.connectFailedAction;
642
+ }
643
+
644
+ return Container(
645
+ width: double.infinity,
646
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
647
+ decoration: BoxDecoration(
648
+ color: AppTheme.brandRed.withValues(alpha: 0.08),
649
+ borderRadius: BorderRadius.circular(12),
650
+ border: Border.all(color: AppTheme.brandRed.withValues(alpha: 0.2)),
651
+ ),
652
+ child: Row(
653
+ children: [
654
+ const Icon(
655
+ Icons.error_outline_rounded,
656
+ size: 16,
657
+ color: AppTheme.brandRed,
658
+ ),
659
+ const SizedBox(width: 8),
660
+ Expanded(
661
+ child: Text(
662
+ msg,
663
+ style: const TextStyle(
664
+ fontSize: 13,
665
+ color: AppTheme.brandRed,
666
+ ),
667
+ ),
668
+ ),
669
+ ],
670
+ ),
671
+ );
672
+ }
673
+
674
+ Widget _buildConnectArea(ThemeData theme, bool isDark, PairingState pState) {
675
+ final isLoading = pState.status == PairingStatus.connecting;
676
+
677
+ return Container(
678
+ width: double.infinity,
679
+ padding: const EdgeInsets.symmetric(horizontal: 12),
680
+ child: Column(
681
+ mainAxisSize: MainAxisSize.min,
682
+ children: [
683
+ if (pState.status == PairingStatus.error &&
684
+ pState.errorMessage != null) ...[
685
+ _buildErrorBanner(theme, pState.errorMessage!),
686
+ const SizedBox(height: 12),
687
+ ],
688
+ SizedBox(
689
+ width: double.infinity,
690
+ height: 56,
691
+ child: DecoratedBox(
692
+ decoration: BoxDecoration(
693
+ color: isLoading
694
+ ? const Color(0xFFD84742)
695
+ : const Color(0xFFEC5B55),
696
+ borderRadius: BorderRadius.circular(20),
697
+ boxShadow: isLoading
698
+ ? null
699
+ : [
700
+ BoxShadow(
701
+ color: const Color(0xFFEC5B55).withValues(alpha: 0.3),
702
+ blurRadius: 15,
703
+ offset: const Offset(0,
704
+ 7), // 10px 15px -3px in CSS equivalent translated loosely
705
+ ),
706
+ ],
707
+ ),
708
+ child: Material(
709
+ color: Colors.transparent,
710
+ borderRadius: BorderRadius.circular(20),
711
+ child: InkWell(
712
+ borderRadius: BorderRadius.circular(20),
713
+ highlightColor: const Color(0xFFD84742),
714
+ splashColor: const Color(0xFFD84742).withValues(alpha: 0.5),
715
+ onTap: isLoading
716
+ ? null
717
+ : () {
718
+ // If already connected, navigate immediately without updating state
719
+ final gw = ref.read(gatewayClientProvider);
720
+ if (gw.state == gateway.ConnectionState.connected) {
721
+ if (widget.fromSettings) {
722
+ Navigator.of(context).pop();
723
+ } else {
724
+ context.go(AppRoutes.chat);
725
+ }
726
+ return;
727
+ }
728
+ ref.read(pairingProvider.notifier).connect();
729
+ },
730
+ child: Row(
731
+ mainAxisAlignment: MainAxisAlignment.center,
732
+ children: isLoading
733
+ ? [
734
+ const SizedBox(
735
+ width: 20,
736
+ height: 20,
737
+ child: CircularProgressIndicator(
738
+ strokeWidth: 2,
739
+ color: Colors.white,
740
+ ),
741
+ ),
742
+ const SizedBox(width: 10),
743
+ Text(
744
+ AppLocalizations.of(context)!.connecting,
745
+ style: const TextStyle(
746
+ fontSize: 18,
747
+ fontWeight: FontWeight.w600,
748
+ color: Colors.white,
749
+ ),
750
+ ),
751
+ ]
752
+ : [
753
+ Text(
754
+ AppLocalizations.of(context)!.connect,
755
+ style: const TextStyle(
756
+ fontSize: 18,
757
+ fontWeight: FontWeight.w600,
758
+ color: Colors.white,
759
+ ),
760
+ ),
761
+ const SizedBox(width: 8),
762
+ const Icon(Icons.arrow_forward_rounded,
763
+ size: 20, color: Colors.white),
764
+ ],
765
+ ),
766
+ ),
767
+ ),
768
+ ),
769
+ ),
770
+ const SizedBox(height: 16),
771
+ _buildLegalText(isDark),
772
+ ],
773
+ ),
774
+ );
775
+ }
776
+
777
+ Widget _buildLegalText(bool isDark) {
778
+ final l10n = AppLocalizations.of(context)!;
779
+ final mutedColor =
780
+ isDark ? AppTheme.darkTextTertiary : const Color(0xFF9CA3AF);
781
+ final linkColor =
782
+ isDark ? AppTheme.darkTextSecondary : const Color(0xFF6B7280);
783
+ const fontSize = 12.0;
784
+
785
+ return Padding(
786
+ padding: const EdgeInsets.symmetric(horizontal: 8),
787
+ child: Text.rich(
788
+ TextSpan(
789
+ style: TextStyle(fontSize: fontSize, color: mutedColor, height: 1.5),
790
+ children: [
791
+ TextSpan(text: l10n.agreementPrefix),
792
+ WidgetSpan(
793
+ alignment: PlaceholderAlignment.baseline,
794
+ baseline: TextBaseline.alphabetic,
795
+ child: GestureDetector(
796
+ onTap: () => _openUrl(AppConstants.privacyPolicyUrl),
797
+ child: Text(
798
+ l10n.privacyPolicy,
799
+ style: TextStyle(
800
+ fontSize: fontSize,
801
+ color: linkColor,
802
+ decoration: TextDecoration.underline,
803
+ decorationColor: linkColor.withOpacity(0.4),
804
+ ),
805
+ ),
806
+ ),
807
+ ),
808
+ TextSpan(text: l10n.agreementAnd),
809
+ WidgetSpan(
810
+ alignment: PlaceholderAlignment.baseline,
811
+ baseline: TextBaseline.alphabetic,
812
+ child: GestureDetector(
813
+ onTap: () => _openUrl(AppConstants.termsOfServiceUrl),
814
+ child: Text(
815
+ l10n.termsOfService,
816
+ style: TextStyle(
817
+ fontSize: fontSize,
818
+ color: linkColor,
819
+ decoration: TextDecoration.underline,
820
+ decorationColor: linkColor.withOpacity(0.4),
821
+ ),
822
+ ),
823
+ ),
824
+ ),
825
+ TextSpan(text: l10n.agreementSuffix),
826
+ ],
827
+ ),
828
+ textAlign: TextAlign.center,
829
+ ),
830
+ );
831
+ }
832
+
833
+ Future<void> _openUrl(String url) async {
834
+ final uri = Uri.parse(url);
835
+ if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
836
+ debugPrint('Could not launch $url');
837
+ }
838
+ }
839
+ }