mirror of
https://github.com/lights0123/n-link.git
synced 2024-12-22 18:25:27 +00:00
310 lines
8.5 KiB
TypeScript
310 lines
8.5 KiB
TypeScript
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<RpcProvider, 'rpc'>;
|
|
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<FileInfo[]> {
|
|
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<string, Device> = {};
|
|
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<WorkerExt> = 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<void> {
|
|
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;
|