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: { rules: {
'no-console': process.env.NODE_ENV === 'production' ? ['warn', { allow: ['warn', 'error'] }] : 'off', '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> <template>
<div class="header border-b px-2 py-2 flex w-full"> <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"/> <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> </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 slot="reference" class="relative w-full focus:outline-none">
<div <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"> 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> </template>
<script lang="ts"> <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 ElPopover from 'element-ui/packages/popover/src/main.vue';
import 'element-ui/lib/theme-chalk/popover.css'; import 'element-ui/lib/theme-chalk/popover.css';
@Component({components: {ElPopover}}) @Component({components: {ElPopover}})
export default class DeviceSelect extends Vue { export default class DeviceSelect extends Vue {
@PropSync('selected', {type: [String]}) selectedCalculator!: string | null; @PropSync('selected', {type: [String]}) selectedCalculator!: string | null;
@Prop({type: Boolean, default: false}) scanHint!: boolean;
active = false; active = false;
select(dev: string) { select(dev: string) {
@ -66,6 +72,25 @@ export default class DeviceSelect extends Vue {
.header { .header {
height: 50px; 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>
<style lang="scss"> <style lang="scss">

View file

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

View file

@ -16,5 +16,7 @@ module.exports = {
// add your custom rules here // add your custom rules here
rules: { rules: {
'import/no-webpack-loader-syntax': 0, '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

@ -3,18 +3,22 @@ import {RpcProvider} from 'worker-rpc';
import { saveAs } from 'file-saver'; import { saveAs } from 'file-saver';
import UsbWorker from 'worker-loader!@/components/usb.worker.ts'; import UsbWorker from 'worker-loader!@/components/usb.worker.ts';
import UsbCompat from '@/components/impl'; 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. /// The USB vendor ID used by all Nspire calculators.
const VID = 0x0451; const VID = 0x0451;
/// The USB vendor ID used by all non-CX and original CX calculators. /// The USB vendor ID used by all non-CX and original CX calculators.
const PID = 0xe012; const PID = 0xe012;
/// The USB vendor ID used by all CX II calculators. /// The USB vendor ID used by all CX II calculators.
const PID_CX2 = 0xe022; const PID_CX2 = 0xe022;
type Rpc = Pick<RpcProvider, 'rpc'>;
async function promisified(...a: any[]): Promise<any> { type WorkerExt = Worker & { rpc: Rpc; compat: UsbCompat };
}
type WorkerExt = Worker & { rpc: RpcProvider };
export type Device = { export type Device = {
device: USBDevice; device: USBDevice;
name: string; name: string;
@ -27,47 +31,44 @@ export type Device = {
running?: boolean; running?: boolean;
}; };
async function downloadFile( async function downloadFile(dev: Rpc, path: [string, number]) {
dev: RpcProvider,
path: [string, number]
) {
const data: Uint8Array = await dev.rpc('downloadFile', { path }); const data: Uint8Array = await dev.rpc('downloadFile', { path });
saveAs(new Blob([data]), path[0].split('/').pop()); saveAs(new Blob([data]), path[0].split('/').pop());
} }
async function uploadFile(dev: RpcProvider, path: string, data: Uint8Array) { async function uploadFile(dev: Rpc, path: string, data: Uint8Array) {
await dev.rpc('uploadFile', { path, data }); await dev.rpc('uploadFile', { path, data });
} }
async function uploadOs(dev: RpcProvider, data: Uint8Array) { async function uploadOs(dev: Rpc, data: Uint8Array) {
await dev.rpc('uploadOs', { data }); await dev.rpc('uploadOs', { data });
} }
async function deleteFile(dev: RpcProvider, path: string) { async function deleteFile(dev: Rpc, path: string) {
await dev.rpc('deleteFile', { path }); await dev.rpc('deleteFile', { path });
} }
async function deleteDir(dev: RpcProvider, path: string) { async function deleteDir(dev: Rpc, path: string) {
await dev.rpc('deleteDir', { path }); await dev.rpc('deleteDir', { path });
} }
async function createDir(dev: RpcProvider, path: string) { async function createDir(dev: Rpc, path: string) {
await dev.rpc('createDir', { path }); await dev.rpc('createDir', { path });
} }
async function move(dev: RpcProvider, src: string, dest: string) { async function move(dev: Rpc, src: string, dest: string) {
await dev.rpc('move', { src, dest }); await dev.rpc('move', { src, dest });
} }
async function copy(dev: RpcProvider, src: string, dest: string) { async function copy(dev: Rpc, src: string, dest: string) {
await dev.rpc('copy', { src, dest }); await dev.rpc('copy', { src, dest });
} }
async function listDir(dev: RpcProvider, path: string) { async function listDir(dev: Rpc, path: string) {
return (await dev.rpc('listDir', { path })) as FileInfo[]; 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]; if (!path.isDir) return [path];
try { try {
const contents = await listDir(dev, path.path); const contents = await listDir(dev, path.path);
@ -93,8 +94,7 @@ class Devices extends Vue implements GenericDevices {
hasEnumerated = false; hasEnumerated = false;
devices: Record<string, Device> = {}; devices: Record<string, Device> = {};
created() { created() {}
}
async runQueue(dev: string) { async runQueue(dev: string) {
const device = this.devices[dev]; const device = this.devices[dev];
@ -116,7 +116,11 @@ class Devices extends Vue implements GenericDevices {
await downloadFile(rpc, cmd.path); await downloadFile(rpc, cmd.path);
} else if (cmd.action === 'upload') { } else if (cmd.action === 'upload') {
if (!('file' in cmd)) return; 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') { } else if (cmd.action === 'uploadOs') {
if (!('file' in cmd)) return; if (!('file' in cmd)) return;
await uploadOs(rpc, new Uint8Array(await cmd.file.arrayBuffer())); await uploadOs(rpc, new Uint8Array(await cmd.file.arrayBuffer()));
@ -194,7 +198,18 @@ class Devices extends Vue implements GenericDevices {
const rpc = new RpcProvider((message, transfer: any) => const rpc = new RpcProvider((message, transfer: any) =>
worker.postMessage(message, transfer) worker.postMessage(message, transfer)
); );
worker.rpc = rpc; 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 }) => { worker.onmessage = ({ data }) => {
if ('usbCmd' in data) return compat.processCmd(data); if ('usbCmd' in data) return compat.processCmd(data);
if ('total' in data) { if ('total' in data) {
@ -203,9 +218,15 @@ class Devices extends Vue implements GenericDevices {
} }
rpc.dispatch(data); rpc.dispatch(data);
}; };
worker.compat = compat;
this.$set(this.devices[dev], 'worker', worker as WorkerExt); 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); await this.update(dev);
} }
@ -235,11 +256,11 @@ class Devices extends Vue implements GenericDevices {
} }
} }
async promptUploadFiles(dev: string, path: string) { async promptUploadFiles(_dev: string, _path: string) {
throw new Error('Unimplemented'); throw new Error('Unimplemented');
} }
async uploadOs(dev: string, filter: string) { async uploadOs(_dev: string, _filter: string) {
throw new Error('Unimplemented'); throw new Error('Unimplemented');
} }

View file

@ -10,14 +10,14 @@ export enum UsbError {
Unknown = 'Unknown', Unknown = 'Unknown',
} }
const exceptionMap = globalThis.DOMException const exceptionMap: Record<string, string> = globalThis.DOMException
? { ? {
[DOMException.NOT_FOUND_ERR]: UsbError.NotFound, NotFoundError: UsbError.NotFound,
[DOMException.SECURITY_ERR]: UsbError.Security, SecurityError: UsbError.Security,
[DOMException.NETWORK_ERR]: UsbError.Network, NetworkError: UsbError.Network,
[DOMException.ABORT_ERR]: UsbError.Abort, AbortError: UsbError.Abort,
[DOMException.INVALID_STATE_ERR]: UsbError.InvalidState, InvalidStateError: UsbError.InvalidState,
[DOMException.INVALID_ACCESS_ERR]: UsbError.InvalidAccess, InvalidAccessError: UsbError.InvalidAccess,
} }
: {}; : {};
@ -85,6 +85,7 @@ let count = 0;
export default class UsbCompat { export default class UsbCompat {
devices: Record<number, USBDevice> = {}; devices: Record<number, USBDevice> = {};
arr: SharedArrayBuffer; arr: SharedArrayBuffer;
lastError?: DOMException;
constructor(arr: SharedArrayBuffer) { constructor(arr: SharedArrayBuffer) {
this.arr = arr; this.arr = arr;
@ -149,7 +150,10 @@ export default class UsbCompat {
return reply; return reply;
} catch (e) { } catch (e) {
console.error(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 Configuration (https://go.nuxtjs.dev/config-build)
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", "nuxt": "^2.14.5",
"vue-async-computed": "^3.9.0", "vue-async-computed": "^3.9.0",
"vue-property-decorator": "^9.0.2", "vue-property-decorator": "^9.0.2",
"web-libnspire": "^0.1.4", "web-libnspire": "^0.1.5",
"worker-rpc": "^0.2.0" "worker-rpc": "^0.2.0"
}, },
"devDependencies": { "devDependencies": {
@ -37,6 +37,7 @@
"eslint-plugin-nuxt": "^1.0.0", "eslint-plugin-nuxt": "^1.0.0",
"eslint-plugin-prettier": "^3.1.4", "eslint-plugin-prettier": "^3.1.4",
"prettier": "^2.1.1", "prettier": "^2.1.1",
"vue-final-modal": "^2.1.0",
"worker-loader": "^3.0.3" "worker-loader": "^3.0.3"
} }
} }

View file

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

View file

@ -1,4 +1,6 @@
import Vue from 'vue'; import Vue from 'vue';
import AsyncComputed from 'vue-async-computed'; import AsyncComputed from 'vue-async-computed';
import VueFinalModal from 'vue-final-modal';
if (process.client) Vue.use(VueFinalModal());
Vue.use(AsyncComputed); 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" esquery "^1.0.1"
lodash "^4.17.15" 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: vue-hot-reload-api@^2.3.0:
version "2.3.4" version "2.3.4"
resolved "https://registry.yarnpkg.com/vue-hot-reload-api/-/vue-hot-reload-api-2.3.4.tgz#532955cc1eb208a3d990b3a9f9a70574657e08f2" 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: dependencies:
defaults "^1.0.3" defaults "^1.0.3"
web-libnspire@^0.1.4: web-libnspire@^0.1.5:
version "0.1.4" version "0.1.5"
resolved "https://registry.yarnpkg.com/web-libnspire/-/web-libnspire-0.1.4.tgz#3ceee8dc3640f33806269b7ee55f379cbe8c7f7a" resolved "https://registry.yarnpkg.com/web-libnspire/-/web-libnspire-0.1.5.tgz#9e7c518f594b3b11b6cba826bf89c757779fd04d"
integrity sha512-N4e/G4HWWjz6cZDwxpKseU/tqZ2qY6+mz+9OPBmYgVQ7NEZ+4PPdFu4pl9aNd7vGo0DqbrEz87raVK84KgoLOA== integrity sha512-njGq7G6bol3DElbPbshxwRKq9gQa9G5XN+Lw+jCfD9bdmmk+vhn8XkR9B/ko/xU7AgamvWfilVx2bXGrVv45ig==
webidl-conversions@^5.0.0: webidl-conversions@^5.0.0:
version "5.0.0" version "5.0.0"