You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
581 lines
18 KiB
581 lines
18 KiB
// CLI console (#883 local, #999 remote; interop plan F2/E2.1 + F3/E3.1). The
|
|
// local-device counterpart of RepeaterCliScreen, built to the owner-approved
|
|
// mockup on #883, and - with a [remoteContact] - the surface for remote CLI to
|
|
// a companion over the mesh. One console, one transcript; the transport
|
|
// ([CliConsoleTransport]) is what differs (local frame 66/29 vs remote TXT
|
|
// type-3). Same pattern, minus path/flood selection and retries: one send per
|
|
// command, a timeout surfaced as its own transcript row.
|
|
//
|
|
// Secrecy rule (W3/F4, applied here from day one): a reply to a command that
|
|
// names a secret (anything containing "pwd"/"password") renders masked and is
|
|
// only recoverable via long-press-to-copy; its cleartext never enters the
|
|
// widget tree as selectable text and is never logged by this screen.
|
|
//
|
|
// Population gating is NOT here: offering the local console only where the
|
|
// classifier allows frame 66 is #884; offering the remote console only on a
|
|
// companion contact is the entry point in chat_screen (#999).
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../connector/device_population.dart';
|
|
import '../connector/meshcore_connector.dart';
|
|
import '../helpers/device_name_validator.dart';
|
|
import '../helpers/snack_bar_builder.dart';
|
|
import '../l10n/l10n.dart';
|
|
import '../models/contact.dart';
|
|
import '../services/cli_console_transport.dart';
|
|
|
|
enum _EntryKind { command, reply, timeout, sendFailed }
|
|
|
|
class _ConsoleEntry {
|
|
_ConsoleEntry.command(this.text)
|
|
: kind = _EntryKind.command,
|
|
isError = false,
|
|
secret = false,
|
|
truncated = false,
|
|
latencyMs = null;
|
|
|
|
_ConsoleEntry.reply(
|
|
this.text, {
|
|
required this.latencyMs,
|
|
required this.truncated,
|
|
required this.secret,
|
|
required this.isError,
|
|
}) : kind = _EntryKind.reply;
|
|
|
|
_ConsoleEntry.timeout(this.text)
|
|
: kind = _EntryKind.timeout,
|
|
isError = true,
|
|
secret = false,
|
|
truncated = false,
|
|
latencyMs = null;
|
|
|
|
_ConsoleEntry.sendFailed(this.text)
|
|
: kind = _EntryKind.sendFailed,
|
|
isError = true,
|
|
secret = false,
|
|
truncated = false,
|
|
latencyMs = null;
|
|
|
|
final _EntryKind kind;
|
|
|
|
/// For a secret reply this still holds the cleartext (it is the long-press
|
|
/// copy source) - rendering is what masks it, so nothing here may be handed
|
|
/// to a logger or a selectable widget when [secret] is set.
|
|
final String text;
|
|
final bool isError;
|
|
final bool secret;
|
|
final bool truncated;
|
|
final int? latencyMs;
|
|
}
|
|
|
|
class DeviceCliScreen extends StatefulWidget {
|
|
const DeviceCliScreen({
|
|
super.key,
|
|
this.commandTimeout,
|
|
this.remoteContact,
|
|
this.transportOverride,
|
|
});
|
|
|
|
/// Per-command reply window; null uses the transport's default. Tests shrink
|
|
/// it so a timeout row renders without a 10 s pump. The remote transport
|
|
/// computes its own mesh window and ignores this.
|
|
final Duration? commandTimeout;
|
|
|
|
/// When set, the console runs CLI on this remote contact over the mesh
|
|
/// (type-3 for a companion, #999) instead of the connected device.
|
|
final Contact? remoteContact;
|
|
|
|
/// Test seam: a transport to use instead of building one from
|
|
/// [remoteContact]. Lets a test drive a deterministic reply or timeout. The
|
|
/// screen takes ownership and disposes it.
|
|
@visibleForTesting
|
|
final CliConsoleTransport? transportOverride;
|
|
|
|
/// Whether to offer the remote console for [contact]. Two conditions, both
|
|
/// required: the target is a companion (repeaters and room servers have
|
|
/// their own CLI via the repeater hub), and the CONNECTED radio speaks the
|
|
/// 1.18 CLI. A pre-1.18 radio refuses to send a type-3 command
|
|
/// (companion-v1.17.1 accepts only plain and CLI_DATA in CMD_SEND_TXT_MSG;
|
|
/// 1.18.0 adds CLI_COMMAND), so offering the console there would only
|
|
/// produce failures. Type-3 send and the local CLI frame shipped together
|
|
/// in 1.18, so this reuses the local console's gate. A null classification
|
|
/// (disconnected, or not yet classified) offers nothing.
|
|
static bool offersRemoteConsole({
|
|
required Contact contact,
|
|
required Classification? classification,
|
|
}) => contact.isCompanion && (classification?.allowsCliFrame ?? false);
|
|
|
|
@override
|
|
State<DeviceCliScreen> createState() => _DeviceCliScreenState();
|
|
}
|
|
|
|
class _DeviceCliScreenState extends State<DeviceCliScreen> {
|
|
final TextEditingController _commandController = TextEditingController();
|
|
final FocusNode _commandFocusNode = FocusNode();
|
|
final ScrollController _scrollController = ScrollController();
|
|
final List<_ConsoleEntry> _entries = [];
|
|
int _historyIndex = -1;
|
|
CliConsoleTransport? _transport;
|
|
|
|
bool get _isRemote => widget.remoteContact != null;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
_transport =
|
|
widget.transportOverride ??
|
|
(widget.remoteContact == null
|
|
? LocalCliTransport(connector)
|
|
: RemoteCliTransport(connector, widget.remoteContact!));
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_transport?.dispose();
|
|
_commandController.dispose();
|
|
_commandFocusNode.dispose();
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/// The device-side handlers echo secrets in cleartext (stock `get wifi.pwd`
|
|
/// reads back the stored password, INVESTIGATION.md section C) - the client
|
|
/// is the only place masking can happen, so match generously.
|
|
static bool isSecretCommand(String command) {
|
|
final c = command.toLowerCase();
|
|
return c.contains('pwd') || c.contains('password');
|
|
}
|
|
|
|
/// The wire has no error codes; stock firmware answers errors as plain
|
|
/// "Unknown command" / "Error..." strings (INVESTIGATION.md section A1), so
|
|
/// error styling is string-matched, exactly as the mockup states.
|
|
static bool looksLikeErrorReply(String text) {
|
|
final t = text.trimLeft();
|
|
return t.startsWith('Unknown command') || t.startsWith('Error');
|
|
}
|
|
|
|
/// Extracts the name argument of a `set name <value>` command (any casing,
|
|
/// any inner spacing), or null when the command is not one. #885 mirrors the
|
|
/// device's own name-character rejection before the frame is sent.
|
|
static String? setNameArg(String command) {
|
|
final m = RegExp(
|
|
r'^set\s+name\s+(.+)$',
|
|
caseSensitive: false,
|
|
).firstMatch(command.trim());
|
|
return m?.group(1);
|
|
}
|
|
|
|
Future<void> _sendCommand() async {
|
|
final command = _commandController.text.trim();
|
|
if (command.isEmpty) return;
|
|
final transport = _transport;
|
|
if (transport == null) return;
|
|
|
|
// Mirror the firmware's `set name` character rejection locally: show the
|
|
// typed command and a rejection row, and never send the frame (#885).
|
|
final nameArg = setNameArg(command);
|
|
if (nameArg != null) {
|
|
final bad = forbiddenCharsIn(nameArg);
|
|
if (bad.isNotEmpty) {
|
|
setState(() {
|
|
_entries.add(_ConsoleEntry.command(command));
|
|
_entries.add(
|
|
_ConsoleEntry.sendFailed(
|
|
context.l10n.deviceCli_nameRejected(bad.join(' ')),
|
|
),
|
|
);
|
|
});
|
|
_commandController.clear();
|
|
_historyIndex = -1;
|
|
_commandFocusNode.requestFocus();
|
|
_scrollToBottom();
|
|
return;
|
|
}
|
|
}
|
|
|
|
final secret = isSecretCommand(command);
|
|
setState(() {
|
|
_entries.add(_ConsoleEntry.command(command));
|
|
});
|
|
_commandController.clear();
|
|
_historyIndex = -1;
|
|
_commandFocusNode.requestFocus();
|
|
_scrollToBottom();
|
|
|
|
final stopwatch = Stopwatch()..start();
|
|
_ConsoleEntry entry;
|
|
try {
|
|
final reply = await transport.run(
|
|
command,
|
|
timeout: widget.commandTimeout,
|
|
);
|
|
stopwatch.stop();
|
|
entry = _ConsoleEntry.reply(
|
|
reply.text,
|
|
latencyMs: stopwatch.elapsedMilliseconds,
|
|
truncated: reply.atReplyBound,
|
|
secret: secret,
|
|
isError: looksLikeErrorReply(reply.text),
|
|
);
|
|
} on CliConsoleTimeout catch (e) {
|
|
if (!mounted) return;
|
|
// A remote companion that has not granted us remote CLI simply never
|
|
// replies, so a timeout is as likely a permission answer as a lost
|
|
// packet. The remote row says so and notes we did not retry (#891); the
|
|
// local row keeps the plain frame-timeout wording. The permission hint
|
|
// assumes a companion target - the only remote entry (chat_screen) is
|
|
// companion-gated; a repeater target would need different wording.
|
|
entry = _ConsoleEntry.timeout(
|
|
_isRemote
|
|
? context.l10n.deviceCli_remoteNoReply(command, e.secondsText)
|
|
: context.l10n.deviceCli_noReply(command, e.secondsText),
|
|
);
|
|
} on CliConsoleUnsupported {
|
|
if (!mounted) return;
|
|
// The connected radio refused the send itself (pre-1.18 firmware), so
|
|
// nothing went out: say so now instead of waiting out a timeout that
|
|
// would blame the target.
|
|
entry = _ConsoleEntry.sendFailed(
|
|
context.l10n.deviceCli_remoteUnsupported(command),
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
entry = _ConsoleEntry.sendFailed(
|
|
context.l10n.deviceCli_sendFailed(e.toString()),
|
|
);
|
|
}
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_entries.add(entry);
|
|
});
|
|
_scrollToBottom();
|
|
}
|
|
|
|
void _scrollToBottom() {
|
|
Future.delayed(const Duration(milliseconds: 100), () {
|
|
if (_scrollController.hasClients) {
|
|
_scrollController.animateTo(
|
|
_scrollController.position.maxScrollExtent,
|
|
duration: const Duration(milliseconds: 200),
|
|
curve: Curves.easeOut,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
void _navigateHistory(bool up) {
|
|
final commands = _entries
|
|
.where((e) => e.kind == _EntryKind.command)
|
|
.map((e) => e.text)
|
|
.toList()
|
|
.reversed
|
|
.toList();
|
|
if (commands.isEmpty) return;
|
|
|
|
if (up) {
|
|
if (_historyIndex < commands.length - 1) {
|
|
_historyIndex++;
|
|
}
|
|
} else {
|
|
if (_historyIndex > 0) {
|
|
_historyIndex--;
|
|
} else {
|
|
_historyIndex = -1;
|
|
_commandController.clear();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (_historyIndex >= 0 && _historyIndex < commands.length) {
|
|
_commandController.text = commands[_historyIndex];
|
|
_commandController.selection = TextSelection.fromPosition(
|
|
TextPosition(offset: _commandController.text.length),
|
|
);
|
|
}
|
|
}
|
|
|
|
void _clearHistory() {
|
|
setState(() {
|
|
_entries.clear();
|
|
_historyIndex = -1;
|
|
});
|
|
}
|
|
|
|
void _copySecret(String cleartext) {
|
|
Clipboard.setData(ClipboardData(text: cleartext));
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(context.l10n.deviceCli_secretCopied),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = context.l10n;
|
|
final connector = context.watch<MeshCoreConnector>();
|
|
final String title;
|
|
final String subtitle;
|
|
if (_isRemote) {
|
|
title = l10n.deviceCli_remoteTitle;
|
|
subtitle = widget.remoteContact!.name;
|
|
} else {
|
|
title = l10n.deviceCli_title;
|
|
final name = connector.selfName;
|
|
final version = connector.firmwareVersion;
|
|
subtitle = [
|
|
if (name != null && name.isNotEmpty) name,
|
|
if (version != null && version.isNotEmpty) '($version)',
|
|
].join(' ');
|
|
}
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(title),
|
|
if (subtitle.isNotEmpty)
|
|
Text(
|
|
subtitle,
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.normal,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
centerTitle: false,
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.clear_all),
|
|
tooltip: l10n.repeater_clearHistory,
|
|
onPressed: _entries.isEmpty ? null : _clearHistory,
|
|
),
|
|
],
|
|
),
|
|
body: Column(
|
|
children: [
|
|
Expanded(
|
|
child: _entries.isEmpty ? _buildEmptyState() : _buildTranscript(),
|
|
),
|
|
const Divider(height: 1),
|
|
_buildCommandInput(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildEmptyState() {
|
|
final l10n = context.l10n;
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.terminal, size: 64, color: Colors.grey[400]),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
l10n.repeater_noCommandsSent,
|
|
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
l10n.deviceCli_emptyHint,
|
|
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTranscript() {
|
|
return ListView.builder(
|
|
controller: _scrollController,
|
|
padding: const EdgeInsets.all(16),
|
|
itemCount: _entries.length,
|
|
itemBuilder: (context, index) => _buildEntry(_entries[index]),
|
|
);
|
|
}
|
|
|
|
Widget _buildEntry(_ConsoleEntry entry) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
final isCommand = entry.kind == _EntryKind.command;
|
|
|
|
final badgeColor = entry.isError
|
|
? scheme.errorContainer
|
|
: isCommand
|
|
? scheme.primaryContainer
|
|
: scheme.secondaryContainer;
|
|
final badgeIconColor = entry.isError
|
|
? scheme.onErrorContainer
|
|
: isCommand
|
|
? scheme.onPrimaryContainer
|
|
: scheme.onSecondaryContainer;
|
|
final badgeIcon = switch (entry.kind) {
|
|
_EntryKind.command => Icons.chevron_right,
|
|
_EntryKind.reply when entry.isError => Icons.warning_amber,
|
|
_EntryKind.reply => Icons.arrow_back,
|
|
_EntryKind.timeout => Icons.timer_off_outlined,
|
|
_EntryKind.sendFailed => Icons.warning_amber,
|
|
};
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(6),
|
|
decoration: BoxDecoration(
|
|
color: badgeColor,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Icon(badgeIcon, size: 16, color: badgeIconColor),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
entry.secret ? _buildSecretText(entry) : _buildPlainText(entry),
|
|
if (entry.truncated) _buildTruncationBadge(),
|
|
],
|
|
),
|
|
),
|
|
if (entry.latencyMs != null) ...[
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
context.l10n.deviceCli_latencyMs(entry.latencyMs!),
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Theme.of(context).colorScheme.outline,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPlainText(_ConsoleEntry entry) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
final color = entry.isError
|
|
? scheme.error
|
|
: entry.kind == _EntryKind.command
|
|
? scheme.primary
|
|
: scheme.onSurface;
|
|
return SelectableText(
|
|
entry.text,
|
|
style: TextStyle(fontFamily: 'monospace', fontSize: 13, color: color),
|
|
);
|
|
}
|
|
|
|
/// Masked rendering: fixed-width dots regardless of the secret's length
|
|
/// (the dot count must not leak it), cleartext only via long-press copy.
|
|
Widget _buildSecretText(_ConsoleEntry entry) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
return GestureDetector(
|
|
onLongPress: () => _copySecret(entry.text),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'●' * 8,
|
|
style: TextStyle(
|
|
fontFamily: 'monospace',
|
|
fontSize: 13,
|
|
color: scheme.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Flexible(
|
|
child: Text(
|
|
context.l10n.deviceCli_secretLongPressToCopy,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontStyle: FontStyle.italic,
|
|
color: scheme.outline,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTruncationBadge() {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.content_cut, size: 12, color: scheme.tertiary),
|
|
const SizedBox(width: 4),
|
|
Flexible(
|
|
child: Text(
|
|
context.l10n.deviceCli_truncatedByDevice,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontStyle: FontStyle.italic,
|
|
color: scheme.tertiary,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCommandInput() {
|
|
final l10n = context.l10n;
|
|
return Container(
|
|
padding: const EdgeInsets.all(12),
|
|
color: Theme.of(context).colorScheme.surface,
|
|
child: SafeArea(
|
|
child: Row(
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.arrow_upward, size: 20),
|
|
tooltip: l10n.repeater_previousCommand,
|
|
onPressed: () => _navigateHistory(true),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.arrow_downward, size: 20),
|
|
tooltip: l10n.repeater_nextCommand,
|
|
onPressed: () => _navigateHistory(false),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _commandController,
|
|
focusNode: _commandFocusNode,
|
|
decoration: InputDecoration(
|
|
hintText: l10n.deviceCli_commandHint,
|
|
border: const OutlineInputBorder(),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
prefixText: '> ',
|
|
),
|
|
style: const TextStyle(fontFamily: 'monospace'),
|
|
textInputAction: TextInputAction.send,
|
|
onSubmitted: (_) => _sendCommand(),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
IconButton.filled(
|
|
icon: const Icon(Icons.send),
|
|
onPressed: _sendCommand,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|