From 9dc2ac8c7afb5e83185c8f4a57a1870944c14781 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 19 Jul 2026 19:43:53 -0400 Subject: [PATCH] feat(desktop): restore headless CLI mode on clap 4 Reuses nlink-cli's already-migrated cli.rs rather than repeating the clap 3-beta -> 4 migration. Also copies NOTICE.txt alongside cli.rs since its include_str! path is relative to the file's own directory. --- desktop/src-tauri/src/NOTICE.txt | 7 + desktop/src-tauri/src/cli.rs | 394 +++++++++++++++++++++++++++++++ desktop/src-tauri/src/main.rs | 4 + 3 files changed, 405 insertions(+) create mode 100644 desktop/src-tauri/src/NOTICE.txt create mode 100644 desktop/src-tauri/src/cli.rs diff --git a/desktop/src-tauri/src/NOTICE.txt b/desktop/src-tauri/src/NOTICE.txt new file mode 100644 index 0000000..c333685 --- /dev/null +++ b/desktop/src-tauri/src/NOTICE.txt @@ -0,0 +1,7 @@ +This program contains parts of other programs licensed under the GPL version +3.0. They are as follows: + +- libnspire, available for download at + https://github.com/lights0123/libnspire-rs +- Flat Remix, originally at https://github.com/daniruiz/flat-remix, with + parts extracted available at {} diff --git a/desktop/src-tauri/src/cli.rs b/desktop/src-tauri/src/cli.rs new file mode 100644 index 0000000..922810a --- /dev/null +++ b/desktop/src-tauri/src/cli.rs @@ -0,0 +1,394 @@ +use std::ffi::OsStr; +use std::io::{Read, Write}; +use std::path::PathBuf; +use std::{fs::File, path::Path}; + +use clap::{Args, Parser, Subcommand}; +use indicatif::{ProgressBar, ProgressStyle}; +use libnspire::{dir::EntryType, PID, PID_CX2, VID}; + +#[derive(Parser, Debug)] +#[clap(author, about, version)] +struct Opt { + #[clap(subcommand)] + cmd: Option, +} + +#[derive(Subcommand, Debug)] +enum SubCommand { + Upload(Upload), + Download(Download), + UploadOS(UploadOS), + Copy(Copy), + Move(Move), + Mkdir(Mkdir), + Rmdir(Rmdir), + Ls(Ls), + /// View license information + License, +} + +/// Upload files to the calculator +#[derive(Args, Debug)] +struct Upload { + /// Files to upload + #[clap(required = true)] + files: Vec, + /// Destination path + dest: String, +} + +/// Download files from the calculator +#[derive(Args, Debug)] +struct Download { + /// Files to download + #[clap(required = true)] + files: Vec, + /// Destination path + #[clap(required = true)] + dest: PathBuf, +} + +/// Upload and install a .tcc/.tco/.tcc2/.tco2/.tct2 OS file +#[derive(Args, Debug)] +struct UploadOS { + /// Path to the OS file + #[clap(required = true)] + file: PathBuf, + + /// Disables the file extension check + #[clap(long)] + no_check_os: bool, +} + +/// Copy a file to a different location +#[derive(Args, 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(Args, 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(Args, Debug)] +struct Mkdir { + /// Path to directory + #[clap(required = true)] + path: String, +} + +/// Delete a directory +#[derive(Args, Debug)] +struct Rmdir { + /// Path to directory + #[clap(required = true)] + path: String, +} + +/// List the contents of a directory +#[derive(Args, Debug)] +struct Ls { + /// Path to directory + #[clap(required = true)] + path: String, +} + +fn get_dev() -> Option> { + rusb::devices() + .unwrap() + .iter() + .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) + }) + .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 { + match cmd { + SubCommand::Upload(Upload { files, mut dest }) => { + if let Some(handle) = get_dev() { + for file in files { + let mut buf = vec![]; + 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_message(&format!("Upload {}", name)); + bar.enable_steady_tick(100); + if dest.ends_with('/') { + dest.remove(dest.len() - 1); + } + 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"); + } + } + SubCommand::License => { + println!("{}", include_str!("../../LICENSE")); + println!(include_str!("NOTICE.txt"), env!("CARGO_PKG_REPOSITORY")); + } + } + true + } else { + false + } +} diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 86bebdc..ca75882 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -9,6 +9,7 @@ use tauri::{Emitter, Manager, Runtime, WebviewWindow}; use crate::device::{add_device, AddDevice, DevId, DEVICES}; +mod cli; mod commands; mod device; mod error; @@ -81,6 +82,9 @@ impl Hotplug for DeviceMon { } fn main() { + if cli::run() { + return; + } tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_shell::init())