Add design spec for Tauri 2 + Vue 3 desktop port

Ports desktop from Tauri 1.0.0-beta.8 (webkit2gtk-4.0, unavailable on
Ubuntu 26.04) to Tauri 2.x (webkit2gtk-4.1), alongside Vue 2 -> Vue 3 and
Vue CLI -> Vite. Wires in the locally patched libnspire-sys already proven
by nlink-cli.

Scoped to Linux (deb + appimage). The web/ package shares n-link-core and
is knowingly left broken, deferred to its own project.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-07-19 09:01:06 -04:00
parent 0472908ef4
commit 754fbf9c70

View file

@ -0,0 +1,191 @@
# Design: Port n-link desktop to Tauri 2 + Vue 3
Date: 2026-07-19
Status: Approved
Branch: `port/tauri2-vue3`
## Problem
The n-link desktop app cannot build or run on Ubuntu 26.04. `desktop/src-tauri`
pins `tauri = "1.0.0-beta.8"`, which links against `webkit2gtk-4.0`. That library
is not available on this system — only `webkit2gtk-4.1` is. The prebuilt 0.1.6
`.deb` fails for the same reason, plus a missing `libssl.so.1.1` (only
`libssl.so.3` is present).
This is the blocker that caused `nlink-cli` to be written as a GUI-free
workaround. This project removes the blocker properly so the GUI works.
Tauri 2.x links against `webkit2gtk-4.1`, which is what makes the port the fix.
### Verified environment
| Component | State |
|---|---|
| `webkit2gtk-4.0` | absent, not installable |
| `libwebkit2gtk-4.1-dev` | available, 2.52.3-0ubuntu0.26.04.2 (not yet installed) |
| `libsoup-3.0-dev` | available, 3.6.6-1 (not yet installed) |
| `libssl` | 3 only; no 1.1 |
| `libusb-1.0` | 1.0.29, installed |
| rustc / cargo | 1.97.1 |
| node | v22.22.1 |
| yarn | **not installed** |
| TI-Nspire CX II | connected, USB `0451:e022` |
## Scope
In scope:
1. `n-link-core` — port 8 components from Vue 2 to Vue 3.
2. `desktop` — port to Tauri 2, Vue 3, and Vite; bundle deb + appimage.
Out of scope (deferred to a separate project):
- `web` (Nuxt 2). It shares `n-link-core`, so porting core **knowingly breaks
it**. `web/` is left untouched at its current commit rather than
half-migrated. It is a browser/WebUSB product and does not bear on the
Linux GUI problem.
- Windows and macOS targets. `msi` and `dmg` are dropped from the bundle
config; they cannot be verified from this machine.
## Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Platform targets | Linux only; deb + appimage | Fully testable here |
| Frontend stack | Vue 3 + Vite | Vue CLI 4.5 / webpack 4 fails on Node 22 (OpenSSL 3); `vue-cli-plugin-tauri` is Tauri-v1-era and abandoned |
| Shared core | Port `n-link-core` to Vue 3 | `web` deferred, accepted as broken |
| Component style | `<script setup>` + Composition API | `vue-class-component` has no Vue 3 future |
| Migration method | Scaffold clean, port code across | v1→v2 config is a near-total rewrite; a generated baseline means only our code is ever in question |
| Verification | Manual, `n-link-cli` as oracle | Repo has zero tests; risk concentrates in USB behavior needing real hardware |
## Approach
Scaffold a fresh Tauri 2 + Vue 3 + Vite + TypeScript app, then move logic into
it. Backend goes first — this is forced, not preference: the Vue 3 frontend
cannot be exercised until a Tauri 2 shell runs, because Tauri 1 cannot launch
on this machine at all.
"Scaffold clean" means the generated baseline **replaces the contents of
`desktop/`** in place, on the `port/tauri2-vue3` branch — it is not a new
top-level package. The workspace keeps its three members (`desktop`,
`n-link-core`, `web`). The old `desktop/src` and `desktop/src-tauri` are
removed in the same commit that adds the scaffold, so history shows one
coherent replacement rather than a half-migrated intermediate state.
Trade-off accepted: per-file `git blame` continuity in `desktop/` is lost.
Given that `tauri.conf.json`, the build tooling, and every component's syntax
are all being replaced anyway, that continuity is largely notional.
## Architecture
```
desktop/
src-tauri/
Cargo.toml tauri 2.x, patched libnspire-sys
tauri.conf.json v2 schema
capabilities/default.json replaces v1 allowlist
src/
main.rs thin: CLI passthrough + Builder
device.rs Device/DeviceState/DEVICES registry
commands.rs the 13 #[tauri::command] wrappers
error.rs SerializedError
cli.rs headless mode
src/ Vue 3 + Vite
```
`main.rs` is currently 478 lines holding the device registry, hotplug monitor,
all 13 commands, and `main()`. Splitting it into focused modules is a targeted
improvement to code being rewritten end-to-end anyway — not unrelated
refactoring.
The patched `vendor/libnspire-sys` (CX II NNSE handshake retry limit raised
10 → 100) is wired via `[patch.crates-io]`, exactly as `nlink-cli` already does.
## IPC contract — held invariant
Deliberately unchanged, so the frontend and backend ports can be verified
independently.
**13 commands:** `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`
**3 events:** `addDevice`, `removeDevice`, `progress`
**Payloads:** `DevId{busNumber,address}`, `AddDevice`, `ProgressUpdate`,
`FileInfo` — all `camelCase` via serde, unchanged.
## Data flow
`rusb` hotplug thread → `DeviceMon` → emits `addDevice`/`removeDevice` → Vue
store in `devices.ts`. Commands mutate the global `DEVICES` map behind a
`RwLock`. Long transfers emit `progress`.
One frontend change: the ~10 `$set`/`$delete` calls in `devices.ts` become
plain assignment and `delete`. Vue 3 proxy reactivity makes them unnecessary —
a deletion, not a rewrite.
## Error handling
Unchanged in shape. `SerializedError` wraps `libnspire::Error` and
`rusb::Error` into a serde-serializable form for crossing IPC. Kept as-is,
relocated to `error.rs`.
## Migration hazards
Specific traps identified in the existing code:
| Hazard | Detail |
|---|---|
| `on_page_load` | Signature changed in v2 (`&Webview` + payload). Hotplug registration at `main.rs:440` must move; `setup()` is the better home |
| `Window``WebviewWindow` | `emit` additionally requires `use tauri::Emitter` in scope |
| `drain_filter` | `cmd.rs:62`, hashbrown 0.11 → renamed `extract_if`, **with changed semantics** |
| `allowlist` → capabilities | allowlist block deleted; `dialog:allow-open` + `shell:allow-open` become plugin permissions |
| Plugins | `dialog` and `shell` are separate crates + JS packages in v2. `notification-all` is in Cargo features but unused by the frontend — dropped |
| CSP | Commit `0472908` fixed a CSP blank-screen in release mode. v2 moves CSP to `app.security.csp`; this fix must be carried forward or release builds go blank again |
| `clap` 3.0.0-beta.2 | `cli.rs` uses it — but `nlink-cli/src/cli.rs` is **already** the clap-4 port of this same file. Reuse it instead of redoing the migration |
| element-ui | Only `el-popover`, 5 usages across 3 files. Swap to element-plus |
| Vue class components | 9 `@Component`, 18 `@Prop`, 6 `@Watch` across 9 files → `<script setup>` |
| yarn missing | Root `package.json` declares yarn workspaces but yarn is absent. Migrating to **npm workspaces** (npm ships with Node 22; the root `yarn.lock` is invalidated by this port's dependency changes regardless) |
## Build sequence
1. Install system deps (`libwebkit2gtk-4.1-dev`, `libsoup-3.0-dev`,
`librsvg2-dev`, `libxdo-dev`, `libayatana-appindicator3-dev`). Requires sudo.
2. Scaffold Tauri 2 + Vue 3 + Vite shell. **Go/no-go gate.**
3. Port Rust backend; wire patched `libnspire-sys`.
4. Port `n-link-core` components to Vue 3 `<script setup>`.
5. Port `desktop/src` (`devices.ts`, `Home.vue`, router); drop `$set`/`$delete`.
6. Bundle deb + appimage.
## Verification
Each gate runs against the connected CX II.
1. **Go/no-go** — an empty Tauri 2 window opens on webkit2gtk-4.1. Cheap, and
everything downstream is wasted if it fails, so it runs first.
2. **Backend**`enumerate` sees the device; `list_dir /` matches
`n-link-cli ls /`.
3. **Frontend** — components render; device appears in the UI.
4. **End-to-end** — download a `.tns`; `sha256sum` matches the same file
fetched via `n-link-cli download`.
5. **Bundle** — deb and appimage build; installed deb launches.
### Known ceiling, not a regression
Per `nlink-cli/README.md`: libnspire's CX II support has been frozen upstream
since 2020, and against 2026 firmware, multi-packet transfers are unreliable —
long operations can fail mid-transfer with `Busy`.
This ceiling is inherited by the GUI. Step 4 therefore uses a **short**
transfer, and any failure is compared against `n-link-cli` behavior before
being treated as a port regression.
## Risks
- **Go/no-go fails.** If webkit2gtk-4.1 cannot produce a window, the whole
approach is void. Mitigated by making it step 2, at near-zero cost.
- **Vue 3 reactivity drift.** Removing `$set`/`$delete` is where silent
behavioral regressions are most likely. Caught by oracle cross-check at
gate 2 and 4, not by tests.
- **`web` stays broken.** Accepted and explicit; tracked as follow-up work.