Error messages

This commit is contained in:
lights0123 2021-04-06 17:01:54 -04:00
parent 5d5b98dd59
commit ac1a26a797
No known key found for this signature in database
GPG key ID: 28F315322E37972F
14 changed files with 242 additions and 115 deletions

View file

@ -13,6 +13,7 @@ module.exports = {
},
rules: {
'no-console': process.env.NODE_ENV === 'production' ? ['warn', { allow: ['warn', 'error'] }] : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
}
}

View file

@ -1,9 +1,14 @@
<template>
<div class="header border-b px-2 py-2 flex w-full">
<button @click="$devices.enumerate()" class="flex-shrink-0 mr-2 focus:outline-none" :class="$devices.enumerating && 'cursor-not-allowed opacity-25'" :disabled="$devices.enumerating">
<button @click="$devices.enumerate()" class="flex-shrink-0 mr-2 focus:outline-none"
:class="$devices.enumerating && 'cursor-not-allowed opacity-25'" :disabled="$devices.enumerating">
<img src="~feather-icons/dist/icons/refresh-cw.svg" class="w-5"/>
<div v-if="scanHint && Object.keys($devices.devices).length === 0" class="p-4 refresh-popup">
Click to connect a device
</div>
</button>
<el-popover width="239" :visible-arrow="false" popper-class="focus:outline-none dev-select-pop" v-model="active" class="w-full overflow-hidden">
<el-popover width="239" :visible-arrow="false" popper-class="focus:outline-none dev-select-pop" v-model="active"
class="w-full overflow-hidden">
<div slot="reference" class="relative w-full focus:outline-none">
<div
class="block w-full bg-white border border-gray-400 hover:border-gray-500 px-4 py-3/2 pr-8 rounded shadow leading-tight focus:outline-none focus:shadow-outline h-8 truncate">
@ -38,13 +43,14 @@
</template>
<script lang="ts">
import {Component, PropSync, Vue} from 'vue-property-decorator';
import {Component, Prop, PropSync, Vue} from 'vue-property-decorator';
import ElPopover from 'element-ui/packages/popover/src/main.vue';
import 'element-ui/lib/theme-chalk/popover.css';
@Component({components: {ElPopover}})
export default class DeviceSelect extends Vue {
@PropSync('selected', {type: [String]}) selectedCalculator!: string | null;
@Prop({type: Boolean, default: false}) scanHint!: boolean;
active = false;
select(dev: string) {
@ -66,6 +72,25 @@ export default class DeviceSelect extends Vue {
.header {
height: 50px;
}
.refresh-popup {
$offset: 5px;
$size: 9px;
margin-left: -4.5px;
margin-top: 10px;
@apply absolute bg-blue-600 text-white;
&:before {
content: "";
position: absolute;
left: $offset;
top: $size * -2;
border-top: $size solid transparent;
border-right: $size solid transparent;
border-bottom: $size solid theme('colors.blue.600');
border-left: $size solid transparent;
}
}
</style>
<style lang="scss">

View file

@ -46,6 +46,9 @@ module.exports = {
blockquote: 'var(--color-ui-blockquote)',
},
},
height: {
min: 'min-content',
},
},
},
variants: {},

View file

@ -16,5 +16,7 @@ module.exports = {
// add your custom rules here
rules: {
'import/no-webpack-loader-syntax': 0,
'require-await': 0,
'unicorn/number-literal-case': 0,
},
};

View file

@ -0,0 +1,82 @@
<template>
<client-only>
<vue-final-modal
v-model="isOpen"
classes="flex justify-center h-screen overflow-auto"
content-class="mt-10 mb-8 xl:mx-10 w-full h-min bg-white rounded p-4"
>
<div class="flex flex-col bg-white rounded p-4">
<button v-if="isOpen" class="self-end" @click="isOpen = false">
<img src="~feather-icons/dist/icons/x.svg" class="w-5" />
</button>
<div v-if="category === 'udev'">
<h1 class="text-3xl">Permission Error</h1>
<p>
A permissions error was encountered when trying to access the
device.
<a
href="https://lights0123.com/n-link/#linux"
class="text-blue-600 underline"
>
Follow the Linux installation steps</a
>
to configure udev rules.
</p>
</div>
<div v-else-if="category === 'reset'">
<h1 class="text-3xl">Resetting Error</h1>
<p>
Unfortunately, you've run into a known bug affecting calculators on
Windows and macOS. There's currently a bug in Chrome that prevents
this from working. Feel free to grab the
<a
href="https://lights0123.com/n-link/"
class="text-blue-600 underline"
>
desktop version</a
>
instead, though.
</p>
</div>
<div v-else>
<h1 class="text-3xl">Unknown Error</h1>
<p>
An unknown error occurred with the message:
{{ error && error.message }}
</p>
</div>
</div>
</vue-final-modal>
</client-only>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
@Component
export default class ErrorMessage extends Vue {
isOpen = false;
category = '';
error: DOMException | null = null;
handleError(error: DOMException, phase: 'connection' | 'operation') {
if (
phase === 'connection' &&
error.name === 'SecurityError' &&
navigator.platform.includes('Linux')
) {
this.category = 'udev';
} else if (
phase === 'connection' &&
error.message === 'Unable to reset the device.' &&
['Win', 'Mac'].some((platform) => navigator.platform.includes(platform))
) {
this.category = 'reset';
} else {
this.category = '';
}
this.isOpen = true;
this.error = error;
}
}
</script>

View file

@ -1,35 +0,0 @@
<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>

View file

@ -1,20 +1,24 @@
import {Component, Vue} from 'vue-property-decorator';
import {RpcProvider} from 'worker-rpc';
import {saveAs} from 'file-saver';
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';
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 };
type Rpc = Pick<RpcProvider, 'rpc'>;
type WorkerExt = Worker & { rpc: Rpc; compat: UsbCompat };
export type Device = {
device: USBDevice;
name: string;
@ -27,54 +31,51 @@ export type Device = {
running?: boolean;
};
async function downloadFile(
dev: RpcProvider,
path: [string, number]
) {
const data: Uint8Array = await dev.rpc('downloadFile', {path});
async function downloadFile(dev: Rpc, path: [string, number]) {
const data: Uint8Array = await dev.rpc('downloadFile', { path });
saveAs(new Blob([data]), path[0].split('/').pop());
}
async function uploadFile(dev: RpcProvider, path: string, data: Uint8Array) {
await dev.rpc('uploadFile', {path, data});
async function uploadFile(dev: Rpc, path: string, data: Uint8Array) {
await dev.rpc('uploadFile', { path, data });
}
async function uploadOs(dev: RpcProvider, data: Uint8Array) {
await dev.rpc('uploadOs', {data});
async function uploadOs(dev: Rpc, data: Uint8Array) {
await dev.rpc('uploadOs', { data });
}
async function deleteFile(dev: RpcProvider, path: string) {
await dev.rpc('deleteFile', {path});
async function deleteFile(dev: Rpc, path: string) {
await dev.rpc('deleteFile', { path });
}
async function deleteDir(dev: RpcProvider, path: string) {
await dev.rpc('deleteDir', {path});
async function deleteDir(dev: Rpc, path: string) {
await dev.rpc('deleteDir', { path });
}
async function createDir(dev: RpcProvider, path: string) {
await dev.rpc('createDir', {path});
async function createDir(dev: Rpc, path: string) {
await dev.rpc('createDir', { path });
}
async function move(dev: RpcProvider, src: string, dest: string) {
await dev.rpc('move', {src, dest});
async function move(dev: Rpc, 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 copy(dev: Rpc, 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 listDir(dev: Rpc, path: string) {
return (await dev.rpc('listDir', { path })) as FileInfo[];
}
async function listAll(dev: RpcProvider, path: FileInfo): Promise<FileInfo[]> {
async function listAll(dev: Rpc, path: FileInfo): Promise<FileInfo[]> {
if (!path.isDir) return [path];
try {
const contents = await listDir(dev, path.path);
const parts: FileInfo[] = [];
for (const file of contents) {
parts.push(
...(await listAll(dev, {...file, path: `${path.path}/${file.path}`}))
...(await listAll(dev, { ...file, path: `${path.path}/${file.path}` }))
);
}
parts.push(path);
@ -93,13 +94,12 @@ class Devices extends Vue implements GenericDevices {
hasEnumerated = false;
devices: Record<string, Device> = {};
created() {
}
created() {}
async runQueue(dev: string) {
const device = this.devices[dev];
if (!device?.queue || !device.worker || device.running) return;
const {rpc} = device.worker;
const { rpc } = device.worker;
this.$set(device, 'running', true);
// eslint-disable-next-line no-constant-condition
while (true) {
@ -116,7 +116,11 @@ class Devices extends Vue implements GenericDevices {
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()));
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()));
@ -147,7 +151,7 @@ class Devices extends Vue implements GenericDevices {
this.$set(device, 'queue', []);
}
device.queue?.push(
...cmds.map((cmd) => ({...cmd, id: queueId++} as Cmd))
...cmds.map((cmd) => ({ ...cmd, id: queueId++ } as Cmd))
);
this.runQueue(dev);
}
@ -157,7 +161,7 @@ class Devices extends Vue implements GenericDevices {
if (!navigator.usb) return;
const device = await navigator.usb.requestDevice({
filters: [
{vendorId: VID, productId: PID},
{ vendorId: VID, productId: PID },
{
vendorId: VID,
productId: PID_CX2,
@ -167,7 +171,7 @@ class Devices extends Vue implements GenericDevices {
navigator.usb.ondisconnect = (e) => {
const [key] =
Object.entries(this.devices).find(
([_, {device}]) => device === e.device
([_, { device }]) => device === e.device
) || [];
if (key) {
this.$delete(this.devices, key);
@ -194,18 +198,35 @@ class Devices extends Vue implements GenericDevices {
const rpc = new RpcProvider((message, transfer: any) =>
worker.postMessage(message, transfer)
);
worker.rpc = rpc;
worker.onmessage = ({data}) => {
worker.rpc = {
async rpc(id, payload, transfer) {
compat.lastError = undefined;
try {
return await rpc.rpc(id, payload, transfer);
} catch (e) {
console.log(e, compat.lastError);
if (compat.lastError) throw compat.lastError;
throw new DOMException(e.toString());
}
},
};
worker.onmessage = ({ data }) => {
if ('usbCmd' in data) return compat.processCmd(data);
if('total' in data) {
if ('total' in data) {
this.$set(this.devices[dev], 'progress', data);
return;
}
rpc.dispatch(data);
};
worker.compat = compat;
this.$set(this.devices[dev], 'worker', worker as WorkerExt);
await rpc.rpc('init', {id, sab, vid: device.vendorId, pid: device.productId});
await worker.rpc.rpc('init', {
id,
sab,
vid: device.vendorId,
pid: device.productId,
});
await this.update(dev);
}
@ -231,25 +252,25 @@ class Devices extends Vue implements GenericDevices {
async uploadFiles(dev: string, path: string, files: File[]) {
for (const file of files) {
this.addToQueue(dev, {action: 'upload', path, file});
this.addToQueue(dev, { action: 'upload', path, file });
}
}
async promptUploadFiles(dev: string, path: string) {
async promptUploadFiles(_dev: string, _path: string) {
throw new Error('Unimplemented');
}
async uploadOs(dev: string, filter: string) {
async uploadOs(_dev: string, _filter: string) {
throw new Error('Unimplemented');
}
async uploadOsFile(dev: string, file: File): Promise<void> {
this.addToQueue(dev, {action: 'uploadOs', file});
this.addToQueue(dev, { action: 'uploadOs', file });
}
async downloadFiles(dev: string, files: [string, number][]) {
for (const path of files) {
this.addToQueue(dev, {action: 'download', path});
this.addToQueue(dev, { action: 'download', path });
}
}
@ -269,15 +290,15 @@ class Devices extends Vue implements GenericDevices {
}
async createDir(dev: string, path: string) {
this.addToQueue(dev, {action: 'createDir', path});
this.addToQueue(dev, { action: 'createDir', path });
}
async copy(dev: string, src: string, dest: string) {
this.addToQueue(dev, {action: 'copy', src, dest});
this.addToQueue(dev, { action: 'copy', src, dest });
}
async move(dev: string, src: string, dest: string) {
this.addToQueue(dev, {action: 'move', src, dest});
this.addToQueue(dev, { action: 'move', src, dest });
}
}

View file

@ -10,14 +10,14 @@ export enum UsbError {
Unknown = 'Unknown',
}
const exceptionMap = globalThis.DOMException
const exceptionMap: Record<string, string> = 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,
NotFoundError: UsbError.NotFound,
SecurityError: UsbError.Security,
NetworkError: UsbError.Network,
AbortError: UsbError.Abort,
InvalidStateError: UsbError.InvalidState,
InvalidAccessError: UsbError.InvalidAccess,
}
: {};
@ -85,6 +85,7 @@ let count = 0;
export default class UsbCompat {
devices: Record<number, USBDevice> = {};
arr: SharedArrayBuffer;
lastError?: DOMException;
constructor(arr: SharedArrayBuffer) {
this.arr = arr;
@ -149,7 +150,10 @@ export default class UsbCompat {
return reply;
} catch (e) {
console.error(e);
return { Err: exceptionMap[e.name] || UsbError.Unknown };
this.lastError = e;
return {
Err: { [exceptionMap[e.name as string] || UsbError.Unknown]: null },
};
}
}

View file

@ -40,6 +40,6 @@ export default {
// Build Configuration (https://go.nuxtjs.dev/config-build)
build: {
transpile: ['n-link-core', 'element-ui'],
transpile: ['n-link-core', 'element-ui', 'vue-final-modal'],
},
};

View file

@ -19,7 +19,7 @@
"nuxt": "^2.14.5",
"vue-async-computed": "^3.9.0",
"vue-property-decorator": "^9.0.2",
"web-libnspire": "^0.1.4",
"web-libnspire": "^0.1.5",
"worker-rpc": "^0.2.0"
},
"devDependencies": {
@ -37,6 +37,7 @@
"eslint-plugin-nuxt": "^1.0.0",
"eslint-plugin-prettier": "^3.1.4",
"prettier": "^2.1.1",
"vue-final-modal": "^2.1.0",
"worker-loader": "^3.0.3"
}
}

View file

@ -1,8 +1,10 @@
<template>
<div class="home h-full overflow-hidden">
<ErrorMessage ref="errorMessage" />
<div class="flex flex-row h-full">
<div class="flex flex-col flex-shrink-0 border-r w-64">
<device-select
:scan-hint="webUSB"
:selected.sync="selectedCalculator"
:class="webUSB || 'opacity-50 pointer-events-none'"
/>
@ -76,12 +78,14 @@
class="flex flex-col items-center justify-center h-full select-text"
>
<p class="text-3xl">Your browser doesn't support WebUSB</p>
<p class="text-xl">
<a
href="https://lights0123.com/n-link/"
class="text-xl text-blue-600 underline"
class="text-blue-600 underline inline"
>
Check out the desktop version instead
</a>
Check out the desktop version instead</a
>, or switch to a Chrome-based browser
</p>
</div>
</div>
</div>
@ -89,11 +93,12 @@
</template>
<script lang="ts">
import { Component, Vue, Watch } from 'vue-property-decorator';
import { Component, Vue, Watch, Ref } 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';
import ErrorMessage from '@/components/ErrorMessage.vue';
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
@ -101,6 +106,7 @@ function sleep(ms: number) {
@Component({
components: {
ErrorMessage,
DeviceSelect,
FileBrowser,
CalcInfo,
@ -110,6 +116,7 @@ export default class Home extends Vue {
selectedCalculator: string | null = null;
showHidden = false;
webUSB = true;
@Ref() readonly errorMessage!: ErrorMessage;
mounted() {
this.webUSB = !!(navigator as any).usb;
@ -147,7 +154,8 @@ export default class Home extends Vue {
try {
await this.$devices.open(dev);
} catch (e) {
console.error(e);
console.error({ e });
this.errorMessage.handleError(e, 'connection');
this.selectedCalculator = null;
}
}

View file

@ -1,4 +1,6 @@
import Vue from 'vue';
import AsyncComputed from 'vue-async-computed';
import VueFinalModal from 'vue-final-modal';
if (process.client) Vue.use(VueFinalModal());
Vue.use(AsyncComputed);

6
web/types/vue-final-modal/index.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
declare module 'vue-final-modal' {
import { PluginFunction } from 'vue';
const install: () => PluginFunction<undefined>;
export default install;
}

View file

@ -13017,6 +13017,13 @@ vue-eslint-parser@^7.0.0:
esquery "^1.0.1"
lodash "^4.17.15"
vue-final-modal@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/vue-final-modal/-/vue-final-modal-2.1.0.tgz#6cb6820b37a4fb063ac3450d81f60c4153f07acf"
integrity sha512-QRZEVx6Omt2PVugIoz0k7URMJUB8bkoQ8dkROg1Ig5cXQTQaMiLonWnf/AaUHMmBh22HtG24LawwW9onv0FOZg==
dependencies:
vue "^2.6.12"
vue-hot-reload-api@^2.3.0:
version "2.3.4"
resolved "https://registry.yarnpkg.com/vue-hot-reload-api/-/vue-hot-reload-api-2.3.4.tgz#532955cc1eb208a3d990b3a9f9a70574657e08f2"
@ -13155,10 +13162,10 @@ wcwidth@^1.0.1:
dependencies:
defaults "^1.0.3"
web-libnspire@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/web-libnspire/-/web-libnspire-0.1.4.tgz#3ceee8dc3640f33806269b7ee55f379cbe8c7f7a"
integrity sha512-N4e/G4HWWjz6cZDwxpKseU/tqZ2qY6+mz+9OPBmYgVQ7NEZ+4PPdFu4pl9aNd7vGo0DqbrEz87raVK84KgoLOA==
web-libnspire@^0.1.5:
version "0.1.5"
resolved "https://registry.yarnpkg.com/web-libnspire/-/web-libnspire-0.1.5.tgz#9e7c518f594b3b11b6cba826bf89c757779fd04d"
integrity sha512-njGq7G6bol3DElbPbshxwRKq9gQa9G5XN+Lw+jCfD9bdmmk+vhn8XkR9B/ko/xU7AgamvWfilVx2bXGrVv45ig==
webidl-conversions@^5.0.0:
version "5.0.0"