diff options
Diffstat (limited to 'html/src/components/zmodem')
-rw-r--r-- | html/src/components/zmodem/index.tsx | 209 |
1 files changed, 209 insertions, 0 deletions
diff --git a/html/src/components/zmodem/index.tsx b/html/src/components/zmodem/index.tsx new file mode 100644 index 0000000..9498863 --- /dev/null +++ b/html/src/components/zmodem/index.tsx @@ -0,0 +1,209 @@ +import { bind } from 'decko'; +import { h, Component } from 'preact'; +import { saveAs } from 'file-saver'; +import { IDisposable, ITerminalAddon, Terminal } from 'xterm'; +import * as Zmodem from 'zmodem.js/src/zmodem_browser'; + +import { Modal } from '../modal'; + +export interface FlowControl { + limit: number; + highWater: number; + lowWater: number; + + pause: () => void; + resume: () => void; +} + +interface Props { + sender: (data: ArrayLike<number>) => void; + control: FlowControl; +} + +interface State { + modal: boolean; +} + +export class ZmodemAddon extends Component<Props, State> implements ITerminalAddon { + private terminal: Terminal | undefined; + private keyDispose: IDisposable | undefined; + private sentry: Zmodem.Sentry; + private session: Zmodem.Session; + + private written = 0; + private pending = 0; + + constructor(props: Props) { + super(props); + + this.zmodemInit(); + } + + render(_, { modal }: State) { + return ( + <Modal show={modal}> + <label class="file-label"> + <input onChange={this.sendFile} class="file-input" type="file" multiple /> + <span class="file-cta">Choose files…</span> + </label> + </Modal> + ); + } + + activate(terminal: Terminal): void { + this.terminal = terminal; + } + + dispose(): void {} + + consume(data: ArrayBuffer) { + const { sentry, handleError } = this; + try { + sentry.consume(data); + } catch (e) { + handleError(e, 'consume'); + } + } + + @bind + private handleError(e: Error, reason: string) { + console.error(`[ttyd] zmodem ${reason}: `, e); + this.zmodemReset(); + } + + @bind + private zmodemInit() { + this.session = null; + this.sentry = new Zmodem.Sentry({ + to_terminal: (octets: ArrayBuffer) => this.zmodemWrite(octets), + sender: (octets: ArrayLike<number>) => this.zmodemSend(octets), + on_retract: () => this.zmodemReset(), + on_detect: (detection: Zmodem.Detection) => this.zmodemDetect(detection), + }); + } + + @bind + private zmodemReset() { + this.terminal.setOption('disableStdin', false); + + if (this.keyDispose) { + this.keyDispose.dispose(); + this.keyDispose = null; + } + this.zmodemInit(); + + this.terminal.focus(); + } + + @bind + private zmodemWrite(data: ArrayBuffer): void { + const { limit, highWater, lowWater, pause, resume } = this.props.control; + const { terminal } = this; + const rawData = new Uint8Array(data); + + this.written += rawData.length; + if (this.written > limit) { + terminal.write(rawData, () => { + this.pending = Math.max(this.pending - 1, 0); + if (this.pending < lowWater) { + resume(); + } + }); + this.pending++; + this.written = 0; + if (this.pending > highWater) { + pause(); + } + } else { + terminal.write(rawData); + } + } + + @bind + private zmodemSend(data: ArrayLike<number>): void { + this.props.sender(data); + } + + @bind + private zmodemDetect(detection: Zmodem.Detection): void { + const { terminal, receiveFile, zmodemReset } = this; + terminal.setOption('disableStdin', true); + + this.keyDispose = terminal.onKey(e => { + const event = e.domEvent; + if (event.ctrlKey && event.key === 'c') { + detection.deny(); + } + }); + + this.session = detection.confirm(); + this.session.on('session_end', zmodemReset); + + if (this.session.type === 'send') { + this.setState({ modal: true }); + } else { + receiveFile(); + } + } + + @bind + private sendFile(event: Event) { + this.setState({ modal: false }); + + const { session, writeProgress, handleError } = this; + const files: FileList = (event.target as HTMLInputElement).files; + + Zmodem.Browser.send_files(session, files, { + on_progress: (_, offer: Zmodem.Offer) => writeProgress(offer), + }) + .then(() => session.close()) + .catch(e => handleError(e, 'send')); + } + + @bind + private receiveFile() { + const { session, writeProgress, handleError } = this; + + session.on('offer', (offer: Zmodem.Offer) => { + const fileBuffer = []; + offer.on('input', payload => { + writeProgress(offer); + fileBuffer.push(new Uint8Array(payload)); + }); + offer + .accept() + .then(() => { + const blob = new Blob(fileBuffer, { type: 'application/octet-stream' }); + saveAs(blob, offer.get_details().name); + }) + .catch(e => handleError(e, 'receive')); + }); + + session.start(); + } + + @bind + private writeProgress(offer: Zmodem.Offer) { + const { terminal, bytesHuman } = this; + + const file = offer.get_details(); + const name = file.name; + const size = file.size; + const offset = offer.get_offset(); + const percent = ((100 * offset) / size).toFixed(2); + + terminal.write(`${name} ${percent}% ${bytesHuman(offset, 2)}/${bytesHuman(size, 2)}\r`); + } + + private bytesHuman(bytes: any, precision: number): string { + if (!/^([-+])?|(\.\d+)(\d+(\.\d+)?|(\d+\.)|Infinity)$/.test(bytes)) { + return '-'; + } + if (bytes === 0) return '0'; + if (typeof precision === 'undefined') precision = 1; + const units = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; + const num = Math.floor(Math.log(bytes) / Math.log(1024)); + const value = (bytes / Math.pow(1024, Math.floor(num))).toFixed(precision); + return `${value} ${units[num]}`; + } +} |