1import { bind } from 'decko'; 2import { h, Component } from 'preact'; 3import { saveAs } from 'file-saver'; 4import { IDisposable, ITerminalAddon, Terminal } from 'xterm'; 5import * as Zmodem from 'zmodem.js/src/zmodem_browser'; 6 7import { Modal } from '../modal'; 8 9export interface FlowControl { 10 limit: number; 11 highWater: number; 12 lowWater: number; 13 14 pause: () => void; 15 resume: () => void; 16} 17 18interface Props { 19 sender: (data: ArrayLike<number>) => void; 20 control: FlowControl; 21} 22 23interface State { 24 modal: boolean; 25} 26 27export class ZmodemAddon extends Component<Props, State> implements ITerminalAddon { 28 private terminal: Terminal | undefined; 29 private keyDispose: IDisposable | undefined; 30 private sentry: Zmodem.Sentry; 31 private session: Zmodem.Session; 32 33 private written = 0; 34 private pending = 0; 35 36 constructor(props: Props) { 37 super(props); 38 39 this.zmodemInit(); 40 } 41 42 render(_, { modal }: State) { 43 return ( 44 <Modal show={modal}> 45 <label class="file-label"> 46 <input onChange={this.sendFile} class="file-input" type="file" multiple /> 47 <span class="file-cta">Choose files…</span> 48 </label> 49 </Modal> 50 ); 51 } 52 53 activate(terminal: Terminal): void { 54 this.terminal = terminal; 55 } 56 57 dispose(): void {} 58 59 consume(data: ArrayBuffer) { 60 const { sentry, handleError } = this; 61 try { 62 sentry.consume(data); 63 } catch (e) { 64 handleError(e, 'consume'); 65 } 66 } 67 68 @bind 69 private handleError(e: Error, reason: string) { 70 console.error(`[ttyd] zmodem ${reason}: `, e); 71 this.zmodemReset(); 72 } 73 74 @bind 75 private zmodemInit() { 76 this.session = null; 77 this.sentry = new Zmodem.Sentry({ 78 to_terminal: (octets: ArrayBuffer) => this.zmodemWrite(octets), 79 sender: (octets: ArrayLike<number>) => this.zmodemSend(octets), 80 on_retract: () => this.zmodemReset(), 81 on_detect: (detection: Zmodem.Detection) => this.zmodemDetect(detection), 82 }); 83 } 84 85 @bind 86 private zmodemReset() { 87 this.terminal.setOption('disableStdin', false); 88 89 if (this.keyDispose) { 90 this.keyDispose.dispose(); 91 this.keyDispose = null; 92 } 93 this.zmodemInit(); 94 95 this.terminal.focus(); 96 } 97 98 @bind 99 private zmodemWrite(data: ArrayBuffer): void { 100 const { limit, highWater, lowWater, pause, resume } = this.props.control; 101 const { terminal } = this; 102 const rawData = new Uint8Array(data); 103 104 this.written += rawData.length; 105 if (this.written > limit) { 106 terminal.write(rawData, () => { 107 this.pending = Math.max(this.pending - 1, 0); 108 if (this.pending < lowWater) { 109 resume(); 110 } 111 }); 112 this.pending++; 113 this.written = 0; 114 if (this.pending > highWater) { 115 pause(); 116 } 117 } else { 118 terminal.write(rawData); 119 } 120 } 121 122 @bind 123 private zmodemSend(data: ArrayLike<number>): void { 124 this.props.sender(data); 125 } 126 127 @bind 128 private zmodemDetect(detection: Zmodem.Detection): void { 129 const { terminal, receiveFile, zmodemReset } = this; 130 terminal.setOption('disableStdin', true); 131 132 this.keyDispose = terminal.onKey(e => { 133 const event = e.domEvent; 134 if (event.ctrlKey && event.key === 'c') { 135 detection.deny(); 136 } 137 }); 138 139 this.session = detection.confirm(); 140 this.session.on('session_end', zmodemReset); 141 142 if (this.session.type === 'send') { 143 this.setState({ modal: true }); 144 } else { 145 receiveFile(); 146 } 147 } 148 149 @bind 150 private sendFile(event: Event) { 151 this.setState({ modal: false }); 152 153 const { session, writeProgress, handleError } = this; 154 const files: FileList = (event.target as HTMLInputElement).files; 155 156 Zmodem.Browser.send_files(session, files, { 157 on_progress: (_, offer: Zmodem.Offer) => writeProgress(offer), 158 }) 159 .then(() => session.close()) 160 .catch(e => handleError(e, 'send')); 161 } 162 163 @bind 164 private receiveFile() { 165 const { session, writeProgress, handleError } = this; 166 167 session.on('offer', (offer: Zmodem.Offer) => { 168 const fileBuffer = []; 169 offer.on('input', payload => { 170 writeProgress(offer); 171 fileBuffer.push(new Uint8Array(payload)); 172 }); 173 offer 174 .accept() 175 .then(() => { 176 const blob = new Blob(fileBuffer, { type: 'application/octet-stream' }); 177 saveAs(blob, offer.get_details().name); 178 }) 179 .catch(e => handleError(e, 'receive')); 180 }); 181 182 session.start(); 183 } 184 185 @bind 186 private writeProgress(offer: Zmodem.Offer) { 187 const { terminal, bytesHuman } = this; 188 189 const file = offer.get_details(); 190 const name = file.name; 191 const size = file.size; 192 const offset = offer.get_offset(); 193 const percent = ((100 * offset) / size).toFixed(2); 194 195 terminal.write(`${name} ${percent}% ${bytesHuman(offset, 2)}/${bytesHuman(size, 2)}\r`); 196 } 197 198 private bytesHuman(bytes: any, precision: number): string { 199 if (!/^([-+])?|(\.\d+)(\d+(\.\d+)?|(\d+\.)|Infinity)$/.test(bytes)) { 200 return '-'; 201 } 202 if (bytes === 0) return '0'; 203 if (typeof precision === 'undefined') precision = 1; 204 const units = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; 205 const num = Math.floor(Math.log(bytes) / Math.log(1024)); 206 const value = (bytes / Math.pow(1024, Math.floor(num))).toFixed(precision); 207 return `${value} ${units[num]}`; 208 } 209} 210