import { Injectable } from '@angular/core' import { execFileSync } from 'child_process' import * as AsyncLock from 'async-lock' import { ConnectableObservable, AsyncSubject, Subject } from 'rxjs' import * as childProcess from 'child_process' import { SessionOptions, SessionPersistenceProvider } from '../api' const TMUX_CONFIG = ` set -g status off set -g focus-events on set -g bell-action any set -g bell-on-alert on set -g visual-bell off set -g set-titles on set -g set-titles-string "#W" set -g window-status-format '#I:#(pwd="#{pane_current_path}"; echo \${pwd####*/})#F' set -g window-status-current-format '#I:#(pwd="#{pane_current_path}"; echo \${pwd####*/})#F' set-option -g status-interval 1 ` export class TMuxBlock { time: number number: number error: boolean lines: string[] constructor (line: string) { this.time = parseInt(line.split(' ')[1]) this.number = parseInt(line.split(' ')[2]) this.lines = [] } } export class TMuxMessage { type: string content: string constructor (line: string) { this.type = line.substring(0, line.indexOf(' ')) this.content = line.substring(line.indexOf(' ') + 1) } } export class TMuxCommandProcess { private process: childProcess.ChildProcess private rawOutput$ = new Subject() private line$ = new Subject() private message$ = new Subject() private block$ = new Subject() private response$: ConnectableObservable private lock = new AsyncLock({ timeout: 1000 }) constructor () { this.process = childProcess.spawn('tmux', ['-C', '-f', '/dev/null', '-L', 'terminus', 'new-session', '-A', '-D', '-s', 'control']) console.log('[tmux] started') this.process.stdout.on('data', data => { // console.debug('tmux says:', data.toString()) this.rawOutput$.next(data.toString()) }) let rawBuffer = '' this.rawOutput$.subscribe(raw => { rawBuffer += raw if (rawBuffer.includes('\n')) { let lines = rawBuffer.split('\n') rawBuffer = lines.pop() lines.forEach(line => this.line$.next(line)) } }) let currentBlock = null this.line$.subscribe(line => { if (currentBlock) { if (line.startsWith('%end ')) { let block = currentBlock currentBlock = null setImmediate(() => { this.block$.next(block) }) } else if (line.startsWith('%error ')) { let block = currentBlock block.error = true currentBlock = null setImmediate(() => { this.block$.next(block) }) } else { currentBlock.lines.push(line) } } else { if (line.startsWith('%begin ')) { currentBlock = new TMuxBlock(line) } else { this.message$.next(line) } } }) this.response$ = this.block$.skip(1).publish() this.response$.connect() this.block$.subscribe(block => { console.debug('[tmux] block:', block) }) this.message$.subscribe(message => { console.debug('[tmux] message:', message) }) } command (command: string): Promise { return this.lock.acquire('key', () => { let p = this.response$.take(1).toPromise() console.debug('[tmux] command:', command) this.process.stdin.write(command + '\n') return p }).then(response => { if (response.error) { throw response } return response }) as Promise } destroy () { this.rawOutput$.complete() this.line$.complete() this.block$.complete() this.message$.complete() this.process.kill('SIGTERM') } } export class TMux { private process: TMuxCommandProcess constructor () { this.process = new TMuxCommandProcess() TMUX_CONFIG.split('\n').filter(x => x).forEach(async (line) => { await this.process.command(line) }) } async create (id: string, options: SessionOptions): Promise { let args = [options.command].concat(options.args) let cmd = args.map(x => `"${x.replace('"', '\\"')}"`) await this.process.command( `new-session -s "${id}" -d` + (options.cwd ? ` -c '${options.cwd.replace("'", "\\'")}'` : '') + ` '${cmd}'` ) } async list (): Promise { let block = await this.process.command('list-sessions -F "#{session_name}"') return block.lines } async getPID (id: string): Promise { let response = await this.process.command(`list-panes -t ${id} -F "#{pane_pid}"`) if (response.lines.length === 0) { return null } else { return parseInt(response.lines[0]) } } async terminate (id: string): Promise { this.process.command(`kill-session -t ${id}`).catch(() => { console.debug('Session already killed') }) } } @Injectable() export class TMuxPersistenceProvider extends SessionPersistenceProvider { id = 'tmux' displayName = 'Tmux' private tmux: TMux constructor () { super() if (this.isAvailable()) { this.tmux = new TMux() } } isAvailable (): boolean { try { execFileSync('tmux', ['-V']) return true } catch (_) { return false } } async attachSession (recoveryId: any): Promise { let sessions = await this.tmux.list() if (!sessions.includes(recoveryId)) { return null } let truePID$ = new AsyncSubject() this.tmux.getPID(recoveryId).then(pid => { truePID$.next(pid) truePID$.complete() }) return { command: 'tmux', args: ['-L', 'terminus', 'attach-session', '-d', '-t', recoveryId, ';', 'refresh-client'], recoveredTruePID$: truePID$.asObservable(), recoveryId, } } async startSession (options: SessionOptions): Promise { // TODO env let recoveryId = Date.now().toString() await this.tmux.create(recoveryId, options) return recoveryId } async terminateSession (recoveryId: string): Promise { await this.tmux.terminate(recoveryId) } }