# Desktop QR (BC-UR) Support Implementation Plan

> **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:** Add animated BC-UR QR support to the desktop online (watch-only) wallet so it can exchange PSBTs and watch-only configurations with the mobile cold wallet without manual file transfer.

**Architecture:** Three layers — Python (existing `transfer/qr_ur.py`), Rust (4 thin Tauri commands wrapping a new Python CLI shim), JS (two reusable widgets: `AnimatedQR` for rendering, `QRScanner` for camera capture). Single source of truth for BC-UR is the Python lib already used by mobile.

**Tech Stack:** Tauri 2.x (Rust + WKWebView on macOS), Python 3 (bundled), vanilla JS, vendored `qrcode-generator` (MIT, ~10KB) and `jsQR` (Apache-2.0, ~80KB).

## Global Constraints

Copied verbatim from `docs/superpowers/specs/2026-06-22-desktop-qr-psbt-design.md`:

- Camera access lands on the **online** config only. The offline (cold-signing) desktop stays cameraless by design.
- Air-gap discipline for the cold side (mobile + offline desktop) unchanged. QR is bidirectional optical, not network.
- Tauri 2.x permissions/capabilities model.
- macOS first. Linux/Windows webview camera behavior will be verified in a Task-1 spike before committing to `getUserMedia`.
- Reuse of `transfer/qr_ur.py` is mandatory — no second BC-UR implementation in JS. This avoids well-known cross-impl drift around `crypto-psbt` CBOR tagged vs untagged.

## Implementation conventions

These apply to every task that touches Rust/JS code:

- **Tauri rebuild:** after Rust changes, restart `cargo tauri dev --config tauri.online.json` (or equivalent dev script). After JS-only changes, the webview hot-reloads.
- **Python CLI pattern:** existing scripts live under `tauri/src-tauri/python-bundle/python/cli/`. They take a subcommand as `argv[1]`, additional args as JSON-encoded strings, and print one JSON value to stdout. Errors print non-zero exit + a JSON `{"error": "..."}` to stderr.
- **Rust commands:** match the existing pattern in `tauri/src-tauri/src/commands_offline.rs` (look at `run_offline_cli`). New helper `run_qr_cli` lives in `commands_online.rs` and constructs a `PythonRunner` from `app: tauri::AppHandle`, then shells the CLI.
- **JS DOM helpers:** existing `app.js` style is plain DOM + `invoke(cmd, args)` returning a Promise. Match that — no frameworks.
- **Modal pattern:** add ONE shared `<div id="qr-modal">` in `index.html` reused by all three flows. The widget (AnimatedQR or QRScanner) is instantiated inside it; closing the modal calls `widget.destroy()`.

---

## File Structure

**Create:**
- `tauri/src-tauri/python-bundle/python/cli/qr_ur_cli.py` — Python CLI shim (one file, 4 subcommands).
- `tauri/src/qr.js` — both JS widgets (one file).
- `tauri/src/vendor/qrcode-generator.min.js` — vendored MIT library.
- `tauri/src/vendor/jsQR.min.js` — vendored Apache-2.0 library.
- `docs/testing/desktop-qr-smoke-test.md` — manual smoke-test doc.
- `tests/test_qr_ur_cli.py` — Python CLI integration tests.
- `tauri/src-tauri/tests/qr_commands.rs` — Rust integration tests.

**Modify:**
- `tauri/src-tauri/src/commands_online.rs` — add 4 commands + `run_qr_cli` helper.
- `tauri/src-tauri/src/main.rs` — register the 4 commands.
- `tauri/src-tauri/tauri.online.json` — add `NSCameraUsageDescription`.
- `tauri/src-tauri/capabilities/default.json` — add camera permission IF the spike (Task 1) finds it required.
- `tauri/src/index.html` — `<script>` tags, 3 new buttons, shared `#qr-modal` div.
- `tauri/src/app.js` — 3 button handlers; refactor `importWallet()` to accept JSON text from either file or QR.

**Don't touch:**
- `transfer/qr_ur.py` — already complete and shared with mobile.
- `tauri/src-tauri/tauri.offline.conf.json` — stays cameraless.
- Existing PSBT broadcast / sign / watch-only import business logic.

---

## Task 1: Spike — verify `getUserMedia` in Tauri 2 webview on macOS

**Files:**
- Modify: `tauri/src/index.html` (temporary scratch button — removed in Task 7)
- Modify: `tauri/src-tauri/tauri.online.json` (Info.plist `NSCameraUsageDescription` — kept)

**Interfaces:**
- Consumes: nothing.
- Produces: a documented finding (commit message) that informs Tasks 7 and 11. No code that later tasks depend on, except the Info.plist entry.

**Why first:** if `getUserMedia` doesn't return a real stream in Tauri's WKWebView, every subsequent task that assumed a pure-JS scanner becomes a rewrite. Find out before committing to the JS path.

- [ ] **Step 1: Add NSCameraUsageDescription to the online config**

Open `tauri/src-tauri/tauri.online.json`. It's currently just `{ "bundle": { "icon": [...] } }`. Add macOS Info.plist override:

```json
{
  "bundle": {
    "icon": [
      "icons/icon-online_32x32.png",
      "icons/icon-online_128x128.png",
      "icons/icon-online_128x128@2x.png",
      "icons/icon-online.icns"
    ],
    "macOS": {
      "infoPlist": {
        "NSCameraUsageDescription": "Armory uses the camera to scan QR codes from your offline wallet."
      }
    }
  }
}
```

- [ ] **Step 2: Add a temporary spike button to `index.html`**

In `tauri/src/index.html`, find the Online home screen panel (look for `id="online-import-wallet"`). Just below its closing `</div>` (before the next panel), add:

```html
<!-- TEMP: QR spike — remove in Task 7 -->
<div id="qr-spike" style="padding: 12px; margin: 12px; border: 1px dashed #888;">
  <button onclick="qrSpike()">Spike: probe getUserMedia</button>
  <pre id="qr-spike-out" style="font-family: var(--mono); font-size: 12px;"></pre>
</div>
```

In `tauri/src/app.js`, append:

```js
async function qrSpike() {
    const out = document.getElementById('qr-spike-out');
    out.textContent = 'requesting…';
    try {
        const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
        const track = stream.getVideoTracks()[0];
        const settings = track.getSettings();
        out.textContent = `OK\nlabel: ${track.label}\nsettings: ${JSON.stringify(settings)}`;
        track.stop();
    } catch (e) {
        out.textContent = `FAIL: ${e.name}: ${e.message}`;
    }
}
```

- [ ] **Step 3: Build + run the online dev app**

```bash
cd tauri/src-tauri
cargo tauri dev --config tauri.online.json
```

Click "Spike: probe getUserMedia" inside the running app. macOS should prompt for camera permission the first time (because `NSCameraUsageDescription` is now in the Info.plist). Approve.

- [ ] **Step 4: Record the finding**

Three possible outcomes:

1. **OK** — output shows track label and resolution settings. Pure-JS scanner is viable. Continue to Task 2.
2. **NotAllowedError / NotFoundError after granting permission** — webview doesn't see the OS permission. Add a Tauri capability and retry; if still failing, switch to native camera plugin in Task 7. Note this in the commit.
3. **TypeError: navigator.mediaDevices is undefined** — WKWebView doesn't expose `mediaDevices`. Must use Tauri native camera plugin for Task 7. Note this in the commit.

- [ ] **Step 5: Commit (spike result documented in commit message)**

Do NOT remove the spike button yet — Task 7 needs it for QRScanner development reference. The commit message must record the outcome.

```bash
git add tauri/src-tauri/tauri.online.json tauri/src/index.html tauri/src/app.js
git commit -m "spike: probe getUserMedia in Tauri 2 webview on macOS

Outcome: <OK | partial | unsupported>
Details: <one line, e.g. 'label=FaceTime HD Camera, settings show 1920x1080'>
"
```

---

## Task 2: Vendor JS QR libraries

**Files:**
- Create: `tauri/src/vendor/qrcode-generator.min.js`
- Create: `tauri/src/vendor/jsQR.min.js`
- Modify: `tauri/src/index.html` (add `<script>` tags)

**Interfaces:**
- Consumes: nothing.
- Produces: globals `qrcode` (from qrcode-generator, callable as `qrcode(typeNumber, errorCorrectionLevel)`) and `jsQR(imageDataArray, width, height)` returning `{ data: string }` or `null`.

- [ ] **Step 1: Download qrcode-generator**

```bash
mkdir -p tauri/src/vendor
curl -L -o tauri/src/vendor/qrcode-generator.min.js https://cdn.jsdelivr.net/npm/qrcode-generator@1.4.4/qrcode.min.js
```

Verify the download is a real minified JS file (~10 KB):

```bash
wc -c tauri/src/vendor/qrcode-generator.min.js
```

Expected: somewhere in the 7,000–15,000 bytes range.

- [ ] **Step 2: Download jsQR**

```bash
curl -L -o tauri/src/vendor/jsQR.min.js https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js
wc -c tauri/src/vendor/jsQR.min.js
```

Expected: somewhere in the 70,000–110,000 bytes range.

- [ ] **Step 3: Add script tags to `index.html`**

In `tauri/src/index.html`, find the existing `<script src="lucide.min.js"></script>` line. Add two more `<script>` tags just before it (so they load before `app.js`):

```html
<script src="vendor/qrcode-generator.min.js"></script>
<script src="vendor/jsQR.min.js"></script>
<script src="lucide.min.js"></script>
```

- [ ] **Step 4: Verify in the existing spike**

Rebuild + reload the dev app. In the browser dev console (Tauri devtools):

```js
typeof qrcode      // expect: 'function'
typeof jsQR        // expect: 'function'
```

If either is `undefined`, the script tag path is wrong — re-check.

- [ ] **Step 5: Commit**

```bash
git add tauri/src/vendor/qrcode-generator.min.js tauri/src/vendor/jsQR.min.js tauri/src/index.html
git commit -m "feat(desktop): vendor qrcode-generator + jsQR for QR widgets"
```

---

## Task 3: Python CLI shim `qr_ur_cli.py`

**Files:**
- Create: `tauri/src-tauri/python-bundle/python/cli/qr_ur_cli.py`
- Create: `tests/test_qr_ur_cli.py`

**Interfaces:**
- Consumes: existing `transfer.qr_ur.{encode_psbt, decode_psbt, encode_account, decode_account}`.
- Produces: CLI invocations:
  - `python3 cli/qr_ur_cli.py encode-psbt <base64>` → stdout: JSON array of UR strings.
  - `python3 cli/qr_ur_cli.py decode-psbt <json-array-of-parts>` → stdout: JSON string (base64 PSBT).
  - `python3 cli/qr_ur_cli.py encode-account <json-string>` → stdout: JSON array of UR strings.
  - `python3 cli/qr_ur_cli.py decode-account <json-array-of-parts>` → stdout: JSON string (JSON-encoded dict).

- [ ] **Step 1: Write the failing test**

Create `tests/test_qr_ur_cli.py`:

```python
"""End-to-end tests for the qr_ur_cli.py shim."""
import base64
import json
import os
import subprocess
import sys
import unittest


CLI = os.path.join(
    os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
    "tauri", "src-tauri", "python-bundle", "python", "cli", "qr_ur_cli.py",
)
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


def run_cli(*args: str) -> str:
    env = os.environ.copy()
    env["PYTHONPATH"] = REPO_ROOT
    result = subprocess.run(
        [sys.executable, "-u", CLI, *args],
        capture_output=True,
        text=True,
        env=env,
        check=False,
    )
    if result.returncode != 0:
        raise RuntimeError(f"CLI failed: {result.stderr}")
    return result.stdout


class TestQRURCli(unittest.TestCase):

    SAMPLE_PSBT_BYTES = b"psbt\xff" + b"\x00" * 200  # any blob; UR doesn't care
    SAMPLE_PSBT_B64 = base64.b64encode(SAMPLE_PSBT_BYTES).decode()

    def test_psbt_round_trip(self):
        frames_json = run_cli("encode-psbt", self.SAMPLE_PSBT_B64)
        frames = json.loads(frames_json)
        self.assertIsInstance(frames, list)
        self.assertGreater(len(frames), 0)
        for f in frames:
            self.assertTrue(f.startswith("ur:crypto-psbt/"))

        decoded_json = run_cli("decode-psbt", json.dumps(frames))
        decoded_b64 = json.loads(decoded_json)
        self.assertEqual(decoded_b64, self.SAMPLE_PSBT_B64)

    def test_account_round_trip(self):
        sample = {
            "watch_only": True,
            "label": "test",
            "xpubs": {"bip84": "xpub6..."},
            "addresses": {"bip84": ["bc1q..."], "bip86": []},
        }
        frames_json = run_cli("encode-account", json.dumps(sample))
        frames = json.loads(frames_json)
        self.assertIsInstance(frames, list)
        self.assertGreater(len(frames), 0)
        for f in frames:
            self.assertTrue(f.startswith("ur:armory-watch-only/"))

        decoded_json = run_cli("decode-account", json.dumps(frames))
        decoded_dict = json.loads(json.loads(decoded_json))
        self.assertEqual(decoded_dict, sample)


if __name__ == "__main__":
    unittest.main()
```

- [ ] **Step 2: Run the test to confirm it fails**

```bash
python3 -m pytest tests/test_qr_ur_cli.py -v
```

Expected: FAIL — CLI file doesn't exist yet.

- [ ] **Step 3: Write the CLI**

Create `tauri/src-tauri/python-bundle/python/cli/qr_ur_cli.py`:

```python
"""
CLI shim for BC-UR encode/decode used by the desktop Tauri commands.

Subcommands:
  encode-psbt <base64>             → stdout JSON: ["ur:crypto-psbt/1-3/...", ...]
  decode-psbt <json-parts>         → stdout JSON: "<base64 PSBT>"
  encode-account <json-string>     → stdout JSON: ["ur:armory-watch-only/1-2/...", ...]
  decode-account <json-parts>      → stdout JSON: "<json-encoded dict>"

Exit code 0 on success, 1 on any failure (with `{"error": "..."}` on stderr).
"""

import base64
import json
import os
import sys

# When invoked by Tauri the runner sets PYTHONPATH to project root.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from transfer.qr_ur import encode_psbt, decode_psbt, encode_account, decode_account


def _fail(msg: str) -> None:
    sys.stderr.write(json.dumps({"error": msg}) + "\n")
    sys.exit(1)


def main() -> None:
    if len(sys.argv) < 3:
        _fail("usage: qr_ur_cli.py <encode-psbt|decode-psbt|encode-account|decode-account> <arg>")
    op, arg = sys.argv[1], sys.argv[2]
    try:
        if op == "encode-psbt":
            psbt_bytes = base64.b64decode(arg)
            frames = encode_psbt(psbt_bytes)
            sys.stdout.write(json.dumps(frames))
        elif op == "decode-psbt":
            parts = json.loads(arg)
            psbt_bytes = decode_psbt(parts)
            sys.stdout.write(json.dumps(base64.b64encode(psbt_bytes).decode()))
        elif op == "encode-account":
            wallet = json.loads(arg)
            frames = encode_account(wallet)
            sys.stdout.write(json.dumps(frames))
        elif op == "decode-account":
            parts = json.loads(arg)
            result_dict = decode_account(parts)
            sys.stdout.write(json.dumps(json.dumps(result_dict)))
        else:
            _fail(f"unknown subcommand: {op}")
    except Exception as e:
        _fail(f"{type(e).__name__}: {e}")


if __name__ == "__main__":
    main()
```

- [ ] **Step 4: Run the test to verify it passes**

```bash
python3 -m pytest tests/test_qr_ur_cli.py -v
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add tauri/src-tauri/python-bundle/python/cli/qr_ur_cli.py tests/test_qr_ur_cli.py
git commit -m "feat(desktop): add qr_ur_cli.py shim wrapping transfer.qr_ur for Tauri"
```

---

## Task 4: Rust commands for PSBT (encode + decode)

**Files:**
- Modify: `tauri/src-tauri/src/commands_online.rs`
- Modify: `tauri/src-tauri/src/main.rs`
- Create: `tauri/src-tauri/tests/qr_commands.rs`

**Interfaces:**
- Consumes: Python CLI from Task 3 — `cli/qr_ur_cli.py encode-psbt | decode-psbt`.
- Produces: Tauri commands callable from JS via `invoke()`:
  - `invoke('encode_psbt_qr', { psbtBase64: string }) -> Promise<string[]>`
  - `invoke('decode_psbt_qr', { parts: string[] }) -> Promise<string>` (returns base64 PSBT)

- [ ] **Step 1: Write a failing Rust integration test**

Tauri commands using a Python subprocess aren't easily testable in isolation. Use a `#[tokio::test]` against the underlying helper, which we'll expose as `pub(crate) async fn run_qr_cli(...)`.

Create `tauri/src-tauri/tests/qr_commands.rs`:

```rust
// Integration test: runs the qr_ur_cli.py with a known input and checks the round-trip.
// Requires python3 in PATH and PYTHONPATH set to repo root so `transfer.qr_ur` imports.

use std::process::Command;

fn repo_root() -> std::path::PathBuf {
    // tests/ → src-tauri/ → tauri/ → repo root
    let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    manifest.parent().unwrap().parent().unwrap().to_path_buf()
}

fn run_cli(args: &[&str]) -> String {
    let root = repo_root();
    let cli = root.join("tauri/src-tauri/python-bundle/python/cli/qr_ur_cli.py");
    let output = Command::new("python3")
        .arg("-u").arg(&cli).args(args)
        .current_dir(&root)
        .env("PYTHONPATH", &root)
        .output().expect("python3 failed");
    assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr));
    String::from_utf8(output.stdout).unwrap()
}

#[test]
fn test_psbt_qr_round_trip() {
    let psbt_b64 = base64::engine::general_purpose::STANDARD.encode(b"psbt-stub-bytes");
    let frames_json = run_cli(&["encode-psbt", &psbt_b64]);
    let frames: Vec<String> = serde_json::from_str(&frames_json).unwrap();
    assert!(!frames.is_empty());
    for f in &frames { assert!(f.starts_with("ur:crypto-psbt/")); }

    let parts_arg = serde_json::to_string(&frames).unwrap();
    let decoded_json = run_cli(&["decode-psbt", &parts_arg]);
    let decoded_b64: String = serde_json::from_str(&decoded_json).unwrap();
    assert_eq!(decoded_b64, psbt_b64);
}
```

Add `base64 = "0.21"` to `[dev-dependencies]` in `tauri/src-tauri/Cargo.toml`. If `[dev-dependencies]` doesn't exist yet, add the section.

- [ ] **Step 2: Run the test to confirm it fails or passes the CLI half**

```bash
cd tauri/src-tauri
cargo test --test qr_commands
```

Expected: PASS (the CLI was added in Task 3 — this test verifies the CLI from Rust's perspective, which is what the commands will rely on). If it fails with "cli not found", check the path. If it passes, proceed.

- [ ] **Step 3: Add the `run_qr_cli` helper to `commands_online.rs`**

Open `tauri/src-tauri/src/commands_online.rs`. At the top, add:

```rust
use crate::python::PythonRunner;
```

(if it's not already imported — check existing `use` statements first.)

Add the helper at module scope, near the bottom of the file:

```rust
/// Helper: shell the qr_ur_cli.py with a subcommand + single arg. Returns parsed JSON.
async fn run_qr_cli(app: &tauri::AppHandle, subcmd: &str, arg: &str) -> Result<serde_json::Value, String> {
    let runner = PythonRunner::new(app);
    runner.run_json("cli/qr_ur_cli.py", &[subcmd, arg]).await
}
```

- [ ] **Step 4: Add the two PSBT commands**

Just below `run_qr_cli`, add:

```rust
#[tauri::command]
pub async fn encode_psbt_qr(app: tauri::AppHandle, psbt_base64: String) -> Result<Vec<String>, String> {
    let value = run_qr_cli(&app, "encode-psbt", &psbt_base64).await?;
    let frames: Vec<String> = serde_json::from_value(value)
        .map_err(|e| format!("Bad CLI output: {}", e))?;
    Ok(frames)
}

#[tauri::command]
pub async fn decode_psbt_qr(app: tauri::AppHandle, parts: Vec<String>) -> Result<String, String> {
    let parts_json = serde_json::to_string(&parts)
        .map_err(|e| format!("serialize parts: {}", e))?;
    let value = run_qr_cli(&app, "decode-psbt", &parts_json).await?;
    let psbt_base64: String = serde_json::from_value(value)
        .map_err(|e| format!("Bad CLI output: {}", e))?;
    Ok(psbt_base64)
}
```

- [ ] **Step 5: Register the commands**

Open `tauri/src-tauri/src/main.rs`. Find the existing `tauri::generate_handler![ ... ]` macro. Add inside the list, in the "Online commands" section (alphabetical or grouped by topic — match what's already there):

```rust
commands_online::encode_psbt_qr,
commands_online::decode_psbt_qr,
```

- [ ] **Step 6: Build to confirm Rust compiles**

```bash
cd tauri/src-tauri
cargo build --release
```

Expected: clean compile, no new warnings. If `PythonRunner::new` isn't accessible from `commands_online.rs`, check the `pub` modifier on `python::PythonRunner::new` in `tauri/src-tauri/src/python.rs` — it should already be public per the existing usage in `commands_offline.rs`.

- [ ] **Step 7: Smoke from the dev app**

Run the dev app (Task 1 instructions). In Tauri devtools console:

```js
const psbtB64 = btoa("psbt-stub-bytes");
const frames = await invoke("encode_psbt_qr", { psbtBase64: psbtB64 });
console.log("frames:", frames.length, frames[0]);
const decoded = await invoke("decode_psbt_qr", { parts: frames });
console.log("decoded match:", decoded === psbtB64);  // expect: true
```

- [ ] **Step 8: Commit**

```bash
git add tauri/src-tauri/src/commands_online.rs tauri/src-tauri/src/main.rs tauri/src-tauri/tests/qr_commands.rs tauri/src-tauri/Cargo.toml
git commit -m "feat(desktop): Tauri commands encode_psbt_qr + decode_psbt_qr"
```

---

## Task 5: Rust commands for Account (encode + decode)

**Files:**
- Modify: `tauri/src-tauri/src/commands_online.rs` (add 2 commands)
- Modify: `tauri/src-tauri/src/main.rs` (register 2 commands)
- Modify: `tauri/src-tauri/tests/qr_commands.rs` (add 1 test)

**Interfaces:**
- Consumes: Python CLI from Task 3, `run_qr_cli` helper from Task 4.
- Produces: Tauri commands:
  - `invoke('encode_account_qr', { jsonStr: string }) -> Promise<string[]>`
  - `invoke('decode_account_qr', { parts: string[] }) -> Promise<string>` (returns JSON string)

- [ ] **Step 1: Add the failing test**

Append to `tauri/src-tauri/tests/qr_commands.rs`:

```rust
#[test]
fn test_account_qr_round_trip() {
    let sample = serde_json::json!({
        "watch_only": true,
        "label": "test",
        "xpubs": {"bip84": "xpub6..."},
        "addresses": {"bip84": ["bc1q..."], "bip86": []},
    });
    let sample_str = sample.to_string();
    let frames_json = run_cli(&["encode-account", &sample_str]);
    let frames: Vec<String> = serde_json::from_str(&frames_json).unwrap();
    assert!(!frames.is_empty());
    for f in &frames { assert!(f.starts_with("ur:armory-watch-only/")); }

    let parts_arg = serde_json::to_string(&frames).unwrap();
    let decoded_json = run_cli(&["decode-account", &parts_arg]);
    let decoded_str: String = serde_json::from_str(&decoded_json).unwrap();
    let decoded_dict: serde_json::Value = serde_json::from_str(&decoded_str).unwrap();
    assert_eq!(decoded_dict, sample);
}
```

- [ ] **Step 2: Run the test to confirm CLI round-trips already**

```bash
cd tauri/src-tauri
cargo test --test qr_commands
```

Expected: both tests PASS (Task 3 CLI covers account already; this test just confirms it from Rust).

- [ ] **Step 3: Add the two account commands**

In `tauri/src-tauri/src/commands_online.rs`, just below `decode_psbt_qr` (added in Task 4):

```rust
#[tauri::command]
pub async fn encode_account_qr(app: tauri::AppHandle, json_str: String) -> Result<Vec<String>, String> {
    let value = run_qr_cli(&app, "encode-account", &json_str).await?;
    let frames: Vec<String> = serde_json::from_value(value)
        .map_err(|e| format!("Bad CLI output: {}", e))?;
    Ok(frames)
}

#[tauri::command]
pub async fn decode_account_qr(app: tauri::AppHandle, parts: Vec<String>) -> Result<String, String> {
    let parts_json = serde_json::to_string(&parts)
        .map_err(|e| format!("serialize parts: {}", e))?;
    let value = run_qr_cli(&app, "decode-account", &parts_json).await?;
    let json_str: String = serde_json::from_value(value)
        .map_err(|e| format!("Bad CLI output: {}", e))?;
    Ok(json_str)
}
```

- [ ] **Step 4: Register the commands**

In `tauri/src-tauri/src/main.rs` `generate_handler![]`, add:

```rust
commands_online::encode_account_qr,
commands_online::decode_account_qr,
```

- [ ] **Step 5: Build**

```bash
cd tauri/src-tauri
cargo build --release
```

Expected: clean compile.

- [ ] **Step 6: Smoke from the dev app**

In Tauri devtools console:

```js
const sample = { watch_only: true, label: "x", xpubs: { bip84: "xpub6..." }, addresses: { bip84: ["bc1q..."], bip86: [] } };
const frames = await invoke("encode_account_qr", { jsonStr: JSON.stringify(sample) });
console.log("frames:", frames.length);
const decoded = await invoke("decode_account_qr", { parts: frames });
console.log("decoded:", decoded);
console.log("roundtrip:", JSON.stringify(JSON.parse(decoded)) === JSON.stringify(sample));  // expect: true
```

- [ ] **Step 7: Commit**

```bash
git add tauri/src-tauri/src/commands_online.rs tauri/src-tauri/src/main.rs tauri/src-tauri/tests/qr_commands.rs
git commit -m "feat(desktop): Tauri commands encode_account_qr + decode_account_qr"
```

---

## Task 6: JS widget `AnimatedQR`

**Files:**
- Create: `tauri/src/qr.js`
- Modify: `tauri/src/index.html` (script tag for `qr.js`; the shared modal `<div>`)

**Interfaces:**
- Consumes: globals `qrcode` (from Task 2 vendor).
- Produces:
  - `window.AnimatedQR(container, frames)` — constructs a widget. Renders first frame immediately, advances every 200 ms. Returns object with `.destroy()`.
  - `window.openQRModal(title, contentEl)` / `window.closeQRModal()` — open/close the shared modal that wraps either widget.

- [ ] **Step 1: Add the modal markup + script tag to `index.html`**

In `tauri/src/index.html`, find the existing `<script src="vendor/jsQR.min.js"></script>` (from Task 2). Add `qr.js` as a script tag after the vendor scripts but before `app.js`:

```html
<script src="vendor/qrcode-generator.min.js"></script>
<script src="vendor/jsQR.min.js"></script>
<script src="qr.js"></script>
<script src="lucide.min.js"></script>
```

Find the closing `</body>` tag. Just above it, add the shared modal:

```html
<div id="qr-modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:1000; align-items:center; justify-content:center;">
  <div style="background:var(--surface); border-radius:12px; padding:20px; max-width:90%; max-height:90%; overflow:auto; min-width:320px;">
    <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
      <h3 id="qr-modal-title" style="margin:0;">QR</h3>
      <button onclick="closeQRModal()" style="background:none; border:none; color:var(--text); font-size:20px; cursor:pointer;">×</button>
    </div>
    <div id="qr-modal-body"></div>
  </div>
</div>
```

- [ ] **Step 2: Write `qr.js` — modal helpers + AnimatedQR**

Create `tauri/src/qr.js`:

```js
// Shared modal + QR widgets. AnimatedQR cycles UR frames at 5 fps;
// QRScanner is added in Task 7.

window.openQRModal = function (title, contentEl) {
    document.getElementById('qr-modal-title').textContent = title;
    const body = document.getElementById('qr-modal-body');
    body.innerHTML = '';
    body.appendChild(contentEl);
    document.getElementById('qr-modal').style.display = 'flex';
};

window.closeQRModal = function () {
    const modal = document.getElementById('qr-modal');
    modal.style.display = 'none';
    // Notify any active widget so it can release resources.
    if (window._activeQRWidget && typeof window._activeQRWidget.destroy === 'function') {
        window._activeQRWidget.destroy();
        window._activeQRWidget = null;
    }
    document.getElementById('qr-modal-body').innerHTML = '';
};

window.AnimatedQR = function (container, frames) {
    if (!Array.isArray(frames) || frames.length === 0) {
        container.textContent = '(no QR frames)';
        return { destroy: () => {} };
    }
    const canvas = document.createElement('canvas');
    canvas.style.width = '320px';
    canvas.style.height = '320px';
    canvas.style.display = 'block';
    canvas.style.margin = '0 auto';
    container.appendChild(canvas);

    const label = document.createElement('div');
    label.style.textAlign = 'center';
    label.style.marginTop = '8px';
    label.style.fontFamily = 'var(--mono)';
    label.style.fontSize = '12px';
    container.appendChild(label);

    let i = 0;
    function render() {
        const text = frames[i];
        // typeNumber 0 lets the library pick the smallest size that fits.
        const qr = qrcode(0, 'M');
        qr.addData(text);
        qr.make();
        const cellSize = Math.floor(canvas.width / qr.getModuleCount()) || 4;
        // Use a fixed canvas pixel size; redraw modules on it.
        canvas.width = qr.getModuleCount() * cellSize;
        canvas.height = qr.getModuleCount() * cellSize;
        const ctx = canvas.getContext('2d');
        ctx.fillStyle = '#fff';
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        ctx.fillStyle = '#000';
        for (let r = 0; r < qr.getModuleCount(); r++) {
            for (let c = 0; c < qr.getModuleCount(); c++) {
                if (qr.isDark(r, c)) {
                    ctx.fillRect(c * cellSize, r * cellSize, cellSize, cellSize);
                }
            }
        }
        label.textContent = `Frame ${i + 1} / ${frames.length}`;
    }
    render();
    const interval = setInterval(() => {
        i = (i + 1) % frames.length;
        render();
    }, 200);

    const widget = {
        destroy() {
            clearInterval(interval);
            container.removeChild(canvas);
            container.removeChild(label);
        },
    };
    window._activeQRWidget = widget;
    return widget;
};
```

- [ ] **Step 3: Smoke from devtools**

Run the dev app. In Tauri devtools console:

```js
const psbtB64 = btoa("psbt-stub-bytes-".repeat(20));
const frames = await invoke("encode_psbt_qr", { psbtBase64: psbtB64 });
const div = document.createElement('div');
openQRModal('Unsigned PSBT', div);
AnimatedQR(div, frames);
```

Expected: modal opens; QR renders; "Frame N / M" label updates every 200ms. Close the modal — animation stops, modal disappears.

- [ ] **Step 4: Commit**

```bash
git add tauri/src/qr.js tauri/src/index.html
git commit -m "feat(desktop): AnimatedQR widget + shared QR modal"
```

---

## Task 7: JS widget `QRScanner`

**Files:**
- Modify: `tauri/src/qr.js` (add `QRScanner` to globals)
- Modify: `tauri/src/index.html` (remove the Task-1 spike button)
- Modify: `tauri/src/app.js` (remove the spike handler)

**Interfaces:**
- Consumes: globals `jsQR` (Task 2), `openQRModal` / `closeQRModal` (Task 6).
- Produces: `window.QRScanner(container, onComplete, onError)`. Calls `onComplete(parts: string[])` when all UR fragments collected; `onError(message: string)` on permission failure or unrelated QR after a threshold.

**Note:** if Task 1 found `getUserMedia` unavailable, replace the camera-source section of this widget with the Tauri native camera plugin. The widget's external contract (`(container, onComplete, onError)`) stays identical.

- [ ] **Step 1: Remove the Task-1 spike scaffolding**

In `tauri/src/index.html`, remove the `<div id="qr-spike">…</div>` block added in Task 1.
In `tauri/src/app.js`, remove the `async function qrSpike() { … }` block.

- [ ] **Step 2: Append `QRScanner` to `qr.js`**

Add to the bottom of `tauri/src/qr.js`:

```js
window.QRScanner = function (container, onComplete, onError) {
    const video = document.createElement('video');
    video.setAttribute('playsinline', '');
    video.style.width = '320px';
    video.style.height = '240px';
    video.style.background = '#000';
    video.style.display = 'block';
    video.style.margin = '0 auto';
    container.appendChild(video);

    const label = document.createElement('div');
    label.style.textAlign = 'center';
    label.style.marginTop = '8px';
    label.style.fontFamily = 'var(--mono)';
    label.style.fontSize = '12px';
    label.textContent = 'Starting camera…';
    container.appendChild(label);

    const hiddenCanvas = document.createElement('canvas');
    const ctx = hiddenCanvas.getContext('2d', { willReadFrequently: true });

    const parts = new Map(); // seq_num (int) → raw UR string
    let seqLen = null;       // total expected fragments
    let stream = null;
    let rafHandle = null;
    let stopped = false;
    let urType = null;       // 'crypto-psbt' or 'armory-watch-only'

    const PART_RE = /^ur:([a-z0-9-]+)\/(\d+)-(\d+)\/.+$/i;

    function onFrame() {
        if (stopped) return;
        if (video.readyState === video.HAVE_ENOUGH_DATA) {
            hiddenCanvas.width = video.videoWidth;
            hiddenCanvas.height = video.videoHeight;
            ctx.drawImage(video, 0, 0, hiddenCanvas.width, hiddenCanvas.height);
            const img = ctx.getImageData(0, 0, hiddenCanvas.width, hiddenCanvas.height);
            const code = jsQR(img.data, img.width, img.height);
            if (code && code.data) {
                handleDecoded(code.data);
            }
        }
        rafHandle = requestAnimationFrame(onFrame);
    }

    function handleDecoded(text) {
        const m = PART_RE.exec(text.trim());
        if (!m) return;
        const ty = m[1].toLowerCase();
        const seqNum = parseInt(m[2], 10);
        const total = parseInt(m[3], 10);
        if (urType === null) {
            urType = ty;
            seqLen = total;
        } else if (ty !== urType || total !== seqLen) {
            return; // foreign or mismatched UR
        }
        if (!parts.has(seqNum)) {
            parts.set(seqNum, text.trim());
            label.textContent = `Scanned ${parts.size} / ${seqLen} (${urType})`;
            if (parts.size === seqLen) {
                stopped = true;
                cleanup();
                const ordered = [];
                for (let i = 1; i <= seqLen; i++) ordered.push(parts.get(i));
                onComplete(ordered, urType);
            }
        }
    }

    function cleanup() {
        if (rafHandle) cancelAnimationFrame(rafHandle);
        if (stream) {
            stream.getTracks().forEach(t => t.stop());
            stream = null;
        }
    }

    (async () => {
        try {
            stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
            video.srcObject = stream;
            await video.play();
            label.textContent = 'Point the camera at the animated QR…';
            rafHandle = requestAnimationFrame(onFrame);
        } catch (e) {
            label.textContent = `Camera error: ${e.name}`;
            onError(`${e.name}: ${e.message}`);
        }
    })();

    const widget = {
        destroy() {
            stopped = true;
            cleanup();
        },
    };
    window._activeQRWidget = widget;
    return widget;
};
```

- [ ] **Step 3: Smoke from devtools (no flow wiring yet)**

Run the dev app. On a phone, run `transfer/qr_ur.py` to generate a multi-frame UR for any PSBT (or use the mobile app's signed-PSBT Show QR). In Tauri devtools console:

```js
const div = document.createElement('div');
openQRModal('Scan test', div);
QRScanner(div, (parts, urType) => {
    console.log("done:", urType, parts.length, "parts");
    closeQRModal();
}, (err) => {
    console.error("scanner failed:", err);
});
```

Expected: camera preview appears, label counts up as frames are scanned, console logs the parts array and UR type at completion.

If the camera doesn't appear (`NotAllowedError` / `NotFoundError`), revisit Task 1's finding — may need the native camera plugin path. That fallback would replace just the `(async () => { ... })()` block.

- [ ] **Step 4: Commit**

```bash
git add tauri/src/qr.js tauri/src/index.html tauri/src/app.js
git commit -m "feat(desktop): QRScanner widget (camera + multi-part UR collector)"
```

---

## Task 8: Wire flow (a) — Show unsigned PSBT QR

**Files:**
- Modify: `tauri/src/index.html` (one new button)
- Modify: `tauri/src/app.js` (one new handler)

**Interfaces:**
- Consumes: existing `currentPsbt` global, `AnimatedQR`, `openQRModal`, `invoke('encode_psbt_qr', ...)`.

- [ ] **Step 1: Add the "Show QR" button to the PSBT result panel**

In `tauri/src/index.html`, find the existing PSBT result block (around line 397, look for `id="psbt-result"`). It currently has Copy PSBT and Download .psbt buttons. Insert a third button between them:

```html
<button class="secondary" id="show-psbt-qr-btn" onclick="showPsbtQR()" style="border-radius:12px;">Show QR</button>
```

So the row becomes:

```html
<button class="secondary" id="copy-psbt-btn" onclick="copyPsbt()" style="border-radius:12px;">Copy PSBT</button>
<button class="secondary" id="show-psbt-qr-btn" onclick="showPsbtQR()" style="border-radius:12px;">Show QR</button>
<button class="secondary" id="download-psbt-btn" onclick="downloadPsbt()" style="border-radius:12px;">Download .psbt</button>
```

- [ ] **Step 2: Add the `showPsbtQR` handler**

In `tauri/src/app.js`, near the existing `copyPsbt` and `downloadPsbt` definitions, add:

```js
async function showPsbtQR() {
    if (!currentPsbt) return;
    const div = document.createElement('div');
    openQRModal('Unsigned PSBT', div);
    try {
        const frames = await invoke('encode_psbt_qr', { psbtBase64: currentPsbt });
        AnimatedQR(div, frames);
    } catch (e) {
        div.innerHTML = `<div class="alert error">Failed to encode QR: ${e}</div>`;
    }
}
```

- [ ] **Step 3: Smoke**

Run the dev app. Create a test PSBT via the existing Create PSBT flow. After the PSBT result panel appears, click "Show QR". Expected: modal opens, animated QR cycles. Tap "×" to close.

- [ ] **Step 4: Commit**

```bash
git add tauri/src/index.html tauri/src/app.js
git commit -m "feat(desktop): Show QR button on unsigned PSBT result"
```

---

## Task 9: Wire flow (b) — Scan signed PSBT QR

**Files:**
- Modify: `tauri/src/index.html` (one new button)
- Modify: `tauri/src/app.js` (one new handler)

**Interfaces:**
- Consumes: `QRScanner`, `openQRModal`, `closeQRModal`, `invoke('decode_psbt_qr', ...)`, existing `#broadcast-psbt` textarea.

- [ ] **Step 1: Add the "Scan QR" button to the Broadcast panel**

In `tauri/src/index.html`, find the broadcast textarea (`id="broadcast-psbt"`, around line 441) and the existing file input next to it. Add a Scan QR button just above the broadcast action button:

```html
<button class="secondary" onclick="scanSignedPsbtQR()" style="border-radius:12px;">Scan QR</button>
```

Place it in a logical spot — right after the existing file picker is fine.

- [ ] **Step 2: Add the `scanSignedPsbtQR` handler**

In `tauri/src/app.js`:

```js
function scanSignedPsbtQR() {
    const div = document.createElement('div');
    openQRModal('Scan signed PSBT', div);
    QRScanner(div, async (parts, urType) => {
        if (urType !== 'crypto-psbt') {
            div.innerHTML = `<div class="alert error">Wrong QR type: expected crypto-psbt, got ${urType}.</div>`;
            return;
        }
        try {
            const psbtBase64 = await invoke('decode_psbt_qr', { parts });
            document.getElementById('broadcast-psbt').value = psbtBase64;
            closeQRModal();
        } catch (e) {
            div.innerHTML = `<div class="alert error">Decode failed: ${e}</div>`;
        }
    }, (err) => {
        div.innerHTML = `<div class="alert error">Camera: ${err}</div>`;
    });
}
```

- [ ] **Step 3: Smoke**

Run the dev app. On the mobile app, sign a test PSBT and show its signed-PSBT animated QR. In the desktop's Broadcast panel, click "Scan QR" and aim at the phone screen. Expected: the textarea fills with the base64 PSBT and the modal closes. User can then tap the existing Broadcast button.

- [ ] **Step 4: Commit**

```bash
git add tauri/src/index.html tauri/src/app.js
git commit -m "feat(desktop): Scan QR button on Broadcast panel"
```

---

## Task 10: Wire flow (c) — Scan watch-only QR + refactor importWallet

**Files:**
- Modify: `tauri/src/index.html` (one new button)
- Modify: `tauri/src/app.js` (refactor + new handler)

**Interfaces:**
- Consumes: `QRScanner`, `openQRModal`, `closeQRModal`, `invoke('decode_account_qr', ...)`, existing `importWallet()` validation pipeline.

- [ ] **Step 1: Refactor `importWallet` to accept JSON text**

In `tauri/src/app.js`, find the existing `async function importWallet()` (around line 803). It currently reads from `#import-file`, parses JSON, validates `watch_only`/`master_xprv`/`account_xpubs`, prompts for a password, then calls `invoke('import_wallet', { walletData, walletPassword })`.

Split it: extract the post-read body into a helper that accepts a parsed object, and have `importWallet` itself just read the file then delegate:

```js
async function importWallet() {
    const fileInput = document.getElementById('import-file');
    const alertDiv = document.getElementById('import-alert');
    if (!fileInput.files.length) {
        alertDiv.innerHTML = '<div class="alert error">Select a wallet file first.</div>';
        return;
    }
    const text = await fileInput.files[0].text();
    return importWatchOnlyFromText(text);
}

async function importWatchOnlyFromText(text) {
    const alertDiv = document.getElementById('import-alert');
    const progressDiv = document.getElementById('import-progress');
    const btn = document.getElementById('import-btn');

    let walletData;
    try { walletData = JSON.parse(text); } catch {
        alertDiv.innerHTML = '<div class="alert error">Invalid JSON.</div>';
        return;
    }
    if (walletData.master_xprv || walletData.seed || walletData.mnemonic) {
        alertDiv.innerHTML = '<div class="alert error"><strong>DANGER: This file contains private keys!</strong><br>Never transfer private keys to your online device.</div>';
        return;
    }
    if (!walletData.watch_only) {
        alertDiv.innerHTML = '<div class="alert error">Not a watch-only wallet file.</div>';
        return;
    }
    if (!walletData.account_xpubs || Object.keys(walletData.account_xpubs).length === 0) {
        alertDiv.innerHTML = '<div class="alert error">No extended public keys in file.</div>';
        return;
    }

    let walletPassword = null;
    if (IS_TAURI) {
        walletPassword = await showPasswordDialog('Set Wallet Password',
            'Encrypt your wallet file on this device.',
            { confirm: true, skippable: true, submitLabel: 'Set Password' });
        if (walletPassword === null) return;
    }

    btn.disabled = true;
    btn.textContent = 'Importing...';
    alertDiv.innerHTML = '';
    progressDiv.style.display = 'block';

    try {
        const data = await invoke('import_wallet', { walletData, walletPassword: walletPassword || '' });
        if (data.error) {
            alertDiv.innerHTML = `<div class="alert error">${data.error}</div>`;
            btn.disabled = false;
            btn.textContent = 'Import Watch-Only Wallet';
            progressDiv.style.display = 'none';
            return;
        }
        // … rest of original importWallet's success/error tail, unchanged
    } catch (e) {
        alertDiv.innerHTML = `<div class="alert error">${e}</div>`;
        btn.disabled = false;
        btn.textContent = 'Import Watch-Only Wallet';
        progressDiv.style.display = 'none';
    }
}
```

**Important:** the "… rest of original importWallet's success/error tail, unchanged" is shorthand. The implementer must preserve every line after the existing `data = await invoke('import_wallet', …)` call from the original function — same success indicator, same progressDiv hide, same button-state reset. The ONLY changes are: (a) the extraction into `importWatchOnlyFromText`, (b) routing `importWallet` to call it.

- [ ] **Step 2: Add the "Scan QR" button on the Import page**

In `tauri/src/index.html`, find the existing file input on the Import page (`id="import-file"`, around line 94). Just below it (before the import button), add:

```html
<button class="secondary" onclick="scanWatchOnlyQR()" style="border-radius:12px; margin-top:8px;">Scan QR instead</button>
```

- [ ] **Step 3: Add the `scanWatchOnlyQR` handler**

In `tauri/src/app.js`:

```js
function scanWatchOnlyQR() {
    const div = document.createElement('div');
    openQRModal('Scan watch-only wallet', div);
    QRScanner(div, async (parts, urType) => {
        if (urType !== 'armory-watch-only') {
            div.innerHTML = `<div class="alert error">Wrong QR type: expected armory-watch-only, got ${urType}.</div>`;
            return;
        }
        try {
            const jsonText = await invoke('decode_account_qr', { parts });
            closeQRModal();
            await importWatchOnlyFromText(jsonText);
        } catch (e) {
            div.innerHTML = `<div class="alert error">Decode failed: ${e}</div>`;
        }
    }, (err) => {
        div.innerHTML = `<div class="alert error">Camera: ${err}</div>`;
    });
}
```

- [ ] **Step 4: Smoke**

Run the dev app. On the mobile app, export a watch-only QR. On desktop's Import page, click "Scan QR instead", point at the phone. Expected: scanner completes, password dialog appears (same as file-based flow), wallet imports.

Also re-verify the file-based path still works — pick a watch-only `.json` and tap the original Import button.

- [ ] **Step 5: Commit**

```bash
git add tauri/src/index.html tauri/src/app.js
git commit -m "feat(desktop): Scan QR for watch-only import; refactor importWallet"
```

---

## Task 11: Camera permissions audit

**Files:**
- Modify: `tauri/src-tauri/capabilities/default.json` (only if Task 1's spike found it required)
- Verify: `tauri/src-tauri/tauri.online.json` already has `NSCameraUsageDescription` from Task 1

**Interfaces:**
- Consumes: outcome of Task 1's spike.
- Produces: production-ready camera-access configuration.

This task is a checklist. Skip steps that don't apply based on the Task-1 finding.

- [ ] **Step 1: Verify `NSCameraUsageDescription` is in place**

```bash
cat tauri/src-tauri/tauri.online.json
```

Expected: contains `bundle.macOS.infoPlist.NSCameraUsageDescription`. If missing (e.g., Task 1 was reverted or never applied), add it now per Task 1 Step 1.

- [ ] **Step 2: Add Tauri capability if spike said it's needed**

If Task 1's commit message said "needs Tauri capability" (a NotAllowedError after permission grant), open `tauri/src-tauri/capabilities/default.json` and add the relevant permission strings to the `"permissions"` array. The specific strings depend on the Tauri 2.x plugin landscape at implementation time — refer to the spike's findings for the exact list. As of writing, Tauri 2.x camera access for `getUserMedia` generally does NOT require a Tauri capability (it's a webview API gated by OS permission); the capability is only needed if using a Tauri plugin like `tauri-plugin-camera`.

If the spike said pure `getUserMedia` works, no capability change is needed — skip to Step 4.

- [ ] **Step 3: If using a native camera plugin (spike found `getUserMedia` unsupported)**

Add `tauri-plugin-camera = "2"` (or current version) to `tauri/src-tauri/Cargo.toml`. Register the plugin in `main.rs` via `.plugin(tauri_plugin_camera::init())`. Update `QRScanner` in `tauri/src/qr.js` to use the plugin's frame API instead of `navigator.mediaDevices`. The widget's external contract `(container, onComplete, onError)` is unchanged.

- [ ] **Step 4: Production build sanity check**

```bash
cd tauri/src-tauri
cargo tauri build --config tauri.online.json
```

Expected: `.app` and `.dmg` produced. Open the resulting `.app` on macOS (not from `cargo tauri dev`), trigger any QR scan flow, confirm the camera permission prompt fires the first time and the camera works subsequently.

- [ ] **Step 5: Commit**

```bash
git add tauri/src-tauri/
git commit -m "chore(desktop): camera permissions for QR scan on online config"
```

If no files changed (Step 2 + Step 3 didn't apply), skip the commit.

---

## Task 12: Smoke-test doc

**Files:**
- Create: `docs/testing/desktop-qr-smoke-test.md`

**Interfaces:**
- Consumes: nothing.
- Produces: a manual smoke-test document.

- [ ] **Step 1: Write the doc**

Create `docs/testing/desktop-qr-smoke-test.md`:

````markdown
# Desktop QR (BC-UR) — Smoke Test

Manual end-to-end test for the desktop online wallet's QR support
(Plan 9). Run on a real desktop + real iOS device. Both flows
require the desktop to be in **online** mode (`tauri.online.json`).

## Setup

1. Build the online desktop app (production):
   ```
   cd tauri/src-tauri
   cargo tauri build --config tauri.online.json
   ```
   Run the resulting `.app` on macOS. Allow camera permission when
   first prompted (`NSCameraUsageDescription`).
2. Build + run the iOS Armory Cold app (any recent build).
3. Have a watch-only wallet already imported on the desktop (so the
   Create PSBT and Broadcast panels are reachable), OR plan to import
   one via QR during the second flow.

## Flow A: PSBT round-trip

1. **Desktop creates an unsigned PSBT** — fill the existing Create PSBT
   form, click Create PSBT. PSBT result panel appears.
2. **Desktop shows QR** — click "Show QR". Modal opens with animated
   QR at ~5 fps. "Frame N / M" label updates.
3. **Mobile scans** — open the mobile cold wallet, Wallet Home →
   Sign → Scan QR. Hold over the desktop screen until "Scanned 100%"
   completes and PSBT review opens.
4. **Mobile signs** — tap Sign on the review screen, enter password,
   confirm. SignResultScreen appears with "Show animated QR".
5. **Mobile shows signed QR** — tap Show QR. Animated QR appears on
   the phone.
6. **Desktop scans** — desktop Broadcast panel → click "Scan QR".
   Aim at the phone. When complete the textarea fills with the
   signed base64 PSBT and the modal closes.
7. **Desktop broadcasts** — tap the existing Broadcast button. Verify
   the transaction lands on the network (block explorer).

Pass criteria:
- Desktop's animated QR cycles smoothly at ~5 fps.
- Mobile scan completes inside ~5 seconds at arm's length.
- Desktop scan completes inside ~5 seconds at arm's length.
- Signed PSBT broadcasts successfully.
- No file transfer used; no base64 text manually pasted.

## Flow B: Watch-only wallet QR import

1. **Mobile creates / has a wallet** — restore from abandon-abandon
   or use an existing wallet.
2. **Mobile exports watch-only QR** — WalletHome → Export watch-only
   → Show QR. Animated QR appears.
3. **Desktop scans** — fresh desktop install or "Re-import wallet"
   page → click "Scan QR instead". Aim at the phone. When complete,
   the password dialog appears.
4. **Set password** — enter a wallet encryption password (or skip
   if testing the skippable path). Wallet imports.
5. **Verify** — desktop now shows the wallet's xpubs / addresses
   identical to those derived from the mobile seed (compare
   fingerprints).

Pass criteria:
- QR scan completes.
- Imported wallet matches the source on the phone (fingerprint, addresses).
- Both the file-based import and the QR-based import remain functional.

## Network safety check (always)

Throughout both flows, watch Xcode's Debug Navigator (for mobile) and
the desktop OS's network monitor (`Activity Monitor → Network` on
macOS). Confirm zero outbound network traffic to anything other than
the configured Electrum/Bitcoin RPC node when broadcasting. QR
transport must not leak to any URL.
````

- [ ] **Step 2: Commit**

```bash
git add docs/testing/desktop-qr-smoke-test.md
git commit -m "docs: Plan 9 smoke test — desktop QR (BC-UR) round-trips"
```

---

## Final whole-branch review

After all 12 tasks land:

1. Generate the review package over the Plan 9 commit range:
   ```bash
   scripts/review-package <first-commit-of-plan-9> HEAD
   ```
2. Dispatch the final reviewer (Opus) with this plan's Global
   Constraints as the attention lens, plus the spec.
3. Triage Critical / Important findings into a single fix-wave
   subagent dispatch.
4. After review-clean, push to `jenna`:
   ```bash
   git push origin jenna
   ```
   (Per user instruction: "push to GitHub" means `jenna`, NEVER `main`.)
