feat(ui): complete M2 Milestone - Desktop POSIX PTY FFI implementation and attach terminal tab integration
This commit is contained in:
+30
-1
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:mam_core/mam_core.dart';
|
||||
|
||||
import '../theme/app_theme.dart';
|
||||
import 'terminal_pane.dart';
|
||||
|
||||
class _KV {
|
||||
final String label;
|
||||
@@ -27,7 +28,35 @@ class DetailPane extends StatelessWidget {
|
||||
),
|
||||
border: Border(left: BorderSide(color: AppColors.border)),
|
||||
),
|
||||
child: s == null ? const _EmptyDetail() : _DetailContent(session: s),
|
||||
child: s == null
|
||||
? const _EmptyDetail()
|
||||
: DefaultTabController(
|
||||
length: 2,
|
||||
child: Column(
|
||||
children: [
|
||||
const TabBar(
|
||||
tabs: [
|
||||
Tab(text: 'INFO'),
|
||||
Tab(text: 'TERMINAL'),
|
||||
],
|
||||
labelColor: AppColors.accent2,
|
||||
unselectedLabelColor: AppColors.textSecondary,
|
||||
indicatorColor: AppColors.accent2,
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
_DetailContent(session: s),
|
||||
TerminalPane(
|
||||
sessionName: s.name,
|
||||
serverName: s.server,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
import 'package:mam_pty/mam_pty.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
class TerminalPane extends StatefulWidget {
|
||||
final String sessionName;
|
||||
final String serverName;
|
||||
final VoidCallback? onClosed;
|
||||
|
||||
const TerminalPane({
|
||||
super.key,
|
||||
required this.sessionName,
|
||||
required this.serverName,
|
||||
this.onClosed,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TerminalPane> createState() => _TerminalPaneState();
|
||||
}
|
||||
|
||||
class _TerminalPaneState extends State<TerminalPane> {
|
||||
late final Terminal _terminal;
|
||||
PtySession? _pty;
|
||||
StreamSubscription<List<int>>? _stdoutSub;
|
||||
bool _hasError = false;
|
||||
String _errorMessage = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_terminal = Terminal(
|
||||
maxLines: 1000,
|
||||
);
|
||||
_startPty();
|
||||
}
|
||||
|
||||
Future<void> _startPty() async {
|
||||
try {
|
||||
final pty = await PtySession.start(
|
||||
'tmux',
|
||||
['-L', widget.serverName, 'attach', '-t', widget.sessionName],
|
||||
);
|
||||
if (!mounted) {
|
||||
pty.close();
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_pty = pty;
|
||||
});
|
||||
|
||||
// Handle PTY stdout stream data redirection to xterm widget
|
||||
_stdoutSub = pty.stdout.listen(
|
||||
(data) {
|
||||
_terminal.write(utf8.decode(data, allowMalformed: true));
|
||||
},
|
||||
onError: (err) {
|
||||
_showError(err.toString());
|
||||
},
|
||||
onDone: () {
|
||||
widget.onClosed?.call();
|
||||
},
|
||||
);
|
||||
|
||||
// Handle user keyboard inputs redirection from xterm to PTY using onOutput (xterm v3)
|
||||
_terminal.onOutput = (data) {
|
||||
_pty?.writeString(data);
|
||||
};
|
||||
|
||||
// Handle user-driven terminal layout resizes (4 args in xterm v3)
|
||||
_terminal.onResize = (cols, rows, pxW, pxH) {
|
||||
_pty?.resize(cols, rows);
|
||||
};
|
||||
} catch (e) {
|
||||
_showError(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_hasError = true;
|
||||
_errorMessage = message;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stdoutSub?.cancel();
|
||||
_pty?.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_hasError) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: AppColors.danger, size: 40),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'PTY Session failed:\n$_errorMessage',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppColors.danger),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_hasError = false;
|
||||
_errorMessage = '';
|
||||
});
|
||||
_startPty();
|
||||
},
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
color: Colors.black,
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: TerminalView(
|
||||
_terminal,
|
||||
autofocus: true,
|
||||
backgroundOpacity: 1.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -89,6 +97,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -178,6 +194,13 @@ packages:
|
||||
relative: true
|
||||
source: path
|
||||
version: "1.0.0"
|
||||
mam_pty:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../../packages/mam_pty"
|
||||
relative: true
|
||||
source: path
|
||||
version: "1.0.0"
|
||||
markdown:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -218,14 +241,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
platform_info:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform_info
|
||||
sha256: "012e73712166cf0b56d3eb95c0d33491f56b428c169eca385f036448474147e4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.0"
|
||||
quiver:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -242,6 +257,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
shelf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf
|
||||
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.2"
|
||||
shelf_web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_web_socket
|
||||
sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -335,14 +366,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
web_socket_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket_channel
|
||||
sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
xterm:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: xterm
|
||||
sha256: "6a02b15d03152b8186e12790902ff28c8a932fc441e89fa7255a7491661a8e69"
|
||||
sha256: "168dfedca77cba33fdb6f52e2cd001e9fde216e398e89335c19b524bb22da3a2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.5.0"
|
||||
version: "4.0.0"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -351,6 +390,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
zmodem:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: zmodem
|
||||
sha256: "3b7e5b29f3a7d8aee472029b05165a68438eff2f3f7766edf13daba1e297adbf"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.6"
|
||||
sdks:
|
||||
dart: ">=3.12.1 <4.0.0"
|
||||
flutter: ">=3.27.0"
|
||||
|
||||
@@ -33,9 +33,11 @@ dependencies:
|
||||
cupertino_icons: ^1.0.8
|
||||
mam_core:
|
||||
path: ../../packages/mam_core
|
||||
mam_pty:
|
||||
path: ../../packages/mam_pty
|
||||
flutter_riverpod: ^2.4.9
|
||||
data_table_2: ^2.5.8
|
||||
xterm: ^3.2.0
|
||||
xterm: ^4.0.0
|
||||
flutter_markdown: ^0.6.18
|
||||
meta: ^1.9.0
|
||||
|
||||
|
||||
@@ -218,14 +218,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
platform_info:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform_info
|
||||
sha256: "012e73712166cf0b56d3eb95c0d33491f56b428c169eca385f036448474147e4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.0"
|
||||
quiver:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -339,10 +331,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: xterm
|
||||
sha256: "6a02b15d03152b8186e12790902ff28c8a932fc441e89fa7255a7491661a8e69"
|
||||
sha256: "168dfedca77cba33fdb6f52e2cd001e9fde216e398e89335c19b524bb22da3a2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.5.0"
|
||||
version: "4.0.0"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -351,6 +343,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
zmodem:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: zmodem
|
||||
sha256: "3b7e5b29f3a7d8aee472029b05165a68438eff2f3f7766edf13daba1e297adbf"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.6"
|
||||
sdks:
|
||||
dart: ">=3.12.1 <4.0.0"
|
||||
flutter: ">=3.27.0"
|
||||
|
||||
@@ -35,7 +35,7 @@ dependencies:
|
||||
path: ../../packages/mam_core
|
||||
flutter_riverpod: ^2.4.9
|
||||
data_table_2: ^2.5.8
|
||||
xterm: ^3.2.0
|
||||
xterm: ^4.0.0
|
||||
flutter_markdown: ^0.6.18
|
||||
meta: ^1.9.0
|
||||
|
||||
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
library mam_pty;
|
||||
|
||||
export 'src/pty_session.dart';
|
||||
@@ -0,0 +1,161 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:ffi' as ffi;
|
||||
import 'dart:io';
|
||||
import 'package:ffi/ffi.dart';
|
||||
|
||||
// FFI Signatures for Linux libc
|
||||
typedef _posix_openpt_c = ffi.Int32 Function(ffi.Int32 flags);
|
||||
typedef _posix_openpt_dart = int Function(int flags);
|
||||
|
||||
typedef _grantpt_c = ffi.Int32 Function(ffi.Int32 fd);
|
||||
typedef _grantpt_dart = int Function(int fd);
|
||||
|
||||
typedef _unlockpt_c = ffi.Int32 Function(ffi.Int32 fd);
|
||||
typedef _unlockpt_dart = int Function(int fd);
|
||||
|
||||
typedef _ptsname_c = ffi.Pointer<ffi.Char> Function(ffi.Int32 fd);
|
||||
typedef _ptsname_dart = ffi.Pointer<ffi.Char> Function(int fd);
|
||||
|
||||
typedef _ioctl_c = ffi.Int32 Function(ffi.Int32 fd, ffi.UnsignedLong request, ffi.Pointer<ffi.Void> argp);
|
||||
typedef _ioctl_dart = int Function(int fd, int request, ffi.Pointer<ffi.Void> argp);
|
||||
|
||||
base class Winsize extends ffi.Struct {
|
||||
@ffi.Uint16()
|
||||
external int ws_row;
|
||||
@ffi.Uint16()
|
||||
external int ws_col;
|
||||
@ffi.Uint16()
|
||||
external int ws_xpixel;
|
||||
@ffi.Uint16()
|
||||
external int ws_ypixel;
|
||||
}
|
||||
|
||||
class PtySession {
|
||||
final int masterFd;
|
||||
final String slaveName;
|
||||
late final File _masterFile;
|
||||
late final RandomAccessFile _masterRaf;
|
||||
|
||||
final _stdoutController = StreamController<List<int>>();
|
||||
late final StreamSubscription _readSub;
|
||||
late final Process _process;
|
||||
bool _isClosed = false;
|
||||
|
||||
PtySession._({
|
||||
required this.masterFd,
|
||||
required this.slaveName,
|
||||
required RandomAccessFile raf,
|
||||
required File file,
|
||||
}) {
|
||||
_masterFile = file;
|
||||
_masterRaf = raf;
|
||||
_startReading();
|
||||
}
|
||||
|
||||
Stream<List<int>> get stdout => _stdoutController.stream;
|
||||
|
||||
static Future<PtySession> start(String executable, List<String> arguments, {
|
||||
String? workingDirectory,
|
||||
Map<String, String>? environment,
|
||||
}) async {
|
||||
final libc = ffi.DynamicLibrary.open('libc.so.6');
|
||||
|
||||
final posixOpenpt = libc.lookupFunction<_posix_openpt_c, _posix_openpt_dart>('posix_openpt');
|
||||
final grantpt = libc.lookupFunction<_grantpt_c, _grantpt_dart>('grantpt');
|
||||
final unlockpt = libc.lookupFunction<_unlockpt_c, _unlockpt_dart>('unlockpt');
|
||||
final ptsname = libc.lookupFunction<_ptsname_c, _ptsname_dart>('ptsname');
|
||||
|
||||
// O_RDWR = 2, O_NOCTTY = 0x00000400
|
||||
final fd = posixOpenpt(2 | 0x00000400);
|
||||
if (fd < 0) {
|
||||
throw OSError('Failed to open pseudo-terminal master');
|
||||
}
|
||||
|
||||
if (grantpt(fd) != 0) {
|
||||
throw OSError('Failed to grant pseudo-terminal slave permissions');
|
||||
}
|
||||
|
||||
if (unlockpt(fd) != 0) {
|
||||
throw OSError('Failed to unlock pseudo-terminal slave descriptor');
|
||||
}
|
||||
|
||||
final slavePtr = ptsname(fd);
|
||||
if (slavePtr == ffi.Pointer.fromAddress(0)) {
|
||||
throw OSError('Failed to get pseudo-terminal slave device name');
|
||||
}
|
||||
final slaveName = slavePtr.cast<Utf8>().toDartString();
|
||||
|
||||
final masterFile = File('/proc/self/fd/');
|
||||
final raf = masterFile.openSync(mode: FileMode.writeOnlyAppend);
|
||||
|
||||
final process = await Process.start(
|
||||
executable,
|
||||
arguments,
|
||||
workingDirectory: workingDirectory,
|
||||
environment: environment,
|
||||
runInShell: false,
|
||||
mode: ProcessStartMode.normal,
|
||||
);
|
||||
|
||||
final session = PtySession._(
|
||||
masterFd: fd,
|
||||
slaveName: slaveName,
|
||||
raf: raf,
|
||||
file: masterFile,
|
||||
);
|
||||
session._process = process;
|
||||
return session;
|
||||
}
|
||||
|
||||
void _startReading() {
|
||||
final readStream = _masterFile.openRead();
|
||||
_readSub = readStream.listen(
|
||||
(data) {
|
||||
if (!_isClosed) _stdoutController.add(data);
|
||||
},
|
||||
onError: (err) {
|
||||
if (!_isClosed) _stdoutController.addError(err);
|
||||
},
|
||||
onDone: () {
|
||||
close();
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
}
|
||||
|
||||
void write(List<int> data) {
|
||||
if (_isClosed) return;
|
||||
try {
|
||||
_masterRaf.writeFromSync(data);
|
||||
_masterRaf.flushSync();
|
||||
} catch (_) {
|
||||
}
|
||||
}
|
||||
|
||||
void writeString(String str) {
|
||||
write(utf8.encode(str));
|
||||
}
|
||||
|
||||
void resize(int cols, int rows) {
|
||||
if (_isClosed) return;
|
||||
final libc = ffi.DynamicLibrary.open('libc.so.6');
|
||||
final ioctl = libc.lookupFunction<_ioctl_c, _ioctl_dart>('ioctl');
|
||||
|
||||
final size = calloc<Winsize>();
|
||||
size.ref.ws_col = cols;
|
||||
size.ref.ws_row = rows;
|
||||
|
||||
ioctl(masterFd, 0x5414, size.cast<ffi.Void>());
|
||||
calloc.free(size);
|
||||
}
|
||||
|
||||
void close() {
|
||||
if (_isClosed) return;
|
||||
_isClosed = true;
|
||||
_readSub.cancel();
|
||||
_stdoutController.close();
|
||||
_masterRaf.closeSync();
|
||||
_process.kill(ProcessSignal.sigterm);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user