mirror of
https://github.com/lights0123/n-link.git
synced 2024-12-22 18:25:27 +00:00
Actually add web workspace
This commit is contained in:
parent
10756ff9e8
commit
9868fa3634
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
@ -93,4 +93,4 @@ jobs:
|
|||
- name: Build
|
||||
working-directory: ./web
|
||||
run: |
|
||||
yarn generate
|
||||
yarn workspace web run generate
|
||||
|
|
13
web/.editorconfig
Normal file
13
web/.editorconfig
Normal file
|
@ -0,0 +1,13 @@
|
|||
# editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
20
web/.eslintrc.js
Normal file
20
web/.eslintrc.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
es2020: true,
|
||||
browser: true,
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
'@nuxtjs/eslint-config-typescript',
|
||||
'prettier',
|
||||
'prettier/vue',
|
||||
'plugin:prettier/recommended',
|
||||
'plugin:nuxt/recommended',
|
||||
],
|
||||
plugins: ['prettier'],
|
||||
// add your custom rules here
|
||||
rules: {
|
||||
'import/no-webpack-loader-syntax': 0,
|
||||
},
|
||||
};
|
90
web/.gitignore
vendored
Normal file
90
web/.gitignore
vendored
Normal file
|
@ -0,0 +1,90 @@
|
|||
# Created by .ignore support plugin (hsz.mobi)
|
||||
### Node template
|
||||
# Logs
|
||||
/logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
|
||||
# next.js build output
|
||||
.next
|
||||
|
||||
# nuxt.js build output
|
||||
.nuxt
|
||||
|
||||
# Nuxt generate
|
||||
dist
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless
|
||||
|
||||
# IDE / Editor
|
||||
.idea
|
||||
|
||||
# Service worker
|
||||
sw.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Vim swap files
|
||||
*.swp
|
4
web/.prettierrc
Normal file
4
web/.prettierrc
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"semi": true,
|
||||
"singleQuote": true
|
||||
}
|
20
web/README.md
Normal file
20
web/README.md
Normal file
|
@ -0,0 +1,20 @@
|
|||
# web
|
||||
|
||||
## Build Setup
|
||||
|
||||
```bash
|
||||
# install dependencies
|
||||
$ yarn install
|
||||
|
||||
# serve with hot reload at localhost:3000
|
||||
$ yarn dev
|
||||
|
||||
# build for production and launch server
|
||||
$ yarn build
|
||||
$ yarn start
|
||||
|
||||
# generate static project
|
||||
$ yarn generate
|
||||
```
|
||||
|
||||
For detailed explanation on how things work, check out [Nuxt.js docs](https://nuxtjs.org).
|
35
web/components/Logo.vue
Normal file
35
web/components/Logo.vue
Normal file
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<svg
|
||||
class="NuxtLogo"
|
||||
width="245"
|
||||
height="180"
|
||||
viewBox="0 0 452 342"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M139 330l-1-2c-2-4-2-8-1-13H29L189 31l67 121 22-16-67-121c-1-2-9-14-22-14-6 0-15 2-22 15L5 303c-1 3-8 16-2 27 4 6 10 12 24 12h136c-14 0-21-6-24-12z"
|
||||
fill="#00C58E"
|
||||
/>
|
||||
<path
|
||||
d="M447 304L317 70c-2-2-9-15-22-15-6 0-15 3-22 15l-17 28v54l39-67 129 230h-49a23 23 0 0 1-2 14l-1 1c-6 11-21 12-23 12h76c3 0 17-1 24-12 3-5 5-14-2-26z"
|
||||
fill="#108775"
|
||||
/>
|
||||
<path
|
||||
d="M376 330v-1l1-2c1-4 2-8 1-12l-4-12-102-178-15-27h-1l-15 27-102 178-4 12a24 24 0 0 0 2 15c4 6 10 12 24 12h190c3 0 18-1 25-12zM256 152l93 163H163l93-163z"
|
||||
fill="#2F495E"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.NuxtLogo {
|
||||
animation: 1s appear;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
@keyframes appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
286
web/components/devices.ts
Normal file
286
web/components/devices.ts
Normal file
|
@ -0,0 +1,286 @@
|
|||
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;
|
||||
|
||||
async function promisified(...a: any[]): Promise<any> {
|
||||
}
|
||||
|
||||
type WorkerExt = Worker & { rpc: RpcProvider };
|
||||
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: RpcProvider,
|
||||
path: [string, number]
|
||||
) {
|
||||
const data: Uint8Array = await dev.rpc('downloadFile', {path});
|
||||
saveAs(new Blob([data]), path[0].split('/').pop());
|
||||
}
|
||||
|
||||
async function uploadFile(dev: RpcProvider, path: string, data: Uint8Array) {
|
||||
await dev.rpc('uploadFile', {path, data});
|
||||
}
|
||||
|
||||
async function uploadOs(dev: RpcProvider, data: Uint8Array) {
|
||||
await dev.rpc('uploadOs', {data});
|
||||
}
|
||||
|
||||
async function deleteFile(dev: RpcProvider, path: string) {
|
||||
await dev.rpc('deleteFile', {path});
|
||||
}
|
||||
|
||||
async function deleteDir(dev: RpcProvider, path: string) {
|
||||
await dev.rpc('deleteDir', {path});
|
||||
}
|
||||
|
||||
async function createDir(dev: RpcProvider, path: string) {
|
||||
await dev.rpc('createDir', {path});
|
||||
}
|
||||
|
||||
async function move(dev: RpcProvider, src: string, dest: string) {
|
||||
await dev.rpc('move', {src, dest});
|
||||
}
|
||||
|
||||
async function copy(dev: RpcProvider, src: string, dest: string) {
|
||||
await dev.rpc('copy', {src, dest});
|
||||
}
|
||||
|
||||
async function listDir(dev: RpcProvider, path: string) {
|
||||
return (await dev.rpc('listDir', {path})) as FileInfo[];
|
||||
}
|
||||
|
||||
async function listAll(dev: RpcProvider, 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> = {};
|
||||
|
||||
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);
|
||||
}
|
||||
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 = rpc;
|
||||
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);
|
||||
};
|
||||
this.$set(this.devices[dev], 'worker', worker as WorkerExt);
|
||||
|
||||
await 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;
|
168
web/components/impl.ts
Normal file
168
web/components/impl.ts
Normal file
|
@ -0,0 +1,168 @@
|
|||
import { Encoder } from '@msgpack/msgpack';
|
||||
|
||||
export enum UsbError {
|
||||
NotFound = 'NotFound',
|
||||
Security = 'Security',
|
||||
Network = 'Network',
|
||||
Abort = 'Abort',
|
||||
InvalidState = 'InvalidState',
|
||||
InvalidAccess = 'InvalidAccess',
|
||||
Unknown = 'Unknown',
|
||||
}
|
||||
|
||||
const exceptionMap = globalThis.DOMException
|
||||
? {
|
||||
[DOMException.NOT_FOUND_ERR]: UsbError.NotFound,
|
||||
[DOMException.SECURITY_ERR]: UsbError.Security,
|
||||
[DOMException.NETWORK_ERR]: UsbError.Network,
|
||||
[DOMException.ABORT_ERR]: UsbError.Abort,
|
||||
[DOMException.INVALID_STATE_ERR]: UsbError.InvalidState,
|
||||
[DOMException.INVALID_ACCESS_ERR]: UsbError.InvalidAccess,
|
||||
}
|
||||
: {};
|
||||
|
||||
export type Cmd =
|
||||
| ({ usbCmd: 'bulkTransferOut' } & BulkTransferOut)
|
||||
| ({ usbCmd: 'bulkTransferIn' } & BulkTransferIn)
|
||||
| ({ usbCmd: 'selectConfiguration' } & SelectConfiguration)
|
||||
| ({ usbCmd: 'claimInterface' } & ClaimInterface)
|
||||
| ({ usbCmd: 'releaseInterface' } & ReleaseInterface)
|
||||
| ({ usbCmd: 'resetDevice' } & ResetDevice)
|
||||
| ({ usbCmd: 'activeConfigDescriptor' } & ActiveConfigDescriptor);
|
||||
|
||||
export type NullReply = { Ok: null } | { Err: UsbError };
|
||||
|
||||
export type BulkTransferOut = {
|
||||
device: number;
|
||||
endpoint: number;
|
||||
data: Uint8Array;
|
||||
};
|
||||
|
||||
export type BulkTransferOutReply = { Ok: number } | { Err: UsbError };
|
||||
|
||||
export type BulkTransferIn = {
|
||||
device: number;
|
||||
endpoint: number;
|
||||
length: number;
|
||||
};
|
||||
|
||||
export type Data = Uint8Array;
|
||||
|
||||
export type BulkTransferInReply = { Ok: Data } | { Err: UsbError };
|
||||
|
||||
export type SelectConfiguration = { device: number; config: number };
|
||||
|
||||
export type ClaimInterface = { device: number; number: number };
|
||||
|
||||
export type ReleaseInterface = { device: number; number: number };
|
||||
|
||||
export type ResetDevice = { device: number };
|
||||
|
||||
export type ActiveConfigDescriptor = { device: number };
|
||||
|
||||
export type USBEndpoint = { address: number; packetSize: number };
|
||||
|
||||
export type USBAlternateInterface = {
|
||||
alternateSetting: number;
|
||||
interfaceClass: number;
|
||||
interfaceSubclass: number;
|
||||
interfaceProtocol: number;
|
||||
endpoints: USBEndpoint[];
|
||||
};
|
||||
|
||||
export type USBConfiguration = {
|
||||
configurationValue: number;
|
||||
interfaces: USBAlternateInterface[][];
|
||||
};
|
||||
|
||||
export type ActiveConfigDescriptorReply =
|
||||
| { Ok: USBConfiguration }
|
||||
| { Err: UsbError };
|
||||
|
||||
const encoder = new Encoder();
|
||||
|
||||
let count = 0;
|
||||
export default class UsbCompat {
|
||||
devices: Record<number, USBDevice> = {};
|
||||
arr: SharedArrayBuffer;
|
||||
|
||||
constructor(arr: SharedArrayBuffer) {
|
||||
this.arr = arr;
|
||||
}
|
||||
|
||||
addDevice(dev: USBDevice) {
|
||||
const i = count++;
|
||||
this.devices[i] = dev;
|
||||
return i;
|
||||
}
|
||||
|
||||
private async _processCmd(cmd: Cmd) {
|
||||
try {
|
||||
if (cmd.usbCmd === 'bulkTransferOut') {
|
||||
const res = await this.devices[cmd.device].transferOut(
|
||||
cmd.endpoint & ~0x80,
|
||||
cmd.data
|
||||
);
|
||||
const reply: BulkTransferOutReply = { Ok: res.bytesWritten };
|
||||
return reply;
|
||||
} else if (cmd.usbCmd === 'bulkTransferIn') {
|
||||
const res = await this.devices[cmd.device].transferIn(
|
||||
cmd.endpoint & ~0x80,
|
||||
cmd.length
|
||||
);
|
||||
const reply: BulkTransferInReply = {
|
||||
Ok: new Uint8Array(res.data!.buffer),
|
||||
};
|
||||
return reply;
|
||||
} else if (cmd.usbCmd === 'selectConfiguration') {
|
||||
await this.devices[cmd.device].selectConfiguration(cmd.config);
|
||||
} else if (cmd.usbCmd === 'claimInterface') {
|
||||
await this.devices[cmd.device].claimInterface(cmd.number);
|
||||
} else if (cmd.usbCmd === 'releaseInterface') {
|
||||
await this.devices[cmd.device].releaseInterface(cmd.number);
|
||||
} else if (cmd.usbCmd === 'resetDevice') {
|
||||
await this.devices[cmd.device].reset();
|
||||
} else if (cmd.usbCmd === 'activeConfigDescriptor') {
|
||||
const configuration = this.devices[cmd.device].configuration!;
|
||||
const reply: ActiveConfigDescriptorReply = {
|
||||
Ok: {
|
||||
configurationValue: configuration.configurationValue,
|
||||
interfaces: configuration.interfaces.map(({ alternates }) => {
|
||||
return alternates.map((alternate) => ({
|
||||
alternateSetting: alternate.alternateSetting,
|
||||
interfaceClass: alternate.interfaceClass,
|
||||
interfaceSubclass: alternate.interfaceSubclass,
|
||||
interfaceProtocol: alternate.interfaceProtocol,
|
||||
endpoints: alternate.endpoints.map((endpoint) => ({
|
||||
address:
|
||||
endpoint.endpointNumber |
|
||||
(endpoint.direction === 'in' ? 0x80 : 0),
|
||||
packetSize: endpoint.packetSize,
|
||||
})),
|
||||
}));
|
||||
}),
|
||||
},
|
||||
};
|
||||
return reply;
|
||||
}
|
||||
const reply: NullReply = { Ok: null };
|
||||
return reply;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return { Err: exceptionMap[e.name] || UsbError.Unknown };
|
||||
}
|
||||
}
|
||||
|
||||
async processCmd(cmd: Cmd) {
|
||||
const msg = await this._processCmd(cmd);
|
||||
const encoded = encoder.encode(msg);
|
||||
|
||||
if (encoded.length > this.arr.length - 4) {
|
||||
throw new Error('too long');
|
||||
}
|
||||
new Uint8Array(this.arr).set(encoded, 4);
|
||||
const notify = new Int32Array(this.arr);
|
||||
Atomics.store(notify, 0, encoded.length);
|
||||
Atomics.notify(notify, 0, Infinity);
|
||||
}
|
||||
}
|
66
web/components/usb.worker.ts
Normal file
66
web/components/usb.worker.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
/* eslint camelcase: 0, require-await: 0 */
|
||||
|
||||
import { RpcProvider } from 'worker-rpc';
|
||||
// eslint-disable-next-line import/no-absolute-path
|
||||
import type { Calculator } from 'web-libnspire';
|
||||
|
||||
console.log('worker!');
|
||||
const ctx: Worker = self as any;
|
||||
const module = import('web-libnspire');
|
||||
let calc: Calculator | undefined;
|
||||
const rpcProvider = new RpcProvider((message, transfer: any) =>
|
||||
ctx.postMessage(message, transfer)
|
||||
);
|
||||
ctx.onmessage = (e) => rpcProvider.dispatch(e.data);
|
||||
|
||||
type Path = { path: string };
|
||||
type Data = { data: Uint8Array };
|
||||
type SrcDest = { src: string; dest: string };
|
||||
|
||||
rpcProvider.registerRpcHandler<{id: number, sab: SharedArrayBuffer, vid: number, pid: number}>('init', async ({ id, sab, vid, pid }) => {
|
||||
if (calc) calc.free();
|
||||
calc = new (await module).Calculator(id, vid, pid, new Int32Array(sab));
|
||||
});
|
||||
|
||||
rpcProvider.registerRpcHandler('updateDevice', async () => {
|
||||
return calc?.update();
|
||||
});
|
||||
|
||||
rpcProvider.registerRpcHandler<{path: [string, number]}, Uint8Array | undefined>('downloadFile', async ({ path }) => {
|
||||
return calc?.download_file(path[0], path[1]);
|
||||
});
|
||||
|
||||
rpcProvider.registerRpcHandler<Path & Data>(
|
||||
'uploadFile',
|
||||
async ({ path, data }) => {
|
||||
calc?.upload_file(path, data);
|
||||
}
|
||||
);
|
||||
|
||||
rpcProvider.registerRpcHandler<Data>('uploadOs', async ({ data }) => {
|
||||
calc?.upload_os(data);
|
||||
});
|
||||
|
||||
rpcProvider.registerRpcHandler<Path>('deleteFile', async ({ path }) => {
|
||||
calc?.delete_file(path);
|
||||
});
|
||||
|
||||
rpcProvider.registerRpcHandler<Path>('deleteDir', async ({ path }) => {
|
||||
calc?.delete_dir(path);
|
||||
});
|
||||
|
||||
rpcProvider.registerRpcHandler<Path>('createDir', async ({ path }) => {
|
||||
calc?.create_dir(path);
|
||||
});
|
||||
|
||||
rpcProvider.registerRpcHandler<SrcDest>('move', async ({ src, dest }) => {
|
||||
calc?.move_file(src, dest);
|
||||
});
|
||||
|
||||
rpcProvider.registerRpcHandler<SrcDest>('copy', async ({ src, dest }) => {
|
||||
calc?.copy_file(src, dest);
|
||||
});
|
||||
|
||||
rpcProvider.registerRpcHandler<Path>('listDir', async ({ path }) => {
|
||||
return calc?.list_dir(path);
|
||||
});
|
27
web/layouts/default.vue
Normal file
27
web/layouts/default.vue
Normal file
|
@ -0,0 +1,27 @@
|
|||
<template>
|
||||
<div class="h-screen">
|
||||
<Nuxt />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
html {
|
||||
color: #2c3e50;
|
||||
user-select: none;
|
||||
font-family: Avenir, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
word-spacing: 1px;
|
||||
-ms-text-size-adjust: 100%;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
40
web/nuxt.config.js
Normal file
40
web/nuxt.config.js
Normal file
|
@ -0,0 +1,40 @@
|
|||
export default {
|
||||
// Target (https://go.nuxtjs.dev/config-target)
|
||||
target: 'static',
|
||||
|
||||
// Global page headers (https://go.nuxtjs.dev/config-head)
|
||||
head: {
|
||||
title: 'web',
|
||||
meta: [
|
||||
{ charset: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
{ hid: 'description', name: 'description', content: '' },
|
||||
],
|
||||
link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }],
|
||||
},
|
||||
|
||||
// Global CSS (https://go.nuxtjs.dev/config-css)
|
||||
css: [],
|
||||
|
||||
// Plugins to run before rendering page (https://go.nuxtjs.dev/config-plugins)
|
||||
plugins: ['@/plugins/index.ts'],
|
||||
|
||||
// Auto import components (https://go.nuxtjs.dev/config-components)
|
||||
components: true,
|
||||
|
||||
// Modules for dev and build (recommended) (https://go.nuxtjs.dev/config-modules)
|
||||
buildModules: [
|
||||
// https://go.nuxtjs.dev/typescript
|
||||
'@nuxt/typescript-build',
|
||||
// https://go.nuxtjs.dev/tailwindcss
|
||||
'@nuxtjs/tailwindcss',
|
||||
],
|
||||
|
||||
// Modules (https://go.nuxtjs.dev/config-modules)
|
||||
modules: [],
|
||||
|
||||
// Build Configuration (https://go.nuxtjs.dev/config-build)
|
||||
build: {
|
||||
transpile: ['n-link-core', 'element-ui'],
|
||||
},
|
||||
};
|
42
web/package.json
Normal file
42
web/package.json
Normal file
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"name": "web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nuxt-ts",
|
||||
"build": "nuxt-ts build",
|
||||
"start": "nuxt-ts start",
|
||||
"generate": "nuxt-ts generate",
|
||||
"lint:js": "eslint --ext .js,.vue --ignore-path .gitignore .",
|
||||
"lint": "yarn lint:js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@msgpack/msgpack": "^2.1.0",
|
||||
"@nuxt/typescript-runtime": "^2.0.0",
|
||||
"core-js": "^3.6.5",
|
||||
"file-saver": "^2.0.2",
|
||||
"n-link-core": "0.0.0",
|
||||
"nuxt": "^2.14.5",
|
||||
"vue-async-computed": "^3.9.0",
|
||||
"vue-property-decorator": "^9.0.2",
|
||||
"web-libnspire": "^0.1.3",
|
||||
"worker-rpc": "^0.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/types": "^2.14.5",
|
||||
"@nuxt/typescript-build": "^2.0.3",
|
||||
"@nuxtjs/eslint-config": "^3.1.0",
|
||||
"@nuxtjs/eslint-config-typescript": "^3.0.0",
|
||||
"@nuxtjs/eslint-module": "^2.0.0",
|
||||
"@nuxtjs/tailwindcss": "^3.0.2",
|
||||
"@types/file-saver": "^2.0.1",
|
||||
"@types/w3c-web-usb": "^1.0.4",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"eslint": "^7.8.1",
|
||||
"eslint-config-prettier": "^6.11.0",
|
||||
"eslint-plugin-nuxt": "^1.0.0",
|
||||
"eslint-plugin-prettier": "^3.1.4",
|
||||
"prettier": "^2.1.1",
|
||||
"worker-loader": "^3.0.3"
|
||||
}
|
||||
}
|
158
web/pages/index.vue
Normal file
158
web/pages/index.vue
Normal file
|
@ -0,0 +1,158 @@
|
|||
<template>
|
||||
<div class="home h-full overflow-hidden">
|
||||
<div class="flex flex-row h-full">
|
||||
<div class="flex-shrink-0 border-r w-64">
|
||||
<device-select :selected.sync="selectedCalculator" />
|
||||
<div class="overflow-auto h-full px-4 py-4">
|
||||
<div v-if="needsDrivers">
|
||||
<h1 class="text-3xl">Drivers required</h1>
|
||||
<p>The WinUSB driver is required to use this device.</p>
|
||||
<p class="text-center mt-2">
|
||||
<a href="#" class="text-blue-600" @click.prevent="installDrivers"
|
||||
>See installation instructions</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="calculator && !calculator.info"
|
||||
class="flex items-center justify-center h-full"
|
||||
>
|
||||
<div class="lds-dual-ring" />
|
||||
</div>
|
||||
<div v-else-if="calculator && calculator.info">
|
||||
<calc-info :info="calculator.info" :dev="selectedCalculator" native-upload />
|
||||
<label class="inline-flex items-center cursor-pointer mr-2 mt-4">
|
||||
<input
|
||||
v-model="showHidden"
|
||||
type="checkbox"
|
||||
class="form-checkbox h-5 w-5 text-blue-600 cursor-pointer"
|
||||
/>
|
||||
<span class="mx-2 text-gray-700 select-none"
|
||||
>Include hidden files</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="h-full">
|
||||
<file-browser
|
||||
v-if="calculator && calculator.info"
|
||||
:dev="selectedCalculator"
|
||||
:show-hidden="showHidden"
|
||||
native-upload
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Watch } from 'vue-property-decorator';
|
||||
import CalcInfo from 'n-link-core/components/CalcInfo.vue';
|
||||
import FileBrowser from 'n-link-core/components/FileBrowser.vue';
|
||||
import DeviceSelect from 'n-link-core/components/DeviceSelect.vue';
|
||||
import '@/components/devices';
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
DeviceSelect,
|
||||
FileBrowser,
|
||||
CalcInfo,
|
||||
},
|
||||
})
|
||||
export default class Home extends Vue {
|
||||
selectedCalculator: string | null = null;
|
||||
showHidden = false;
|
||||
|
||||
@Watch('$devices.hasEnumerated')
|
||||
onEnumerated() {
|
||||
const first = Object.keys(this.$devices.devices)[0];
|
||||
if (first) this.selectedCalculator = first;
|
||||
}
|
||||
|
||||
@Watch('$devices.devices')
|
||||
async onDeviceChange() {
|
||||
if (!this.selectedCalculator) {
|
||||
await sleep(1000);
|
||||
if (this.selectedCalculator) return;
|
||||
const first = Object.keys(this.$devices.devices)[0];
|
||||
if (first) this.selectedCalculator = first;
|
||||
} else if (
|
||||
!Object.keys(this.$devices.devices).includes(this.selectedCalculator)
|
||||
) {
|
||||
this.selectedCalculator = null;
|
||||
// go back and choose the first if available
|
||||
this.onDeviceChange();
|
||||
}
|
||||
}
|
||||
|
||||
@Watch('selectedCalculator')
|
||||
async onSelectCalculator(dev: string | null) {
|
||||
if (
|
||||
dev &&
|
||||
!this.$devices.devices[dev].info &&
|
||||
!this.$devices.devices[dev].needsDrivers
|
||||
) {
|
||||
try {
|
||||
await this.$devices.open(dev);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.selectedCalculator = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get calculator() {
|
||||
return (
|
||||
this.selectedCalculator && this.$devices.devices[this.selectedCalculator]
|
||||
);
|
||||
}
|
||||
|
||||
get needsDrivers() {
|
||||
return (
|
||||
this.selectedCalculator &&
|
||||
this.$devices.devices[this.selectedCalculator]?.needsDrivers
|
||||
);
|
||||
}
|
||||
|
||||
installDrivers() {
|
||||
open('https://lights0123.com/n-link/#windows');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.lds-dual-ring {
|
||||
display: inline-block;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.lds-dual-ring:after {
|
||||
$color: theme('colors.gray.400');
|
||||
content: ' ';
|
||||
display: block;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 8px;
|
||||
border-radius: 50%;
|
||||
border: 6px solid $color;
|
||||
border-color: $color transparent $color transparent;
|
||||
animation: lds-dual-ring 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes lds-dual-ring {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
4
web/plugins/index.ts
Normal file
4
web/plugins/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import Vue from 'vue';
|
||||
import AsyncComputed from 'vue-async-computed';
|
||||
|
||||
Vue.use(AsyncComputed);
|
BIN
web/static/favicon.ico
Normal file
BIN
web/static/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
1
web/tailwind.config.js
Normal file
1
web/tailwind.config.js
Normal file
|
@ -0,0 +1 @@
|
|||
module.exports = require('n-link-core/tailwind.config');
|
37
web/tsconfig.json
Normal file
37
web/tsconfig.json
Normal file
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2018",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"lib": [
|
||||
"ESNext",
|
||||
"ESNext.AsyncIterable",
|
||||
"DOM"
|
||||
],
|
||||
"esModuleInterop": true,
|
||||
"allowJs": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"experimentalDecorators": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": [
|
||||
"./*"
|
||||
],
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"types": [
|
||||
"@types/node",
|
||||
"@nuxt/types",
|
||||
"@types/w3c-web-usb"
|
||||
]
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
".nuxt",
|
||||
"dist"
|
||||
]
|
||||
}
|
10
web/types/shims-vue.d.ts
vendored
Normal file
10
web/types/shims-vue.d.ts
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
declare module 'worker-loader!*' {
|
||||
// You need to change `Worker`, if you specified a different value for the `workerType` option
|
||||
class WebpackWorker extends Worker {
|
||||
constructor();
|
||||
}
|
||||
|
||||
// Uncomment this if you set the `esModule` option to `false`
|
||||
// export = WebpackWorker;
|
||||
export default WebpackWorker;
|
||||
}
|
Loading…
Reference in a new issue