openclaw-app 1.2.0 → 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.js +1 -1
- package/index.ts +34 -3
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/pairing_page.dart +839 -0
package/index.js
CHANGED
|
@@ -231,7 +231,7 @@ function getRelayState(accountId) {
|
|
|
231
231
|
}
|
|
232
232
|
// ── Account resolution ───────────────────────────────────────────────────────
|
|
233
233
|
/** Default relay deployed at https://github.com/openclaw/openclaw-app */
|
|
234
|
-
const DEFAULT_RELAY_URL = "wss://openclaw
|
|
234
|
+
const DEFAULT_RELAY_URL = "wss://openclaw.rewen.org";
|
|
235
235
|
const DEFAULT_ROOM_ID = "default";
|
|
236
236
|
function listAccountIds(cfg) {
|
|
237
237
|
const ids = Object.keys(cfg.channels?.[CHANNEL_ID]?.accounts ?? {});
|
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
|
-
|
|
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
|
|
|
@@ -360,7 +377,7 @@ function getRelayState(accountId: string): RelayState {
|
|
|
360
377
|
// ── Account resolution ───────────────────────────────────────────────────────
|
|
361
378
|
|
|
362
379
|
/** Default relay deployed at https://github.com/openclaw/openclaw-app */
|
|
363
|
-
const DEFAULT_RELAY_URL = "wss://openclaw
|
|
380
|
+
const DEFAULT_RELAY_URL = "wss://openclaw.rewen.org";
|
|
364
381
|
const DEFAULT_ROOM_ID = "default";
|
|
365
382
|
|
|
366
383
|
interface ResolvedAccount {
|
|
@@ -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
|
|
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
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -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
|
+
}
|