Compare commits

...

26 commits
v0.1.5 ... main

Author SHA1 Message Date
lights0123 0472908ef4
Fix csp causing blank screen in release mode 2021-11-13 12:42:10 -05:00
lights0123 89a613d25a switch to tauri-build 2021-10-31 15:57:03 -04:00
lights0123 a8dddd1756 Small tweaks 2021-10-27 16:59:52 -04:00
JandereDev 3311e0d358 Check OS update file extension 2021-10-27 16:59:52 -04:00
JandereDev abd8797751 Added upload-os, copy, move subcommands 2021-10-27 16:59:52 -04:00
JandereDev cdd06cb3e6 Added download and ls, formatted cli.rs 2021-10-27 16:59:52 -04:00
JandereDev fd6d7d87b6 Added error handling to upload subcommand 2021-10-27 16:59:52 -04:00
JandereDev a08eb0b791 Added mkdir and rmdir subcommands 2021-10-27 16:59:52 -04:00
lights0123 5ce4d8a560
Workaround CI issue on Windows 2021-08-30 19:55:16 -04:00
lights0123 ade24ce050
Workaround CI issue 2021-08-30 17:57:59 -04:00
lights0123 eb961b8973
Port to tauri beta 2021-08-30 16:49:54 -04:00
lights0123 35cc07f0ed
Bump version 2021-04-06 17:39:41 -04:00
lights0123 75ef04b742
Show errors when udev rules are required on desktop 2021-04-06 17:38:45 -04:00
lights0123 4b90a1a8da
Error messages during passive operation 2021-04-06 17:26:08 -04:00
lights0123 ac1a26a797
Error messages 2021-04-06 17:01:54 -04:00
lights0123 5d5b98dd59
Ensure cache is valid: change the restore keys... 2021-04-05 18:07:40 -04:00
lights0123 b555130353
Ensure cache is valid: bump cache key 2021-04-05 17:59:56 -04:00
lights0123 fd07af00e7
Ensure cache is valid: ok, fix for new changes 2021-04-05 17:55:00 -04:00
lights0123 4c0b2277c6
Ensure cache is valid: wtf actions/cache#403 2021-04-05 17:49:51 -04:00
lights0123 a07d785ca1
Ensure cache is valid: ah, it's the target directory 2021-04-05 17:36:43 -04:00
lights0123 6823ee76f6
Ensure cache is valid... again 2021-04-05 17:33:32 -04:00
lights0123 f90d912568
Ensure cache is valid 2021-04-05 17:29:00 -04:00
lights0123 d2a30f97f8
Fix cli with an AppImage 2021-04-05 16:57:18 -04:00
lights0123 697a7365f3
Update deps 2021-04-05 15:21:43 -04:00
lights0123 7cbf54dabd
Add additional info to web version 2020-11-22 19:24:17 -05:00
lights0123 4a957aba88
fix version again 2020-11-22 19:11:14 -05:00
31 changed files with 3819 additions and 2015 deletions

View file

@ -16,6 +16,10 @@ jobs:
runs-on: ${{ matrix.platform }} runs-on: ${{ matrix.platform }}
steps: steps:
- uses: actions/checkout@v2 - 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 - name: Get yarn cache directory path
id: yarn-cache-dir-path id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)" run: echo "::set-output name=dir::$(yarn cache dir)"
@ -34,9 +38,9 @@ jobs:
~/.cargo/git ~/.cargo/git
~/.cargo/bin ~/.cargo/bin
desktop/src-tauri/target desktop/src-tauri/target
key: ${{ matrix.platform }}-cargo-${{ hashFiles('**/Cargo.lock') }} key: ${{ matrix.platform }}-cargo-v2-${{ hashFiles('**/Cargo.lock') }}
restore-keys: | restore-keys: |
${{ matrix.platform }}-cargo- ${{ matrix.platform }}-cargo-v2-
- name: setup node - name: setup node
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
@ -59,9 +63,14 @@ jobs:
sudo apt-get install -y webkit2gtk-4.0 squashfs-tools sudo apt-get install -y webkit2gtk-4.0 squashfs-tools
- name: install app dependencies - name: install app dependencies
run: yarn 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: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} COMMAND: 'fs.writeFileSync("desktop/yarn.lock","")'
- uses: tauri-apps/tauri-action@v0
with: with:
npmScript: "tauri:build" npmScript: "tauri:build"
projectPath: desktop/ projectPath: desktop/

View file

@ -13,6 +13,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - 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 - name: setup node
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
@ -58,9 +62,9 @@ jobs:
~/.cargo/git ~/.cargo/git
~/.cargo/bin ~/.cargo/bin
desktop/src-tauri/target desktop/src-tauri/target
key: ${{ matrix.platform }}-cargo-${{ hashFiles('**/Cargo.lock') }} key: ${{ matrix.platform }}-cargo-v2-${{ hashFiles('**/Cargo.lock') }}
restore-keys: | restore-keys: |
${{ matrix.platform }}-cargo- ${{ matrix.platform }}-cargo-v2-
- name: setup node - name: setup node
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
@ -102,7 +106,7 @@ jobs:
const {repo: {owner, repo}, sha} = context; const {repo: {owner, repo}, sha} = context;
console.log({owner, repo, sha}); 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; const arch = process.arch === 'x64' ? 'amd64' : process.arch;
await github.repos.uploadReleaseAsset({ await github.repos.uploadReleaseAsset({

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,7 +1,7 @@
{ {
"name": "desktop", "name": "desktop",
"description": "Free, cross-platform, CX-II compatible computer linking program for the TI-Nspire", "description": "Free, cross-platform, CX-II compatible computer linking program for the TI-Nspire",
"version": "0.1.5", "version": "0.1.6",
"private": true, "private": true,
"scripts": { "scripts": {
"lint": "vue-cli-service lint", "lint": "vue-cli-service lint",
@ -15,7 +15,7 @@
"feather-icons": "^4.28.0", "feather-icons": "^4.28.0",
"filesize": "^6.1.0", "filesize": "^6.1.0",
"n-link-core": "0.0.0", "n-link-core": "0.0.0",
"tauri": "^0.12.0", "@tauri-apps/api": "^1.0.0-beta.8",
"vue": "^2.6.11", "vue": "^2.6.11",
"vue-async-computed": "^3.9.0", "vue-async-computed": "^3.9.0",
"vue-class-component": "^7.2.3", "vue-class-component": "^7.2.3",
@ -38,7 +38,7 @@
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
"typescript": "~4.0.3", "typescript": "~4.0.3",
"vue-cli-plugin-tailwind": "~1.5.0", "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" "vue-template-compiler": "^2.6.11"
} }
} }

View file

@ -8,3 +8,5 @@ WixTools
config.json config.json
bundle.json bundle.json
/.cargo

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package] [package]
name = "n-link" 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" description = "Free, cross-platform, CX-II compatible computer linking program for the TI-Nspire"
authors = [ "Ben Schattinger <developer@lights0123.com>" ] authors = [ "Ben Schattinger <developer@lights0123.com>" ]
license = "GPL-3.0" license = "GPL-3.0"
@ -16,19 +16,18 @@ libnspire = "0.2.2"
lazy_static = "1.4.0" lazy_static = "1.4.0"
rusb = "0.6.4" rusb = "0.6.4"
serde = { version = "1.0", features = [ "derive" ] } serde = { version = "1.0", features = [ "derive" ] }
tauri = { version = "0.9", features = [ "event", "notification", "open" ] } tauri = { version = "1.0.0-beta.8", features = ["dialog-open", "dialog-save", "notification-all", "shell-open"] }
native-dialog = "0.4.3"
clap = "3.0.0-beta.2" clap = "3.0.0-beta.2"
indicatif = "0.15.0" indicatif = "0.15"
libusb1-sys = { version = "0.4.2", features = [ "vendored" ] } libusb1-sys = { version = "0.4.2", features = [ "vendored" ] }
hashbrown = "0.9.0" hashbrown = "0.11"
[target."cfg(windows)".build-dependencies] [build-dependencies]
winres = "0.1" tauri-build = { version = "1.0.0-beta.4" }
[features] [features]
embedded-server = [ "tauri/embedded-server" ] custom-protocol = [ "tauri/custom-protocol" ]
no-server = [ "tauri/no-server" ] default = [ "custom-protocol" ]
[[bin]] [[bin]]
name = "n-link" name = "n-link"

View file

@ -1,16 +1,3 @@
#[cfg(windows)]
extern crate winres;
#[cfg(windows)]
fn main() { fn main() {
if std::path::Path::new("icons/icon.ico").exists() { tauri_build::build()
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");
}
} }
#[cfg(not(windows))]
fn main() {}

View file

@ -1,10 +1,11 @@
use std::fs::File; use std::ffi::OsStr;
use std::io::Read; use std::io::{Read, Write};
use std::path::PathBuf; use std::path::PathBuf;
use std::{fs::File, path::Path};
use clap::Clap; use clap::Clap;
use indicatif::{ProgressBar, ProgressStyle}; use indicatif::{ProgressBar, ProgressStyle};
use libnspire::{PID, PID_CX2, VID}; use libnspire::{dir::EntryType, PID, PID_CX2, VID};
#[derive(Clap, Debug)] #[derive(Clap, Debug)]
#[clap(author, about, version)] #[clap(author, about, version)]
@ -16,9 +17,15 @@ struct Opt {
#[derive(Clap, Debug)] #[derive(Clap, Debug)]
enum SubCommand { enum SubCommand {
Upload(Upload), Upload(Upload),
Download(Download),
UploadOS(UploadOS),
Copy(Copy),
Move(Move),
Mkdir(Mkdir),
Rmdir(Rmdir),
Ls(Ls),
/// View license information /// View license information
License, License,
// Download(Download),
} }
/// Upload files to the calculator /// Upload files to the calculator
@ -31,32 +38,101 @@ struct Upload {
dest: String, dest: String,
} }
// /// Download files from the calculator /// Download files from the calculator
// #[derive(Clap, Debug)] #[derive(Clap, Debug)]
// struct Download { struct Download {
// /// Files to download /// Files to download
// #[clap(required = true)] #[clap(required = true)]
// files: Vec<String>, files: Vec<String>,
// /// Destination path /// Destination path
// #[clap(parse(from_os_str))] #[clap(required = true, parse(from_os_str))]
// dest: PathBuf, 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>> { fn get_dev() -> Option<libnspire::Handle<rusb::GlobalContext>> {
rusb::devices() rusb::devices()
.unwrap() .unwrap()
.iter() .iter()
.filter(|dev| { .find(|dev| {
let descriptor = match dev.device_descriptor() { let descriptor = match dev.device_descriptor() {
Ok(d) => d, Ok(d) => d,
Err(_) => return false, Err(_) => return false,
}; };
descriptor.vendor_id() == VID && matches!(descriptor.product_id(), PID | PID_CX2) descriptor.vendor_id() == VID && matches!(descriptor.product_id(), PID | PID_CX2)
}) })
.next()
.map(|dev| libnspire::Handle::new(dev.open().unwrap()).unwrap()) .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 { pub fn run() -> bool {
let opt: Opt = Opt::parse(); let opt: Opt = Opt::parse();
if let Some(cmd) = opt.cmd { if let Some(cmd) = opt.cmd {
@ -65,26 +141,243 @@ pub fn run() -> bool {
if let Some(handle) = get_dev() { if let Some(handle) = get_dev() {
for file in files { for file in files {
let mut buf = vec![]; 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 let name = file
.file_name() .file_name()
.expect("Failed to get file name") .expect("Failed to get file name")
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
let bar = ProgressBar::new(buf.len() as u64); 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.set_message(&format!("Upload {}", name));
bar.enable_steady_tick(100); bar.enable_steady_tick(100);
if dest.ends_with('/') { if dest.ends_with('/') {
dest.remove(dest.len() - 1); dest.remove(dest.len() - 1);
} }
handle let res = handle.write_file(&format!("{}/{}", dest, name), &buf, &mut |remaining| {
.write_file(&format!("{}/{}", dest, name), &buf, &mut |remaining| {
bar.set_position((buf.len() - remaining) as u64) bar.set_position((buf.len() - remaining) as u64)
}) });
.unwrap();
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(); 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 { } else {
eprintln!("Couldn't find any device"); eprintln!("Couldn't find any device");
} }

View file

@ -2,18 +2,12 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use libnspire::{PID, PID_CX2, VID}; use libnspire::{PID, PID_CX2, VID};
use rusb::{DeviceHandle, Error, GlobalContext}; use rusb::GlobalContext;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::{Runtime, Window};
use crate::{Device, DeviceState}; use crate::{Device, DeviceState, SerializedError};
use tauri::WebviewMut;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Promise {
pub callback: String,
pub error: String,
}
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[derive(Copy, Clone, Eq, PartialEq, Debug, Hash)] #[derive(Copy, Clone, Eq, PartialEq, Debug, Hash)]
@ -21,131 +15,6 @@ pub struct DevId {
pub bus_number: u8, pub bus_number: u8,
pub address: 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)> { pub fn add_device(dev: Arc<rusb::Device<GlobalContext>>) -> rusb::Result<((u8, u8), Device)> {
let descriptor = dev.device_descriptor()?; let descriptor = dev.device_descriptor()?;
@ -162,7 +31,7 @@ pub fn add_device(dev: Arc<rusb::Device<GlobalContext>>) -> rusb::Result<((u8, u
)?, )?,
false, false,
), ),
Err(rusb::Error::NotSupported) => ( Err(rusb::Error::NotSupported) | Err(rusb::Error::Access) => (
if descriptor.product_id() == PID_CX2 { if descriptor.product_id() == PID_CX2 {
"TI-Nspire CX II" "TI-Nspire CX II"
} else { } 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 devices: Vec<_> = rusb::devices()?.iter().collect();
let mut map = crate::DEVICES.write().unwrap(); let mut map = crate::DEVICES.write().unwrap();
map 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) .all(|d| d.bus_number() != k.0 || d.address() != k.1)
}) })
.for_each(|d| { .for_each(|d| {
if let Err(msg) = tauri::event::emit( if let Err(msg) = handle.emit(
handle,
"removeDevice", "removeDevice",
Some(DevId { DevId {
bus_number: (d.0).0, bus_number: (d.0).0,
address: (d.0).1, address: (d.0).1,
}), },
) { ) {
eprintln!("{}", msg); eprintln!("{}", msg);
} }

View file

@ -3,25 +3,20 @@
windows_subsystem = "windows" windows_subsystem = "windows"
)] )]
use hashbrown::HashMap; use std::sync::atomic::{AtomicBool, Ordering};
use std::fs::File;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::sync::{Arc, Mutex, RwLock}; use std::sync::{Arc, Mutex, RwLock};
use std::time::Duration; use std::time::Duration;
use libnspire::dir::EntryType; use hashbrown::HashMap;
use libnspire::{PID_CX2, VID}; use libnspire::{PID_CX2, VID};
use native_dialog::Dialog;
use rusb::{GlobalContext, Hotplug, UsbContext}; 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::cmd::{add_device, AddDevice, DevId, ProgressUpdate};
use crate::promise::promise_fn;
mod cli; mod cli;
mod cmd; mod cmd;
mod promise;
pub enum DeviceState { pub enum DeviceState {
Open( Open(
@ -40,13 +35,13 @@ pub struct Device {
lazy_static::lazy_static! { lazy_static::lazy_static! {
static ref DEVICES: RwLock<HashMap<(u8, u8), Device>> = RwLock::new(HashMap::new()); static ref DEVICES: RwLock<HashMap<(u8, u8), Device>> = RwLock::new(HashMap::new());
} }
struct DeviceMon { struct DeviceMon<R: Runtime> {
handle: WebviewMut, 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>) { 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 let is_cx_ii = device
.device_descriptor() .device_descriptor()
.map(|d| d.product_id() == PID_CX2) .map(|d| d.product_id() == PID_CX2)
@ -58,10 +53,9 @@ impl Hotplug<GlobalContext> for DeviceMon {
let name = (dev.1).name.clone(); let name = (dev.1).name.clone();
let needs_drivers = (dev.1).needs_drivers; let needs_drivers = (dev.1).needs_drivers;
DEVICES.write().unwrap().insert(dev.0, dev.1); DEVICES.write().unwrap().insert(dev.0, dev.1);
if let Err(msg) = tauri::event::emit( if let Err(msg) = handle.emit(
&mut handle,
"addDevice", "addDevice",
Some(AddDevice { AddDevice {
dev: DevId { dev: DevId {
bus_number: (dev.0).0, bus_number: (dev.0).0,
address: (dev.0).1, address: (dev.0).1,
@ -69,7 +63,7 @@ impl Hotplug<GlobalContext> for DeviceMon {
name, name,
is_cx_ii, is_cx_ii,
needs_drivers, needs_drivers,
}), },
) { ) {
eprintln!("{}", msg); eprintln!("{}", msg);
}; };
@ -93,13 +87,12 @@ impl Hotplug<GlobalContext> for DeviceMon {
.unwrap() .unwrap()
.remove_entry(&(device.bus_number(), device.address())) .remove_entry(&(device.bus_number(), device.address()))
{ {
if let Err(msg) = tauri::event::emit( if let Err(msg) = self.window.emit(
&mut self.handle,
"removeDevice", "removeDevice",
Some(DevId { DevId {
bus_number: dev.0, bus_number: dev.0,
address: dev.1, address: dev.1,
}), },
) { ) {
eprintln!("{}", msg); 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>, res: Result<T, libnspire::Error>,
dev: DevId, dev: DevId,
handle: &mut WebviewMut, window: &Window<R>,
) -> Result<T, libnspire::Error> { ) -> Result<T, libnspire::Error> {
if let Err(libnspire::Error::NoDevice) = res { if let Err(libnspire::Error::NoDevice) = res {
DEVICES DEVICES
.write() .write()
.unwrap() .unwrap()
.remove(&(dev.bus_number, dev.address)); .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); eprintln!("{}", msg);
}; };
} }
res res
} }
fn progress_sender<'a>( fn progress_sender<R: Runtime>(
handle: &'a mut WebviewMut, window: &Window<R>,
dev: DevId, dev: DevId,
total: usize, total: usize,
) -> impl FnMut(usize) + 'a { ) -> impl FnMut(usize) + '_ {
let mut i = 0; let mut i = 0;
move |remaining| { move |remaining| {
if i > 5 { if i > 5 {
i = 0; i = 0;
} }
if i == 0 || remaining == 0 { if i == 0 || remaining == 0 {
if let Err(msg) = tauri::event::emit( if let Err(msg) = window.emit(
handle,
"progress", "progress",
Some(ProgressUpdate { ProgressUpdate {
dev, dev,
remaining, remaining,
total, total,
}), },
) { ) {
eprintln!("{}", msg); eprintln!("{}", msg);
}; };
@ -164,107 +156,92 @@ fn get_open_dev(
} }
} }
fn main() { #[derive(Serialize)]
if cli::run() { pub struct SerializedError(String);
return;
impl<T: std::fmt::Display> From<T> for SerializedError {
fn from(f: T) -> Self {
SerializedError(f.to_string())
} }
let mut has_registered_callback = false; }
tauri::AppBuilder::new()
.invoke_handler(move |webview, arg| { mod invoked {
use cmd::Cmd::*; use std::fs::File;
match serde_json::from_str(arg) { use std::io::{Read, Write};
Err(e) => Err(e.to_string()), use std::path::PathBuf;
Ok(command) => { use std::sync::{Arc, Mutex};
let mut wv_handle = webview.as_mut();
match command { use libnspire::dir::EntryType;
Enumerate { promise } => { use serde::Serialize;
if !has_registered_callback { use tauri::{Runtime, Window};
has_registered_callback = true;
if rusb::has_hotplug() { use crate::cmd::{DevId, FileInfo};
if let Err(msg) = GlobalContext::default().register_callback( use crate::{err_wrap, get_open_dev, progress_sender, DeviceState, SerializedError};
Some(VID),
None, use super::DEVICES;
None,
Box::new(DeviceMon { #[tauri::command]
handle: webview.as_mut(), 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) {
eprintln!("{}", msg); return Err("Already open".into());
}; };
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() dev.device.clone()
} else { } else {
anyhow::bail!("Failed to find device"); return Err("Failed to find device".into());
}; };
let handle = libnspire::Handle::new(device.open()?)?; let handle = libnspire::Handle::new(device.open()?)?;
let info = handle.info()?; let info = handle.info()?;
{ {
let mut guard = DEVICES.write().unwrap(); let mut guard = DEVICES.write().unwrap();
let device = guard let device = guard
.get_mut(&(dev.bus_number, dev.address)) .get_mut(&(bus_number, address))
.ok_or_else(|| anyhow::anyhow!("Device lost"))?; .ok_or_else(|| anyhow::anyhow!("Device lost"))?;
device.state = DeviceState::Open(Arc::new(Mutex::new(handle)), info.clone()); device.state = DeviceState::Open(Arc::new(Mutex::new(handle)), info.clone());
} }
Ok(info) Ok(info)
},
promise,
);
} }
CloseDevice { promise, dev } => {
promise_fn( #[tauri::command]
webview, pub fn close_device(bus_number: u8, address: u8) -> Result<impl Serialize, SerializedError> {
move || {
{
let mut guard = DEVICES.write().unwrap(); let mut guard = DEVICES.write().unwrap();
let device = guard let device = guard
.get_mut(&(dev.bus_number, dev.address)) .get_mut(&(bus_number, address))
.ok_or_else(|| anyhow::anyhow!("Device lost"))?; .ok_or_else(|| anyhow::anyhow!("Device lost"))?;
device.state = DeviceState::Closed; device.state = DeviceState::Closed;
}
Ok(()) Ok(())
},
promise,
);
} }
UpdateDevice { promise, dev } => {
promise_fn( #[tauri::command]
webview, pub fn update_device<R: Runtime>(
move || { 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 = get_open_dev(&dev)?;
let handle = handle.lock().unwrap(); let handle = handle.lock().unwrap();
let info = err_wrap(handle.info(), dev, &mut wv_handle)?; let info = err_wrap(handle.info(), dev, &window)?;
Ok(info) Ok(info)
},
promise,
);
} }
ListDir { promise, dev, path } => {
promise_fn( #[tauri::command]
webview, pub fn list_dir<R: Runtime>(
move || { 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 = get_open_dev(&dev)?;
let handle = handle.lock().unwrap(); let handle = handle.lock().unwrap();
let dir = err_wrap(handle.list_dir(&path), dev, &mut wv_handle)?; let dir = err_wrap(handle.list_dir(&path), dev, &window)?;
Ok( Ok(
dir dir
@ -277,19 +254,21 @@ fn main() {
}) })
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
) )
},
promise,
);
} }
DownloadFile {
promise, #[tauri::command]
dev, pub fn download_file<R: Runtime>(
path: (file, size), bus_number: u8,
dest, address: u8,
} => { path: (String, u64),
promise_fn( dest: String,
webview, window: Window<R>,
move || { ) -> Result<impl Serialize, SerializedError> {
let dev = DevId {
bus_number,
address,
};
let (file, size) = path;
let dest = PathBuf::from(dest); let dest = PathBuf::from(dest);
let handle = get_open_dev(&dev)?; let handle = get_open_dev(&dev)?;
let handle = handle.lock().unwrap(); let handle = handle.lock().unwrap();
@ -298,28 +277,29 @@ fn main() {
handle.read_file( handle.read_file(
&file, &file,
&mut buf, &mut buf,
&mut progress_sender(&mut wv_handle.clone(), dev, size as usize), &mut progress_sender(&window, dev, size as usize),
), ),
dev, dev,
&mut wv_handle, &window,
)?; )?;
if let Some(name) = file.split('/').last() { if let Some(name) = file.split('/').last() {
File::create(dest.join(name))?.write_all(&buf)?; File::create(dest.join(name))?.write_all(&buf)?;
} }
Ok(()) Ok(())
},
promise,
);
} }
UploadFile {
promise, #[tauri::command]
dev, pub fn upload_file<R: Runtime>(
path, bus_number: u8,
src, address: u8,
} => { path: String,
promise_fn( src: String,
webview, window: Window<R>,
move || { ) -> Result<impl Serialize, SerializedError> {
let dev = DevId {
bus_number,
address,
};
let file = PathBuf::from(src); let file = PathBuf::from(src);
let handle = get_open_dev(&dev)?; let handle = get_open_dev(&dev)?;
let handle = handle.lock().unwrap(); let handle = handle.lock().unwrap();
@ -334,151 +314,165 @@ fn main() {
handle.write_file( handle.write_file(
&format!("{}/{}", path, name), &format!("{}/{}", path, name),
&buf, &buf,
&mut progress_sender(&mut wv_handle.clone(), dev, buf.len()), &mut progress_sender(&window, dev, buf.len()),
), ),
dev, dev,
&mut wv_handle, &window,
)?; )?;
Ok(()) Ok(())
},
promise,
);
} }
UploadOs { promise, dev, src } => {
promise_fn( #[tauri::command]
webview, pub fn upload_os<R: Runtime>(
move || { 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 = get_open_dev(&dev)?;
let handle = handle.lock().unwrap(); let handle = handle.lock().unwrap();
let mut buf = vec![]; let mut buf = vec![];
File::open(&src)?.read_to_end(&mut buf)?; File::open(&src)?.read_to_end(&mut buf)?;
err_wrap( err_wrap(
handle.send_os( handle.send_os(&buf, &mut progress_sender(&window, dev, buf.len())),
&buf,
&mut progress_sender(&mut wv_handle.clone(), dev, buf.len()),
),
dev, dev,
&mut wv_handle, &window,
)?; )?;
Ok(()) Ok(())
},
promise,
);
} }
DeleteFile { promise, dev, path } => {
promise_fn( #[tauri::command]
webview, pub fn delete_file<R: Runtime>(
move || { 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 = get_open_dev(&dev)?;
let handle = handle.lock().unwrap(); let handle = handle.lock().unwrap();
err_wrap(handle.delete_file(&path), dev, &mut wv_handle)?; err_wrap(handle.delete_file(&path), dev, &window)?;
Ok(()) Ok(())
},
promise,
);
} }
DeleteDir { promise, dev, path } => {
promise_fn( #[tauri::command]
webview, pub fn delete_dir<R: Runtime>(
move || { 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 = get_open_dev(&dev)?;
let handle = handle.lock().unwrap(); let handle = handle.lock().unwrap();
err_wrap(handle.delete_dir(&path), dev, &mut wv_handle)?; err_wrap(handle.delete_dir(&path), dev, &window)?;
Ok(()) Ok(())
},
promise,
);
} }
CreateNspireDir { promise, dev, path } => {
promise_fn( #[tauri::command]
webview, pub fn create_nspire_dir<R: Runtime>(
move || { 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 = get_open_dev(&dev)?;
let handle = handle.lock().unwrap(); let handle = handle.lock().unwrap();
err_wrap(handle.create_dir(&path), dev, &mut wv_handle)?; err_wrap(handle.create_dir(&path), dev, &window)?;
Ok(()) Ok(())
},
promise,
);
} }
Move {
promise, #[tauri::command]
dev, pub fn move_file<R: Runtime>(
src, bus_number: u8,
dest, address: u8,
} => { src: String,
promise_fn( dest: String,
webview, window: Window<R>,
move || { ) -> Result<impl Serialize, SerializedError> {
let dev = DevId {
bus_number,
address,
};
let handle = get_open_dev(&dev)?; let handle = get_open_dev(&dev)?;
let handle = handle.lock().unwrap(); let handle = handle.lock().unwrap();
err_wrap(handle.move_file(&src, &dest), dev, &mut wv_handle)?; err_wrap(handle.move_file(&src, &dest), dev, &window)?;
Ok(()) Ok(())
},
promise,
);
} }
Copy {
promise, #[tauri::command]
dev, pub fn copy<R: Runtime>(
src, bus_number: u8,
dest, address: u8,
} => { src: String,
promise_fn( dest: String,
webview, window: Window<R>,
move || { ) -> Result<impl Serialize, SerializedError> {
let dev = DevId {
bus_number,
address,
};
let handle = get_open_dev(&dev)?; let handle = get_open_dev(&dev)?;
let handle = handle.lock().unwrap(); let handle = handle.lock().unwrap();
err_wrap(handle.copy_file(&src, &dest), dev, &mut wv_handle)?; err_wrap(handle.copy_file(&src, &dest), dev, &window)?;
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(()) Ok(())
} }
} }
})
.build() fn main() {
.run(); if cli::run() {
return;
}
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");
}
}
})
.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");
} }

View file

@ -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");
});
}

View file

@ -1,9 +1,5 @@
{ {
"ctx": {},
"tauri": { "tauri": {
"embeddedServer": {
"active": true
},
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["deb", "msi", "appimage", "dmg"], "targets": ["deb", "msi", "appimage", "dmg"],
@ -17,34 +13,46 @@
], ],
"resources": [], "resources": [],
"externalBin": [], "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", "category": "Utility",
"shortDescription": "Free, cross-platform, CX-II compatible computer linking program for the TI-Nspire", "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", "longDescription": "Free, cross-platform, CX-II compatible computer linking program for the TI-Nspire",
"osx": { "macOS": {
"frameworks": [], "frameworks": [],
"minimumSystemVersion": "", "minimumSystemVersion": "",
"useBootstrapper": false "useBootstrapper": false,
"exceptionDomain": "",
"signingIdentity": null,
"entitlements": null
}, },
"exceptionDomain": "" "windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
}
},
"updater": {
"active": false
}, },
"allowlist": { "allowlist": {
"event": true, "shell": {
"notification": true,
"open": true "open": true
}, },
"window": { "dialog": {
"open": true
}
},
"windows": [
{
"title": "N-Link", "title": "N-Link",
"width": 800, "width": 800,
"height": 600, "height": 600,
"resizable": true, "resizable": true,
"fullscreen": false "fullscreen": false
}, }
],
"security": { "security": {
"csp": "default-src blob: data: filesystem: ws: http: https: 'unsafe-eval' 'unsafe-inline'" "csp": "default-src blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self' img-src: 'self'"
},
"inliner": {
"active": true
} }
} }
} }

View file

@ -1,5 +1,6 @@
import {promisified} from 'tauri/api/tauri'; import {invoke} from '@tauri-apps/api/tauri';
import {listen} from 'tauri/api/event'; import {listen} from '@tauri-apps/api/event';
import {open as openDialog} from '@tauri-apps/api/dialog';
import {Component, Vue} from 'vue-property-decorator'; import {Component, Vue} from 'vue-property-decorator';
import type {GenericDevices} from 'n-link-core/components/devices'; 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) { async function downloadFile(dev: DevId | string, path: [string, number], dest: string) {
if (typeof dev === 'string') dev = stringToDev(dev); 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) { async function uploadFile(dev: DevId | string, path: string, src: string) {
if (typeof dev === 'string') dev = stringToDev(dev); 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) { async function uploadOs(dev: DevId | string, src: string) {
if (typeof dev === 'string') dev = stringToDev(dev); 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) { async function deleteFile(dev: DevId | string, path: string) {
if (typeof dev === 'string') dev = stringToDev(dev); 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) { async function deleteDir(dev: DevId | string, path: string) {
if (typeof dev === 'string') dev = stringToDev(dev); 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) { async function createDir(dev: DevId | string, path: string) {
if (typeof dev === 'string') dev = stringToDev(dev); 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) { async function move(dev: DevId | string, src: string, dest: string) {
if (typeof dev === 'string') dev = stringToDev(dev); 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) { async function copy(dev: DevId | string, src: string, dest: string) {
if (typeof dev === 'string') dev = stringToDev(dev); 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) { async function listDir(dev: DevId | string, path: string) {
if (typeof dev === 'string') dev = stringToDev(dev); 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[]> { async function listAll(dev: DevId | string, path: FileInfo): Promise<FileInfo[]> {
@ -195,7 +196,7 @@ class Devices extends Vue implements GenericDevices {
async enumerate() { async enumerate() {
this.enumerating = true; this.enumerating = true;
try { 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); this.$set(this.devices, devToString(dev as DevId), dev);
} }
} finally { } finally {
@ -205,19 +206,19 @@ class Devices extends Vue implements GenericDevices {
async open(dev: DevId | string) { async open(dev: DevId | string) {
if (typeof dev === 'string') dev = stringToDev(dev); 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); this.$set(this.devices[devToString(dev)], 'info', info);
} }
async close(dev: DevId | string) { async close(dev: DevId | string) {
if (typeof dev === 'string') dev = stringToDev(dev); if (typeof dev === 'string') dev = stringToDev(dev);
await promisified({...dev, cmd: 'closeDevice'}); await invoke('close_device', {...dev});
this.$delete(this.devices[devToString(dev)], 'info'); this.$delete(this.devices[devToString(dev)], 'info');
} }
async update(dev: DevId | string) { async update(dev: DevId | string) {
if (typeof dev === 'string') dev = stringToDev(dev); 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); 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) { async promptUploadFiles(dev: DevId | string, path: string) {
if (typeof dev !== 'string') dev = devToString(dev); 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) { for (const src of files) {
this.addToQueue(dev, {action: 'upload', path, src}); this.addToQueue(dev, {action: 'upload', path, src});
} }
@ -235,14 +236,14 @@ class Devices extends Vue implements GenericDevices {
async uploadOs(dev: DevId | string, filter: string) { async uploadOs(dev: DevId | string, filter: string) {
if (typeof dev !== 'string') dev = devToString(dev); 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; if (!src) return;
this.addToQueue(dev, {action: 'uploadOs', src}); this.addToQueue(dev, {action: 'uploadOs', src});
} }
async downloadFiles(dev: DevId | string, files: [string, number][]) { async downloadFiles(dev: DevId | string, files: [string, number][]) {
if (typeof dev !== 'string') dev = devToString(dev); 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; if (!dest) return;
for (const path of files) { for (const path of files) {
this.addToQueue(dev, {action: 'download', path, dest}); this.addToQueue(dev, {action: 'download', path, dest});

View file

@ -4,11 +4,19 @@
<div class="flex-shrink-0 border-r w-64"> <div class="flex-shrink-0 border-r w-64">
<device-select :selected.sync="selectedCalculator"/> <device-select :selected.sync="selectedCalculator"/>
<div class="overflow-auto h-full px-4 py-4"> <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> <h1 class="text-3xl">Drivers required</h1>
<p>The WinUSB driver is required to use this device.</p> <p>The WinUSB driver is required to use this device.</p>
<p class="text-center mt-2"> <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> </p>
</div> </div>
<div v-else-if="calculator && !calculator.info" class="flex items-center justify-center h-full"> <div v-else-if="calculator && !calculator.info" class="flex items-center justify-center h-full">
@ -34,7 +42,7 @@
<script lang="ts"> <script lang="ts">
import {Component, Vue, Watch} from 'vue-property-decorator'; 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 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";
@ -94,8 +102,12 @@ export default class Home extends Vue {
return this.selectedCalculator && this.$devices.devices[this.selectedCalculator]?.needsDrivers; return this.selectedCalculator && this.$devices.devices[this.selectedCalculator]?.needsDrivers;
} }
installDrivers() { get isLinux() {
open('https://lights0123.com/n-link/#windows'); return navigator.platform.includes('Linux');
}
installDrivers(platform = 'windows') {
open(`https://lights0123.com/n-link/#${platform}`);
} }
} }
</script> </script>

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

@ -41,6 +41,7 @@ export interface GenericDevices {
devices: Record<string, Device>; devices: Record<string, Device>;
enumerating: boolean; enumerating: boolean;
hasEnumerated: boolean; hasEnumerated: boolean;
errorHandler?: (e: DOMException) => void;
enumerate(): Promise<void>; enumerate(): Promise<void>;
open(dev: string): Promise<void>; open(dev: string): Promise<void>;

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,11 @@ 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,
'no-console':
process.env.NODE_ENV === 'production'
? ['warn', { allow: ['warn', 'error'] }]
: 'off',
}, },
}; };

View file

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

View file

@ -1,35 +0,0 @@
<template>
<svg
class="NuxtLogo"
width="245"
height="180"
viewBox="0 0 452 342"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M139 330l-1-2c-2-4-2-8-1-13H29L189 31l67 121 22-16-67-121c-1-2-9-14-22-14-6 0-15 2-22 15L5 303c-1 3-8 16-2 27 4 6 10 12 24 12h136c-14 0-21-6-24-12z"
fill="#00C58E"
/>
<path
d="M447 304L317 70c-2-2-9-15-22-15-6 0-15 3-22 15l-17 28v54l39-67 129 230h-49a23 23 0 0 1-2 14l-1 1c-6 11-21 12-23 12h76c3 0 17-1 24-12 3-5 5-14-2-26z"
fill="#108775"
/>
<path
d="M376 330v-1l1-2c1-4 2-8 1-12l-4-12-102-178-15-27h-1l-15 27-102 178-4 12a24 24 0 0 0 2 15c4 6 10 12 24 12h190c3 0 18-1 25-12zM256 152l93 163H163l93-163z"
fill="#2F495E"
/>
</svg>
</template>
<style>
.NuxtLogo {
animation: 1s appear;
margin: auto;
}
@keyframes appear {
0% {
opacity: 0;
}
}
</style>

View file

@ -1,20 +1,24 @@
import {Component, Vue} from 'vue-property-decorator'; import { Component, Vue } from 'vue-property-decorator';
import {RpcProvider} from 'worker-rpc'; 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,54 +31,51 @@ export type Device = {
running?: boolean; running?: boolean;
}; };
async function downloadFile( async function downloadFile(dev: Rpc, path: [string, number]) {
dev: RpcProvider, const data: Uint8Array = await dev.rpc('downloadFile', { path });
path: [string, number]
) {
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);
const parts: FileInfo[] = []; const parts: FileInfo[] = [];
for (const file of contents) { for (const file of contents) {
parts.push( parts.push(
...(await listAll(dev, {...file, path: `${path.path}/${file.path}`})) ...(await listAll(dev, { ...file, path: `${path.path}/${file.path}` }))
); );
} }
parts.push(path); parts.push(path);
@ -92,14 +93,14 @@ class Devices extends Vue implements GenericDevices {
enumerating = false; enumerating = false;
hasEnumerated = false; hasEnumerated = false;
devices: Record<string, Device> = {}; devices: Record<string, Device> = {};
errorHandler?: (e: DOMException) => void;
created() { created() {}
}
async runQueue(dev: string) { async runQueue(dev: string) {
const device = this.devices[dev]; const device = this.devices[dev];
if (!device?.queue || !device.worker || device.running) return; if (!device?.queue || !device.worker || device.running) return;
const {rpc} = device.worker; const { rpc } = device.worker;
this.$set(device, 'running', true); this.$set(device, 'running', true);
// eslint-disable-next-line no-constant-condition // eslint-disable-next-line no-constant-condition
while (true) { while (true) {
@ -116,7 +117,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()));
@ -133,6 +138,7 @@ class Devices extends Vue implements GenericDevices {
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
this.errorHandler?.(e);
} }
if ('progress' in device) this.$delete(device, 'progress'); if ('progress' in device) this.$delete(device, 'progress');
device.queue.shift(); device.queue.shift();
@ -147,7 +153,7 @@ class Devices extends Vue implements GenericDevices {
this.$set(device, 'queue', []); this.$set(device, 'queue', []);
} }
device.queue?.push( device.queue?.push(
...cmds.map((cmd) => ({...cmd, id: queueId++} as Cmd)) ...cmds.map((cmd) => ({ ...cmd, id: queueId++ } as Cmd))
); );
this.runQueue(dev); this.runQueue(dev);
} }
@ -157,7 +163,7 @@ class Devices extends Vue implements GenericDevices {
if (!navigator.usb) return; if (!navigator.usb) return;
const device = await navigator.usb.requestDevice({ const device = await navigator.usb.requestDevice({
filters: [ filters: [
{vendorId: VID, productId: PID}, { vendorId: VID, productId: PID },
{ {
vendorId: VID, vendorId: VID,
productId: PID_CX2, productId: PID_CX2,
@ -167,7 +173,7 @@ class Devices extends Vue implements GenericDevices {
navigator.usb.ondisconnect = (e) => { navigator.usb.ondisconnect = (e) => {
const [key] = const [key] =
Object.entries(this.devices).find( Object.entries(this.devices).find(
([_, {device}]) => device === e.device ([_, { device }]) => device === e.device
) || []; ) || [];
if (key) { if (key) {
this.$delete(this.devices, key); this.$delete(this.devices, key);
@ -194,18 +200,35 @@ 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 = {
worker.onmessage = ({data}) => { 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 ('usbCmd' in data) return compat.processCmd(data);
if('total' in data) { if ('total' in data) {
this.$set(this.devices[dev], 'progress', data); this.$set(this.devices[dev], 'progress', data);
return; return;
} }
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);
} }
@ -231,25 +254,25 @@ class Devices extends Vue implements GenericDevices {
async uploadFiles(dev: string, path: string, files: File[]) { async uploadFiles(dev: string, path: string, files: File[]) {
for (const file of files) { 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'); throw new Error('Unimplemented');
} }
async uploadOs(dev: string, filter: string) { async uploadOs(_dev: string, _filter: string) {
throw new Error('Unimplemented'); throw new Error('Unimplemented');
} }
async uploadOsFile(dev: string, file: File): Promise<void> { 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][]) { async downloadFiles(dev: string, files: [string, number][]) {
for (const path of files) { 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) { 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) { 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) { async move(dev: string, src: string, dest: string) {
this.addToQueue(dev, {action: 'move', src, dest}); this.addToQueue(dev, { action: 'move', src, dest });
} }
} }

View file

@ -10,14 +10,14 @@ export enum UsbError {
Unknown = 'Unknown', 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

@ -4,11 +4,16 @@ export default {
// Global page headers (https://go.nuxtjs.dev/config-head) // Global page headers (https://go.nuxtjs.dev/config-head)
head: { head: {
title: 'web', title: 'N-Link',
meta: [ meta: [
{ charset: 'utf-8' }, { charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' }, { 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' }], 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 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

@ -1,6 +1,6 @@
{ {
"name": "web", "name": "web",
"version": "0.1.5", "version": "0.1.6",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "nuxt-ts", "dev": "nuxt-ts",
@ -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, Ref, Vue, Watch } 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,9 +116,13 @@ 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;
this.$devices.errorHandler = (e: DOMException) => {
this.errorMessage.handleError(e, 'operation');
};
} }
@Watch('$devices.hasEnumerated') @Watch('$devices.hasEnumerated')
@ -147,7 +157,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);

3
web/static/_headers Normal file
View 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
View file

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

1328
yarn.lock

File diff suppressed because it is too large Load diff