Flutter 终端模拟器开源篇

前言

  • 代码仅供交流学习,所有代码的开源都选择了宽松的协议。所有代码都在大量测试中,请勿随意引入到正式项目中使用。

开源列表

dart_pty

简介

创建一个伪终端并执行一个子进程。也是终端模拟器的底层实现。 与语言提供的执行进程不同,它不仅能够无缓冲的拿到 stdout、stderr 输出,还能拿到所有的终端序列,也能对进程进行交互。

起因

在各种语言都提供了创建子进程的函数,但这类函数都无法无缓冲的获得子进程在运行时得到的输出,并且无法与子进程交互。
例如:
在终端模拟器运行

1
python

那么它应该输出

1
2
3
4
Python 3.8.6 (default, Oct  8 2020, 14:06:32) 
[Clang 12.0.0 (clang-1200.0.32.2)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>

而我们在 java 中使用:

1
Runtime.getRuntime().exec("python");

python 中:

1
system("python");

c 语言:

1
system("python");

dart 中

1
Process.start("python");

进程创建后,我们并不能够与子进程交互,并且无论是在哪一种语言,我们执行python都获取不到像终端一样的输出,这是由于缓冲还没有满 4096 字节(通常),在进程不主动刷新缓冲的情况下,我们是获取不到它的输出的。

c 语言被编译成二进制后能交互,是因为 system 函数子进程与主进程是同一个输入输出。

原理解析

原理解析移步文章:Flutter 终端模拟器探索篇(二)| 完整终端模拟器

涉及东西有点多,不是本文关注的重点。

开始使用

这是一个纯 dart 项目,你可以集成到 dart 项目或者 flutter 项目中。

配置 yaml

1
2
dart_pty:
git: https://github.com/termare/dart_pty

已经上传 dart package,在发布 1.0 版本前, dart package 上代码可能差异会很大。

导入包

1
import 'package:dart_pty/src/unix_pty_c.dart';

创建 pty 对象

1
2
3
4
5
Map<String, String> environment = {'TEST': 'TEST_VALUE'};
UnixPtyC unixPthC = UnixPtyC(
environment: environment,
libPath: 'dynamic_library/libterm.dylib',
);

当对象创建的时候,就已经存在一个读写指向同一进程的文件描述符了,通过对这个文件描述符的写入即可实现与子进程的交互,读取即可获得子进程的输出,这个输出是无缓冲的。

读写子进程

1
2
3
4
5
6
7
8
9
10
11
12
13
await Future.delayed(Duration(milliseconds: 100), () async {
while (true) {
print('请向终端输入一些东西');
String input = stdin.readLineSync();
unixPthC.write(input + '\n');
await Future.delayed(Duration(milliseconds: 200));
result = unixPthC.read();
print('\x1b[31m' + '-' * 20 + 'result' + '-' * 20);
print('result -> $result');
print('-' * 20 + 'result' + '-' * 20 + '\x1b[0m');
await Future.delayed(Duration(milliseconds: 100));
}
});

缺点

  • 需要引入 so 库。
  • 还不支持 windows。

能不用集成 so 库就能实现本地终端吗?

使用 dart:ffi 来编写原来底层的逻辑,这样就能完全的脱离 c 语言部分,不用再为每一个平台编译一份本地库,与西南交大的大佬在 20 年暑假的时候其实就已经实现了,他采用了 isolate 来解决了读文件描述符会阻塞 UI 线程的问题,但isolate是不能热重载的,并且当终端数量过多的时候,vscode 就会显示一堆isolate runtime

而我想要通过设置文件描述符非阻塞的方式来实现,终端的创建与子进程的 fork 都没有问题,但在执行以下代码的时候,发现了差异性。
在 PC 上能够正常的运行,而在 android 设备上失效,跟设备上flag的宏定义应该有关系,只要能找出O_NONBLOCK这个宏在安卓上的定义值就能解决。

除了以上问题,dart:ffi 在使用一些头文件的时候,在 android 端存在几率性 crash 的情况。

以下为 dart 代码:

1
2
3
4
5
6
7
8
9
10
void setNonblock(int fd, {bool verbose = false}) {
int flag = -1;
flag = cfcntl.fcntl(fd, F_GETFL, 0); //获取当前flag
if (verbose) print('>>>>>>>> 当前flag = $flag');
flag |= O_NONBLOCK; //设置新falg
if (verbose) print('>>>>>>>> 设置新flag = $flag');
cfcntl.fcntl(fd, F_SETFL, flag); //更新flag
flag = cfcntl.fcntl(fd, F_GETFL, 0); //获取当前flag
if (verbose) print('>>>>>>>> 再次获取到的flag = $flag');
}

这段 dart 代码原本的 c 语言代码为:

1
2
3
4
5
6
7
void setNonblock(int fd)
{
int flag = -1;
flag = fcntl(fd, F_GETFL); //获取当前flag
flag |= O_NONBLOCK; //设置新falg
fcntl(fd, F_SETFL, flag); //更新flag
}

termare_view

支持全平台的终端模拟器,使用 Flutter 开发,不依赖平台代码。

如果想得到更稳定的 flutter 终端模拟器,可以使用 xterm.dart,上面提到西南交大的大佬开发的,我选择自己开发一份主要是为了更多是适配移动端的表现,还有就是对这个终端花费了很多精力,自己项目的终端组件还是想要通过自己经手编写。

起因

这是在“Flutter 终端模拟器探索篇(三)| 原理解析与集成”文章的探索后,持续开发维护后的代码仓库。
其中因为一些个人的技术原因,导致了一次代码的重构。
无法扩展与持续维护后,我尝试读了xtem.dart了解 xterm.js的一些原理实现,最后选用canvas作为终端的上层组件。

需求分析

需要维护一个自己的终端模拟器,这个模拟器需要集成到移动应用或者桌面应用,起初考虑用android-terminal-emulator,后来因为这个终端模拟器已经很久没有再维护过了,就转向了termuxtermux在整个组织中都有非常丰富的开源,但如果完全使用termux代码集成到个人项目,在安卓端我可以跳转activity并通过am命令发送广播通知原生终端模拟器自动键入命令,但在桌面端就没有办法。
于是idea就出现了,如果用Flutter重写这个模拟器呢?一次编写,到处运行,想法很美好,但对我来说,的确很难,整个过程比较坎坷,随着技术的不断学习,很多实现也在逐渐向一种规范的方式靠近。
第一想到的是读termux的源码,注释极少,非常难理解,在移动端自定义源有非常多的坑,于是想着请教一些会知道整个开发流程的人,首先termux的作者肯定是会的,再就是neoterm的开发者,我认识他的时候他才高三。就已经上架了neoterm这个终端模拟器,目前下载量11w,所以在整个开发中他与xterm.dart的作者都很好的帮到了我。

前面开发的重大错误

  • 我不应该将终端模拟器的上层渲染组件与本地底层的pseudo terminal强行整合起来,参考了xterm.jsxterm.dart后,终端应该是一个独立的上层组件,可以接收任何的输入流才对,除了本地终端的输入流,还可以是来自 ssh 的流,或者其他地方的输入。
  • 不应该在未参考现有类似组件实现的情况下盲目编写,选用了上层为WidgetSpan这个组件。

重构后

  • 使用 canvas 绘制整个终端模拟器。
  • 将终端输入流的内容按终端序列解析成二维数组,在CustomPainter内部根据二维数组绘制内容与光标。

开始使用

引入项目

这是一个纯 flutter package,所以只需要在 yaml 配置文件的 dependencies 下引入:

1
2
termare_view:
git: https://github.com/termare/termare_view

创建终端控制器

1
2
3
TermareController controller = TermareController(
showBackgroundLine: true,
);

showBackgroundLine 属性是打开 debug 的格子背景开关。

使用组件

1
2
3
TermareView(
controller: controller,
),

让终端显示一些东西

1
controller.write('hello termare_view');

运行如下:

你可能会发现,不就是画了一个格子背景,然后绘制了文本而已吗?

再执行:

1
2
controller.write('\x1B[1;31mhello termare_view\x1B[0m\n');
controller.write('\x1B[1;32mhello termare_view\x1B[0m\n');
所以关于什么是终端序列的这一问题也能通过这个例子体现出来了,所以在来自任何终端的输出流中,都是通过类似“\x1B\[1;31m”的序列来告知模拟器的绘制行为与其他行为。

详见 example

使用 dart 的 print 打印 ‘\x1B[1;31mhello termare_view\x1B[0m\n’ 也有效果哦。

终端序列列表

取自 xterm.js

termare_pty

这是一个用 pty 实现本地终端的例子。依赖 dart_ptytermare_view

原理解析

通过第一个开源库dart_pty,去实现一个本地终端,再将终端的输入输出流与termare_view绑定起来。

创建 pty

1
2
3
4
5
6
7
8
9
10
11
12
13
unixPtyC = widget.unixPtyC ??
UnixPtyC(
libPath: Platform.isMacOS
? '/Users/nightmare/Desktop/termare-space/dart_pty/dynamic_library/libterm.dylib'
: 'libterm.so',
rowLen: row,
columnLen: column - 2,
environment: <String, String>{
'TERM': 'screen-256color',
'PATH':
'/data/data/com.nightmare/files/usr/bin:${Platform.environment['PATH']}',
},
);

目前写得很死,传入的 libPath 就是依赖的 so 库,在dart_pty下有 linux、macos 已经编译好的库,在termare_pty中也有安卓四个架构的so库。

绑定到终端组件

1
2
3
4
5
6
7
8
9
10
11
while (mounted) {
final String cur = unixPtyC.read();
if (cur.isNotEmpty) {
controller.write(cur);
controller.autoScroll = true;
controller.notifyListeners();
await Future<void>.delayed(const Duration(milliseconds: 10));
} else {
await Future<void>.delayed(const Duration(milliseconds: 100));
}
}

这段代码应该是比较好理解的。controllerTermareController

显示

1
2
3
4
5
6
7
8
Widget build(BuildContext context) {
return TermareView(
keyboardInput: (String data) {
unixPtyC.write(data);
},
controller: controller,
);
}

运行截图

好像截图都有点大,
详见示例代码termare_pty.dart

termare_ssh

这是一个用 ssh 连接服务器终端的例子,也可以连接 localhost 。

原理解析

通过dartssh这个纯 dart 的package来获得一个连接到服务器的输出流,并将输入输出绑定到termare_view

连接到服务器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void connect() {
controller.write('connecting ${widget.hostName}...\n');
client = SSHClient(
hostport: Uri.parse('ssh://' + widget.hostName + ':22'),
login: widget.loginName,
print: print,
termWidth: 80,
termHeight: 25,
termvar: 'xterm-256color',
getPassword: () => Uint8List.fromList(utf8.encode(widget.password)),
response: (SSHTransport transport, String data) {
controller.write(data);
},
success: () {
controller.write('connected.\n');
},
disconnected: () {
controller.write('disconnected.');
},
);

controllerTermareController

显示

1
2
3
4
5
6
7
8
Widget build(BuildContext context) {
return TermareView(
controller: controller,
keyboardInput: (String data) {
client?.sendChannelData(Uint8List.fromList(utf8.encode(data)));
},
);
}

运行截图

Android

Linux

Macos

Windows

Ios

详见示例代码termare_ssh.dart

总结

开源仅为交流学习,交流学习。

读完的朋友已经很不错了,可能会想说“啥呀,怎么一点用也没有”,有这种想法也是正常的,但如果你对终端模拟器有兴趣,欢迎跟我一起贡献这些仓库,随时与我交流,对这些有任何疑问欢迎评论区留言。

作者

梦魇兽

发布于

2020-12-13

更新于

2023-03-11

许可协议

评论