- Correct the vendored libnspire-sys retry limit in spec and plan: the
actual constant in vendor/libnspire-sys/libnspire/src/cx2.cpp:391 is 30,
not 100. The '10 -> 100' claim originated in nlink-cli's own Cargo.toml
comment and was propagated into both documents unverified.
- Drop @tauri-apps/plugin-opener from desktop/package.json; the Rust plugin
and its capability entry were removed in Task 3, leaving this dangling.
- Reconcile desktop/package.json version 0.1.0 -> 0.1.6 to match Cargo.toml
and tauri.conf.json.
- Rewrite desktop/README.md, which still instructed users to
'sudo apt install webkit2gtk-4.0' -- the exact unavailable package this
port exists to move off. Now documents npm workspaces, the --legacy-peer-deps
requirement, the dist/-before-cargo gotcha, snap LD_LIBRARY_PATH breakage,
AppImage FUSE fallback, and the vendored patch.
Disclosure note: commit 4626858 also committed the previously untracked
nlink-cli/ and vendor/ trees (~5900 lines). Committing vendor/ is required --
the [patch.crates-io] override cannot resolve without it -- but the commit
message did not say so. Recorded here rather than rewriting branch history.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
46 KiB
n-link Desktop — Tauri 2 Backend Port (Plan A)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Get a Tauri 2 shell running on Ubuntu 26.04 with the full n-link Rust backend ported and verified against a physically connected TI-Nspire CX II.
Architecture: Scaffold a clean Tauri 2 + Vue 3 + Vite baseline that replaces the contents of desktop/, then port the Rust backend into it as focused modules (device.rs, commands.rs, error.rs, cli.rs) rather than one 478-line main.rs. The IPC contract — 13 commands, 3 events, camelCase payloads — is held byte-identical so the frontend port (Plan B) is a separate, independently verifiable change.
Tech Stack: Rust 1.97.1, tauri 2.11.5, tauri-plugin-dialog 2.7.2, tauri-plugin-shell 2.3.5, libnspire 0.2.3, rusb 0.6.4, clap 4, Vite 8.1.5, Vue 3.5.40, Node 22.22.1, npm workspaces.
Source of truth: docs/superpowers/specs/2026-07-19-desktop-tauri2-vue3-port-design.md
Global Constraints
- Branch:
port/tauri2-vue3. Do not commit tomain. - Linux only. Bundle targets are exactly
["deb", "appimage"]. Never addmsiordmg. web/is out of scope and knowingly broken. Do not modify any file underweb/.vendor/libnspire-sysis wired via[patch.crates-io]. Its raised CX II NNSE handshake retry limit (10 → 30) is required; do not drop the patch or bumplibnspire-syspast it.- The IPC contract is frozen. Command names, event names, and payload field names (camelCase via serde) must not change.
- Do not commit
target/ornode_modules/. - No test framework is introduced. The spec approved manual verification with
n-link-clias oracle; verification steps below replace the usual TDD cycle. This is a deliberate, recorded decision, not an omission.
Oracle binary: /home/jjureta/Projects/n-link/nlink-cli/target/release/n-link-cli
AMENDMENT 2026-07-19: oracle unavailable
Verified on this machine: n-link-cli ls / returns Busy and then hangs
indefinitely (exit 124 at 45s, no output). Ruled out as causes: device-node
permissions (ACL grants the user rw), the udev rule (present), and device
disconnection (continuously enumerated).
This is the known TI-Nspire CX II firmware issue described in
nlink-cli/README.md. The user has taken ownership of it and it is out of
scope for Plan A.
Consequence for verification: any gate requiring a libnspire data transfer cannot pass and is downgraded to non-blocking. Specifically:
| Gate | Status |
|---|---|
| Task 1 Step 5 (oracle baseline) | Skipped — cannot produce a listing |
Task 7 Step 3, open_device / list_dir |
Non-blocking — expected to hang or return Busy |
| Task 7 Step 4 (download + sha256 cross-check) | Skipped entirely |
What Plan A must still prove, unchanged and fully achievable:
- A Tauri 2 window opens on webkit2gtk-4.1 — the actual goal of this port.
- The backend compiles and all 13 commands register.
enumeratereturns the CX II with correct camelCase keys. This reads only USB descriptors viarusband performs no libnspire transfer, so it is unaffected by theBusyissue — and it exercises serde serialization, IPC, and event wiring, which is the bulk of what this plan ports.- Hotplug
addDevice/removeDeviceevents fire. - Headless
--helpworks; deb and AppImage build, install, and render.
A transfer-path failure at Task 7 is therefore expected and must not be treated as a port regression or a reason to halt.
Task 1: Install system dependencies and confirm the toolchain
Files: none (environment only)
Interfaces:
-
Consumes: nothing
-
Produces: a system on which
pkg-config --modversion webkit2gtk-4.1succeeds; prerequisite for every later task. -
Step 1: Confirm the CX II is connected
Run: lsusb | grep 0451
Expected: a line containing 0451:e022 Texas Instruments, Inc. Nspire CX II
If absent, plug in the calculator and power it on before continuing. Every verification gate depends on it.
- Step 2: Install the Tauri 2 system dependencies
Requires sudo. Run:
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libjavascriptcoregtk-4.1-dev \
libsoup-3.0-dev \
librsvg2-dev \
libxdo-dev \
libayatana-appindicator3-dev \
build-essential curl wget file
- Step 3: Verify every required library now resolves
Run:
for p in webkit2gtk-4.1 javascriptcoregtk-4.1 libsoup-3.0 gtk+-3.0 libusb-1.0; do
printf "%-24s " "$p"; pkg-config --modversion "$p" || echo MISSING
done
Expected: five version numbers, no MISSING. webkit2gtk-4.1 should report 2.52.x.
If any line reports MISSING, stop. The port cannot proceed and the spec's core premise has failed.
- Step 4: Verify the oracle binary works
Run: /home/jjureta/Projects/n-link/nlink-cli/target/release/n-link-cli ls /
Expected: a directory listing from the calculator.
If the binary is missing, build it: cd /home/jjureta/Projects/n-link/nlink-cli && cargo build --release
If it errors with a USB permission problem, apply the udev rule documented in nlink-cli/README.md before continuing.
- Step 5: Record the oracle baseline
Run and save the output — later gates compare against it:
/home/jjureta/Projects/n-link/nlink-cli/target/release/n-link-cli ls / > /tmp/oracle-root-listing.txt
cat /tmp/oracle-root-listing.txt
No commit for this task — it changes no files.
Task 2: Scaffold the Tauri 2 baseline (GO/NO-GO GATE)
Files:
- Delete:
desktop/src/,desktop/src-tauri/,desktop/babel.config.js,desktop/vue.config.js,desktop/.browserslistrc,desktop/postcss.config.js,desktop/tailwind.config.js,desktop/tsconfig.json,desktop/.eslintrc.js - Create:
desktop/scaffold (package.json, vite.config.ts, index.html, src/, src-tauri/) - Preserve:
desktop/app-icon.png,desktop/app-icon.svg,desktop/LICENSE,desktop/README.md
Interfaces:
-
Consumes: system deps from Task 1
-
Produces: a runnable Tauri 2 project at
desktop/, withsrc-tauri/Cargo.toml(package namen-link) andsrc-tauri/tauri.conf.json(v2 schema). Task 3 onward add modules underdesktop/src-tauri/src/. -
Step 1: Preserve the files worth keeping
cd /home/jjureta/Projects/n-link/desktop
mkdir -p /tmp/nlink-keep
cp app-icon.png app-icon.svg LICENSE README.md /tmp/nlink-keep/
cp src-tauri/src/cli.rs /tmp/nlink-keep/cli-old.rs
cp src-tauri/src/main.rs /tmp/nlink-keep/main-old.rs
cp src-tauri/src/cmd.rs /tmp/nlink-keep/cmd-old.rs
cp src-tauri/tauri.conf.json /tmp/nlink-keep/tauri.conf.old.json
ls /tmp/nlink-keep
Expected: 8 files listed. These are reference copies; git history also retains them.
- Step 2: Remove the Vue CLI / Tauri 1 tree
cd /home/jjureta/Projects/n-link/desktop
git rm -r --quiet src src-tauri
git rm --quiet babel.config.js vue.config.js .browserslistrc postcss.config.js tailwind.config.js tsconfig.json .eslintrc.js package.json
git status --short
Expected: deletions staged. app-icon.*, LICENSE, README.md remain untouched.
- Step 3: Scaffold a fresh Tauri 2 + Vue + TS project
Run from the repo root so the scaffold lands in desktop/:
cd /home/jjureta/Projects/n-link
npm create tauri-app@latest -- \
--directory . \
--name desktop \
--manager npm \
--template vue-ts \
--identifier com.lights0123.n-link \
--yes
If the CLI refuses to write into the non-empty desktop/, scaffold to a temp dir and move the generated files in:
cd /tmp && rm -rf nlink-scaffold
npm create tauri-app@latest nlink-scaffold -- --manager npm --template vue-ts --identifier com.lights0123.n-link --yes
cp -r /tmp/nlink-scaffold/. /home/jjureta/Projects/n-link/desktop/
rm -rf /home/jjureta/Projects/n-link/desktop/.git
- Step 4: Restore the preserved assets
cd /home/jjureta/Projects/n-link/desktop
cp /tmp/nlink-keep/app-icon.png /tmp/nlink-keep/app-icon.svg /tmp/nlink-keep/LICENSE /tmp/nlink-keep/README.md .
ls app-icon.png app-icon.svg LICENSE README.md
Expected: all four present.
- Step 5: Convert the workspace from yarn to npm
Replace /home/jjureta/Projects/n-link/package.json with:
{
"private": true,
"workspaces": [
"desktop",
"n-link-core",
"web"
]
}
Then remove the stale lockfile — this port invalidates it:
cd /home/jjureta/Projects/n-link
git rm --quiet yarn.lock
- Step 6: Install dependencies
cd /home/jjureta/Projects/n-link
npm install --workspace desktop
Expected: completes without error.
If it fails resolving web/'s dependencies (npm reads every workspace member's manifest even when installing just one, and web/ is on Nuxt 2 with a 2020-era tree), temporarily drop web from the workspaces array:
{
"private": true,
"workspaces": ["desktop", "n-link-core"]
}
This is consistent with the spec — web/ is knowingly out of scope and left broken. Record it in the commit message so Plan C knows to restore the entry.
- Step 7: GO/NO-GO — launch the scaffold
cd /home/jjureta/Projects/n-link/desktop
npm run tauri dev
Expected: a native window opens showing the default Tauri + Vue welcome page.
This is the decisive gate. If the window opens, webkit2gtk-4.1 works and the entire approach is validated. If it fails to build or open, stop and report the exact error — everything downstream depends on this and no further work is worthwhile until it is resolved.
Close the window with Ctrl+C in the terminal.
- Step 8: Commit the scaffold
cd /home/jjureta/Projects/n-link
cat >> .gitignore <<'EOF'
# build artifacts
node_modules/
target/
dist/
EOF
git add -A
git commit -m "feat(desktop): replace Tauri 1 / Vue CLI tree with Tauri 2 + Vue 3 + Vite scaffold
Tauri 1.0.0-beta.8 links webkit2gtk-4.0, which is unavailable on
Ubuntu 26.04. Tauri 2 links webkit2gtk-4.1. Verified: scaffold window
opens.
Workspace moved from yarn to npm; yarn.lock invalidated by this port."
Task 3: Port the error type and device registry
Files:
- Create:
desktop/src-tauri/src/error.rs - Create:
desktop/src-tauri/src/device.rs - Modify:
desktop/src-tauri/Cargo.toml
Interfaces:
-
Consumes: the scaffold from Task 2.
-
Produces:
error::SerializedError—#[derive(Serialize)] pub struct SerializedError(String), with a blanketFrom<T: Display>.device::DevId { bus_number: u8, address: u8 }—Copy + Serialize + Deserialize, serializedcamelCaseasbusNumber/address.device::Device { name: String, device: Arc<rusb::Device<GlobalContext>>, state: DeviceState, needs_drivers: bool }device::DeviceState::{Open(Arc<Mutex<libnspire::Handle<GlobalContext>>>, libnspire::info::Info), Closed}device::DEVICES: RwLock<HashMap<(u8, u8), Device>>(std HashMap, not hashbrown)device::add_device(Arc<rusb::Device<GlobalContext>>) -> rusb::Result<((u8, u8), Device)>device::get_open_dev(&DevId) -> Result<Arc<Mutex<libnspire::Handle<GlobalContext>>>, anyhow::Error>device::{AddDevice, ProgressUpdate, FileInfo}payload structs
-
Step 1: Set the backend dependencies
Replace the [dependencies] section of desktop/src-tauri/Cargo.toml so the file reads:
[package]
name = "n-link"
version = "0.1.6"
description = "Free, cross-platform, CX-II compatible computer linking program for the TI-Nspire"
authors = ["Ben Schattinger <developer@lights0123.com>"]
license = "GPL-3.0"
repository = "https://github.com/lights0123/n-link"
default-run = "n-link"
edition = "2021"
build = "build.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2.11.5", features = [] }
tauri-plugin-dialog = "2.7.2"
tauri-plugin-shell = "2.3.5"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
lazy_static = "1.4"
libnspire = "0.2.3"
rusb = "0.6.4"
clap = { version = "4", features = ["derive"] }
indicatif = "0.15"
[patch.crates-io]
libnspire-sys = { path = "../../vendor/libnspire-sys" }
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
[[bin]]
name = "n-link"
path = "src/main.rs"
Two deliberate changes from the original, both required:
hashbrownis dropped. The oldcmd.rs:62useddrain_filter, a hashbrown 0.11 API that was renamed toextract_ifwith changed semantics. Task 4 replaces that call with an explicit collect-then-remove on a stdHashMap, which is stable and needs no third-party map.libusb1-sysvendored is dropped.nlink-cliestablished that the vendored build is broken on this toolchain; the systemlibusb-1.0(1.0.29, already installed) is used instead.
Note the patch path is ../../vendor/libnspire-sys — two levels up from desktop/src-tauri/, unlike nlink-cli's one level.
- Step 2: Write
error.rs
Create desktop/src-tauri/src/error.rs:
use serde::Serialize;
/// Wraps any displayable error into a serde-serializable form so it can
/// cross the Tauri IPC boundary.
#[derive(Serialize)]
pub struct SerializedError(String);
impl<T: std::fmt::Display> From<T> for SerializedError {
fn from(f: T) -> Self {
SerializedError(f.to_string())
}
}
- Step 3: Write
device.rs
Create desktop/src-tauri/src/device.rs:
use std::collections::HashMap;
use std::sync::{Arc, Mutex, RwLock};
use std::time::Duration;
use libnspire::{PID, PID_CX2, VID};
use rusb::GlobalContext;
use serde::{Deserialize, Serialize};
pub enum DeviceState {
Open(
Arc<Mutex<libnspire::Handle<GlobalContext>>>,
libnspire::info::Info,
),
Closed,
}
pub struct Device {
pub name: String,
pub device: Arc<rusb::Device<GlobalContext>>,
pub state: DeviceState,
pub needs_drivers: bool,
}
lazy_static::lazy_static! {
pub static ref DEVICES: RwLock<HashMap<(u8, u8), Device>> = RwLock::new(HashMap::new());
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(Copy, Clone, Eq, PartialEq, Debug, Hash)]
pub struct DevId {
pub bus_number: u8,
pub address: u8,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AddDevice {
#[serde(flatten)]
pub dev: DevId,
pub name: String,
pub is_cx_ii: bool,
pub needs_drivers: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProgressUpdate {
#[serde(flatten)]
pub dev: DevId,
pub remaining: usize,
pub total: usize,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileInfo {
pub path: String,
pub is_dir: bool,
pub date: u64,
pub size: u64,
}
pub fn add_device(dev: Arc<rusb::Device<GlobalContext>>) -> rusb::Result<((u8, u8), Device)> {
let descriptor = dev.device_descriptor()?;
if !(descriptor.vendor_id() == VID && matches!(descriptor.product_id(), PID | PID_CX2)) {
return Err(rusb::Error::Other);
}
let (name, needs_drivers) = match dev.open() {
Ok(handle) => (
handle.read_product_string(
handle.read_languages(Duration::from_millis(100))?[0],
&descriptor,
Duration::from_millis(100),
)?,
false,
),
Err(rusb::Error::NotSupported) | Err(rusb::Error::Access) => (
if descriptor.product_id() == PID_CX2 {
"TI-Nspire CX II"
} else {
"TI-Nspire"
}
.to_string(),
true,
),
Err(other) => return Err(other),
};
Ok((
(dev.bus_number(), dev.address()),
Device {
name,
device: dev,
state: DeviceState::Closed,
needs_drivers,
},
))
}
pub fn get_open_dev(
dev: &DevId,
) -> Result<Arc<Mutex<libnspire::Handle<GlobalContext>>>, anyhow::Error> {
if let Some(dev) = DEVICES.read().unwrap().get(&(dev.bus_number, dev.address)) {
match &dev.state {
DeviceState::Open(handle, _) => Ok(handle.clone()),
DeviceState::Closed => anyhow::bail!("Device closed"),
}
} else {
anyhow::bail!("Failed to find device");
}
}
- Step 4: Wire the modules and check it compiles
Replace desktop/src-tauri/src/main.rs with a temporary stub so the crate builds while later tasks fill it in:
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod device;
mod error;
fn main() {
tauri::Builder::default()
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Run: cd /home/jjureta/Projects/n-link/desktop/src-tauri && cargo check
Expected: compiles. Warnings about unused items in device.rs/error.rs are expected at this stage.
If libnspire-sys fails to build, confirm the patch path resolves: ls ../../vendor/libnspire-sys/Cargo.toml must exist.
- Step 5: Commit
cd /home/jjureta/Projects/n-link
git add desktop/src-tauri/Cargo.toml desktop/src-tauri/Cargo.lock desktop/src-tauri/src/
git commit -m "feat(desktop): port device registry and error type to Tauri 2
Splits the former 478-line main.rs into focused modules. Drops hashbrown
in favour of std HashMap (the drain_filter API it was used for was
renamed with changed semantics) and drops vendored libusb1-sys in favour
of the system library, matching nlink-cli."
Task 4: Port the 13 commands
Files:
- Create:
desktop/src-tauri/src/commands.rs
Interfaces:
- Consumes: everything
device.rsanderror.rsproduce in Task 3. - Produces: 13
#[tauri::command]functions registered by Task 5 —enumerate,open_device,close_device,update_device,list_dir,download_file,upload_file,upload_os,delete_file,delete_dir,create_nspire_dir,move_file,copy. Alsocommands::{err_wrap, progress_sender}helpers, used only within this module.
Three Tauri 2 API changes apply throughout this file:
Window<R>→WebviewWindow<R>window.emit(...)requiresuse tauri::Emitterin scopedrain_filteris gone — replaced with collect-then-remove
- Step 1: Write
commands.rs
Create desktop/src-tauri/src/commands.rs:
use std::fs::File;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::sync::Arc;
use libnspire::dir::EntryType;
use libnspire::PID_CX2;
use serde::Serialize;
use tauri::{Emitter, Runtime, WebviewWindow};
use crate::device::{
add_device, get_open_dev, AddDevice, DevId, DeviceState, FileInfo, ProgressUpdate, DEVICES,
};
use crate::error::SerializedError;
/// If the device vanished mid-operation, drop it from the registry and tell
/// the frontend, then pass the original result through unchanged.
fn err_wrap<T, R: Runtime>(
res: Result<T, libnspire::Error>,
dev: DevId,
window: &WebviewWindow<R>,
) -> Result<T, libnspire::Error> {
if let Err(libnspire::Error::NoDevice) = res {
DEVICES
.write()
.unwrap()
.remove(&(dev.bus_number, dev.address));
if let Err(msg) = window.emit("removeDevice", dev) {
eprintln!("{}", msg);
};
}
res
}
/// Emits a `progress` event on every 6th callback, and always on completion,
/// to avoid flooding the IPC channel during large transfers.
fn progress_sender<R: Runtime>(
window: &WebviewWindow<R>,
dev: DevId,
total: usize,
) -> impl FnMut(usize) + '_ {
let mut i = 0;
move |remaining| {
if i > 5 {
i = 0;
}
if i == 0 || remaining == 0 {
if let Err(msg) = window.emit(
"progress",
ProgressUpdate {
dev,
remaining,
total,
},
) {
eprintln!("{}", msg);
};
}
i += 1;
}
}
#[tauri::command]
pub fn enumerate<R: Runtime>(window: WebviewWindow<R>) -> Result<Vec<AddDevice>, SerializedError> {
let devices: Vec<_> = rusb::devices()?.iter().collect();
let mut map = DEVICES.write().unwrap();
// Replaces hashbrown's `drain_filter`: collect the keys of devices that are
// no longer present on the bus, then remove them. std HashMap has no stable
// drain-with-predicate, and this is clearer than the original anyway.
let stale: Vec<(u8, u8)> = map
.keys()
.filter(|k| {
devices
.iter()
.all(|d| d.bus_number() != k.0 || d.address() != k.1)
})
.copied()
.collect();
for key in stale {
map.remove(&key);
if let Err(msg) = window.emit(
"removeDevice",
DevId {
bus_number: key.0,
address: key.1,
},
) {
eprintln!("{}", msg);
}
}
let filtered: Vec<_> = devices
.into_iter()
.filter(|d| !map.contains_key(&(d.bus_number(), d.address())))
.collect();
Ok(
filtered
.into_iter()
.filter_map(|dev| add_device(Arc::new(dev)).ok())
.map(|dev| {
let msg = AddDevice {
dev: DevId {
bus_number: (dev.0).0,
address: (dev.0).1,
},
name: (dev.1).name.clone(),
is_cx_ii: (dev.1)
.device
.device_descriptor()
.map(|d| d.product_id() == PID_CX2)
.unwrap_or(false),
needs_drivers: (dev.1).needs_drivers,
};
map.insert(dev.0, dev.1);
msg
})
.collect(),
)
}
#[tauri::command]
pub fn open_device(bus_number: u8, address: u8) -> Result<impl Serialize, SerializedError> {
let device = if let Some(dev) = DEVICES.read().unwrap().get(&(bus_number, address)) {
if !matches!(dev.state, DeviceState::Closed) {
return Err("Already open".into());
};
dev.device.clone()
} else {
return Err("Failed to find device".into());
};
let handle = libnspire::Handle::new(device.open()?)?;
let info = handle.info()?;
{
let mut guard = DEVICES.write().unwrap();
let device = guard
.get_mut(&(bus_number, address))
.ok_or_else(|| anyhow::anyhow!("Device lost"))?;
device.state = DeviceState::Open(Arc::new(std::sync::Mutex::new(handle)), info.clone());
}
Ok(info)
}
#[tauri::command]
pub fn close_device(bus_number: u8, address: u8) -> Result<impl Serialize, SerializedError> {
let mut guard = DEVICES.write().unwrap();
let device = guard
.get_mut(&(bus_number, address))
.ok_or_else(|| anyhow::anyhow!("Device lost"))?;
device.state = DeviceState::Closed;
Ok(())
}
#[tauri::command]
pub fn update_device<R: Runtime>(
bus_number: u8,
address: u8,
window: WebviewWindow<R>,
) -> Result<impl Serialize, SerializedError> {
let dev = DevId {
bus_number,
address,
};
let handle = get_open_dev(&dev)?;
let handle = handle.lock().unwrap();
let info = err_wrap(handle.info(), dev, &window)?;
Ok(info)
}
#[tauri::command]
pub fn list_dir<R: Runtime>(
bus_number: u8,
address: u8,
path: String,
window: WebviewWindow<R>,
) -> Result<impl Serialize, SerializedError> {
let dev = DevId {
bus_number,
address,
};
let handle = get_open_dev(&dev)?;
let handle = handle.lock().unwrap();
let dir = err_wrap(handle.list_dir(&path), dev, &window)?;
Ok(
dir
.iter()
.map(|file| FileInfo {
path: file.name().to_string_lossy().to_string(),
is_dir: file.entry_type() == EntryType::Directory,
date: file.date(),
size: file.size(),
})
.collect::<Vec<_>>(),
)
}
#[tauri::command]
pub fn download_file<R: Runtime>(
bus_number: u8,
address: u8,
path: (String, u64),
dest: String,
window: WebviewWindow<R>,
) -> Result<impl Serialize, SerializedError> {
let dev = DevId {
bus_number,
address,
};
let (file, size) = path;
let dest = PathBuf::from(dest);
let handle = get_open_dev(&dev)?;
let handle = handle.lock().unwrap();
let mut buf = vec![0; size as usize];
err_wrap(
handle.read_file(
&file,
&mut buf,
&mut progress_sender(&window, dev, size as usize),
),
dev,
&window,
)?;
if let Some(name) = file.split('/').last() {
File::create(dest.join(name))?.write_all(&buf)?;
}
Ok(())
}
#[tauri::command]
pub fn upload_file<R: Runtime>(
bus_number: u8,
address: u8,
path: String,
src: String,
window: WebviewWindow<R>,
) -> Result<impl Serialize, SerializedError> {
let dev = DevId {
bus_number,
address,
};
let file = PathBuf::from(src);
let handle = get_open_dev(&dev)?;
let handle = handle.lock().unwrap();
let mut buf = vec![];
File::open(&file)?.read_to_end(&mut buf)?;
let name = file
.file_name()
.ok_or_else(|| anyhow::anyhow!("Failed to get file name"))?
.to_string_lossy()
.to_string();
err_wrap(
handle.write_file(
&format!("{}/{}", path, name),
&buf,
&mut progress_sender(&window, dev, buf.len()),
),
dev,
&window,
)?;
Ok(())
}
#[tauri::command]
pub fn upload_os<R: Runtime>(
bus_number: u8,
address: u8,
src: String,
window: WebviewWindow<R>,
) -> Result<impl Serialize, SerializedError> {
let dev = DevId {
bus_number,
address,
};
let handle = get_open_dev(&dev)?;
let handle = handle.lock().unwrap();
let mut buf = vec![];
File::open(&src)?.read_to_end(&mut buf)?;
err_wrap(
handle.send_os(&buf, &mut progress_sender(&window, dev, buf.len())),
dev,
&window,
)?;
Ok(())
}
#[tauri::command]
pub fn delete_file<R: Runtime>(
bus_number: u8,
address: u8,
path: String,
window: WebviewWindow<R>,
) -> Result<impl Serialize, SerializedError> {
let dev = DevId {
bus_number,
address,
};
let handle = get_open_dev(&dev)?;
let handle = handle.lock().unwrap();
err_wrap(handle.delete_file(&path), dev, &window)?;
Ok(())
}
#[tauri::command]
pub fn delete_dir<R: Runtime>(
bus_number: u8,
address: u8,
path: String,
window: WebviewWindow<R>,
) -> Result<impl Serialize, SerializedError> {
let dev = DevId {
bus_number,
address,
};
let handle = get_open_dev(&dev)?;
let handle = handle.lock().unwrap();
err_wrap(handle.delete_dir(&path), dev, &window)?;
Ok(())
}
#[tauri::command]
pub fn create_nspire_dir<R: Runtime>(
bus_number: u8,
address: u8,
path: String,
window: WebviewWindow<R>,
) -> Result<impl Serialize, SerializedError> {
let dev = DevId {
bus_number,
address,
};
let handle = get_open_dev(&dev)?;
let handle = handle.lock().unwrap();
err_wrap(handle.create_dir(&path), dev, &window)?;
Ok(())
}
#[tauri::command]
pub fn move_file<R: Runtime>(
bus_number: u8,
address: u8,
src: String,
dest: String,
window: WebviewWindow<R>,
) -> Result<impl Serialize, SerializedError> {
let dev = DevId {
bus_number,
address,
};
let handle = get_open_dev(&dev)?;
let handle = handle.lock().unwrap();
err_wrap(handle.move_file(&src, &dest), dev, &window)?;
Ok(())
}
#[tauri::command]
pub fn copy<R: Runtime>(
bus_number: u8,
address: u8,
src: String,
dest: String,
window: WebviewWindow<R>,
) -> Result<impl Serialize, SerializedError> {
let dev = DevId {
bus_number,
address,
};
let handle = get_open_dev(&dev)?;
let handle = handle.lock().unwrap();
err_wrap(handle.copy_file(&src, &dest), dev, &window)?;
Ok(())
}
Note enumerate now takes window: WebviewWindow<R> where the original took handle: Window<R> — same injected value, clearer name.
- Step 2: Add the module and compile
Add mod commands; to desktop/src-tauri/src/main.rs alongside the existing mod device; / mod error;.
Run: cd /home/jjureta/Projects/n-link/desktop/src-tauri && cargo check
Expected: compiles cleanly. Unused-function warnings are fine — Task 5 registers them.
- Step 3: Commit
cd /home/jjureta/Projects/n-link
git add desktop/src-tauri/src/commands.rs desktop/src-tauri/src/main.rs
git commit -m "feat(desktop): port all 13 IPC commands to Tauri 2
Window -> WebviewWindow, emit now requires the Emitter trait, and
hashbrown's drain_filter is replaced by an explicit collect-then-remove
on std HashMap. IPC contract unchanged."
Task 5: Wire the Tauri 2 builder, hotplug monitor, and config
Files:
- Modify:
desktop/src-tauri/src/main.rs - Modify:
desktop/src-tauri/tauri.conf.json - Create:
desktop/src-tauri/capabilities/default.json
Interfaces:
-
Consumes:
commands::*from Task 4;device::*from Task 3. -
Produces: a running app that registers all 13 commands and emits
addDevice/removeDeviceon USB hotplug. Task 6 addscli::run()ahead of the GUI launch. -
Step 1: Write the v2 config
Replace desktop/src-tauri/tauri.conf.json entirely:
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "n-link",
"version": "0.1.6",
"identifier": "com.lights0123.n-link",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "N-Link",
"width": 800,
"height": 600,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": "default-src blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self'; img-src 'self' asset: http://asset.localhost blob: data:"
}
},
"bundle": {
"active": true,
"targets": ["deb", "appimage"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"resources": [],
"externalBin": [],
"copyright": "Copyright (c) 2021 Ben Schattinger. Licensed under GPL-3.0",
"category": "Utility",
"shortDescription": "Free, cross-platform, CX-II compatible computer linking program for the TI-Nspire",
"longDescription": "Free, cross-platform, CX-II compatible computer linking program for the TI-Nspire"
}
}
Three things carried forward deliberately:
-
targetsis["deb", "appimage"]— msi and dmg dropped per the spec's Linux-only scope. -
The CSP is preserved. Upstream commit
0472908fixed a blank release-mode screen caused by CSP; v2 moves this key toapp.security.csp. The malformedimg-src:from the v1 config is corrected to a properimg-srcdirective here. -
devUrlis port 1420 — the Tauri scaffold's Vite default. Ifvite.config.tsuses a different port, make these match or dev mode shows a blank window. -
Step 2: Write the capability file
Tauri 2 replaces the v1 allowlist with capabilities. Create desktop/src-tauri/capabilities/default.json:
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Core, dialog, and shell permissions for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:allow-open",
"shell:allow-open"
]
}
The v1 allowlist granted exactly shell.open and dialog.open; this is the v2 equivalent. notification-all appeared in the old Cargo features but no frontend code uses it, so it is not carried over.
- Step 3: Write
main.rs
Replace desktop/src-tauri/src/main.rs:
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::sync::Arc;
use std::time::Duration;
use libnspire::{PID_CX2, VID};
use rusb::{GlobalContext, Hotplug, UsbContext};
use tauri::{Emitter, Manager, Runtime, WebviewWindow};
use crate::device::{add_device, AddDevice, DevId, DEVICES};
mod commands;
mod device;
mod error;
struct DeviceMon<R: Runtime> {
window: WebviewWindow<R>,
}
impl<R: Runtime> Hotplug<GlobalContext> for DeviceMon<R> {
fn device_arrived(&mut self, device: rusb::Device<GlobalContext>) {
let handle = self.window.clone();
let is_cx_ii = device
.device_descriptor()
.map(|d| d.product_id() == PID_CX2)
.unwrap_or(false);
let device = Arc::new(device);
// The calculator often reports Busy immediately after enumeration, so
// retry until it settles.
std::thread::spawn(move || loop {
match add_device(device.clone()) {
Ok(dev) => {
let name = (dev.1).name.clone();
let needs_drivers = (dev.1).needs_drivers;
DEVICES.write().unwrap().insert(dev.0, dev.1);
if let Err(msg) = handle.emit(
"addDevice",
AddDevice {
dev: DevId {
bus_number: (dev.0).0,
address: (dev.0).1,
},
name,
is_cx_ii,
needs_drivers,
},
) {
eprintln!("{}", msg);
};
return;
}
Err(rusb::Error::Busy) => {
println!("busy");
}
Err(e) => {
eprintln!("{}", e);
return;
}
}
std::thread::sleep(Duration::from_millis(250));
});
}
fn device_left(&mut self, device: rusb::Device<GlobalContext>) {
if let Some((dev, _)) = DEVICES
.write()
.unwrap()
.remove_entry(&(device.bus_number(), device.address()))
{
if let Err(msg) = self.window.emit(
"removeDevice",
DevId {
bus_number: dev.0,
address: dev.1,
},
) {
eprintln!("{}", msg);
};
}
}
}
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.setup(|app| {
// Tauri 1 registered this in on_page_load, guarded by an AtomicBool to
// avoid double-registering on reload. In v2, setup runs exactly once,
// so the guard is unnecessary.
let window = app
.get_webview_window("main")
.expect("main window not found");
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");
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::enumerate,
commands::open_device,
commands::close_device,
commands::update_device,
commands::list_dir,
commands::download_file,
commands::upload_file,
commands::upload_os,
commands::delete_file,
commands::delete_dir,
commands::create_nspire_dir,
commands::move_file,
commands::copy,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
The AtomicBool guard from the original is deliberately gone: it existed because on_page_load fires on every navigation, whereas setup runs once.
- Step 4: Build and launch
cd /home/jjureta/Projects/n-link/desktop
npm run tauri dev
Expected: window opens; terminal shows no no hotplug warning on a normal Linux system.
- Step 5: Verify hotplug end to end
With the app running, unplug the CX II. The terminal should stay quiet (events go to the frontend, which does not yet listen). Replug it; expect busy lines to appear briefly and then stop, indicating add_device retried and succeeded.
This confirms the hotplug thread and DEVICES registry work even before the frontend exists.
- Step 6: Commit
cd /home/jjureta/Projects/n-link
git add desktop/src-tauri/src/main.rs desktop/src-tauri/tauri.conf.json desktop/src-tauri/capabilities/
git commit -m "feat(desktop): wire Tauri 2 builder, hotplug, and capabilities
Hotplug registration moves from on_page_load to setup (runs once, so the
AtomicBool guard is dropped). v1 allowlist replaced by a capability file
granting dialog:allow-open and shell:allow-open. CSP from 0472908 carried
forward to app.security.csp; bundle targets narrowed to deb + appimage."
Task 6: Restore headless CLI mode
Files:
- Create:
desktop/src-tauri/src/cli.rs - Modify:
desktop/src-tauri/src/main.rs
Interfaces:
-
Consumes:
device::*. -
Produces:
cli::run() -> bool— returnstruewhen CLI arguments were handled and the GUI should not launch. -
Step 1: Copy the already-migrated CLI
The old desktop/src-tauri/src/cli.rs used clap = "3.0.0-beta.2". nlink-cli/src/cli.rs is already the clap 4 port of this same file — reuse it rather than redoing the migration:
cd /home/jjureta/Projects/n-link
cp nlink-cli/src/cli.rs desktop/src-tauri/src/cli.rs
- Step 2: Confirm it compiles unmodified
cli.rs is fully self-contained — verified: it contains no crate:: or super:: references and carries its own get_dev() built directly on rusb. No import reconciliation is needed. Its only external needs are clap 4 and indicatif 0.15, both already in Cargo.toml from Task 3.
Run: cd /home/jjureta/Projects/n-link/desktop/src-tauri && cargo check 2>&1 | head -40
Expected: compiles clean (unused-function warnings until Step 3 wires it in).
Leave cli.rs's private get_dev() alone even though device.rs has similar logic. They are deliberately separate: get_dev() opens a handle directly for one-shot headless use, while device.rs::add_device populates the global DEVICES registry that only the GUI's hotplug monitor and IPC commands use. Merging them would couple the headless path to GUI state.
Public surface produced: cli::run() -> bool and cli::cwd() -> PathBuf. run() returns true when a subcommand was parsed and handled, false when no subcommand was given.
- Step 3: Call the CLI before the GUI
In desktop/src-tauri/src/main.rs, add mod cli; with the other module declarations, and make main start with the early return:
fn main() {
if cli::run() {
return;
}
tauri::Builder::default()
// ... unchanged
- Step 4: Verify both modes still work
cd /home/jjureta/Projects/n-link/desktop/src-tauri
cargo build --release
./target/release/n-link --help
Expected: CLI help text, no window.
./target/release/n-link ls /
Expected: a listing matching /tmp/oracle-root-listing.txt from Task 1.
Then confirm the GUI path is unaffected: cd .. && npm run tauri dev opens a window.
- Step 5: Commit
cd /home/jjureta/Projects/n-link
git add desktop/src-tauri/src/cli.rs desktop/src-tauri/src/main.rs
git commit -m "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."
Task 7: Verify the backend against the calculator
Files:
- Create:
desktop/src/App.vue(temporary verification harness, replaced by Plan B)
Interfaces:
-
Consumes: the full backend from Tasks 3–6.
-
Produces: evidence that all IPC commands work from the webview. No lasting interface — Plan B replaces this file.
-
Step 1: Install the frontend plugin packages
cd /home/jjureta/Projects/n-link
npm install --workspace desktop @tauri-apps/api@2 @tauri-apps/plugin-dialog@2 @tauri-apps/plugin-shell@2
- Step 2: Write a throwaway harness
Replace desktop/src/App.vue:
<template>
<main style="font-family: monospace; padding: 1rem">
<button @click="run">Run backend check</button>
<pre>{{ log }}</pre>
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
const log = ref('');
const add = (line: string) => (log.value += line + '\n');
listen('addDevice', (e) => add('event addDevice: ' + JSON.stringify(e.payload)));
listen('removeDevice', (e) => add('event removeDevice: ' + JSON.stringify(e.payload)));
listen('progress', (e) => add('event progress: ' + JSON.stringify(e.payload)));
async function run() {
log.value = '';
try {
const devices = (await invoke('enumerate')) as any[];
add('enumerate -> ' + JSON.stringify(devices, null, 2));
if (!devices.length) return add('NO DEVICES FOUND');
const { busNumber, address } = devices[0];
add(`opening ${busNumber}:${address}`);
const info = await invoke('open_device', { busNumber, address });
add('open_device -> ' + JSON.stringify(info, null, 2));
const files = await invoke('list_dir', { busNumber, address, path: '/' });
add('list_dir / -> ' + JSON.stringify(files, null, 2));
await invoke('close_device', { busNumber, address });
add('close_device -> ok');
} catch (e) {
add('ERROR: ' + String(e));
}
}
</script>
Note the Tauri 2 import path: @tauri-apps/api/core, not the v1 @tauri-apps/api/tauri.
- Step 3: GATE — enumerate and list against the real device
cd /home/jjureta/Projects/n-link/desktop
npm run tauri dev
Click "Run backend check". Expected in the page:
enumeratereturns one entry withbusNumber,address,name,isCxIi: true,needsDrivers: falseopen_devicereturns an info object withname,free_storage,version,batterylist_dir /returns an array of{path, isDir, date, size}
Oracle cross-check: the file and folder names from list_dir must match /tmp/oracle-root-listing.txt. If they differ, the port has a defect — investigate before proceeding.
The camelCase keys (busNumber, isDir) confirm serde serialization is intact, which is what Plan B's frontend depends on.
- Step 4: GATE — verify a real file transfer
Pick a small .tns from the listing. In the harness, temporarily append to run():
await invoke('open_device', { busNumber, address });
await invoke('download_file', {
busNumber, address,
path: ['/YOURFILE.tns', 1234],
dest: '/tmp/nlink-gui-test',
});
add('download_file -> ok');
Substitute the real filename and its exact size from the list_dir output. Create the destination first: mkdir -p /tmp/nlink-gui-test.
Then compare against the oracle:
mkdir -p /tmp/nlink-cli-test
/home/jjureta/Projects/n-link/nlink-cli/target/release/n-link-cli download "/YOURFILE.tns" /tmp/nlink-cli-test/
sha256sum /tmp/nlink-gui-test/YOURFILE.tns /tmp/nlink-cli-test/YOURFILE.tns
Expected: identical hashes.
Use a small file. Per the spec, libnspire's frozen CX II support makes multi-packet transfers unreliable against 2026 firmware — a Busy failure on a large file is the known upstream ceiling, not a port regression. Confirm any failure reproduces with n-link-cli before treating it as a bug in this work.
- Step 5: Commit the harness
cd /home/jjureta/Projects/n-link
git add desktop/src/App.vue desktop/package.json package-lock.json
git commit -m "test(desktop): add temporary backend verification harness
Verified against a connected CX II: enumerate, open_device, list_dir, and
download_file all work through Tauri 2 IPC, with list_dir output and file
checksum matching n-link-cli. Replaced by the real UI in Plan B."
Task 8: Produce Linux bundles
Files:
- Modify:
desktop/src-tauri/icons/(regenerate fromapp-icon.png)
Interfaces:
-
Consumes: a verified backend from Task 7.
-
Produces:
.deband.AppImageartifacts. Terminal deliverable of Plan A. -
Step 1: Regenerate icons from the project artwork
The scaffold ships placeholder icons; replace them with n-link's own:
cd /home/jjureta/Projects/n-link/desktop
npm run tauri icon app-icon.png
Expected: src-tauri/icons/ repopulated.
- Step 2: Build the release bundles
cd /home/jjureta/Projects/n-link/desktop
npm run tauri build
Expected: completes and reports paths under src-tauri/target/release/bundle/.
- Step 3: Confirm the artifacts exist
find /home/jjureta/Projects/n-link/desktop/src-tauri/target/release/bundle -type f \( -name '*.deb' -o -name '*.AppImage' \)
Expected: one .deb and one .AppImage.
- Step 4: GATE — verify the release build actually renders
cd /home/jjureta/Projects/n-link/desktop
./src-tauri/target/release/n-link
Expected: the window opens and shows the harness UI.
A blank window here means the CSP regressed — this is exactly the failure upstream commit 0472908 fixed. If blank, revisit app.security.csp in tauri.conf.json before continuing.
- Step 5: Verify the installable package
sudo dpkg -i /home/jjureta/Projects/n-link/desktop/src-tauri/target/release/bundle/deb/*.deb
n-link --help
Expected: installs cleanly, CLI help prints. Confirms the deb has no unsatisfied dependency on a webkit2gtk-4.0-era library — the original failure this whole port exists to fix.
- Step 6: Commit
cd /home/jjureta/Projects/n-link
git add desktop/src-tauri/icons/
git commit -m "build(desktop): regenerate icons and verify deb/appimage bundles
Release build renders (no CSP regression) and the deb installs cleanly on
Ubuntu 26.04 — the failure mode that motivated this port."
Definition of Done (Plan A)
- A Tauri 2 window opens on Ubuntu 26.04 with webkit2gtk-4.1
- All 13 commands are registered and callable from the webview
enumeratedetects the CX II with correct camelCase payload keys— dropped, see amendmentlist_dir /output matchesn-link-cli ls /Downloaded file sha256 matches— dropped, see amendmentn-link-cli- Hotplug add/remove events fire
- Headless CLI mode still works
.deband.AppImagebuild; the release binary renders without a blank screenweb/is untouched- All work is on
port/tauri2-vue3
Follow-on
Plan B (frontend) covers n-link-core → Vue 3, desktop/src → Vue 3, and replaces the Task 7 harness. It is written after Plan A is verified, so it can be grounded in the real scaffold output rather than assumptions. Known hazards already identified for it:
Vue.prototype.$devices = devices(devices.ts:291) andnew Devices()are Vue 2 patterns with no Vue 3 equivalent — the store becomesreactive()plusapp.config.globalProperties.@PropSync('selected')(DeviceSelect.vue:52) has no Vue 3 counterpart — becomesdefineModel().<div slot="reference">is Vue 2 slot syntax — becomes<template #reference>.src="~feather-icons/..."uses webpack~resolution, which Vite does not support.- Tailwind 1.8 +
@tailwindcss/custom-forms→ Tailwind 4.3.3, which is CSS-first and dropstailwind.config.js. - The
declare module 'vue/types/vue'augmentation (n-link-core/components/devices.ts:62) is Vue 2 — becomesComponentCustomPropertieson@vue/runtime-core. - ~10
$set/$deletecalls indevices.tsare deleted outright; Vue 3 proxy reactivity makes them unnecessary. This is the likeliest source of silent regressions.