Flutter 终端模拟器组件 - 开源篇

4,680 阅读7分钟

前言

  • 本文主要是介绍终端模拟器系列的相关开源。
  • 代码仅供交流学习,所有代码的开源都选择了宽松的协议。代码都在大量测试中,谨慎引入到正式项目中使用。

开源列表

dart_pty

简介

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

起因

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

python

那么它应该输出

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 中使用:

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

python 中:

system("python");

c 语言:

system("python");

dart 中

Process.start("python");

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

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

原理解析

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

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

开始使用

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

配置 yaml

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

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

导入包

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

创建 pty 对象

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

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

读写子进程

  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 代码:

  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 语言代码为:

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 终端模拟器探索篇(三)| 原理解析与集成”文章的探索后,持续开发维护后的代码仓库。

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

背景分析

需要维护一个自己的终端模拟器,这个模拟器需要集成到移动应用或者桌面应用,起初考虑用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 下引入:

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

创建终端控制器

TermareController controller = TermareController(
  showBackgroundLine: true,
);

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

使用组件

TermareView(
  controller: controller,
),

让终端显示一些东西

controller.write('hello termare_view');

运行如下:

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

再执行:

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

    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库。

绑定到终端组件

    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

显示

  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

连接到服务器

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

显示

  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

总结

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

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