開源一個Flutter編寫的完整終端模擬器

上次開源了一個簡易的終端模擬器,我也知道並非標準的,但本身也一直在用,而後就發現了一些棘手的問題,就又跑去研究了一些完整終端的源碼,termux,Android Terminal,最後成功的將他們的原理在Flutter實現html

其實這個源也可能會是你學習使用dart:ffi的一個例子,其中用到的char **,也就是二級指針的傳遞在也不多能在官方的example中也很難找到直接的例子,也是我處理這種類型碰見的比較麻煩的坑,主要就是沒有案例。我將termux的C語言部分徹底重構以供Flutter使用,因爲UI框架使用的Flutter通過測試能夠在Macos上跑起來!!!java

Process類的stdout是哪裏來的?

本身在使用中碰見了這個棘手的問題,仍是因爲經驗不夠,還去知乎上提了我碰見的問題, 知乎傳送 通過與同窗的探討後(死皮賴臉問人家),能夠知道Process中的stdout是來自於pipe(管道),也能夠看到stdout也有pipe這個方法,而管道是存在緩衝的,舉個🌰ios

使用c++

cp -rv sourceDir targetDir
複製代碼

命令,因爲開啓了-v參數,因此在標準終端中,cp命令會一行一行打印出正在複製的文件,而當用dart的Process去執行這樣的操做,你在對stdout的監聽中並不會收到一次一行的回調,而是一次一堆的回調,那就是因爲管道是存在緩衝機制的,達到緩衝上限後才能拿到一次,或者程序結束後,緩衝區未滿也能拿到。 咱們再切換到標準終端模擬器git

cp -rv sourceDir targetDir | xargs echo
複製代碼

咱們在終端中也使用管道,經過xargs將其打印出來,這個時候會發現,打印的東西跟次數,跟dart中stdout的回調是同樣的,不止dart,包括java中runtime拿到的輸入流,也沒法拿到無緩衝的輸出.github

終端與管道的緩衝差異

終端也具備緩衝,終端爲行緩衝,管道爲全緩衝,行緩衝中,碰見換行符\n便可向終端中輸出一次,或者主動在C語言中調用fflush()方法,會將已經在緩衝區的內容輸出一次,若是沒有以上兩個條件,就只能等到緩衝區滿1024個字節,才能輸出一次shell

標準終端又是怎麼作到拿到行緩衝的輸出的?

我能想到的最快的方法就是去看一些標準終端的開源庫,如今比較優秀有termux,跟Android Terminal,termux能夠說是目前安卓上最強大的終端了,有大量的可擴展資源,我就直接clone下來,從manifest中找到主類,從Activity中oncreate中一點一點看,仍是花了挺多時間,畢竟termux仍是比較大型的儲存庫,也有註釋,但始終找不到關鍵的地方,可以在Flutter實現的地方,最後定位到了UI中獲取輸入,包括將輸出同步到屏幕,這一系列都指向了JNI,也就是一個java到c/c++的一個通道,我也是從這纔開始知道項目中的那個C語言是何時用的了。api

標準終端實現原理

這種終端稱僞終端(pty)app

必須先看一波來自互聯網的科普框架

僞終端(pseudo terminal,有時也被稱爲 pty)是指僞終端 master 和僞終端 slave 這一對字符設備。其中的 slave 對應 /dev/pts/ 目錄下的一個文件,而 master 則在內存中標識爲一個文件描述符(fd)。僞終端由終端模擬器提供,終端模擬器是一個運行在用戶態的應用程序。

Master 端是更接近用戶顯示器、鍵盤的一端,slave 端是在虛擬終端上運行的 CLI(Command Line Interface,命令行接口)程序。Linux 的僞終端驅動程序,會把 master 端(如鍵盤)寫入的數據轉發給 slave 端供程序輸入,把程序寫入 slave 端的數據轉發給 master 端供(顯示器驅動等)讀取。請參考下面的示意圖(此圖來自互聯網):

咱們打開的終端桌面程序,好比 GNOME Terminal,實際上是一種終端模擬軟件。當終端模擬軟件運行時,它經過打開 /dev/ptmx 文件建立了一個僞終端的 master 和 slave 對,並讓 shell 運行在 slave 端。當用戶在終端模擬軟件中按下鍵盤按鍵時,它產生字節流並寫入 master 中,shell 進程即可從 slave 中讀取輸入;shell 和它的子程序,將輸出內容寫入 slave 中,由終端模擬軟件負責將字符打印到窗口中。

文本描述符又是啥!? 來自百度:

Linux 中一切皆文件,好比 C++ 源文件、視頻文件、Shell腳本、可執行文件等,就連鍵盤、顯示器、鼠標等硬件設備也都是文件。 一個 Linux 進程能夠打開成百上千個文件,爲了表示和區分已經打開的文件,Linux 會給每一個文件分配一個編號(一個 ID),這個編號就是一個整數,被稱爲文件描述符(File Descriptor)。

如下操做僅在Unix系統上

大體知道這個文本描述符就是一個int值,經過這個值就能進行讀寫,C語言中write(fd, str, length),就能直接寫入文本描述符,java中也有一個FileDescriptor類,用來讀寫文本描述符,Dart沒有,不過能夠解決。 簡述一下終端原理,在C語言中調用open("/dev/ptmx")會獲得一個文本描述符,而後同時會在/dev/pts/下得到一個文件的產生,文件名是0,1,2,3,系統會依次往上給你分配。 /dev/ptmx 是一個字符設備文件,當進程打開 /dev/ptmx 文件時,進程會同時得到一個指向 pseudoterminal master(ptm)的文件描述符和一個在 /dev/pts 目錄中建立的 pseudoterminal slave(pts) 設備。經過打開 /dev/ptmx 文件得到的每一個文件描述符都是一個獨立的 ptm,它有本身關聯的 pts 直接看我更改後的實現

int get_ptm_int( int rows, int columns) {
    //調用open這個路徑會隨機得到一個大於0的整形值
    int ptm = open("/dev/ptmx", O_RDWR | O_CLOEXEC);
    //這個值會從0依次上增
    // if (ptm < 0) return throw_runtime_exception(env, "Cannot open /dev/ptmx");
#ifdef LACKS_PTSNAME_R
    char *devname;
#else
    char devname[64];
#endif
    if (grantpt(ptm) || unlockpt(ptm) ||
#ifdef LACKS_PTSNAME_R
        (devname = ptsname(ptm)) == NULL
#else
        ptsname_r(ptm, devname, sizeof(devname))
#endif
    )
    {
        // return throw_runtime_exception(env, "Cannot grantpt()/unlockpt()/ptsname_r() on /dev/ptmx");
    }

    // Enable UTF-8 mode and disable flow control to prevent Ctrl+S from locking up the display.
    struct termios tios;
    tcgetattr(ptm, &tios);
    tios.c_iflag |= IUTF8;
    tios.c_iflag &= ~(IXON | IXOFF);
    tcsetattr(ptm, TCSANOW, &tios);

    /** Set initial winsize. */
    struct winsize sz = {.ws_row = (unsigned short)rows, .ws_col = (unsigned short)columns};
    ioctl(ptm, TIOCSWINSZ, &sz);
    return ptm;
}
複製代碼

這個函數主要就用來獲得ptm的文本描述符,中間還有一些對終端,因爲時間緣故,我暫時註釋了對java的回調報錯,以後用對dart的回調代替。拿到這個ptm描述符後,咱們就能夠對這個ptm描述符讀寫,往裏面寫的內容都能再讀出來,感受有點對此一舉?並非,任何的二進制程序往裏面進行寫操做,而你的終端UI,只須要一直讀就能夠了,看一下termux在java部分的實現

new Thread("TermSessionInputReader[pid=" + mShellPid + "]") {
            @Override
            public void run() {
                try (InputStream termIn = new FileInputStream(terminalFileDescriptorWrapped)) {
                    final byte[] buffer = new byte[4096];
                    while (true) {
                        int read = termIn.read(buffer);
                        if (read == -1) return;
                        if (!mProcessToTerminalIOQueue.write(buffer, 0, read)) return;
                        mMainThreadHandler.sendEmptyMessage(MSG_NEW_INPUT);
                    }
                } catch (Exception e) {
                    // Ignore, just shutting down.
                }
            }
        }.start();

        new Thread("TermSessionOutputWriter[pid=" + mShellPid + "]") {
            @Override
            public void run() {
                final byte[] buffer = new byte[4096];
                try (FileOutputStream termOut = new FileOutputStream(terminalFileDescriptorWrapped)) {
                    while (true) {
                        int bytesToWrite = mTerminalToProcessIOQueue.read(buffer, true);
                        if (bytesToWrite == -1) return;
                        termOut.write(buffer, 0, bytesToWrite);
                    }
                } catch (IOException e) {
                    // Ignore.
                }
            }
        }.start();
複製代碼

兩個死循環,一個負責讀ptm,將讀出的內容同步到UI 而另外一個負責將輸入隊列的類容寫進ptm

在看termux中比較關鍵的一個函數(通過我更改後的)

void create_subprocess(char *env, char const *cmd, char const *cwd, char *const argv[], char **envp, int *pProcessId, int ptmfd) {
#ifdef LACKS_PTSNAME_R
    char *devname;
#else
    char devname[64];
#endif

#ifdef LACKS_PTSNAME_R
    devname = ptsname(ptmfd);
#else
    ptsname_r(ptmfd, devname, sizeof(devname));
#endif
    //建立一個進程,返回是它的pid
    pid_t pid = fork();
    if (pid < 0)
    {
        // return throw_runtime_exception(env, "Fork failed");
    }
    else if (pid > 0)
    {
        *pProcessId = (int)pid;
    }
    else
    {
        // Clear signals which the Android java process may have blocked:
        sigset_t signals_to_unblock;
        sigfillset(&signals_to_unblock);
        sigprocmask(SIG_UNBLOCK, &signals_to_unblock, 0);

        close(ptmfd);
        setsid();
        //O_RDWR讀寫,devname爲/dev/pts/0,1,2,3...
        int pts = open(devname, O_RDWR);
        if (pts < 0)
            exit(-1);
        //下面三個大概將stdin,stdout,stderr複製到了這個pts裏面
        //ptmx,pts pseudo terminal master and slave
        dup2(pts, 0);
        dup2(pts, 1);
        dup2(pts, 2);
        //Linux的api,打開一個文件夾
        DIR *self_dir = opendir("/proc/self/fd");
        if (self_dir != NULL)
        {
            //dirfd沒查到,好像把文件夾轉換爲文件描述符
            int self_dir_fd = dirfd(self_dir);
            struct dirent *entry;
            while ((entry = readdir(self_dir)) != NULL)
            {
                int fd = atoi(entry->d_name);
                if (fd > 2 && fd != self_dir_fd)
                    close(fd);
            }
            closedir(self_dir);
        } //清除環境變量
        // clearenv();

        if (envp)
            for (; *envp; ++envp)
                putenv(*envp);

        if (chdir(cwd) != 0)
        {
            char *error_message;
            // No need to free asprintf()-allocated memory since doing execvp() or exit() below.
            if (asprintf(&error_message, "chdir(\"%s\")", cwd) == -1)
                error_message = "chdir()";
            perror(error_message);
            fflush(stderr);
        }
        //執行程序
        execvp(cmd, argv);

        // Show terminal output about failing exec() call:
        char *error_message;
        if (asprintf(&error_message, "exec(\"%s\")", cmd) == -1)
            error_message = "exec()";
        perror(error_message);
        _exit(1);
    }
}
複製代碼

實際上我爲了配合Dart的部分,將termux原有的create_subprocess拆分紅了兩塊,具體邏輯並未作修改,增長了中文註釋,留意其中調用了一次fork(),這個函數調用後,就會再分叉一個進程,以後的代碼都會被執行兩次,函數中經過pid的值來判斷父進程與子進程分別應該幹啥,pid大於0即爲父進程,能夠看到父進程更改了pProcessId這個指針指向的值,子進程去執行了調用函數時的命令,包括設置當前環境,執行參數等,經過ptsname_r函數拿到了ptm對應的pts,而後經過dup2函數將改程序的0,1,2複製到了pts(/dev/pts/*),也就是stdin,stdout,stderr,最後調用exec,因此此時exec調用的二進制的輸出全會寫進pts,而寫進pts就能從ptm出來,也就實現了僞終端

Dart不能讀寫文本描述符怎麼辦?

經過dart:ff對接,C語言能夠讀就不存在

void write_to_fd(int fd, char *str) {
    write(fd, str, strlen(str));
}
char *get_output_from_fd(int fd) {
    int flag = -1;
    flag = fcntl(fd, F_GETFL); //獲取當前flag
    flag |= O_NONBLOCK;        //設置新falg
    fcntl(fd, F_SETFL, flag);  //更新flag
    //動態申請空間
    char *str = (char *)malloc((4097) * sizeof(char));
    //read函數返回從fd中讀取到字符的長度
    //讀取的內容存進str,4096表示這次讀取4096個字節,若是隻讀到10個則length爲10
    int length = read(fd, str, 4096);
    if (length == -1)
    {
        free(str);
        return NULL;
    }
    else
    {
        str[length] = '\0';
        return str;
    }
}
複製代碼

Flutter的部分實現也比較複雜,由於要重寫一套完整的終端序列不是簡單的事,termux做爲安卓原生項目,有大量的社區資源跟第三方開發者的支持,如今才已經比較完善,關於Dart調用ffi也能夠參考我以前的帖子

效果!!!

Python的使用:

Python
光標移動:
在這裏插入圖片描述

ls等命令顏色的輸出:

在這裏插入圖片描述

開源地址

flutter_terminal

目前這個新的終端模擬器已經徹底的引進了本身的項目,做者的維護能力很是有限,更新速度也比較慢,若是對這個項目有興趣有問題均可以在下面留言,感謝各位前輩!!!

參考帖子

Linux 僞終端(pty)

關於Linux的緩衝機制

Linux下的consolen(控制檯)和terminal(終端)

ptmx/pts

相關文章
相關標籤/搜索