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;
+}