import { Component, Vue } from 'vue-property-decorator'; import { RpcProvider } from 'worker-rpc'; import { saveAs } from 'file-saver'; import UsbWorker from 'worker-loader!@/components/usb.worker.ts'; import UsbCompat from '@/components/impl'; import { Cmd, FileInfo, GenericDevices, Info, PartialCmd, Progress, } from 'n-link-core/components/devices'; /// The USB vendor ID used by all Nspire calculators. const VID = 0x0451; /// The USB vendor ID used by all non-CX and original CX calculators. const PID = 0xe012; /// The USB vendor ID used by all CX II calculators. const PID_CX2 = 0xe022; type Rpc = Pick; type WorkerExt = Worker & { rpc: Rpc; compat: UsbCompat }; export type Device = { device: USBDevice; name: string; isCxIi: boolean; needsDrivers: boolean; worker?: WorkerExt; info?: Info; progress?: Progress; queue?: Cmd[]; running?: boolean; }; async function downloadFile(dev: Rpc, path: [string, number]) { const data: Uint8Array = await dev.rpc('downloadFile', { path }); saveAs(new Blob([data]), path[0].split('/').pop()); } async function uploadFile(dev: Rpc, path: string, data: Uint8Array) { await dev.rpc('uploadFile', { path, data }); } async function uploadOs(dev: Rpc, data: Uint8Array) { await dev.rpc('uploadOs', { data }); } async function deleteFile(dev: Rpc, path: string) { await dev.rpc('deleteFile', { path }); } async function deleteDir(dev: Rpc, path: string) { await dev.rpc('deleteDir', { path }); } async function createDir(dev: Rpc, path: string) { await dev.rpc('createDir', { path }); } async function move(dev: Rpc, src: string, dest: string) { await dev.rpc('move', { src, dest }); } async function copy(dev: Rpc, src: string, dest: string) { await dev.rpc('copy', { src, dest }); } async function listDir(dev: Rpc, path: string) { return (await dev.rpc('listDir', { path })) as FileInfo[]; } async function listAll(dev: Rpc, path: FileInfo): Promise { if (!path.isDir) return [path]; try { const contents = await listDir(dev, path.path); const parts: FileInfo[] = []; for (const file of contents) { parts.push( ...(await listAll(dev, { ...file, path: `${path.path}/${file.path}` })) ); } parts.push(path); return parts; } catch (e) { console.error(path, e); return []; } } let queueId = 0; @Component class Devices extends Vue implements GenericDevices { enumerating = false; hasEnumerated = false; devices: Record = {}; errorHandler?: (e: DOMException) => void; created() {} async runQueue(dev: string) { const device = this.devices[dev]; if (!device?.queue || !device.worker || device.running) return; const { rpc } = device.worker; this.$set(device, 'running', true); // eslint-disable-next-line no-constant-condition while (true) { // The device has been removed if (!this.devices[dev]) return; const cmd = device.queue[0]; if (!cmd) { device.running = false; return; } try { if (cmd.action === 'download') { await downloadFile(rpc, cmd.path); } else if (cmd.action === 'upload') { if (!('file' in cmd)) return; await uploadFile( rpc, `${cmd.path}/${cmd.file.name}`, new Uint8Array(await cmd.file.arrayBuffer()) ); } else if (cmd.action === 'uploadOs') { if (!('file' in cmd)) return; await uploadOs(rpc, new Uint8Array(await cmd.file.arrayBuffer())); } else if (cmd.action === 'deleteFile') { await deleteFile(rpc, cmd.path); } else if (cmd.action === 'deleteDir') { await deleteDir(rpc, cmd.path); } else if (cmd.action === 'createDir') { await createDir(rpc, cmd.path); } else if (cmd.action === 'move') { await move(rpc, cmd.src, cmd.dest); } else if (cmd.action === 'copy') { await copy(rpc, cmd.src, cmd.dest); } } catch (e) { console.error(e); this.errorHandler?.(e); } if ('progress' in device) this.$delete(device, 'progress'); device.queue.shift(); await this.update(dev); } } private addToQueue(dev: string, ...cmds: PartialCmd[]) { const device = this.devices[dev]; if (!device) return; if (!device.queue) { this.$set(device, 'queue', []); } device.queue?.push( ...cmds.map((cmd) => ({ ...cmd, id: queueId++ } as Cmd)) ); this.runQueue(dev); } async enumerate() { try { if (!navigator.usb) return; const device = await navigator.usb.requestDevice({ filters: [ { vendorId: VID, productId: PID }, { vendorId: VID, productId: PID_CX2, }, ], }); navigator.usb.ondisconnect = (e) => { const [key] = Object.entries(this.devices).find( ([_, { device }]) => device === e.device ) || []; if (key) { this.$delete(this.devices, key); } }; this.$set(this.devices, queueId++, { device, name: device.productName, isCxIi: device.productId === PID_CX2, needsDrivers: false, } as Device); } catch (e) { console.error(e); } } async open(dev: string) { const device = this.devices[dev].device; await device.open(); const worker: Worker & Partial = new UsbWorker(); const sab = new SharedArrayBuffer(10000); const compat = new UsbCompat(sab); const id = compat.addDevice(device); const rpc = new RpcProvider((message, transfer: any) => worker.postMessage(message, transfer) ); worker.rpc = { async rpc(id, payload, transfer) { compat.lastError = undefined; try { return await rpc.rpc(id, payload, transfer); } catch (e) { console.log(e, compat.lastError); if (compat.lastError) throw compat.lastError; throw new DOMException(e.toString()); } }, }; worker.onmessage = ({ data }) => { if ('usbCmd' in data) return compat.processCmd(data); if ('total' in data) { this.$set(this.devices[dev], 'progress', data); return; } rpc.dispatch(data); }; worker.compat = compat; this.$set(this.devices[dev], 'worker', worker as WorkerExt); await worker.rpc.rpc('init', { id, sab, vid: device.vendorId, pid: device.productId, }); await this.update(dev); } async close(dev: string) { const device = this.devices[dev]; device.worker?.terminate(); device.device.close(); this.$delete(this.devices, dev); } async update(dev: string) { console.log('up'); const info = await this.devices[dev].worker?.rpc.rpc('updateDevice'); console.log(info); this.$set(this.devices[dev], 'info', info); } async listDir(dev: string, path: string) { const worker = this.devices[dev].worker; if (!worker) return []; return await listDir(worker.rpc, path); } async uploadFiles(dev: string, path: string, files: File[]) { for (const file of files) { this.addToQueue(dev, { action: 'upload', path, file }); } } async promptUploadFiles(_dev: string, _path: string) { throw new Error('Unimplemented'); } async uploadOs(_dev: string, _filter: string) { throw new Error('Unimplemented'); } async uploadOsFile(dev: string, file: File): Promise { this.addToQueue(dev, { action: 'uploadOs', file }); } async downloadFiles(dev: string, files: [string, number][]) { for (const path of files) { this.addToQueue(dev, { action: 'download', path }); } } async delete(dev: string, files: FileInfo[]) { const rpc = this.devices[dev].worker?.rpc; if (!rpc) return; const toDelete: FileInfo[] = []; for (const file of files) { toDelete.push(...(await listAll(rpc, file))); } for (const file of toDelete) { this.addToQueue(dev, { action: file.isDir ? 'deleteDir' : 'deleteFile', path: file.path, }); } } async createDir(dev: string, path: string) { this.addToQueue(dev, { action: 'createDir', path }); } async copy(dev: string, src: string, dest: string) { this.addToQueue(dev, { action: 'copy', src, dest }); } async move(dev: string, src: string, dest: string) { this.addToQueue(dev, { action: 'move', src, dest }); } } const devices = new Devices(); export default devices; Vue.prototype.$devices = devices;