mirror of
https://github.com/lights0123/n-link.git
synced 2025-08-17 22:01:32 +00:00
Compare commits
26 commits
Author | SHA1 | Date | |
---|---|---|---|
|
0472908ef4 | ||
|
89a613d25a | ||
|
a8dddd1756 | ||
|
3311e0d358 | ||
|
abd8797751 | ||
|
cdd06cb3e6 | ||
|
fd6d7d87b6 | ||
|
a08eb0b791 | ||
|
5ce4d8a560 | ||
|
ade24ce050 | ||
|
eb961b8973 | ||
|
35cc07f0ed | ||
|
75ef04b742 | ||
|
4b90a1a8da | ||
|
ac1a26a797 | ||
|
5d5b98dd59 | ||
|
b555130353 | ||
|
fd07af00e7 | ||
|
4c0b2277c6 | ||
|
a07d785ca1 | ||
|
6823ee76f6 | ||
|
f90d912568 | ||
|
d2a30f97f8 | ||
|
697a7365f3 | ||
|
7cbf54dabd | ||
|
4a957aba88 |
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
|
@ -16,6 +16,10 @@ jobs:
|
|||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install GNU tar
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
echo "/usr/local/opt/gnu-tar/libexec/gnubin" >> $GITHUB_PATH
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
|
@ -34,9 +38,9 @@ jobs:
|
|||
~/.cargo/git
|
||||
~/.cargo/bin
|
||||
desktop/src-tauri/target
|
||||
key: ${{ matrix.platform }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
key: ${{ matrix.platform }}-cargo-v2-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ matrix.platform }}-cargo-
|
||||
${{ matrix.platform }}-cargo-v2-
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
|
@ -59,9 +63,14 @@ jobs:
|
|||
sudo apt-get install -y webkit2gtk-4.0 squashfs-tools
|
||||
- name: install app dependencies
|
||||
run: yarn
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
# https://github.com/tauri-apps/tauri-action/issues/162
|
||||
- name: build app
|
||||
run: cd desktop; yarn tauri:build
|
||||
- name: fake yarn
|
||||
run: node -e "eval(process.env.COMMAND)"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
COMMAND: 'fs.writeFileSync("desktop/yarn.lock","")'
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
with:
|
||||
npmScript: "tauri:build"
|
||||
projectPath: desktop/
|
||||
|
|
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
|
@ -13,6 +13,10 @@ jobs:
|
|||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install GNU tar
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
echo "/usr/local/opt/gnu-tar/libexec/gnubin" >> $GITHUB_PATH
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
|
@ -58,9 +62,9 @@ jobs:
|
|||
~/.cargo/git
|
||||
~/.cargo/bin
|
||||
desktop/src-tauri/target
|
||||
key: ${{ matrix.platform }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
key: ${{ matrix.platform }}-cargo-v2-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ matrix.platform }}-cargo-
|
||||
${{ matrix.platform }}-cargo-v2-
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
|
@ -102,7 +106,7 @@ jobs:
|
|||
|
||||
const {repo: {owner, repo}, sha} = context;
|
||||
console.log({owner, repo, sha});
|
||||
const {name, version} = require(`${process.env.GITHUB_WORKSPACE}/package.json`);
|
||||
const {name, version} = require(`${process.env.GITHUB_WORKSPACE}/desktop/package.json`);
|
||||
const arch = process.arch === 'x64' ? 'amd64' : process.arch;
|
||||
|
||||
await github.repos.uploadReleaseAsset({
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "desktop",
|
||||
"description": "Free, cross-platform, CX-II compatible computer linking program for the TI-Nspire",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "vue-cli-service lint",
|
||||
|
@ -15,7 +15,7 @@
|
|||
"feather-icons": "^4.28.0",
|
||||
"filesize": "^6.1.0",
|
||||
"n-link-core": "0.0.0",
|
||||
"tauri": "^0.12.0",
|
||||
"@tauri-apps/api": "^1.0.0-beta.8",
|
||||
"vue": "^2.6.11",
|
||||
"vue-async-computed": "^3.9.0",
|
||||
"vue-class-component": "^7.2.3",
|
||||
|
@ -38,7 +38,7 @@
|
|||
"sass-loader": "^8.0.2",
|
||||
"typescript": "~4.0.3",
|
||||
"vue-cli-plugin-tailwind": "~1.5.0",
|
||||
"vue-cli-plugin-tauri": "~0.12.1",
|
||||
"vue-cli-plugin-tauri": "^1.0.0-beta.6",
|
||||
"vue-template-compiler": "^2.6.11"
|
||||
}
|
||||
}
|
||||
|
|
2
desktop/src-tauri/.gitignore
vendored
2
desktop/src-tauri/.gitignore
vendored
|
@ -8,3 +8,5 @@ WixTools
|
|||
|
||||
config.json
|
||||
bundle.json
|
||||
|
||||
/.cargo
|
||||
|
|
2763
desktop/src-tauri/Cargo.lock
generated
2763
desktop/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "n-link"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
description = "Free, cross-platform, CX-II compatible computer linking program for the TI-Nspire"
|
||||
authors = [ "Ben Schattinger <developer@lights0123.com>" ]
|
||||
license = "GPL-3.0"
|
||||
|
@ -16,19 +16,18 @@ libnspire = "0.2.2"
|
|||
lazy_static = "1.4.0"
|
||||
rusb = "0.6.4"
|
||||
serde = { version = "1.0", features = [ "derive" ] }
|
||||
tauri = { version = "0.9", features = [ "event", "notification", "open" ] }
|
||||
native-dialog = "0.4.3"
|
||||
tauri = { version = "1.0.0-beta.8", features = ["dialog-open", "dialog-save", "notification-all", "shell-open"] }
|
||||
clap = "3.0.0-beta.2"
|
||||
indicatif = "0.15.0"
|
||||
indicatif = "0.15"
|
||||
libusb1-sys = { version = "0.4.2", features = [ "vendored" ] }
|
||||
hashbrown = "0.9.0"
|
||||
hashbrown = "0.11"
|
||||
|
||||
[target."cfg(windows)".build-dependencies]
|
||||
winres = "0.1"
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "1.0.0-beta.4" }
|
||||
|
||||
[features]
|
||||
embedded-server = [ "tauri/embedded-server" ]
|
||||
no-server = [ "tauri/no-server" ]
|
||||
custom-protocol = [ "tauri/custom-protocol" ]
|
||||
default = [ "custom-protocol" ]
|
||||
|
||||
[[bin]]
|
||||
name = "n-link"
|
||||
|
|
|
@ -1,16 +1,3 @@
|
|||
#[cfg(windows)]
|
||||
extern crate winres;
|
||||
|
||||
#[cfg(windows)]
|
||||
fn main() {
|
||||
if std::path::Path::new("icons/icon.ico").exists() {
|
||||
let mut res = winres::WindowsResource::new();
|
||||
res.set_icon("icons/icon.ico");
|
||||
res.compile().expect("Unable to find visual studio tools");
|
||||
} else {
|
||||
panic!("No Icon.ico found. Please add one or check the path");
|
||||
}
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn main() {}
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::ffi::OsStr;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::{fs::File, path::Path};
|
||||
|
||||
use clap::Clap;
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use libnspire::{PID, PID_CX2, VID};
|
||||
use libnspire::{dir::EntryType, PID, PID_CX2, VID};
|
||||
|
||||
#[derive(Clap, Debug)]
|
||||
#[clap(author, about, version)]
|
||||
|
@ -16,9 +17,15 @@ struct Opt {
|
|||
#[derive(Clap, Debug)]
|
||||
enum SubCommand {
|
||||
Upload(Upload),
|
||||
Download(Download),
|
||||
UploadOS(UploadOS),
|
||||
Copy(Copy),
|
||||
Move(Move),
|
||||
Mkdir(Mkdir),
|
||||
Rmdir(Rmdir),
|
||||
Ls(Ls),
|
||||
/// View license information
|
||||
License,
|
||||
// Download(Download),
|
||||
}
|
||||
|
||||
/// Upload files to the calculator
|
||||
|
@ -31,32 +38,101 @@ struct Upload {
|
|||
dest: String,
|
||||
}
|
||||
|
||||
// /// Download files from the calculator
|
||||
// #[derive(Clap, Debug)]
|
||||
// struct Download {
|
||||
// /// Files to download
|
||||
// #[clap(required = true)]
|
||||
// files: Vec<String>,
|
||||
// /// Destination path
|
||||
// #[clap(parse(from_os_str))]
|
||||
// dest: PathBuf,
|
||||
// }
|
||||
/// Download files from the calculator
|
||||
#[derive(Clap, Debug)]
|
||||
struct Download {
|
||||
/// Files to download
|
||||
#[clap(required = true)]
|
||||
files: Vec<String>,
|
||||
/// Destination path
|
||||
#[clap(required = true, parse(from_os_str))]
|
||||
dest: PathBuf,
|
||||
}
|
||||
|
||||
/// Upload and install a .tcc/.tco/.tcc2/.tco2/.tct2 OS file
|
||||
#[derive(Clap, Debug)]
|
||||
struct UploadOS {
|
||||
/// Path to the OS file
|
||||
#[clap(required = true, parse(from_os_str))]
|
||||
file: PathBuf,
|
||||
|
||||
/// Disables the file extension check
|
||||
#[clap(long)]
|
||||
no_check_os: bool,
|
||||
}
|
||||
|
||||
/// Copy a file to a different location
|
||||
#[derive(Clap, Debug)]
|
||||
struct Copy {
|
||||
/// Path to file
|
||||
#[clap(required = true)]
|
||||
from_path: String,
|
||||
|
||||
/// Path to new location
|
||||
#[clap(required = true)]
|
||||
dist_path: String,
|
||||
}
|
||||
|
||||
/// Move a file or directory to a new location
|
||||
#[derive(Clap, Debug)]
|
||||
struct Move {
|
||||
/// Path to file
|
||||
#[clap(required = true)]
|
||||
from_path: String,
|
||||
|
||||
/// Path to new location
|
||||
#[clap(required = true)]
|
||||
dist_path: String,
|
||||
}
|
||||
|
||||
/// Create a directory
|
||||
#[derive(Clap, Debug)]
|
||||
struct Mkdir {
|
||||
/// Path to directory
|
||||
#[clap(required = true)]
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// Delete a directory
|
||||
#[derive(Clap, Debug)]
|
||||
struct Rmdir {
|
||||
/// Path to directory
|
||||
#[clap(required = true)]
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// List the contents of a directory
|
||||
#[derive(Clap, Debug)]
|
||||
struct Ls {
|
||||
/// Path to directory
|
||||
#[clap(required = true)]
|
||||
path: String,
|
||||
}
|
||||
|
||||
fn get_dev() -> Option<libnspire::Handle<rusb::GlobalContext>> {
|
||||
rusb::devices()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|dev| {
|
||||
.find(|dev| {
|
||||
let descriptor = match dev.device_descriptor() {
|
||||
Ok(d) => d,
|
||||
Err(_) => return false,
|
||||
};
|
||||
descriptor.vendor_id() == VID && matches!(descriptor.product_id(), PID | PID_CX2)
|
||||
})
|
||||
.next()
|
||||
.map(|dev| libnspire::Handle::new(dev.open().unwrap()).unwrap())
|
||||
}
|
||||
|
||||
pub fn cwd() -> PathBuf {
|
||||
#[cfg(target_os = "linux")]
|
||||
if std::env::var_os("APPIMAGE").is_some() && std::env::var_os("APPDIR").is_some() {
|
||||
if let Some(cwd) = std::env::var_os("OWD") {
|
||||
return cwd.into();
|
||||
}
|
||||
};
|
||||
std::env::current_dir().expect("Couldn't get current directory")
|
||||
}
|
||||
|
||||
pub fn run() -> bool {
|
||||
let opt: Opt = Opt::parse();
|
||||
if let Some(cmd) = opt.cmd {
|
||||
|
@ -65,25 +141,242 @@ pub fn run() -> bool {
|
|||
if let Some(handle) = get_dev() {
|
||||
for file in files {
|
||||
let mut buf = vec![];
|
||||
File::open(&file).unwrap().read_to_end(&mut buf).unwrap();
|
||||
File::open(cwd().join(&file))
|
||||
.unwrap()
|
||||
.read_to_end(&mut buf)
|
||||
.unwrap();
|
||||
let name = file
|
||||
.file_name()
|
||||
.expect("Failed to get file name")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let bar = ProgressBar::new(buf.len() as u64);
|
||||
bar.set_style(ProgressStyle::default_bar().template("{spinner:.green} {msg}[{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})"));
|
||||
bar.set_style(ProgressStyle::default_bar().template("{spinner:.green} {msg} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})"));
|
||||
bar.set_message(&format!("Upload {}", name));
|
||||
bar.enable_steady_tick(100);
|
||||
if dest.ends_with('/') {
|
||||
dest.remove(dest.len() - 1);
|
||||
}
|
||||
handle
|
||||
.write_file(&format!("{}/{}", dest, name), &buf, &mut |remaining| {
|
||||
bar.set_position((buf.len() - remaining) as u64)
|
||||
})
|
||||
.unwrap();
|
||||
bar.finish();
|
||||
let res = handle.write_file(&format!("{}/{}", dest, name), &buf, &mut |remaining| {
|
||||
bar.set_position((buf.len() - remaining) as u64)
|
||||
});
|
||||
|
||||
match res {
|
||||
Ok(_) => {
|
||||
bar.finish_with_message(&format!("Upload {}: Ok", dest));
|
||||
}
|
||||
Err(error) => {
|
||||
bar.abandon_with_message(&format!("Failed: {}", error));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("Couldn't find any device");
|
||||
}
|
||||
}
|
||||
SubCommand::Download(Download { dest, files }) => {
|
||||
if let Some(handle) = get_dev() {
|
||||
for file in files {
|
||||
let attr = handle.file_attr(&file);
|
||||
match attr {
|
||||
Ok(attr) => {
|
||||
let path = Path::new(&file);
|
||||
let dest_path = Path::join(&dest, path.file_name().unwrap().to_str().unwrap());
|
||||
match File::create(dest_path) {
|
||||
Ok(mut dest_file) => {
|
||||
let mut buf = vec![0u8; attr.size() as usize];
|
||||
|
||||
let bar = ProgressBar::new(buf.len() as u64);
|
||||
bar.set_style(ProgressStyle::default_bar().template("{spinner:.green} {msg} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})"));
|
||||
bar.set_message(&format!(
|
||||
"Download {}",
|
||||
path.file_name().unwrap().to_str().unwrap()
|
||||
));
|
||||
bar.enable_steady_tick(100);
|
||||
|
||||
let len = buf.len();
|
||||
|
||||
let res = handle.read_file(&file, &mut buf, &mut |remaining| {
|
||||
bar.set_position((len - remaining) as u64);
|
||||
});
|
||||
|
||||
match res {
|
||||
Ok(_) => {
|
||||
bar.set_message("Writing file to disk");
|
||||
|
||||
match dest_file.write_all(&buf) {
|
||||
Ok(_) => {
|
||||
bar.finish_with_message("Transfer completed");
|
||||
}
|
||||
Err(error) => {
|
||||
bar.abandon_with_message(&format!(
|
||||
"Failed to write file to disk: {}",
|
||||
error
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
bar.abandon_with_message(&format!("Failed to transfer file: {}", error))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("Failed to open destination file: {}", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("Failed to read file info: {}", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("Couldn't find any device");
|
||||
}
|
||||
}
|
||||
SubCommand::UploadOS(UploadOS { file, no_check_os }) => {
|
||||
if let Some(handle) = get_dev() {
|
||||
let calc_info = handle.info().expect("Failed to obtain device info");
|
||||
|
||||
let file_ext = file
|
||||
.extension()
|
||||
.unwrap_or(OsStr::new(""))
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let mut buf = vec![];
|
||||
let mut f = File::open(cwd().join(&file)).unwrap_or_else(|err| {
|
||||
eprintln!("Failed to open file: {}", err);
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
if format!(".{}", file_ext) != calc_info.os_extension {
|
||||
if no_check_os {
|
||||
eprintln!(
|
||||
"Warning: {} expects file of type {}",
|
||||
calc_info.name, calc_info.os_extension
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"Error: {} expects file of type {}",
|
||||
calc_info.name, calc_info.os_extension
|
||||
);
|
||||
eprintln!("Provide --no-check-os to bypass this check.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
f.read_to_end(&mut buf).unwrap();
|
||||
|
||||
let name = file
|
||||
.file_name()
|
||||
.expect("Failed to get file name")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let bar = ProgressBar::new(buf.len() as u64);
|
||||
bar.set_style(ProgressStyle::default_bar().template("{spinner:.green} {msg} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})"));
|
||||
bar.set_message(&format!("Upload OS {}", name));
|
||||
bar.enable_steady_tick(100);
|
||||
|
||||
let res = handle.send_os(&buf, &mut |remaining| {
|
||||
bar.set_position((buf.len() - remaining) as u64);
|
||||
});
|
||||
|
||||
match res {
|
||||
Ok(_) => {
|
||||
bar.finish();
|
||||
}
|
||||
Err(error) => {
|
||||
bar.abandon_with_message(&format!("OS Upload failed: {}", error));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("Couldn't find any device");
|
||||
}
|
||||
}
|
||||
SubCommand::Copy(Copy {
|
||||
from_path,
|
||||
dist_path,
|
||||
}) => {
|
||||
if let Some(handle) = get_dev() {
|
||||
match handle.copy_file(&from_path, &dist_path) {
|
||||
Ok(_) => {
|
||||
println!("Copy {} => {}: Ok", from_path, dist_path);
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("Failed to copy file or directory: {}", error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("Couldn't find any device");
|
||||
}
|
||||
}
|
||||
SubCommand::Move(Move {
|
||||
from_path,
|
||||
dist_path,
|
||||
}) => {
|
||||
if let Some(handle) = get_dev() {
|
||||
match handle.move_file(&from_path, &dist_path) {
|
||||
Ok(_) => {
|
||||
println!("Move {} => {}: Ok", from_path, dist_path);
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("Failed to move file or directory: {}", error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("Couldn't find any device");
|
||||
}
|
||||
}
|
||||
SubCommand::Mkdir(Mkdir { path }) => {
|
||||
if let Some(handle) = get_dev() {
|
||||
match handle.create_dir(&path) {
|
||||
Ok(_) => {
|
||||
println!("Create {}: Ok", path);
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("Failed to create directory: {}", error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("Couldn't find any device");
|
||||
}
|
||||
}
|
||||
SubCommand::Rmdir(Rmdir { path }) => {
|
||||
if let Some(handle) = get_dev() {
|
||||
match handle.delete_dir(&path) {
|
||||
Ok(_) => {
|
||||
println!("Remove {}: Ok", path);
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("Failed to delete directory: {}", error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("Couldn't find any device");
|
||||
}
|
||||
}
|
||||
SubCommand::Ls(Ls { path }) => {
|
||||
if let Some(handle) = get_dev() {
|
||||
match handle.list_dir(&path) {
|
||||
Ok(dir_list) => {
|
||||
for item in dir_list.iter() {
|
||||
println!(
|
||||
"{}{}",
|
||||
item.name().to_str().unwrap(),
|
||||
if item.entry_type() == EntryType::Directory {
|
||||
"/"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("Failed to list directory: {}", error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("Couldn't find any device");
|
||||
|
|
|
@ -2,18 +2,12 @@ use std::sync::Arc;
|
|||
use std::time::Duration;
|
||||
|
||||
use libnspire::{PID, PID_CX2, VID};
|
||||
use rusb::{DeviceHandle, Error, GlobalContext};
|
||||
use rusb::GlobalContext;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{Runtime, Window};
|
||||
|
||||
use crate::{Device, DeviceState};
|
||||
use tauri::WebviewMut;
|
||||
use crate::{Device, DeviceState, SerializedError};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Promise {
|
||||
pub callback: String,
|
||||
pub error: String,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Debug, Hash)]
|
||||
|
@ -21,131 +15,6 @@ pub struct DevId {
|
|||
pub bus_number: u8,
|
||||
pub address: u8,
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "cmd", rename_all = "camelCase")]
|
||||
pub enum Cmd {
|
||||
// your custom commands
|
||||
// multiple arguments are allowed
|
||||
// note that rename_all = "camelCase": you need to use "myCustomCommand" on JS
|
||||
Enumerate {
|
||||
#[serde(flatten)]
|
||||
promise: Promise,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
OpenDevice {
|
||||
#[serde(flatten)]
|
||||
promise: Promise,
|
||||
#[serde(flatten)]
|
||||
dev: DevId,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
CloseDevice {
|
||||
#[serde(flatten)]
|
||||
promise: Promise,
|
||||
#[serde(flatten)]
|
||||
dev: DevId,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
UpdateDevice {
|
||||
#[serde(flatten)]
|
||||
promise: Promise,
|
||||
#[serde(flatten)]
|
||||
dev: DevId,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ListDir {
|
||||
#[serde(flatten)]
|
||||
promise: Promise,
|
||||
#[serde(flatten)]
|
||||
dev: DevId,
|
||||
path: String,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
DownloadFile {
|
||||
#[serde(flatten)]
|
||||
promise: Promise,
|
||||
#[serde(flatten)]
|
||||
dev: DevId,
|
||||
path: (String, u64),
|
||||
dest: String,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
UploadFile {
|
||||
#[serde(flatten)]
|
||||
promise: Promise,
|
||||
#[serde(flatten)]
|
||||
dev: DevId,
|
||||
path: String,
|
||||
src: String,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
UploadOs {
|
||||
#[serde(flatten)]
|
||||
promise: Promise,
|
||||
#[serde(flatten)]
|
||||
dev: DevId,
|
||||
src: String,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
DeleteFile {
|
||||
#[serde(flatten)]
|
||||
promise: Promise,
|
||||
#[serde(flatten)]
|
||||
dev: DevId,
|
||||
path: String,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
DeleteDir {
|
||||
#[serde(flatten)]
|
||||
promise: Promise,
|
||||
#[serde(flatten)]
|
||||
dev: DevId,
|
||||
path: String,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
CreateNspireDir {
|
||||
#[serde(flatten)]
|
||||
promise: Promise,
|
||||
#[serde(flatten)]
|
||||
dev: DevId,
|
||||
path: String,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Move {
|
||||
#[serde(flatten)]
|
||||
promise: Promise,
|
||||
#[serde(flatten)]
|
||||
dev: DevId,
|
||||
src: String,
|
||||
dest: String,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Copy {
|
||||
#[serde(flatten)]
|
||||
promise: Promise,
|
||||
#[serde(flatten)]
|
||||
dev: DevId,
|
||||
src: String,
|
||||
dest: String,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
SelectFile {
|
||||
#[serde(flatten)]
|
||||
promise: Promise,
|
||||
filter: Vec<String>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
SelectFiles {
|
||||
#[serde(flatten)]
|
||||
promise: Promise,
|
||||
filter: Vec<String>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
SelectFolder {
|
||||
#[serde(flatten)]
|
||||
promise: Promise,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn add_device(dev: Arc<rusb::Device<GlobalContext>>) -> rusb::Result<((u8, u8), Device)> {
|
||||
let descriptor = dev.device_descriptor()?;
|
||||
|
@ -162,7 +31,7 @@ pub fn add_device(dev: Arc<rusb::Device<GlobalContext>>) -> rusb::Result<((u8, u
|
|||
)?,
|
||||
false,
|
||||
),
|
||||
Err(rusb::Error::NotSupported) => (
|
||||
Err(rusb::Error::NotSupported) | Err(rusb::Error::Access) => (
|
||||
if descriptor.product_id() == PID_CX2 {
|
||||
"TI-Nspire CX II"
|
||||
} else {
|
||||
|
@ -185,7 +54,8 @@ pub fn add_device(dev: Arc<rusb::Device<GlobalContext>>) -> rusb::Result<((u8, u
|
|||
))
|
||||
}
|
||||
|
||||
pub fn enumerate(handle: &mut WebviewMut) -> Result<Vec<AddDevice>, libnspire::Error> {
|
||||
#[tauri::command]
|
||||
pub fn enumerate<R: Runtime>(handle: Window<R>) -> Result<Vec<AddDevice>, SerializedError> {
|
||||
let devices: Vec<_> = rusb::devices()?.iter().collect();
|
||||
let mut map = crate::DEVICES.write().unwrap();
|
||||
map
|
||||
|
@ -195,13 +65,12 @@ pub fn enumerate(handle: &mut WebviewMut) -> Result<Vec<AddDevice>, libnspire::E
|
|||
.all(|d| d.bus_number() != k.0 || d.address() != k.1)
|
||||
})
|
||||
.for_each(|d| {
|
||||
if let Err(msg) = tauri::event::emit(
|
||||
handle,
|
||||
if let Err(msg) = handle.emit(
|
||||
"removeDevice",
|
||||
Some(DevId {
|
||||
DevId {
|
||||
bus_number: (d.0).0,
|
||||
address: (d.0).1,
|
||||
}),
|
||||
},
|
||||
) {
|
||||
eprintln!("{}", msg);
|
||||
}
|
||||
|
|
|
@ -3,25 +3,20 @@
|
|||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
use hashbrown::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use libnspire::dir::EntryType;
|
||||
use hashbrown::HashMap;
|
||||
use libnspire::{PID_CX2, VID};
|
||||
use native_dialog::Dialog;
|
||||
use rusb::{GlobalContext, Hotplug, UsbContext};
|
||||
use tauri::WebviewMut;
|
||||
use serde::Serialize;
|
||||
use tauri::{Runtime, Window};
|
||||
|
||||
use crate::cmd::{add_device, AddDevice, DevId, FileInfo, ProgressUpdate};
|
||||
use crate::promise::promise_fn;
|
||||
use crate::cmd::{add_device, AddDevice, DevId, ProgressUpdate};
|
||||
|
||||
mod cli;
|
||||
mod cmd;
|
||||
mod promise;
|
||||
|
||||
pub enum DeviceState {
|
||||
Open(
|
||||
|
@ -40,13 +35,13 @@ pub struct Device {
|
|||
lazy_static::lazy_static! {
|
||||
static ref DEVICES: RwLock<HashMap<(u8, u8), Device>> = RwLock::new(HashMap::new());
|
||||
}
|
||||
struct DeviceMon {
|
||||
handle: WebviewMut,
|
||||
struct DeviceMon<R: Runtime> {
|
||||
window: Window<R>,
|
||||
}
|
||||
|
||||
impl Hotplug<GlobalContext> for DeviceMon {
|
||||
impl<R: Runtime> Hotplug<GlobalContext> for DeviceMon<R> {
|
||||
fn device_arrived(&mut self, device: rusb::Device<GlobalContext>) {
|
||||
let mut handle = self.handle.clone();
|
||||
let handle = self.window.clone();
|
||||
let is_cx_ii = device
|
||||
.device_descriptor()
|
||||
.map(|d| d.product_id() == PID_CX2)
|
||||
|
@ -58,10 +53,9 @@ impl Hotplug<GlobalContext> for DeviceMon {
|
|||
let name = (dev.1).name.clone();
|
||||
let needs_drivers = (dev.1).needs_drivers;
|
||||
DEVICES.write().unwrap().insert(dev.0, dev.1);
|
||||
if let Err(msg) = tauri::event::emit(
|
||||
&mut handle,
|
||||
if let Err(msg) = handle.emit(
|
||||
"addDevice",
|
||||
Some(AddDevice {
|
||||
AddDevice {
|
||||
dev: DevId {
|
||||
bus_number: (dev.0).0,
|
||||
address: (dev.0).1,
|
||||
|
@ -69,7 +63,7 @@ impl Hotplug<GlobalContext> for DeviceMon {
|
|||
name,
|
||||
is_cx_ii,
|
||||
needs_drivers,
|
||||
}),
|
||||
},
|
||||
) {
|
||||
eprintln!("{}", msg);
|
||||
};
|
||||
|
@ -93,13 +87,12 @@ impl Hotplug<GlobalContext> for DeviceMon {
|
|||
.unwrap()
|
||||
.remove_entry(&(device.bus_number(), device.address()))
|
||||
{
|
||||
if let Err(msg) = tauri::event::emit(
|
||||
&mut self.handle,
|
||||
if let Err(msg) = self.window.emit(
|
||||
"removeDevice",
|
||||
Some(DevId {
|
||||
DevId {
|
||||
bus_number: dev.0,
|
||||
address: dev.1,
|
||||
}),
|
||||
},
|
||||
) {
|
||||
eprintln!("{}", msg);
|
||||
};
|
||||
|
@ -107,42 +100,41 @@ impl Hotplug<GlobalContext> for DeviceMon {
|
|||
}
|
||||
}
|
||||
|
||||
fn err_wrap<T>(
|
||||
fn err_wrap<T, R: Runtime>(
|
||||
res: Result<T, libnspire::Error>,
|
||||
dev: DevId,
|
||||
handle: &mut WebviewMut,
|
||||
window: &Window<R>,
|
||||
) -> Result<T, libnspire::Error> {
|
||||
if let Err(libnspire::Error::NoDevice) = res {
|
||||
DEVICES
|
||||
.write()
|
||||
.unwrap()
|
||||
.remove(&(dev.bus_number, dev.address));
|
||||
if let Err(msg) = tauri::event::emit(handle, "removeDevice", Some(dev)) {
|
||||
if let Err(msg) = window.emit("removeDevice", dev) {
|
||||
eprintln!("{}", msg);
|
||||
};
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
fn progress_sender<'a>(
|
||||
handle: &'a mut WebviewMut,
|
||||
fn progress_sender<R: Runtime>(
|
||||
window: &Window<R>,
|
||||
dev: DevId,
|
||||
total: usize,
|
||||
) -> impl FnMut(usize) + 'a {
|
||||
) -> impl FnMut(usize) + '_ {
|
||||
let mut i = 0;
|
||||
move |remaining| {
|
||||
if i > 5 {
|
||||
i = 0;
|
||||
}
|
||||
if i == 0 || remaining == 0 {
|
||||
if let Err(msg) = tauri::event::emit(
|
||||
handle,
|
||||
if let Err(msg) = window.emit(
|
||||
"progress",
|
||||
Some(ProgressUpdate {
|
||||
ProgressUpdate {
|
||||
dev,
|
||||
remaining,
|
||||
total,
|
||||
}),
|
||||
},
|
||||
) {
|
||||
eprintln!("{}", msg);
|
||||
};
|
||||
|
@ -164,321 +156,323 @@ fn get_open_dev(
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SerializedError(String);
|
||||
|
||||
impl<T: std::fmt::Display> From<T> for SerializedError {
|
||||
fn from(f: T) -> Self {
|
||||
SerializedError(f.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
mod invoked {
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use libnspire::dir::EntryType;
|
||||
use serde::Serialize;
|
||||
use tauri::{Runtime, Window};
|
||||
|
||||
use crate::cmd::{DevId, FileInfo};
|
||||
use crate::{err_wrap, get_open_dev, progress_sender, DeviceState, SerializedError};
|
||||
|
||||
use super::DEVICES;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_device(bus_number: u8, address: u8) -> Result<impl Serialize, SerializedError> {
|
||||
let device = if let Some(dev) = DEVICES.read().unwrap().get(&(bus_number, address)) {
|
||||
if !matches!(dev.state, DeviceState::Closed) {
|
||||
return Err("Already open".into());
|
||||
};
|
||||
dev.device.clone()
|
||||
} else {
|
||||
return Err("Failed to find device".into());
|
||||
};
|
||||
let handle = libnspire::Handle::new(device.open()?)?;
|
||||
let info = handle.info()?;
|
||||
{
|
||||
let mut guard = DEVICES.write().unwrap();
|
||||
let device = guard
|
||||
.get_mut(&(bus_number, address))
|
||||
.ok_or_else(|| anyhow::anyhow!("Device lost"))?;
|
||||
device.state = DeviceState::Open(Arc::new(Mutex::new(handle)), info.clone());
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn close_device(bus_number: u8, address: u8) -> Result<impl Serialize, SerializedError> {
|
||||
let mut guard = DEVICES.write().unwrap();
|
||||
let device = guard
|
||||
.get_mut(&(bus_number, address))
|
||||
.ok_or_else(|| anyhow::anyhow!("Device lost"))?;
|
||||
device.state = DeviceState::Closed;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_device<R: Runtime>(
|
||||
bus_number: u8,
|
||||
address: u8,
|
||||
window: Window<R>,
|
||||
) -> Result<impl Serialize, SerializedError> {
|
||||
let dev = DevId {
|
||||
bus_number,
|
||||
address,
|
||||
};
|
||||
let handle = get_open_dev(&dev)?;
|
||||
let handle = handle.lock().unwrap();
|
||||
let info = err_wrap(handle.info(), dev, &window)?;
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_dir<R: Runtime>(
|
||||
bus_number: u8,
|
||||
address: u8,
|
||||
path: String,
|
||||
window: Window<R>,
|
||||
) -> Result<impl Serialize, SerializedError> {
|
||||
let dev = DevId {
|
||||
bus_number,
|
||||
address,
|
||||
};
|
||||
let handle = get_open_dev(&dev)?;
|
||||
let handle = handle.lock().unwrap();
|
||||
let dir = err_wrap(handle.list_dir(&path), dev, &window)?;
|
||||
|
||||
Ok(
|
||||
dir
|
||||
.iter()
|
||||
.map(|file| FileInfo {
|
||||
path: file.name().to_string_lossy().to_string(),
|
||||
is_dir: file.entry_type() == EntryType::Directory,
|
||||
date: file.date(),
|
||||
size: file.size(),
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn download_file<R: Runtime>(
|
||||
bus_number: u8,
|
||||
address: u8,
|
||||
path: (String, u64),
|
||||
dest: String,
|
||||
window: Window<R>,
|
||||
) -> Result<impl Serialize, SerializedError> {
|
||||
let dev = DevId {
|
||||
bus_number,
|
||||
address,
|
||||
};
|
||||
let (file, size) = path;
|
||||
let dest = PathBuf::from(dest);
|
||||
let handle = get_open_dev(&dev)?;
|
||||
let handle = handle.lock().unwrap();
|
||||
let mut buf = vec![0; size as usize];
|
||||
err_wrap(
|
||||
handle.read_file(
|
||||
&file,
|
||||
&mut buf,
|
||||
&mut progress_sender(&window, dev, size as usize),
|
||||
),
|
||||
dev,
|
||||
&window,
|
||||
)?;
|
||||
if let Some(name) = file.split('/').last() {
|
||||
File::create(dest.join(name))?.write_all(&buf)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn upload_file<R: Runtime>(
|
||||
bus_number: u8,
|
||||
address: u8,
|
||||
path: String,
|
||||
src: String,
|
||||
window: Window<R>,
|
||||
) -> Result<impl Serialize, SerializedError> {
|
||||
let dev = DevId {
|
||||
bus_number,
|
||||
address,
|
||||
};
|
||||
let file = PathBuf::from(src);
|
||||
let handle = get_open_dev(&dev)?;
|
||||
let handle = handle.lock().unwrap();
|
||||
let mut buf = vec![];
|
||||
File::open(&file)?.read_to_end(&mut buf)?;
|
||||
let name = file
|
||||
.file_name()
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to get file name"))?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
err_wrap(
|
||||
handle.write_file(
|
||||
&format!("{}/{}", path, name),
|
||||
&buf,
|
||||
&mut progress_sender(&window, dev, buf.len()),
|
||||
),
|
||||
dev,
|
||||
&window,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn upload_os<R: Runtime>(
|
||||
bus_number: u8,
|
||||
address: u8,
|
||||
src: String,
|
||||
window: Window<R>,
|
||||
) -> Result<impl Serialize, SerializedError> {
|
||||
let dev = DevId {
|
||||
bus_number,
|
||||
address,
|
||||
};
|
||||
let handle = get_open_dev(&dev)?;
|
||||
let handle = handle.lock().unwrap();
|
||||
let mut buf = vec![];
|
||||
File::open(&src)?.read_to_end(&mut buf)?;
|
||||
err_wrap(
|
||||
handle.send_os(&buf, &mut progress_sender(&window, dev, buf.len())),
|
||||
dev,
|
||||
&window,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_file<R: Runtime>(
|
||||
bus_number: u8,
|
||||
address: u8,
|
||||
path: String,
|
||||
window: Window<R>,
|
||||
) -> Result<impl Serialize, SerializedError> {
|
||||
let dev = DevId {
|
||||
bus_number,
|
||||
address,
|
||||
};
|
||||
let handle = get_open_dev(&dev)?;
|
||||
let handle = handle.lock().unwrap();
|
||||
err_wrap(handle.delete_file(&path), dev, &window)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_dir<R: Runtime>(
|
||||
bus_number: u8,
|
||||
address: u8,
|
||||
path: String,
|
||||
window: Window<R>,
|
||||
) -> Result<impl Serialize, SerializedError> {
|
||||
let dev = DevId {
|
||||
bus_number,
|
||||
address,
|
||||
};
|
||||
let handle = get_open_dev(&dev)?;
|
||||
let handle = handle.lock().unwrap();
|
||||
err_wrap(handle.delete_dir(&path), dev, &window)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_nspire_dir<R: Runtime>(
|
||||
bus_number: u8,
|
||||
address: u8,
|
||||
path: String,
|
||||
window: Window<R>,
|
||||
) -> Result<impl Serialize, SerializedError> {
|
||||
let dev = DevId {
|
||||
bus_number,
|
||||
address,
|
||||
};
|
||||
let handle = get_open_dev(&dev)?;
|
||||
let handle = handle.lock().unwrap();
|
||||
err_wrap(handle.create_dir(&path), dev, &window)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn move_file<R: Runtime>(
|
||||
bus_number: u8,
|
||||
address: u8,
|
||||
src: String,
|
||||
dest: String,
|
||||
window: Window<R>,
|
||||
) -> Result<impl Serialize, SerializedError> {
|
||||
let dev = DevId {
|
||||
bus_number,
|
||||
address,
|
||||
};
|
||||
let handle = get_open_dev(&dev)?;
|
||||
let handle = handle.lock().unwrap();
|
||||
err_wrap(handle.move_file(&src, &dest), dev, &window)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn copy<R: Runtime>(
|
||||
bus_number: u8,
|
||||
address: u8,
|
||||
src: String,
|
||||
dest: String,
|
||||
window: Window<R>,
|
||||
) -> Result<impl Serialize, SerializedError> {
|
||||
let dev = DevId {
|
||||
bus_number,
|
||||
address,
|
||||
};
|
||||
let handle = get_open_dev(&dev)?;
|
||||
let handle = handle.lock().unwrap();
|
||||
err_wrap(handle.copy_file(&src, &dest), dev, &window)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if cli::run() {
|
||||
return;
|
||||
}
|
||||
let mut has_registered_callback = false;
|
||||
tauri::AppBuilder::new()
|
||||
.invoke_handler(move |webview, arg| {
|
||||
use cmd::Cmd::*;
|
||||
match serde_json::from_str(arg) {
|
||||
Err(e) => Err(e.to_string()),
|
||||
Ok(command) => {
|
||||
let mut wv_handle = webview.as_mut();
|
||||
match command {
|
||||
Enumerate { promise } => {
|
||||
if !has_registered_callback {
|
||||
has_registered_callback = true;
|
||||
if rusb::has_hotplug() {
|
||||
if let Err(msg) = GlobalContext::default().register_callback(
|
||||
Some(VID),
|
||||
None,
|
||||
None,
|
||||
Box::new(DeviceMon {
|
||||
handle: webview.as_mut(),
|
||||
}),
|
||||
) {
|
||||
eprintln!("{}", msg);
|
||||
};
|
||||
std::thread::spawn(|| loop {
|
||||
GlobalContext::default().handle_events(None).unwrap();
|
||||
});
|
||||
} else {
|
||||
println!("no hotplug");
|
||||
}
|
||||
}
|
||||
promise_fn(
|
||||
webview,
|
||||
move || Ok(cmd::enumerate(&mut wv_handle)?),
|
||||
promise,
|
||||
);
|
||||
}
|
||||
OpenDevice { promise, dev } => {
|
||||
promise_fn(
|
||||
webview,
|
||||
move || {
|
||||
let device = if let Some(dev) =
|
||||
DEVICES.read().unwrap().get(&(dev.bus_number, dev.address))
|
||||
{
|
||||
anyhow::ensure!(matches!(dev.state, DeviceState::Closed), "Already open");
|
||||
dev.device.clone()
|
||||
} else {
|
||||
anyhow::bail!("Failed to find device");
|
||||
};
|
||||
let handle = libnspire::Handle::new(device.open()?)?;
|
||||
let info = handle.info()?;
|
||||
{
|
||||
let mut guard = DEVICES.write().unwrap();
|
||||
let device = guard
|
||||
.get_mut(&(dev.bus_number, dev.address))
|
||||
.ok_or_else(|| anyhow::anyhow!("Device lost"))?;
|
||||
device.state = DeviceState::Open(Arc::new(Mutex::new(handle)), info.clone());
|
||||
}
|
||||
Ok(info)
|
||||
},
|
||||
promise,
|
||||
);
|
||||
}
|
||||
CloseDevice { promise, dev } => {
|
||||
promise_fn(
|
||||
webview,
|
||||
move || {
|
||||
{
|
||||
let mut guard = DEVICES.write().unwrap();
|
||||
let device = guard
|
||||
.get_mut(&(dev.bus_number, dev.address))
|
||||
.ok_or_else(|| anyhow::anyhow!("Device lost"))?;
|
||||
device.state = DeviceState::Closed;
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
promise,
|
||||
);
|
||||
}
|
||||
UpdateDevice { promise, dev } => {
|
||||
promise_fn(
|
||||
webview,
|
||||
move || {
|
||||
let handle = get_open_dev(&dev)?;
|
||||
let handle = handle.lock().unwrap();
|
||||
let info = err_wrap(handle.info(), dev, &mut wv_handle)?;
|
||||
Ok(info)
|
||||
},
|
||||
promise,
|
||||
);
|
||||
}
|
||||
ListDir { promise, dev, path } => {
|
||||
promise_fn(
|
||||
webview,
|
||||
move || {
|
||||
let handle = get_open_dev(&dev)?;
|
||||
let handle = handle.lock().unwrap();
|
||||
let dir = err_wrap(handle.list_dir(&path), dev, &mut wv_handle)?;
|
||||
|
||||
Ok(
|
||||
dir
|
||||
.iter()
|
||||
.map(|file| FileInfo {
|
||||
path: file.name().to_string_lossy().to_string(),
|
||||
is_dir: file.entry_type() == EntryType::Directory,
|
||||
date: file.date(),
|
||||
size: file.size(),
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
},
|
||||
promise,
|
||||
);
|
||||
}
|
||||
DownloadFile {
|
||||
promise,
|
||||
dev,
|
||||
path: (file, size),
|
||||
dest,
|
||||
} => {
|
||||
promise_fn(
|
||||
webview,
|
||||
move || {
|
||||
let dest = PathBuf::from(dest);
|
||||
let handle = get_open_dev(&dev)?;
|
||||
let handle = handle.lock().unwrap();
|
||||
let mut buf = vec![0; size as usize];
|
||||
err_wrap(
|
||||
handle.read_file(
|
||||
&file,
|
||||
&mut buf,
|
||||
&mut progress_sender(&mut wv_handle.clone(), dev, size as usize),
|
||||
),
|
||||
dev,
|
||||
&mut wv_handle,
|
||||
)?;
|
||||
if let Some(name) = file.split('/').last() {
|
||||
File::create(dest.join(name))?.write_all(&buf)?;
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
promise,
|
||||
);
|
||||
}
|
||||
UploadFile {
|
||||
promise,
|
||||
dev,
|
||||
path,
|
||||
src,
|
||||
} => {
|
||||
promise_fn(
|
||||
webview,
|
||||
move || {
|
||||
let file = PathBuf::from(src);
|
||||
let handle = get_open_dev(&dev)?;
|
||||
let handle = handle.lock().unwrap();
|
||||
let mut buf = vec![];
|
||||
File::open(&file)?.read_to_end(&mut buf)?;
|
||||
let name = file
|
||||
.file_name()
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to get file name"))?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
err_wrap(
|
||||
handle.write_file(
|
||||
&format!("{}/{}", path, name),
|
||||
&buf,
|
||||
&mut progress_sender(&mut wv_handle.clone(), dev, buf.len()),
|
||||
),
|
||||
dev,
|
||||
&mut wv_handle,
|
||||
)?;
|
||||
Ok(())
|
||||
},
|
||||
promise,
|
||||
);
|
||||
}
|
||||
UploadOs { promise, dev, src } => {
|
||||
promise_fn(
|
||||
webview,
|
||||
move || {
|
||||
let handle = get_open_dev(&dev)?;
|
||||
let handle = handle.lock().unwrap();
|
||||
let mut buf = vec![];
|
||||
File::open(&src)?.read_to_end(&mut buf)?;
|
||||
err_wrap(
|
||||
handle.send_os(
|
||||
&buf,
|
||||
&mut progress_sender(&mut wv_handle.clone(), dev, buf.len()),
|
||||
),
|
||||
dev,
|
||||
&mut wv_handle,
|
||||
)?;
|
||||
Ok(())
|
||||
},
|
||||
promise,
|
||||
);
|
||||
}
|
||||
DeleteFile { promise, dev, path } => {
|
||||
promise_fn(
|
||||
webview,
|
||||
move || {
|
||||
let handle = get_open_dev(&dev)?;
|
||||
let handle = handle.lock().unwrap();
|
||||
err_wrap(handle.delete_file(&path), dev, &mut wv_handle)?;
|
||||
Ok(())
|
||||
},
|
||||
promise,
|
||||
);
|
||||
}
|
||||
DeleteDir { promise, dev, path } => {
|
||||
promise_fn(
|
||||
webview,
|
||||
move || {
|
||||
let handle = get_open_dev(&dev)?;
|
||||
let handle = handle.lock().unwrap();
|
||||
err_wrap(handle.delete_dir(&path), dev, &mut wv_handle)?;
|
||||
Ok(())
|
||||
},
|
||||
promise,
|
||||
);
|
||||
}
|
||||
CreateNspireDir { promise, dev, path } => {
|
||||
promise_fn(
|
||||
webview,
|
||||
move || {
|
||||
let handle = get_open_dev(&dev)?;
|
||||
let handle = handle.lock().unwrap();
|
||||
err_wrap(handle.create_dir(&path), dev, &mut wv_handle)?;
|
||||
Ok(())
|
||||
},
|
||||
promise,
|
||||
);
|
||||
}
|
||||
Move {
|
||||
promise,
|
||||
dev,
|
||||
src,
|
||||
dest,
|
||||
} => {
|
||||
promise_fn(
|
||||
webview,
|
||||
move || {
|
||||
let handle = get_open_dev(&dev)?;
|
||||
let handle = handle.lock().unwrap();
|
||||
err_wrap(handle.move_file(&src, &dest), dev, &mut wv_handle)?;
|
||||
Ok(())
|
||||
},
|
||||
promise,
|
||||
);
|
||||
}
|
||||
Copy {
|
||||
promise,
|
||||
dev,
|
||||
src,
|
||||
dest,
|
||||
} => {
|
||||
promise_fn(
|
||||
webview,
|
||||
move || {
|
||||
let handle = get_open_dev(&dev)?;
|
||||
let handle = handle.lock().unwrap();
|
||||
err_wrap(handle.copy_file(&src, &dest), dev, &mut wv_handle)?;
|
||||
Ok(())
|
||||
},
|
||||
promise,
|
||||
);
|
||||
}
|
||||
SelectFile { promise, filter } => {
|
||||
promise_fn(
|
||||
webview,
|
||||
move || {
|
||||
let filter = filter.iter().map(|t| t.as_str()).collect::<Vec<_>>();
|
||||
Ok(
|
||||
(native_dialog::OpenSingleFile {
|
||||
filter: Some(&filter),
|
||||
dir: None,
|
||||
})
|
||||
.show()?,
|
||||
)
|
||||
},
|
||||
promise,
|
||||
);
|
||||
}
|
||||
SelectFiles { promise, filter } => {
|
||||
promise_fn(
|
||||
webview,
|
||||
move || {
|
||||
let filter = filter.iter().map(|t| t.as_str()).collect::<Vec<_>>();
|
||||
Ok(
|
||||
(native_dialog::OpenMultipleFile {
|
||||
filter: Some(&filter),
|
||||
dir: None,
|
||||
})
|
||||
.show()?,
|
||||
)
|
||||
},
|
||||
promise,
|
||||
);
|
||||
}
|
||||
SelectFolder { promise } => {
|
||||
promise_fn(
|
||||
webview,
|
||||
move || Ok((native_dialog::OpenSingleDir { dir: None }).show()?),
|
||||
promise,
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
let has_registered_callback = AtomicBool::new(false);
|
||||
tauri::Builder::default()
|
||||
.on_page_load(move |window, _p| {
|
||||
if !has_registered_callback.swap(true, Ordering::SeqCst) {
|
||||
if rusb::has_hotplug() {
|
||||
if let Err(msg) = GlobalContext::default().register_callback(
|
||||
Some(VID),
|
||||
None,
|
||||
None,
|
||||
Box::new(DeviceMon { window }),
|
||||
) {
|
||||
eprintln!("{}", msg);
|
||||
};
|
||||
std::thread::spawn(|| loop {
|
||||
GlobalContext::default().handle_events(None).unwrap();
|
||||
});
|
||||
} else {
|
||||
println!("no hotplug");
|
||||
}
|
||||
}
|
||||
})
|
||||
.build()
|
||||
.run();
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
cmd::enumerate,
|
||||
invoked::open_device,
|
||||
invoked::close_device,
|
||||
invoked::update_device,
|
||||
invoked::list_dir,
|
||||
invoked::download_file,
|
||||
invoked::upload_file,
|
||||
invoked::upload_os,
|
||||
invoked::delete_file,
|
||||
invoked::delete_dir,
|
||||
invoked::create_nspire_dir,
|
||||
invoked::move_file,
|
||||
invoked::copy,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
|
|
@ -1,28 +0,0 @@
|
|||
use crate::cmd::Promise;
|
||||
use serde::Serialize;
|
||||
use tauri::api::rpc::{format_callback, format_callback_result};
|
||||
use tauri::Webview;
|
||||
|
||||
pub fn promise_fn<R: Serialize, F: FnOnce() -> tauri::Result<R> + Send + 'static>(
|
||||
webview: &mut Webview<'_>,
|
||||
task: F,
|
||||
Promise {
|
||||
callback: success_callback,
|
||||
error: error_callback,
|
||||
}: Promise,
|
||||
) {
|
||||
let mut webview = webview.as_mut();
|
||||
std::thread::spawn(move || {
|
||||
let callback_string = match format_callback_result(
|
||||
task().map_err(|err| err.to_string()),
|
||||
success_callback,
|
||||
error_callback.clone(),
|
||||
) {
|
||||
Ok(callback_string) => callback_string,
|
||||
Err(e) => format_callback(error_callback, e.to_string()),
|
||||
};
|
||||
webview
|
||||
.dispatch(move |webview_ref| webview_ref.eval(callback_string.as_str()))
|
||||
.expect("Failed to dispatch promise callback");
|
||||
});
|
||||
}
|
|
@ -1,9 +1,5 @@
|
|||
{
|
||||
"ctx": {},
|
||||
"tauri": {
|
||||
"embeddedServer": {
|
||||
"active": true
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["deb", "msi", "appimage", "dmg"],
|
||||
|
@ -17,34 +13,46 @@
|
|||
],
|
||||
"resources": [],
|
||||
"externalBin": [],
|
||||
"copyright": "Copyright (c) 2020 Ben Schattinger. Licensed under GPL-3.0",
|
||||
"copyright": "Copyright (c) 2021 Ben Schattinger. Licensed under GPL-3.0",
|
||||
"category": "Utility",
|
||||
"shortDescription": "Free, cross-platform, CX-II compatible computer linking program for the TI-Nspire",
|
||||
"longDescription": "Free, cross-platform, CX-II compatible computer linking program for the TI-Nspire",
|
||||
"osx": {
|
||||
"macOS": {
|
||||
"frameworks": [],
|
||||
"minimumSystemVersion": "",
|
||||
"useBootstrapper": false
|
||||
"useBootstrapper": false,
|
||||
"exceptionDomain": "",
|
||||
"signingIdentity": null,
|
||||
"entitlements": null
|
||||
},
|
||||
"exceptionDomain": ""
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": ""
|
||||
}
|
||||
},
|
||||
"updater": {
|
||||
"active": false
|
||||
},
|
||||
"allowlist": {
|
||||
"event": true,
|
||||
"notification": true,
|
||||
"open": true
|
||||
},
|
||||
"window": {
|
||||
"title": "N-Link",
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
"shell": {
|
||||
"open": true
|
||||
},
|
||||
"dialog": {
|
||||
"open": true
|
||||
}
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"title": "N-Link",
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src blob: data: filesystem: ws: http: https: 'unsafe-eval' 'unsafe-inline'"
|
||||
},
|
||||
"inliner": {
|
||||
"active": true
|
||||
"csp": "default-src blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self' img-src: 'self'"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import {promisified} from 'tauri/api/tauri';
|
||||
import {listen} from 'tauri/api/event';
|
||||
import {invoke} from '@tauri-apps/api/tauri';
|
||||
import {listen} from '@tauri-apps/api/event';
|
||||
import {open as openDialog} from '@tauri-apps/api/dialog';
|
||||
import {Component, Vue} from 'vue-property-decorator';
|
||||
import type {GenericDevices} from 'n-link-core/components/devices';
|
||||
|
||||
|
@ -55,47 +56,47 @@ export type Device = { name: string; isCxIi: boolean; needsDrivers: boolean; inf
|
|||
|
||||
async function downloadFile(dev: DevId | string, path: [string, number], dest: string) {
|
||||
if (typeof dev === 'string') dev = stringToDev(dev);
|
||||
await promisified({...dev, cmd: 'downloadFile', path, dest});
|
||||
await invoke('download_file', {...dev, path, dest});
|
||||
}
|
||||
|
||||
async function uploadFile(dev: DevId | string, path: string, src: string) {
|
||||
if (typeof dev === 'string') dev = stringToDev(dev);
|
||||
await promisified({...dev, cmd: 'uploadFile', path, src});
|
||||
await invoke('upload_file', {...dev, path, src});
|
||||
}
|
||||
|
||||
async function uploadOs(dev: DevId | string, src: string) {
|
||||
if (typeof dev === 'string') dev = stringToDev(dev);
|
||||
await promisified({...dev, cmd: 'uploadOs', src});
|
||||
await invoke('upload_os', {...dev, src});
|
||||
}
|
||||
|
||||
async function deleteFile(dev: DevId | string, path: string) {
|
||||
if (typeof dev === 'string') dev = stringToDev(dev);
|
||||
await promisified({...dev, cmd: 'deleteFile', path});
|
||||
await invoke('delete_file', {...dev, path});
|
||||
}
|
||||
|
||||
async function deleteDir(dev: DevId | string, path: string) {
|
||||
if (typeof dev === 'string') dev = stringToDev(dev);
|
||||
await promisified({...dev, cmd: 'deleteDir', path});
|
||||
await invoke('delete_dir', {...dev, path});
|
||||
}
|
||||
|
||||
async function createDir(dev: DevId | string, path: string) {
|
||||
if (typeof dev === 'string') dev = stringToDev(dev);
|
||||
await promisified({...dev, cmd: 'createNspireDir', path});
|
||||
await invoke('create_nspire_dir', {...dev, path});
|
||||
}
|
||||
|
||||
async function move(dev: DevId | string, src: string, dest: string) {
|
||||
if (typeof dev === 'string') dev = stringToDev(dev);
|
||||
await promisified({...dev, cmd: 'move', src, dest});
|
||||
await invoke('move_file', {...dev, src, dest});
|
||||
}
|
||||
|
||||
async function copy(dev: DevId | string, src: string, dest: string) {
|
||||
if (typeof dev === 'string') dev = stringToDev(dev);
|
||||
await promisified({...dev, cmd: 'copy', src, dest});
|
||||
await invoke('copy', {...dev, src, dest});
|
||||
}
|
||||
|
||||
async function listDir(dev: DevId | string, path: string) {
|
||||
if (typeof dev === 'string') dev = stringToDev(dev);
|
||||
return await promisified({...dev, cmd: 'listDir', path}) as FileInfo[];
|
||||
return await invoke('list_dir', {...dev, path}) as FileInfo[];
|
||||
}
|
||||
|
||||
async function listAll(dev: DevId | string, path: FileInfo): Promise<FileInfo[]> {
|
||||
|
@ -195,7 +196,7 @@ class Devices extends Vue implements GenericDevices {
|
|||
async enumerate() {
|
||||
this.enumerating = true;
|
||||
try {
|
||||
for (const dev of await promisified({cmd: 'enumerate'}) as (Device & DevId)[]) {
|
||||
for (const dev of await invoke('enumerate') as (Device & DevId)[]) {
|
||||
this.$set(this.devices, devToString(dev as DevId), dev);
|
||||
}
|
||||
} finally {
|
||||
|
@ -205,19 +206,19 @@ class Devices extends Vue implements GenericDevices {
|
|||
|
||||
async open(dev: DevId | string) {
|
||||
if (typeof dev === 'string') dev = stringToDev(dev);
|
||||
const info = await promisified({...dev, cmd: 'openDevice'});
|
||||
const info = await invoke('open_device', {...dev});
|
||||
this.$set(this.devices[devToString(dev)], 'info', info);
|
||||
}
|
||||
|
||||
async close(dev: DevId | string) {
|
||||
if (typeof dev === 'string') dev = stringToDev(dev);
|
||||
await promisified({...dev, cmd: 'closeDevice'});
|
||||
await invoke('close_device', {...dev});
|
||||
this.$delete(this.devices[devToString(dev)], 'info');
|
||||
}
|
||||
|
||||
async update(dev: DevId | string) {
|
||||
if (typeof dev === 'string') dev = stringToDev(dev);
|
||||
const info = await promisified({...dev, cmd: 'updateDevice'});
|
||||
const info = await invoke('update_device', {...dev});
|
||||
this.$set(this.devices[devToString(dev)], 'info', info);
|
||||
}
|
||||
|
||||
|
@ -227,7 +228,7 @@ class Devices extends Vue implements GenericDevices {
|
|||
|
||||
async promptUploadFiles(dev: DevId | string, path: string) {
|
||||
if (typeof dev !== 'string') dev = devToString(dev);
|
||||
const files = await promisified({cmd: 'selectFiles', filter: ['tns']}) as string[];
|
||||
const files = await openDialog({filters:[{extensions:['tns'], name:'TNS files'}], multiple: true});
|
||||
for (const src of files) {
|
||||
this.addToQueue(dev, {action: 'upload', path, src});
|
||||
}
|
||||
|
@ -235,14 +236,14 @@ class Devices extends Vue implements GenericDevices {
|
|||
|
||||
async uploadOs(dev: DevId | string, filter: string) {
|
||||
if (typeof dev !== 'string') dev = devToString(dev);
|
||||
const src = await promisified({cmd: 'selectFile', filter: [filter]}) as string | null;
|
||||
const src = await openDialog({filters:[{extensions:[filter], name:'TI Nspire OS upgrade files'}]}) as string | null;
|
||||
if (!src) return;
|
||||
this.addToQueue(dev, {action: 'uploadOs', src});
|
||||
}
|
||||
|
||||
async downloadFiles(dev: DevId | string, files: [string, number][]) {
|
||||
if (typeof dev !== 'string') dev = devToString(dev);
|
||||
const dest = await promisified({cmd: 'selectFolder'}) as string | null;
|
||||
const dest = await openDialog({directory: true}) as string | null;
|
||||
if (!dest) return;
|
||||
for (const path of files) {
|
||||
this.addToQueue(dev, {action: 'download', path, dest});
|
||||
|
|
|
@ -4,11 +4,19 @@
|
|||
<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">
|
||||
<div v-if="needsDrivers && !isLinux">
|
||||
<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="#" @click.prevent="installDrivers" class="text-blue-600">See installation instructions</a>
|
||||
<a href="#" @click.prevent="installDrivers()" class="text-blue-600">See installation instructions</a>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="needsDrivers && isLinux">
|
||||
<h1 class="text-3xl">udev rules required</h1>
|
||||
<p>udev rules are required to access this device.</p>
|
||||
<p class="text-center mt-2">
|
||||
<a href="#" @click.prevent="installDrivers('linux')" class="text-blue-600">See installation
|
||||
instructions</a>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="calculator && !calculator.info" class="flex items-center justify-center h-full">
|
||||
|
@ -34,7 +42,7 @@
|
|||
|
||||
<script lang="ts">
|
||||
import {Component, Vue, Watch} from 'vue-property-decorator';
|
||||
import {open} from 'tauri/api/window';
|
||||
import {open} from '@tauri-apps/api/shell';
|
||||
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";
|
||||
|
@ -94,8 +102,12 @@ export default class Home extends Vue {
|
|||
return this.selectedCalculator && this.$devices.devices[this.selectedCalculator]?.needsDrivers;
|
||||
}
|
||||
|
||||
installDrivers() {
|
||||
open('https://lights0123.com/n-link/#windows');
|
||||
get isLinux() {
|
||||
return navigator.platform.includes('Linux');
|
||||
}
|
||||
|
||||
installDrivers(platform = 'windows') {
|
||||
open(`https://lights0123.com/n-link/#${platform}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
<img src="~feather-icons/dist/icons/refresh-cw.svg" class="w-5"/>
|
||||
<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">
|
||||
|
|
|
@ -41,6 +41,7 @@ export interface GenericDevices {
|
|||
devices: Record<string, Device>;
|
||||
enumerating: boolean;
|
||||
hasEnumerated: boolean;
|
||||
errorHandler?: (e: DOMException) => void;
|
||||
|
||||
enumerate(): Promise<void>;
|
||||
open(dev: string): Promise<void>;
|
||||
|
|
|
@ -46,6 +46,9 @@ module.exports = {
|
|||
blockquote: 'var(--color-ui-blockquote)',
|
||||
},
|
||||
},
|
||||
height: {
|
||||
min: 'min-content',
|
||||
},
|
||||
},
|
||||
},
|
||||
variants: {},
|
||||
|
|
|
@ -16,5 +16,11 @@ module.exports = {
|
|||
// add your custom rules here
|
||||
rules: {
|
||||
'import/no-webpack-loader-syntax': 0,
|
||||
'require-await': 0,
|
||||
'unicorn/number-literal-case': 0,
|
||||
'no-console':
|
||||
process.env.NODE_ENV === 'production'
|
||||
? ['warn', { allow: ['warn', 'error'] }]
|
||||
: 'off',
|
||||
},
|
||||
};
|
||||
|
|
82
web/components/ErrorMessage.vue
Normal file
82
web/components/ErrorMessage.vue
Normal 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>
|
|
@ -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>
|
|
@ -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);
|
||||
|
@ -92,14 +93,14 @@ class Devices extends Vue implements GenericDevices {
|
|||
enumerating = false;
|
||||
hasEnumerated = false;
|
||||
devices: Record<string, Device> = {};
|
||||
errorHandler?: (e: DOMException) => void;
|
||||
|
||||
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 +117,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()));
|
||||
|
@ -133,6 +138,7 @@ class Devices extends Vue implements GenericDevices {
|
|||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.errorHandler?.(e);
|
||||
}
|
||||
if ('progress' in device) this.$delete(device, 'progress');
|
||||
device.queue.shift();
|
||||
|
@ -147,7 +153,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 +163,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,
|
||||
|
@ -166,9 +172,9 @@ class Devices extends Vue implements GenericDevices {
|
|||
});
|
||||
navigator.usb.ondisconnect = (e) => {
|
||||
const [key] =
|
||||
Object.entries(this.devices).find(
|
||||
([_, {device}]) => device === e.device
|
||||
) || [];
|
||||
Object.entries(this.devices).find(
|
||||
([_, { device }]) => device === e.device
|
||||
) || [];
|
||||
if (key) {
|
||||
this.$delete(this.devices, key);
|
||||
}
|
||||
|
@ -194,18 +200,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 +254,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 +292,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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -4,11 +4,16 @@ export default {
|
|||
|
||||
// Global page headers (https://go.nuxtjs.dev/config-head)
|
||||
head: {
|
||||
title: 'web',
|
||||
title: 'N-Link',
|
||||
meta: [
|
||||
{ charset: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
{ hid: 'description', name: 'description', content: '' },
|
||||
{
|
||||
hid: 'description',
|
||||
name: 'description',
|
||||
content:
|
||||
'Free, cross-platform, CX-II compatible computer linking program for the TI-Nspire.',
|
||||
},
|
||||
],
|
||||
link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }],
|
||||
},
|
||||
|
@ -35,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'],
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "web",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nuxt-ts",
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
<a
|
||||
href="https://lights0123.com/n-link/"
|
||||
class="text-xl text-blue-600 underline"
|
||||
>
|
||||
Check out the desktop version instead
|
||||
</a>
|
||||
<p class="text-xl">
|
||||
<a
|
||||
href="https://lights0123.com/n-link/"
|
||||
class="text-blue-600 underline inline"
|
||||
>
|
||||
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, Ref, 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';
|
||||
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,9 +116,13 @@ 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;
|
||||
this.$devices.errorHandler = (e: DOMException) => {
|
||||
this.errorMessage.handleError(e, 'operation');
|
||||
};
|
||||
}
|
||||
|
||||
@Watch('$devices.hasEnumerated')
|
||||
|
@ -147,7 +157,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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
3
web/static/_headers
Normal file
3
web/static/_headers
Normal file
|
@ -0,0 +1,3 @@
|
|||
/*
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Cross-Origin-Embedder-Policy: require-corp
|
6
web/types/vue-final-modal/index.d.ts
vendored
Normal file
6
web/types/vue-final-modal/index.d.ts
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
declare module 'vue-final-modal' {
|
||||
import { PluginFunction } from 'vue';
|
||||
|
||||
const install: () => PluginFunction<undefined>;
|
||||
export default install;
|
||||
}
|
Loading…
Reference in a new issue