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