Переглянути джерело

dev: add terminal multiplexing

This involves establishing the protocol through which phoenix instances
run a command on the emulator. The pty is able to communicate with the
terminal in both directions. This commit adds logs to be removed later.

There are a few things left that this commit does not address:
- handling close of delegate process
- handling sigint from phoenix to delegate process
- closing the connection to twisp
KernelDeimos 8 місяців тому
батько
коміт
404fbaa4cb

+ 89 - 3
src/emulator/src/main.js

@@ -9,6 +9,16 @@ const {
     DataBuilder,
 } = require("../../puter-wisp/src/exports");
 
+const status = {
+    ready: false,
+};
+
+const state = {
+    ready_listeners: [],
+};
+
+let ptyMgr;
+
 class WispClient {
     constructor ({
         packetStream,
@@ -23,8 +33,74 @@ class WispClient {
     }
 }
 
+const setup_pty = (ptt, pty) => {
+    console.log('PTY created', pty);
+
+    // resize
+    ptt.on('ioctl.set', evt => {
+        console.log('event?', evt);
+        pty.resize(evt.windowSize);
+    });
+    ptt.TIOCGWINSZ();
+
+    // data from PTY
+    pty.on_payload = data => {
+        ptt.out.write(data);
+    }
+
+    // data to PTY
+    (async () => {
+        // for (;;) {
+        //     const buff = await ptt.in.read();
+        //     if ( buff === undefined ) continue;
+        //     console.log('this is what ptt in gave', buff);
+        //     pty.send(buff);
+        // }
+        const stream = ptt.readableStream;
+        for await ( const chunk of stream ) {
+            if ( chunk === undefined ) {
+                console.error('huh, missing chunk', chunk);
+                continue;
+            }
+            console.log('sending to pty', chunk);
+            pty.send(chunk);
+        }
+    })()
+}
+
+
 puter.ui.on('connection', event => {
+    const { conn, accept, reject } = event;
+    if ( ! status.ready ) {
+        console.log('status not ready, adding listener');
+        state.ready_listeners.push(() => {
+            console.log('a listener was called');
+            conn.postMessage({
+                $: 'status',
+                ...status,
+            });
+        });
+    }
+    accept({
+        version: '1.0.0',
+        status,
+    });
     console.log('emulator got connection event', event);
+
+    const pty_on_first_message = message => {
+        conn.off('message', pty_on_first_message);
+        console.log('[!!] message from connection', message);
+        const pty = ptyMgr.getPTY({
+            command: '/bin/bash'
+        });
+        console.log('setting up ptt with...', conn);
+        const ptt = new XDocumentPTT(conn, {
+            disableReader: true,
+        });
+        ptt.termios.echo = false;
+        setup_pty(ptt, pty);
+    }
+    conn.on('message', pty_on_first_message);
 });
 
 window.onload = async function()
@@ -83,12 +159,14 @@ window.onload = async function()
     const virtioStream = NewVirtioFrameStream(byteStream);
     const wispStream = NewWispPacketStream(virtioStream);
 
+    /*
     const shell = puter.ui.parentApp();
     const ptt = new XDocumentPTT(shell, {
         disableReader: true,
     })
 
     ptt.termios.echo = false;
+    */
     
     class PTYManager {
         static STATE_INIT = {
@@ -110,6 +188,13 @@ window.onload = async function()
                 }
             },
             on: function () {
+                console.log('ready.on called')
+                status.ready = true;
+                for ( const listener of state.ready_listeners ) {
+                    console.log('calling listener');
+                    listener();
+                }
+                return;
                 const pty = this.getPTY();
                 console.log('PTY created', pty);
 
@@ -176,14 +261,14 @@ window.onload = async function()
             }
         }
 
-        getPTY () {
+        getPTY ({ command }) {
             const streamId = ++this.streamId;
             const data = new DataBuilder({ leb: true })
                 .uint8(0x01)
                 .uint32(streamId)
                 .uint8(0x03)
                 .uint16(10)
-                .utf8('/bin/bash')
+                .utf8(command)
                 // .utf8('/usr/bin/htop')
                 .build();
             const packet = new WispPacket(
@@ -235,7 +320,7 @@ window.onload = async function()
         }
     }
     
-    const ptyMgr = new PTYManager({
+    ptyMgr = new PTYManager({
         client: new WispClient({
             packetStream: wispStream,
             sendFn: packet => {
@@ -249,4 +334,5 @@ window.onload = async function()
         })
     });
     ptyMgr.init();
+
 }

+ 25 - 1
src/gui/src/definitions.js

@@ -18,6 +18,7 @@
  */
 
 import { concepts, AdvancedBase } from "@heyputer/putility";
+import TeePromise from "./util/TeePromise.js";
 
 export class Service extends concepts.Service {
     // TODO: Service todo items
@@ -153,13 +154,36 @@ export class PortalProcess extends Process {
         }, '*');
     }
 
-    handle_connection (connection, args) {
+    async handle_connection (connection, args) {
         const target = this.references.iframe.contentWindow;
+        const connection_response = new TeePromise();
+        window.addEventListener('message', (evt) => {
+            if ( evt.source !== target ) return;
+            // Using '$' instead of 'msg' to avoid handling by IPC.js
+            // (following type-tagged message convention)
+            if ( evt.data.$ !== 'connection-resp' ) return;
+            if ( evt.data.connection !== connection.uuid ) return;
+            if ( evt.data.accept ) {
+                connection_response.resolve(evt.data.value);
+            } else {
+                connection_response.reject(evt.data.value
+                    ?? new Error('Connection rejected'));
+            }
+        });
         target.postMessage({
             msg: 'connection',
             appInstanceID: connection.uuid,
             args,
         });
+        const outcome = await Promise.race([
+            connection_response,
+            new Promise((resolve, reject) => {
+                setTimeout(() => {
+                    reject(new Error('Connection timeout'));
+                }, 5000);
+            })
+        ]);
+        return outcome;
     }
 };
 export class PseudoProcess extends Process {

+ 6 - 1
src/gui/src/services/ExecService.js

@@ -108,6 +108,10 @@ export class ExecService extends Service {
         const options = svc_process.select_by_name(app_name);
         const process = options[0];
 
+        if ( ! process ) {
+            throw new Error(`No process found: ${app_name}`);
+        }
+
         const svc_ipc = this.services.get('ipc');
         const connection = svc_ipc.add_connection({
             source: caller_process.uuid,
@@ -116,9 +120,10 @@ export class ExecService extends Service {
 
         const response = await process.handle_connection(
             connection.backward, args);
-
+        
         return {
             appInstanceID: connection.forward.uuid,
+            usesSDK: true,
             response,
         };
     }

+ 92 - 11
src/phoenix/src/puter-shell/providers/EmuCommandProvider.js

@@ -1,21 +1,19 @@
+import { TeePromise } from "@heyputer/putility/src/libs/promise";
 import { Exit } from "../coreutils/coreutil_lib/exit";
 
 export class EmuCommandProvider {
-    static AVAILABLE = [
-        'bash',
-        'htop',
-    ];
+    static AVAILABLE = {
+        'bash': '/bin/bash',
+        'htop': '/usr/bin/htop',
+    };
 
     static EMU_APP_NAME = 'test-emu';
 
     constructor () {
         this.available = this.constructor.AVAILABLE;
-        this.emulator = null;
     }
 
-    async aquire_emulator () {
-        if ( this.emulator ) return this.emulator;
-
+    async aquire_emulator ({ ctx }) {
         // FUTURE: when we have a way to query instances
         // without exposing the real instance id
         /*
@@ -27,20 +25,103 @@ export class EmuCommandProvider {
         */
 
         const conn = await puter.ui.connectToInstance(this.constructor.EMU_APP_NAME);
-        return this.emulator = conn;
+        const p_ready = new TeePromise();
+        conn.on('message', message => {
+            if ( message.$ === 'status' ) {
+                p_ready.resolve();
+            }
+            console.log('[!!] message from the emulator', message);
+        });
+        if ( conn.response.status.ready ) {
+            p_ready.resolve();
+        }
+        console.log('awaiting emulator ready');
+        ctx.externs.out.write('Waiting for emulator...\n');
+        await p_ready;
+        console.log('emulator ready');
+        return conn;
     }
 
     async lookup (id, { ctx }) {
-        if ( ! this.available.includes(id) ) {
+        if ( ! (id in this.available) ) {
             return;
         }
 
-        const emu = await this.aquire_emulator();
+        const emu = await this.aquire_emulator({ ctx });
         if ( ! emu ) {
             ctx.externs.out.write('No emulator available.\n');
             return new Exit(1);
         }
 
         ctx.externs.out.write(`Launching ${id} in emulator ${emu.appInstanceID}\n`);
+
+        return {
+            name: id,
+            path: 'Emulator',
+            execute: this.execute.bind(this, { id, emu, ctx }),
+        }
+    }
+
+    async execute ({ id, emu }, ctx) {
+        // TODO: DRY: most copied from PuterAppCommandProvider
+        const resize_listener = evt => {
+            emu.postMessage({
+                $: 'ioctl.set',
+                windowSize: {
+                    rows: evt.detail.rows,
+                    cols: evt.detail.cols,
+                }
+            });
+        };
+        ctx.shell.addEventListener('signal.window-resize', resize_listener);
+
+        // TODO: handle CLOSE -> emu needs to close connection first
+        const app_close_promise = new TeePromise();
+        const sigint_promise = new TeePromise();
+
+        const decoder = new TextDecoder();
+        emu.on('message', message => {
+            if (message.$ === 'stdout') {
+                ctx.externs.out.write(decoder.decode(message.data));
+            }
+            if (message.$ === 'chtermios') {
+                if ( message.termios.echo !== undefined ) {
+                    if ( message.termios.echo ) {
+                        ctx.externs.echo.on();
+                    } else {
+                        ctx.externs.echo.off();
+                    }
+                }
+            }
+        });
+
+        // Repeatedly copy data from stdin to the child, while it's running.
+        // DRY: Initially copied from PathCommandProvider
+        let data, done;
+        const next_data = async () => {
+            console.log('!~!!!!!');
+            ({ value: data, done } = await Promise.race([
+                app_close_promise, sigint_promise, ctx.externs.in_.read(),
+            ]));
+            console.log('next_data', data, done);
+            if (data) {
+                console.log('sending stdin data');
+                emu.postMessage({
+                    $: 'stdin',
+                    data: data,
+                });
+                if (!done) setTimeout(next_data, 0);
+            }
+        };
+        setTimeout(next_data, 0);
+
+        emu.postMessage({
+            $: 'exec',
+            command: this.available[id],
+            args: [],
+        });
+
+        const never_resolve = new TeePromise();
+        await never_resolve;
     }
 }

+ 23 - 1
src/puter-js/src/modules/UI.js

@@ -25,6 +25,11 @@ class AppConnection extends EventListener {
             values.appInstanceID,
             values.usesSDK
         );
+
+        // When a connection is established the app is able to
+        // provide some additional information about itself
+        connection.response = values.response;
+
         return connection;
     }
 
@@ -466,12 +471,29 @@ class UI extends EventListener {
                 this.#lastBroadcastValue.set(name, data);
             }
             else if ( e.data.msg === 'connection' ) {
+                e.data.usesSDK = true; // we can safely assume this
                 const conn = AppConnection.from(e.data, {
                     appInstanceID: this.appInstanceID,
                     messageTarget: window.parent,
                 });
+                const accept = value => {
+                    this.messageTarget?.postMessage({
+                        $: 'connection-resp',
+                        connection: e.data.appInstanceID,
+                        accept: true,
+                        value,
+                    });
+                };
+                const reject = value => {
+                    this.messageTarget?.postMessage({
+                        $: 'connection-resp',
+                        connection: e.data.appInstanceID,
+                        accept: false,
+                        value,
+                    });
+                };
                 this.emit('connection', {
-                    conn
+                    conn, accept, reject,
                 });
             }
         });