Compare commits

..

3 Commits

Author SHA1 Message Date
Vantz Stockwell
b7742b0247 docs: fired XO audit — spec vs reality gap analysis
Full codebase audit against the 983-line design spec. Documents what
works, what's implemented but unwired, what's missing, bugs found and
fixed, unused dependencies, and recommended priority fix order.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 13:26:03 -04:00
Vantz Stockwell
05776b7eb6 fix: streaming UTF-8 decoder + rAF write batching for terminal performance
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 1m6s
Three fixes:

1. Streaming TextDecoder: a single TextDecoder instance with {stream: true}
   persists across events. Split multi-byte UTF-8 sequences at Go read()
   boundaries are now buffered and decoded correctly across chunks.

2. requestAnimationFrame batching: incoming SSH data is accumulated and
   flushed to xterm.js once per frame instead of on every Wails event.
   Eliminates the laggy typewriter effect when output arrives in small
   chunks (which is normal for SSH PTY output).

3. PTY baud rate: bumped TTY_OP_ISPEED/OSPEED from 14400 (modem speed)
   to 115200. Some remote PTYs throttle output to match the declared rate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 13:22:50 -04:00
Vantz Stockwell
9fce0b6c1e fix: UTF-8 terminal rendering — atob() decodes as Latin-1, not UTF-8
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 1m5s
atob() returns a "binary string" where each byte is a Latin-1 char code.
Multi-byte UTF-8 sequences (box-drawing, em dashes, arrows) were split
into separate Latin-1 codepoints, producing mojibake. Now reconstructs
the raw byte array and decodes via TextDecoder('utf-8') before writing
to xterm.js.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 13:00:29 -04:00
3 changed files with 271 additions and 5 deletions

View File

@ -0,0 +1,221 @@
# Wraith Desktop — Fired XO Audit
> **Date:** 2026-03-17
> **Auditor:** VHQ XO (Claude Opus 4.6, assuming the con)
> **Spec:** `docs/superpowers/specs/2026-03-17-wraith-desktop-design.md` (983 lines, 16 sections)
> **Codebase:** 20 commits at time of audit, two prior XOs contributed
---
## Executive Summary
The spec describes a MobaXTerm replacement with multi-tabbed SSH terminals, SFTP sidebar with full file operations, RDP via FreeRDP3, encrypted vault, terminal theming, command palette, tab detach/reattach, CWD following, and workspace crash recovery.
What was delivered is a skeleton with functional SSH terminals and a partially-wired SFTP sidebar. The Go backend is deceptively complete at the code level (~75%), but critical services are implemented and never connected to each other. The frontend tells the real story: 4 of 7 SFTP toolbar buttons do nothing, themes don't apply, tab features are stubs, and the editor opens as a 300px inline panel instead of a separate window.
**User-facing functionality: ~40% of spec. Code-level completeness: ~65%.**
---
## Bugs Fixed During This Audit
### BUG-001: UTF-8 Terminal Rendering (v0.7.1)
**Root cause:** `atob()` decodes base64 but returns a Latin-1 "binary string" where each byte is a separate character code. Multi-byte UTF-8 sequences (box-drawing chars, em dashes, arrows) were split into separate Latin-1 codepoints, producing mojibake.
**Fix:** Reconstruct raw byte array from `atob()` output, decode via `TextDecoder('utf-8')`.
**File:** `frontend/src/composables/useTerminal.ts` line 117
### BUG-002: Split UTF-8 Across Chunk Boundaries (v0.7.2)
**Root cause:** The v0.7.1 fix created a `new TextDecoder()` on every Wails event. The Go read loop uses a 32KB buffer — multi-byte UTF-8 characters split across two `reader.Read()` calls produced two separate events. Each half was decoded independently, still producing mojibake for characters that happened to land on a chunk boundary.
**Fix:** Single `TextDecoder` instance with `{ stream: true }` persisted for the session lifetime. Buffers incomplete multi-byte sequences between events.
**File:** `frontend/src/composables/useTerminal.ts`
### BUG-003: Typewriter Lag (v0.7.2)
**Root cause:** Every Go `reader.Read()` (sometimes single bytes for SSH PTY output) triggered a separate Wails event emission, which triggered a separate `terminal.write()`. The serialization round-trip (base64 encode -> Wails event -> JS listener -> base64 decode -> TextDecoder -> xterm.js write) for every few bytes produced visible line-by-line lag.
**Fix:** `requestAnimationFrame` write batching. Incoming data accumulates in a string buffer and flushes to xterm.js once per animation frame (~16ms). All chunks within a frame render in a single write.
**File:** `frontend/src/composables/useTerminal.ts`
### BUG-004: PTY Baud Rate (v0.7.2)
**Root cause:** `TTY_OP_ISPEED` and `TTY_OP_OSPEED` set to 14400 (1995 modem speed). Some remote PTYs throttle output to match the declared baud rate.
**Fix:** Bumped to 115200.
**File:** `internal/ssh/service.go` line 76-77
---
## What Actually Works (End-to-End)
| Feature | Status |
|---|---|
| Vault (master password, Argon2id, AES-256-GCM) | Working |
| Connection manager (groups, tree, CRUD, search) | Working |
| SSH terminal (multi-tab, xterm.js, resize) | Working (fixed in v0.7.1/v0.7.2) |
| SFTP directory listing + navigation | Working |
| SFTP file read/write via CodeMirror editor | Working (but editor is inline, not separate window) |
| MobaXTerm .mobaconf import | Working |
| Credential management (passwords + SSH keys) | Working |
| Auto-updater (Gitea releases, SHA256 verify) | Working |
| Command palette (Ctrl+K) | Partial — searches connections, "Open Vault" is TODO |
| Quick connect (toolbar) | Working |
| Context menu (right-click connections) | Working |
| Status bar | Partial — terminal dimensions hardcoded "120x40" |
| 7 built-in terminal themes | Selectable but don't apply to xterm.js |
---
## Implemented in Go But Never Wired
These are fully implemented backend services sitting disconnected from the rest of the application:
### Host Key Verification — SECURITY GAP
`internal/ssh/hostkey.go``HostKeyStore` with `Verify()`, `Store()`, `Delete()`, `GetFingerprint()`. All real logic.
**Problem:** SSH service hardcodes `ssh.InsecureIgnoreHostKey()` at `service.go:58`. The `HostKeyStore` is never instantiated or called. Every connection silently accepts any server key. MITM attacks would succeed without warning.
**Frontend:** `HostKeyDialog.vue` exists with accept/reject UI and MITM warning text. Also never wired.
### CWD Tracking (SFTP "Follow Terminal Folder")
`internal/ssh/cwd.go` — Full OSC 7 escape sequence parser. `ProcessOutput()` scans SSH stdout for `\033]7;file://hostname/path\033\\`, strips them before forwarding to xterm.js, and emits CWD change events. Shell hook generator for bash/zsh/fish.
**Problem:** `CWDTracker` is never instantiated in `SSHService` or wired into the read loop. The SFTP sidebar's "Follow terminal folder" checkbox renders but nothing pushes CWD changes from the terminal.
### Session Manager
`internal/session/manager.go``Create()`, `Detach()`, `Reattach()`, `SetState()`, max 32 sessions enforcement. All real logic.
**Problem:** Instantiated in `app.go` but `Create()` is never called when SSH/RDP sessions open. The SSH service has its own internal session map. These two tracking systems are parallel and unconnected. Tab detach/reattach (which depends on the Session Manager) cannot work.
### Workspace Restore (Crash Recovery)
`internal/app/workspace.go``WorkspaceService` with `Save()`, `Load()`, `MarkCleanShutdown()`, `WasCleanShutdown()`, `ClearCleanShutdown()`. All real logic via settings persistence.
**Problem:** `WorkspaceService` is never instantiated in `app.go`. Dead code. Crash recovery does not exist.
### Plugin Registry
`internal/plugin/registry.go``RegisterProtocol()`, `RegisterImporter()`, `GetProtocol()`, `GetImporter()`. All real logic.
**Problem:** Instantiated in `app.go`, nothing is ever registered. The MobaXTerm importer bypasses the registry entirely (called directly). Empty scaffolding.
---
## Missing Features (Spec vs. Reality)
### Phase 2 (SSH + SFTP) — Should Be Done
| Feature | Status | Notes |
|---|---|---|
| SFTP upload | Button rendered, no click handler | `FileTree.vue` toolbar |
| SFTP download | Button rendered, no click handler | `FileTree.vue` toolbar |
| SFTP delete | Button rendered, no click handler | `FileTree.vue` toolbar |
| SFTP new folder | Button rendered, no click handler | `FileTree.vue` toolbar |
| SFTP CWD following | Checkbox rendered, does nothing | Go code exists but unwired |
| Transfer progress | UI panel exists, always empty | `TransferProgress.vue` — decorative |
| CodeMirror in separate window | Opens as 300px inline panel | Needs `application.NewWebviewWindow()` |
| Multiple connections to same host | Not working | Session store may block duplicate connections |
### Phase 3 (RDP)
| Feature | Status | Notes |
|---|---|---|
| RDP via FreeRDP3 | Code complete on Go side | Windows-only, mock backend on other platforms |
| RDP clipboard sync | `SendClipboard()` is `return nil` stub | `freerdp_windows.go` — TODO comment |
### Phase 4 (Polish) — Mostly Missing
| Feature | Status |
|---|---|
| Tab detach/reattach | Not implemented (Session Manager unwired) |
| Tab drag-to-reorder | Not implemented |
| Tab badges (PROD/ROOT/DEV) | `isRootUser()` hardcoded `false` |
| Theme application | ThemePicker selects, persists name, but never passes theme to xterm.js |
| Per-connection theme | Not implemented |
| Custom theme creation | Not implemented |
| Vault management UI | No standalone UI; keys/passwords created inline in ConnectionEditDialog |
| Host key management UI | `HostKeyDialog.vue` exists, not wired |
| Keyboard shortcuts | Only Ctrl+K works. No Ctrl+T/W/Tab/1-9/B/Shift+D/F11/F |
| Connection history | `connection_history` table not in migrations |
| First-run .mobaconf auto-detect | Manual import only |
| Workspace crash recovery | Go code exists, never wired |
---
## Unused Dependencies
| Package | Declared In | Actually Used |
|---|---|---|
| `naive-ui` | `package.json` | Zero components imported anywhere — entire UI is hand-rolled Tailwind |
| `@xterm/addon-webgl` | `package.json` | Never imported — FitAddon/SearchAddon/WebLinksAddon are used |
---
## Phase Completion Assessment
| Phase | Scope | Completion | Notes |
|---|---|---|---|
| **1: Foundation** | Vault, connections, SQLite, themes, scaffold | ~90% | Plugin registry is empty scaffolding |
| **2: SSH + SFTP** | SSH terminal, SFTP sidebar, CWD, CodeMirror | ~40% | SSH works; SFTP browse/read works; upload/download/delete/mkdir/CWD all missing |
| **3: RDP** | FreeRDP3, canvas, input, clipboard | ~70% | Code is real and impressive; clipboard is a no-op |
| **4: Polish** | Command palette, detach, themes, import, shortcuts | ~15% | Command palette partial; everything else missing or cosmetic |
---
## Architectural Observations
### The "Parallel Systems" Problem
The codebase has a recurring pattern: a service is fully implemented in isolation but never integrated with the services it depends on or that depend on it.
- `session.Manager` and `ssh.SSHService` both track sessions independently
- `HostKeyStore` and `SSHService.Connect()` both exist but aren't connected
- `CWDTracker` and the SFTP sidebar both exist but aren't connected
- `WorkspaceService` exists but nothing calls it
- `plugin.Registry` exists but nothing registers with it
This suggests the XOs worked on individual packages in isolation without doing the integration pass that connects them into a working system.
### The "Rendered But Not Wired" Problem
The frontend has multiple UI elements that look functional but have no behavior:
- 4 SFTP toolbar buttons with no `@click` handlers
- "Follow terminal folder" checkbox bound to a ref that nothing updates
- `TransferProgress.vue` that never receives transfer data
- `HostKeyDialog.vue` that's never shown
- Theme selection that persists a name but doesn't change terminal colors
This creates a deceptive impression of completeness when demoing the app.
### What Was Done Well
- **Vault encryption** is properly implemented (Argon2id + AES-256-GCM with versioned format)
- **FreeRDP3 purego bindings** are genuinely impressive — full DLL integration without CGo
- **SSH service** core is solid (after the encoding fixes)
- **Connection manager** with groups, search, and tag filtering works well
- **MobaXTerm importer** correctly parses the .mobaconf format
- **Auto-updater** with SHA256 verification is production-ready
---
## Priority Fix Order (Recommended)
1. **SFTP toolbar buttons** — wire upload/download/delete/mkdir. The Go `SFTPService` already has all these methods. Just needs `@click` handlers in `FileTree.vue`.
2. **Host key verification** — wire `HostKeyStore` into `SSHService.Connect()`. Security gap.
3. **CWD following** — wire `CWDTracker` into the SSH read loop. This is MobaXTerm's killer differentiator.
4. **Theme application** — pass selected theme to `terminal.options.theme`. One line of code.
5. **Session Manager integration** — call `sessionMgr.Create()` on connect. Prerequisite for tab detach.
6. **Editor in separate window** — requires Go-side `application.NewWebviewWindow()` + dedicated route.
7. **Keyboard shortcuts** — straightforward `handleKeydown` additions in `MainLayout.vue`.
8. **Workspace restore** — wire `WorkspaceService` into app lifecycle.

View File

@ -91,6 +91,32 @@ export function useTerminal(sessionId: string): UseTerminalReturn {
let resizeObserver: ResizeObserver | null = null; let resizeObserver: ResizeObserver | null = null;
// Streaming TextDecoder persists across events so split multi-byte UTF-8
// sequences at chunk boundaries are decoded correctly (e.g. a 3-byte em-dash
// split across two Go read() calls).
const utf8Decoder = new TextDecoder("utf-8", { fatal: false });
// Write batching — accumulate chunks and flush once per animation frame.
// Without this, every tiny SSH read (sometimes single characters) triggers
// a separate terminal.write(), producing a laggy typewriter effect.
let pendingData = "";
let rafId: number | null = null;
function flushPendingData(): void {
rafId = null;
if (pendingData) {
terminal.write(pendingData);
pendingData = "";
}
}
function queueWrite(data: string): void {
pendingData += data;
if (rafId === null) {
rafId = requestAnimationFrame(flushPendingData);
}
}
function mount(container: HTMLElement): void { function mount(container: HTMLElement): void {
terminal.open(container); terminal.open(container);
fitAddon.fit(); fitAddon.fit();
@ -114,11 +140,21 @@ export function useTerminal(sessionId: string): UseTerminalReturn {
} }
try { try {
const decoded = atob(b64data); // atob() returns Latin-1 — each byte becomes a char code 0x000xFF.
terminal.write(decoded); // Reconstruct raw bytes, then decode with the streaming TextDecoder
// which buffers incomplete multi-byte sequences between calls.
const binaryStr = atob(b64data);
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i);
}
const decoded = utf8Decoder.decode(bytes, { stream: true });
if (decoded) {
queueWrite(decoded);
}
} catch { } catch {
// Fallback: write raw if not valid base64 // Fallback: write raw if not valid base64
terminal.write(b64data); queueWrite(b64data);
} }
}); });
@ -130,6 +166,15 @@ export function useTerminal(sessionId: string): UseTerminalReturn {
} }
function destroy(): void { function destroy(): void {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
// Flush any remaining buffered data before teardown
if (pendingData) {
terminal.write(pendingData);
pendingData = "";
}
if (cleanupEvent) { if (cleanupEvent) {
cleanupEvent(); cleanupEvent();
cleanupEvent = null; cleanupEvent = null;

View File

@ -72,8 +72,8 @@ func (s *SSHService) Connect(hostname string, port int, username string, authMet
modes := ssh.TerminalModes{ modes := ssh.TerminalModes{
ssh.ECHO: 1, ssh.ECHO: 1,
ssh.TTY_OP_ISPEED: 14400, ssh.TTY_OP_ISPEED: 115200,
ssh.TTY_OP_OSPEED: 14400, ssh.TTY_OP_OSPEED: 115200,
} }
if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil { if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil {