From 9868fa36340ad94c2b6e44a855c8ace9083aa584 Mon Sep 17 00:00:00 2001 From: lights0123 <developer@lights0123.com> Date: Tue, 6 Oct 2020 11:01:36 -0400 Subject: [PATCH] Actually add web workspace --- .github/workflows/ci.yml | 2 +- web/.editorconfig | 13 ++ web/.eslintrc.js | 20 +++ web/.gitignore | 90 +++++++++++ web/.prettierrc | 4 + web/README.md | 20 +++ web/components/Logo.vue | 35 +++++ web/components/devices.ts | 286 +++++++++++++++++++++++++++++++++++ web/components/impl.ts | 168 ++++++++++++++++++++ web/components/usb.worker.ts | 66 ++++++++ web/layouts/default.vue | 27 ++++ web/nuxt.config.js | 40 +++++ web/package.json | 42 +++++ web/pages/index.vue | 158 +++++++++++++++++++ web/plugins/index.ts | 4 + web/static/favicon.ico | Bin 0 -> 4286 bytes web/tailwind.config.js | 1 + web/tsconfig.json | 37 +++++ web/types/shims-vue.d.ts | 10 ++ 19 files changed, 1022 insertions(+), 1 deletion(-) create mode 100644 web/.editorconfig create mode 100644 web/.eslintrc.js create mode 100644 web/.gitignore create mode 100644 web/.prettierrc create mode 100644 web/README.md create mode 100644 web/components/Logo.vue create mode 100644 web/components/devices.ts create mode 100644 web/components/impl.ts create mode 100644 web/components/usb.worker.ts create mode 100644 web/layouts/default.vue create mode 100644 web/nuxt.config.js create mode 100644 web/package.json create mode 100644 web/pages/index.vue create mode 100644 web/plugins/index.ts create mode 100644 web/static/favicon.ico create mode 100644 web/tailwind.config.js create mode 100644 web/tsconfig.json create mode 100644 web/types/shims-vue.d.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70a878d..54232fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,4 +93,4 @@ jobs: - name: Build working-directory: ./web run: | - yarn generate + yarn workspace web run generate diff --git a/web/.editorconfig b/web/.editorconfig new file mode 100644 index 0000000..5d12634 --- /dev/null +++ b/web/.editorconfig @@ -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 diff --git a/web/.eslintrc.js b/web/.eslintrc.js new file mode 100644 index 0000000..5ce634b --- /dev/null +++ b/web/.eslintrc.js @@ -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, + }, +}; diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..e8f682b --- /dev/null +++ b/web/.gitignore @@ -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 diff --git a/web/.prettierrc b/web/.prettierrc new file mode 100644 index 0000000..937375d --- /dev/null +++ b/web/.prettierrc @@ -0,0 +1,4 @@ +{ + "semi": true, + "singleQuote": true +} diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..4bb3f53 --- /dev/null +++ b/web/README.md @@ -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). diff --git a/web/components/Logo.vue b/web/components/Logo.vue new file mode 100644 index 0000000..bf7c01a --- /dev/null +++ b/web/components/Logo.vue @@ -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> diff --git a/web/components/devices.ts b/web/components/devices.ts new file mode 100644 index 0000000..21bbeba --- /dev/null +++ b/web/components/devices.ts @@ -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; diff --git a/web/components/impl.ts b/web/components/impl.ts new file mode 100644 index 0000000..44c1197 --- /dev/null +++ b/web/components/impl.ts @@ -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); + } +} diff --git a/web/components/usb.worker.ts b/web/components/usb.worker.ts new file mode 100644 index 0000000..ad379a8 --- /dev/null +++ b/web/components/usb.worker.ts @@ -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); +}); diff --git a/web/layouts/default.vue b/web/layouts/default.vue new file mode 100644 index 0000000..9efecc1 --- /dev/null +++ b/web/layouts/default.vue @@ -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> diff --git a/web/nuxt.config.js b/web/nuxt.config.js new file mode 100644 index 0000000..6fd5933 --- /dev/null +++ b/web/nuxt.config.js @@ -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'], + }, +}; diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..62905fb --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/pages/index.vue b/web/pages/index.vue new file mode 100644 index 0000000..d475908 --- /dev/null +++ b/web/pages/index.vue @@ -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> diff --git a/web/plugins/index.ts b/web/plugins/index.ts new file mode 100644 index 0000000..3e25971 --- /dev/null +++ b/web/plugins/index.ts @@ -0,0 +1,4 @@ +import Vue from 'vue'; +import AsyncComputed from 'vue-async-computed'; + +Vue.use(AsyncComputed); diff --git a/web/static/favicon.ico b/web/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..74b9f5f876de24b5ea0d08330c64fb39de55f8d1 GIT binary patch literal 4286 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 K)NkZIa{Ud7?NdDf literal 0 HcmV?d00001 diff --git a/web/tailwind.config.js b/web/tailwind.config.js new file mode 100644 index 0000000..4098b1e --- /dev/null +++ b/web/tailwind.config.js @@ -0,0 +1 @@ +module.exports = require('n-link-core/tailwind.config'); diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..b5c681e --- /dev/null +++ b/web/tsconfig.json @@ -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" + ] +} diff --git a/web/types/shims-vue.d.ts b/web/types/shims-vue.d.ts new file mode 100644 index 0000000..6dc0bd6 --- /dev/null +++ b/web/types/shims-vue.d.ts @@ -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; +}