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.
This commit is contained in:
Your Name 2026-07-19 19:43:53 -04:00
parent 4c629aaf53
commit 9dc2ac8c7a
3 changed files with 405 additions and 0 deletions

View file

@ -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 {}

View file

@ -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<SubCommand>,
}
#[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<PathBuf>,
/// Destination path
dest: String,
}
/// Download files from the calculator
#[derive(Args, Debug)]
struct Download {
/// Files to download
#[clap(required = true)]
files: Vec<String>,
/// 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<libnspire::Handle<rusb::GlobalContext>> {
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
}
}

View file

@ -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<R: Runtime> Hotplug<GlobalContext> for DeviceMon<R> {
}
fn main() {
if cli::run() {
return;
}
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())