From 4d31eb8858c90b64f4be2b675f79a72d308a541f Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 14 Jun 2026 20:28:15 +0700 Subject: [PATCH 1/6] docs(spec): add OSC 52 clipboard design (write-only, per-host opt-in) --- .../2026-06-14-osc52-clipboard-design.md | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-14-osc52-clipboard-design.md diff --git a/docs/superpowers/specs/2026-06-14-osc52-clipboard-design.md b/docs/superpowers/specs/2026-06-14-osc52-clipboard-design.md new file mode 100644 index 00000000..49d8b56e --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-osc52-clipboard-design.md @@ -0,0 +1,187 @@ +# OSC 52 Clipboard Design + +**Date:** 2026-06-14 +**Feature:** OSC 52 clipboard — let remote apps (tmux, vim) write to the local clipboard via the OSC 52 escape sequence +**Priority:** P1 (Terminal UX & protocol support) + +--- + +## Goal + +Let a program on the remote host write to the user's local system clipboard through the +`OSC 52` escape sequence (`ESC ] 52 ; Pc ; Pd ST`). This is what makes `tmux set-clipboard`, +vim/neovim with the OSC 52 clipboard providers, and similar tools put text on the local +machine's clipboard across an SSH session — without a SFTP round-trip or mouse selection. + +**Write only.** The read side of OSC 52 (a query `OSC 52 ; c ; ?` that asks the terminal to +report the local clipboard back to the remote) is deliberately **not** implemented: it lets a +remote server exfiltrate whatever is on the user's clipboard (passwords, tokens). Most +terminals disable read by default for the same reason. + +**Opt-in per host, default off.** A remote that can write the clipboard can stage a +paste-injection (set the clipboard to a malicious command the user later pastes into a shell). +The feature is therefore gated behind a per-host toggle that defaults off, matching the +existing `Host.agentForwarding` opt-in pattern. + +--- + +## Key finding + +OSC 52 already reaches the app. The xterm parser special-cases only OSC `0`/`1`/`2` (title / +icon) and routes **every other** OSC code to `handler.unknownOSC(ps, args)` → +`Terminal.onPrivateOSC(code, args)` (`packages/xterm/lib/src/core/escape/parser.dart` +`_escHandleOSC`, `terminal.dart:917`). For `ESC ] 52 ; c ; ST` the callback fires as +`onPrivateOSC('52', ['c', ''])`. `_consumeOsc` accumulates the full payload into a +`StringBuffer` until BEL/ST with **no length cap**, so large clipboard payloads are not +truncated by the parser. + +Consequence: the **write path needs no change to the xterm core.** The work is to route code +`52` in the app, decode it, gate it per-host, and call `Clipboard.setData` (which lives in +`flutter/services`, i.e. the app layer — not the pure-Dart xterm core). + +One wiring gap: `SshService.openShell` only assigns `terminal.onPrivateOSC` when shell +integration is active (`ssh_service.dart:605` — `siOn`). For OSC 52 to work independently of +shell integration, the callback must be wired whenever **either** shell integration **or** +OSC 52 is enabled, and the callback must dispatch by code. + +--- + +## Architecture (app-layer routing — no xterm fork change) + +### 1. `app/lib/services/osc52_clipboard.dart` (new, pure — no Flutter import) + +Parses the OSC 52 argument list into a typed result. Pure so it is unit-testable without a +terminal or Flutter binding. + +```dart +const int kOsc52MaxBytes = 1 << 20; // 1 MiB decoded cap + +sealed class Osc52Result {} +class Osc52Write extends Osc52Result { final String text; Osc52Write(this.text); } +class Osc52Ignored extends Osc52Result {} // read query, bad base64, oversized, empty + +class Osc52Clipboard { + /// `args` is the tail after the code, i.e. `['c', '']` for + /// `OSC 52 ; c ; `. Returns [Osc52Write] only for a valid, in-cap, + /// UTF-8-decodable write; everything else maps to [Osc52Ignored] (fail-soft). + static Osc52Result parse(List args); +} +``` + +Rules: +- First element is the selection target (`c`/`p`/`s`/`0`–`7`/empty). Desktop has a single + system clipboard, so the target is ignored — all map to the system clipboard. +- The data element is the last element. If it is `?` → read query → `Osc52Ignored` (never + read the local clipboard). +- Base64-decode the data (`base64.decode`, tolerant of missing padding via normalization). On + `FormatException` → `Osc52Ignored`. +- If decoded length > `kOsc52MaxBytes` → `Osc52Ignored`. +- Decode bytes as UTF-8 (`utf8.decode(..., allowMalformed: true)`) → `Osc52Write(text)`. +- Empty / malformed arg list → `Osc52Ignored`. + +### 2. `app/lib/models/host.dart` + +Add `bool osc52Clipboard = false`: +- Constructor default `false`. +- `toJson`: `'osc52Clipboard': osc52Clipboard`. +- `fromJson`: `(json['osc52Clipboard'] as bool?) ?? false` (old rows / sync payloads default + off). +- `copyWith` parameter. + +Rides the existing Supabase + P2P sync (host JSON is synced as-is). + +### 3. `app/lib/services/ssh_service.dart` + +Change the `onPrivateOSC` wiring in `openShell`: + +```dart +final siOn = /* unchanged */; +final osc52On = session.host.osc52Clipboard; +if (siOn || osc52On) { + session.terminal.onPrivateOSC = (code, args) { + if (code == '52' && osc52On) { + final r = Osc52Clipboard.parse(args); + if (r is Osc52Write) { + clipboardWriter(r.text); // injected; default Clipboard.setData + } + return; + } + if (siOn) { + shellIntegration!.handleOsc( + session.id, code, args, session.terminal.buffer.absoluteCursorY); + } + }; +} +``` + +`clipboardWriter` is a `Future Function(String text)` field on `SshService` +(constructor-injected, like the other callbacks) defaulting to: +```dart +(text) => Clipboard.setData(ClipboardData(text: text)); +``` +so tests assert on it without a platform clipboard. The write is silent — no SnackBar, no +notification (per-host opt-in is the security gate; matches iTerm2/kitty/wezterm). + +### 4. `app/lib/widgets/host_detail_panel.dart` + +Add an "OSC 52 clipboard" `SwitchListTile` in the SSH section, next to the Agent forwarding / +Shell integration toggles. Hidden when the host protocol is RDP (same as the other SSH-only +toggles). Subtitle: a one-line security note, e.g. "Let remote apps (tmux, vim) set your local +clipboard. Off by default — only enable for hosts you trust." `_save` carries the new field. + +--- + +## Data flow + +``` +remote app ──ESC]52;c;ST──▶ PTY ──▶ xterm parser (_escHandleOSC) + └─▶ handler.unknownOSC('52', ['c','']) + └─▶ Terminal.onPrivateOSC('52', ['c','']) + └─▶ SshService dispatcher (host.osc52Clipboard?) + └─▶ Osc52Clipboard.parse → Osc52Write(text) + └─▶ clipboardWriter(text) ⇒ Clipboard.setData +``` + +--- + +## Security & fail-soft + +- **Write only.** A `?` data field (read query) is ignored — the local clipboard is never read + or sent to the remote. +- **Opt-in, default off** per host; RDP hosts never see the toggle. +- **Size cap** of 1 MiB decoded prevents a remote from flooding the clipboard. +- **Fail-soft:** invalid base64, oversized payload, or empty args silently map to `Osc52Ignored` + — the terminal never crashes and nothing is written. +- Silent write (no UI feedback) by design. + +--- + +## Testing + +**`test/services/osc52_clipboard_test.dart`** (pure unit): +- Valid write: `['c', base64('hello')]` → `Osc52Write('hello')`. +- Empty selection: `['', base64('x')]` → write (mapped to system clipboard). +- Read query: `['c', '?']` → `Osc52Ignored`. +- Invalid base64: `['c', '!!!notb64']` → `Osc52Ignored`. +- Oversized: decoded > 1 MiB → `Osc52Ignored`. +- Non-UTF8 bytes: decoded with `allowMalformed` → `Osc52Write` (no throw). +- Malformed arg list (`[]`, `['52']`) → `Osc52Ignored`. + +**`test/services/ssh_service_osc52_test.dart`** (dispatch, fake `clipboardWriter`): +- Code `52` + `host.osc52Clipboard = true` → `clipboardWriter` called with decoded text. +- Code `52` + toggle off → `clipboardWriter` not called; `onPrivateOSC` may not even be wired. +- Codes `7`/`133` still route to shell integration when `siOn`. +- A payload fed across two chunks (mid-base64 split) composes to one write — documents the + parser's existing resume behavior (same as a long title OSC). + +**`test/models/host_osc52_test.dart`**: `toJson`/`fromJson` round-trip (default false on a row +without the key), `copyWith`. + +--- + +## Out of scope + +- OSC 52 **read** / clipboard query (security; deliberately rejected). +- Primary-selection vs clipboard distinction (desktop has one system clipboard). +- A global Settings toggle — per-host opt-in is sufficient (revisit only if users ask). +- Any xterm core/UI fork change. From d2229c6067cf3a78f0e70a1212ed49853e0b5067 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 14 Jun 2026 20:32:40 +0700 Subject: [PATCH 2/6] docs(plan): OSC 52 clipboard implementation plan --- .../plans/2026-06-14-osc52-clipboard.md | 630 ++++++++++++++++++ 1 file changed, 630 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-14-osc52-clipboard.md diff --git a/docs/superpowers/plans/2026-06-14-osc52-clipboard.md b/docs/superpowers/plans/2026-06-14-osc52-clipboard.md new file mode 100644 index 00000000..aac5e240 --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-osc52-clipboard.md @@ -0,0 +1,630 @@ +# OSC 52 Clipboard 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:** Let remote apps (tmux, vim) write the user's local system clipboard via the OSC 52 escape sequence, write-only, gated by a per-host opt-in that defaults off. + +**Architecture:** App-layer routing (no xterm core change). OSC 52 already surfaces as `Terminal.onPrivateOSC('52', ['c', ''])`. A new pure `Osc52Clipboard` parser decodes/validates the payload; `SshService` dispatches code `52` (when the host toggle is on) to an injectable `clipboardWriter` defaulting to `Clipboard.setData`, and keeps routing codes `7`/`133` to shell integration. A per-host `SwitchListTile` in the host panel controls the opt-in. + +**Tech Stack:** Flutter/Dart, `dart:convert` (base64/utf8), `flutter/services` (`Clipboard`), existing xterm fork (unchanged), `flutter_test`. + +**Spec:** `docs/superpowers/specs/2026-06-14-osc52-clipboard-design.md` + +--- + +## File structure + +- Create: `app/lib/services/osc52_clipboard.dart` — pure parser (`Osc52Clipboard.parse` → `Osc52Result`). +- Create: `app/test/services/osc52_clipboard_test.dart` — parser unit tests. +- Modify: `app/lib/models/host.dart` — add `bool osc52Clipboard` (ctor/toJson/fromJson/copyWith). +- Create: `app/test/models/host_osc52_test.dart` — model round-trip tests. +- Modify: `app/lib/services/ssh_service.dart` — add `clipboardWriter` field, `dispatchPrivateOsc`, rewire `onPrivateOSC`. +- Create: `app/test/services/ssh_service_osc52_test.dart` — dispatch tests. +- Modify: `app/lib/widgets/host_detail_panel.dart` — `_osc52Clipboard` state + `SwitchListTile` + save wiring. +- Create: `app/test/widgets/host_detail_panel_osc52_test.dart` — toggle widget test. + +All commands run from `app/` unless noted. + +--- + +### Task 1: Pure `Osc52Clipboard` parser + +**Files:** +- Create: `app/lib/services/osc52_clipboard.dart` +- Test: `app/test/services/osc52_clipboard_test.dart` + +- [ ] **Step 1: Write the failing test** + +Create `app/test/services/osc52_clipboard_test.dart`: + +```dart +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/services/osc52_clipboard.dart'; + +String b64(String s) => base64.encode(utf8.encode(s)); + +void main() { + group('Osc52Clipboard.parse', () { + test('valid write decodes base64 text', () { + final r = Osc52Clipboard.parse(['c', b64('hello world')]); + expect(r, isA()); + expect((r as Osc52Write).text, 'hello world'); + }); + + test('empty selection target still writes', () { + final r = Osc52Clipboard.parse(['', b64('x')]); + expect((r as Osc52Write).text, 'x'); + }); + + test('read query (?) is ignored', () { + expect(Osc52Clipboard.parse(['c', '?']), isA()); + }); + + test('invalid base64 is ignored', () { + expect(Osc52Clipboard.parse(['c', '!!!not base64!!!']), + isA()); + }); + + test('payload over the cap is ignored', () { + final big = base64.encode(List.filled(kOsc52MaxBytes + 1, 65)); + expect(Osc52Clipboard.parse(['c', big]), isA()); + }); + + test('non-utf8 bytes decode without throwing', () { + final raw = base64.encode([0xff, 0xfe, 0x41]); + expect(Osc52Clipboard.parse(['c', raw]), isA()); + }); + + test('malformed arg lists are ignored', () { + expect(Osc52Clipboard.parse([]), isA()); + expect(Osc52Clipboard.parse(['c']), isA()); + }); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/services/osc52_clipboard_test.dart` +Expected: FAIL — `Error: Couldn't resolve the package 'yourssh' ... osc52_clipboard.dart` / `Osc52Clipboard` undefined. + +- [ ] **Step 3: Write minimal implementation** + +Create `app/lib/services/osc52_clipboard.dart`: + +```dart +import 'dart:convert'; + +/// Maximum decoded clipboard payload accepted from an OSC 52 write (1 MiB). +const int kOsc52MaxBytes = 1 << 20; + +/// Result of parsing an OSC 52 argument list. +sealed class Osc52Result { + const Osc52Result(); +} + +/// A clipboard-write request carrying the decoded [text]. +class Osc52Write extends Osc52Result { + final String text; + const Osc52Write(this.text); +} + +/// Not a write we honor: a read query (`?`), invalid base64, an oversized +/// payload, or a malformed argument list. +class Osc52Ignored extends Osc52Result { + const Osc52Ignored(); +} + +class Osc52Clipboard { + /// Parses the OSC 52 argument tail (everything after the `52` code). + /// + /// For `OSC 52 ; c ; ` the caller hands us `['c', '']`. + /// The selection target (first element) is ignored — desktop has a single + /// system clipboard. Returns [Osc52Write] only for a valid, in-cap payload; + /// every other case is [Osc52Ignored] (fail-soft — never throws). + static Osc52Result parse(List args) { + if (args.length < 2) return const Osc52Ignored(); + final data = args.last; + if (data == '?') return const Osc52Ignored(); // read query — never honored + final List bytes; + try { + bytes = base64.decode(base64.normalize(data)); + } on FormatException { + return const Osc52Ignored(); + } + if (bytes.length > kOsc52MaxBytes) return const Osc52Ignored(); + return Osc52Write(utf8.decode(bytes, allowMalformed: true)); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/services/osc52_clipboard_test.dart` +Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +cd /Users/thangnguyen/Projects/Personal/yourssh +git add app/lib/services/osc52_clipboard.dart app/test/services/osc52_clipboard_test.dart +git commit -m "feat(osc52): pure clipboard payload parser" +``` + +--- + +### Task 2: `Host.osc52Clipboard` field + +**Files:** +- Modify: `app/lib/models/host.dart` (lines ~48, ~85, ~135, ~225, ~255, ~286 — next to `agentForwarding`) +- Test: `app/test/models/host_osc52_test.dart` + +- [ ] **Step 1: Write the failing test** + +Create `app/test/models/host_osc52_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/host.dart'; + +void main() { + group('Host.osc52Clipboard', () { + test('defaults to false', () { + final h = Host(label: 'a', host: 'h', username: 'u'); + expect(h.osc52Clipboard, isFalse); + }); + + test('round-trips through toJson/fromJson', () { + final h = Host(label: 'a', host: 'h', username: 'u', osc52Clipboard: true); + final back = Host.fromJson(h.toJson()); + expect(back.osc52Clipboard, isTrue); + }); + + test('absent key in json defaults to false', () { + final json = Host(label: 'a', host: 'h', username: 'u').toJson() + ..remove('osc52Clipboard'); + expect(Host.fromJson(json).osc52Clipboard, isFalse); + }); + + test('copyWith overrides the field', () { + final h = Host(label: 'a', host: 'h', username: 'u'); + expect(h.copyWith(osc52Clipboard: true).osc52Clipboard, isTrue); + expect(h.copyWith().osc52Clipboard, isFalse); + }); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/models/host_osc52_test.dart` +Expected: FAIL — `No named parameter with the name 'osc52Clipboard'` / `osc52Clipboard` getter undefined. + +- [ ] **Step 3: Write minimal implementation** + +In `app/lib/models/host.dart`: + +Field — after `bool agentForwarding;` (line ~48): +```dart + bool osc52Clipboard; +``` + +Constructor — after `this.agentForwarding = false,` (line ~85): +```dart + this.osc52Clipboard = false, +``` + +`toJson` — after `'agentForwarding': agentForwarding,` (line ~135): +```dart + 'osc52Clipboard': osc52Clipboard, +``` + +`fromJson` — after `agentForwarding: (json['agentForwarding'] as bool?) ?? false,` (line ~225): +```dart + osc52Clipboard: (json['osc52Clipboard'] as bool?) ?? false, +``` + +`copyWith` signature — after `bool? agentForwarding,` (line ~255): +```dart + bool? osc52Clipboard, +``` + +`copyWith` body — after `agentForwarding: agentForwarding ?? this.agentForwarding,` (line ~286): +```dart + osc52Clipboard: osc52Clipboard ?? this.osc52Clipboard, +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/models/host_osc52_test.dart` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +cd /Users/thangnguyen/Projects/Personal/yourssh +git add app/lib/models/host.dart app/test/models/host_osc52_test.dart +git commit -m "feat(osc52): add Host.osc52Clipboard opt-in field" +``` + +--- + +### Task 3: `SshService` OSC dispatch + injectable clipboard writer + +**Files:** +- Modify: `app/lib/services/ssh_service.dart` (imports; new field near line ~104; new method; rewire `onPrivateOSC` at lines 605-615) +- Test: `app/test/services/ssh_service_osc52_test.dart` + +- [ ] **Step 1: Write the failing test** + +Create `app/test/services/ssh_service_osc52_test.dart`: + +```dart +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/providers/shell_integration_provider.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; + +String b64(String s) => base64.encode(utf8.encode(s)); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('code 52 with osc52 on writes decoded text to the clipboard', () { + final svc = SshService(StorageService()); + final written = []; + svc.clipboardWriter = (t) async => written.add(t); + + svc.dispatchPrivateOsc('52', ['c', b64('copied!')], + osc52On: true, siOn: false, sessionId: 's1', absoluteCursorY: 0); + + expect(written, ['copied!']); + }); + + test('code 52 with osc52 off does not write', () { + final svc = SshService(StorageService()); + final written = []; + svc.clipboardWriter = (t) async => written.add(t); + + svc.dispatchPrivateOsc('52', ['c', b64('nope')], + osc52On: false, siOn: false, sessionId: 's1', absoluteCursorY: 0); + + expect(written, isEmpty); + }); + + test('OSC 52 read query is never written', () { + final svc = SshService(StorageService()); + final written = []; + svc.clipboardWriter = (t) async => written.add(t); + + svc.dispatchPrivateOsc('52', ['c', '?'], + osc52On: true, siOn: false, sessionId: 's1', absoluteCursorY: 0); + + expect(written, isEmpty); + }); + + test('code 7 routes to shell integration (cwd), not the clipboard', () { + final si = ShellIntegrationProvider(); + final svc = SshService(StorageService(), shellIntegration: si); + final written = []; + svc.clipboardWriter = (t) async => written.add(t); + + svc.dispatchPrivateOsc('7', ['file://host/home/user'], + osc52On: true, siOn: true, sessionId: 's1', absoluteCursorY: 0); + + expect(written, isEmpty); + expect(si.cwdFor('s1'), '/home/user'); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/services/ssh_service_osc52_test.dart` +Expected: FAIL — `clipboardWriter` setter / `dispatchPrivateOsc` undefined. + +- [ ] **Step 3: Write minimal implementation** + +In `app/lib/services/ssh_service.dart`: + +(a) Add imports after line 5 (`import 'package:flutter/foundation.dart';`): +```dart +import 'package:flutter/services.dart'; +import 'osc52_clipboard.dart'; +``` + +(b) Add the injectable writer field just before the constructor (after the `sudoPasswordPrompt` field, line ~104): +```dart + /// Writes [text] to the system clipboard for an OSC 52 write request. + /// Injectable for tests; defaults to the platform clipboard. + Future Function(String text) clipboardWriter = + (text) => Clipboard.setData(ClipboardData(text: text)); +``` + +(c) Add the dispatch method (place it right after `openShell`, or anywhere in the class body — e.g. directly above `openShell`): +```dart + /// Routes a private-OSC event. OSC 52 (when [osc52On]) writes the local + /// clipboard; everything else falls through to shell integration when + /// [siOn]. Extracted so the routing is unit-testable without a live session. + @visibleForTesting + void dispatchPrivateOsc( + String code, + List args, { + required bool osc52On, + required bool siOn, + required String sessionId, + required int absoluteCursorY, + }) { + if (code == '52' && osc52On) { + final r = Osc52Clipboard.parse(args); + if (r is Osc52Write) clipboardWriter(r.text); + return; + } + if (siOn) { + shellIntegration?.handleOsc(sessionId, code, args, absoluteCursorY); + } + } +``` + +(d) Replace the `onPrivateOSC` wiring at lines 605-615. Current: +```dart + final siOn = shellIntegration != null && + session.host.shellIntegration && + (isShellIntegrationEnabled?.call() ?? true); + if (siOn) { + session.terminal.onPrivateOSC = (code, args) => shellIntegration!.handleOsc( + session.id, + code, + args, + session.terminal.buffer.absoluteCursorY, + ); + } +``` +Replace with: +```dart + final siOn = shellIntegration != null && + session.host.shellIntegration && + (isShellIntegrationEnabled?.call() ?? true); + final osc52On = session.host.osc52Clipboard; + if (siOn || osc52On) { + session.terminal.onPrivateOSC = (code, args) => dispatchPrivateOsc( + code, + args, + osc52On: osc52On, + siOn: siOn, + sessionId: session.id, + absoluteCursorY: session.terminal.buffer.absoluteCursorY, + ); + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/services/ssh_service_osc52_test.dart` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +cd /Users/thangnguyen/Projects/Personal/yourssh +git add app/lib/services/ssh_service.dart app/test/services/ssh_service_osc52_test.dart +git commit -m "feat(osc52): dispatch OSC 52 writes to the system clipboard" +``` + +--- + +### Task 4: Host panel opt-in toggle + +**Files:** +- Modify: `app/lib/widgets/host_detail_panel.dart` (state field ~line 70; init ~line 110; save ~line 231; `SwitchListTile` after the Agent forwarding block ~line 803, inside the existing `if (!_isRdp)` block so RDP hides it) +- Test: `app/test/widgets/host_detail_panel_osc52_test.dart` + +- [ ] **Step 1: Write the failing test** + +Create `app/test/widgets/host_detail_panel_osc52_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/providers/host_provider.dart'; +import 'package:yourssh/providers/key_provider.dart'; +import 'package:yourssh/services/agent_probe.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/widgets/host_detail_panel.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + Host? saved; + + Future pumpPanel(WidgetTester tester, {Host? existing}) async { + saved = null; + await tester.binding.setSurfaceSize(const Size(500, 2400)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => KeyProvider()), + ChangeNotifierProvider( + create: (_) => HostProvider(StorageService())), + Provider(create: (_) => SshService(StorageService())), + ], + child: MaterialApp( + home: Scaffold( + body: HostDetailPanel( + existing: existing, + agentProbe: () async => const AgentProbeSystem(1), + onClose: () {}, + onSave: (host, _) async => saved = host, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + Host hostWith({bool osc52 = false}) => + Host(label: 'srv', host: '1.2.3.4', username: 'root', osc52Clipboard: osc52); + + testWidgets('toggle defaults off and saves true after switching on', + (tester) async { + await pumpPanel(tester, existing: hostWith()); + + final toggle = find.widgetWithText(SwitchListTile, 'OSC 52 clipboard'); + await tester.ensureVisible(toggle); + expect(tester.widget(toggle).value, isFalse); + + await tester.tap(toggle); + await tester.pumpAndSettle(); + + final save = find.text('SAVE ONLY'); + await tester.ensureVisible(save); + await tester.tap(save); + await tester.pumpAndSettle(); + + expect(saved, isNotNull); + expect(saved!.osc52Clipboard, isTrue); + }); + + testWidgets('editing a host with osc52 on shows the switch on', + (tester) async { + await pumpPanel(tester, existing: hostWith(osc52: true)); + final toggle = find.widgetWithText(SwitchListTile, 'OSC 52 clipboard'); + await tester.ensureVisible(toggle); + expect(tester.widget(toggle).value, isTrue); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/widgets/host_detail_panel_osc52_test.dart` +Expected: FAIL — `osc52Clipboard` named param undefined (Task 2 supplies it) and/or no `SwitchListTile` with text `OSC 52 clipboard` found. + +> Note: Task 2 must be complete first (provides `Host.osc52Clipboard`). + +- [ ] **Step 3: Write minimal implementation** + +In `app/lib/widgets/host_detail_panel.dart`: + +(a) State field — after `bool _agentForwarding = false;` (line ~70): +```dart + bool _osc52Clipboard = false; +``` + +(b) Init — after `_agentForwarding = h?.agentForwarding ?? false;` (line ~110): +```dart + _osc52Clipboard = h?.osc52Clipboard ?? false; +``` + +(c) Save — in the `Host(...)` build, after `agentForwarding: !_isRdp && _agentForwarding,` (line ~231): +```dart + osc52Clipboard: !_isRdp && _osc52Clipboard, +``` + +(d) UI — add a `SwitchListTile` immediately after the Agent forwarding `SwitchListTile` closing `),` at line ~803 (before the `if (_agentForwarding && ...) AgentStatusLine(...)` block), so it sits in the same SSH-only `_Card`: +```dart + SwitchListTile( + value: _osc52Clipboard, + onChanged: (v) => setState(() => _osc52Clipboard = v), + title: const Text( + 'OSC 52 clipboard', + style: TextStyle( + color: AppColors.textPrimary, fontSize: 13), + ), + subtitle: const Text( + 'Let remote apps (tmux, vim) set your local clipboard. ' + 'Write-only. Off by default — only enable for hosts you ' + 'trust.', + style: TextStyle( + color: AppColors.textTertiary, fontSize: 11), + ), + dense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 2), + activeThumbColor: AppColors.accent, + ), +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/widgets/host_detail_panel_osc52_test.dart` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +cd /Users/thangnguyen/Projects/Personal/yourssh +git add app/lib/widgets/host_detail_panel.dart app/test/widgets/host_detail_panel_osc52_test.dart +git commit -m "feat(osc52): per-host opt-in toggle in the host panel" +``` + +--- + +### Task 5: Verify whole feature (analyze + full test run) + +**Files:** none (verification + final wrap-up) + +- [ ] **Step 1: Static analysis** + +Run: `flutter analyze` +Expected: No new issues in `osc52_clipboard.dart`, `host.dart`, `ssh_service.dart`, `host_detail_panel.dart` (pre-existing warnings elsewhere unchanged). + +- [ ] **Step 2: Run the OSC 52 + touched-area tests together** + +Run: +```bash +flutter test \ + test/services/osc52_clipboard_test.dart \ + test/models/host_osc52_test.dart \ + test/services/ssh_service_osc52_test.dart \ + test/widgets/host_detail_panel_osc52_test.dart +``` +Expected: all PASS (17 tests total). + +- [ ] **Step 3: Regression — host model + host panel suites** + +Run: +```bash +flutter test test/models/ test/widgets/host_detail_panel_agent_forwarding_test.dart +``` +Expected: PASS (the new `osc52Clipboard` field must not break existing Host round-trip / copyWith / panel tests). + +- [ ] **Step 4: Manual smoke (optional, if an SSH host is available)** + +Enable "OSC 52 clipboard" on a test host, connect, then on the remote run: +```bash +printf '\033]52;c;%s\007' "$(printf 'osc52 works' | base64)" +``` +Paste locally → clipboard contains `osc52 works`. With the toggle off, the clipboard is unchanged. + +- [ ] **Step 5: Final docs + roadmap** + +Move the OSC 52 bullet from P1 (Terminal UX) into the "Already shipped" list in `docs/roadmap.md` (next version), and add a line to `docs/wiki/` per-feature docs if a Terminal user guide exists. (Defer the version bump / CHANGELOG to the normal release checklist.) + +--- + +## Self-review + +**Spec coverage:** +- Pure parser (write-only, `?` ignored, base64 decode, 1 MiB cap, UTF-8 malformed-tolerant) → Task 1. ✓ +- `Host.osc52Clipboard` default-off + sync round-trip → Task 2. ✓ +- `onPrivateOSC` wired when `siOn || osc52On`; code 52 → clipboard, 7/133 → shell integration; injectable `clipboardWriter` → Task 3. ✓ +- Per-host toggle, SSH-only (RDP-hidden via `!_isRdp`), silent write → Task 4 + save uses `!_isRdp && _osc52Clipboard`. ✓ +- Security/fail-soft (no read, drop bad/oversized) → covered by Task 1 tests + dispatch tests. ✓ + +**Placeholder scan:** none — every code step has full code; no TBD/TODO. + +**Type consistency:** `Osc52Result`/`Osc52Write`/`Osc52Ignored`, `kOsc52MaxBytes`, `Osc52Clipboard.parse`, `clipboardWriter`, `dispatchPrivateOsc` named params (`osc52On`/`siOn`/`sessionId`/`absoluteCursorY`), `Host.osc52Clipboard` — used identically across Tasks 1-4. Save button text `SAVE ONLY` matches the existing panel harness. ✓ From 23d3464734e7c0dd89c831d80f942869d9e170bd Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 14 Jun 2026 20:33:44 +0700 Subject: [PATCH 3/6] feat(osc52): pure clipboard payload parser --- app/lib/services/osc52_clipboard.dart | 43 ++++++++++++++++++++ app/test/services/osc52_clipboard_test.dart | 45 +++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 app/lib/services/osc52_clipboard.dart create mode 100644 app/test/services/osc52_clipboard_test.dart diff --git a/app/lib/services/osc52_clipboard.dart b/app/lib/services/osc52_clipboard.dart new file mode 100644 index 00000000..c1a0f735 --- /dev/null +++ b/app/lib/services/osc52_clipboard.dart @@ -0,0 +1,43 @@ +import 'dart:convert'; + +/// Maximum decoded clipboard payload accepted from an OSC 52 write (1 MiB). +const int kOsc52MaxBytes = 1 << 20; + +/// Result of parsing an OSC 52 argument list. +sealed class Osc52Result { + const Osc52Result(); +} + +/// A clipboard-write request carrying the decoded [text]. +class Osc52Write extends Osc52Result { + final String text; + const Osc52Write(this.text); +} + +/// Not a write we honor: a read query (`?`), invalid base64, an oversized +/// payload, or a malformed argument list. +class Osc52Ignored extends Osc52Result { + const Osc52Ignored(); +} + +class Osc52Clipboard { + /// Parses the OSC 52 argument tail (everything after the `52` code). + /// + /// For `OSC 52 ; c ; ` the caller hands us `['c', '']`. + /// The selection target (first element) is ignored — desktop has a single + /// system clipboard. Returns [Osc52Write] only for a valid, in-cap payload; + /// every other case is [Osc52Ignored] (fail-soft — never throws). + static Osc52Result parse(List args) { + if (args.length < 2) return const Osc52Ignored(); + final data = args.last; + if (data == '?') return const Osc52Ignored(); // read query — never honored + final List bytes; + try { + bytes = base64.decode(base64.normalize(data)); + } on FormatException { + return const Osc52Ignored(); + } + if (bytes.length > kOsc52MaxBytes) return const Osc52Ignored(); + return Osc52Write(utf8.decode(bytes, allowMalformed: true)); + } +} diff --git a/app/test/services/osc52_clipboard_test.dart b/app/test/services/osc52_clipboard_test.dart new file mode 100644 index 00000000..b5391a0f --- /dev/null +++ b/app/test/services/osc52_clipboard_test.dart @@ -0,0 +1,45 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/services/osc52_clipboard.dart'; + +String b64(String s) => base64.encode(utf8.encode(s)); + +void main() { + group('Osc52Clipboard.parse', () { + test('valid write decodes base64 text', () { + final r = Osc52Clipboard.parse(['c', b64('hello world')]); + expect(r, isA()); + expect((r as Osc52Write).text, 'hello world'); + }); + + test('empty selection target still writes', () { + final r = Osc52Clipboard.parse(['', b64('x')]); + expect((r as Osc52Write).text, 'x'); + }); + + test('read query (?) is ignored', () { + expect(Osc52Clipboard.parse(['c', '?']), isA()); + }); + + test('invalid base64 is ignored', () { + expect(Osc52Clipboard.parse(['c', '!!!not base64!!!']), + isA()); + }); + + test('payload over the cap is ignored', () { + final big = base64.encode(List.filled(kOsc52MaxBytes + 1, 65)); + expect(Osc52Clipboard.parse(['c', big]), isA()); + }); + + test('non-utf8 bytes decode without throwing', () { + final raw = base64.encode([0xff, 0xfe, 0x41]); + expect(Osc52Clipboard.parse(['c', raw]), isA()); + }); + + test('malformed arg lists are ignored', () { + expect(Osc52Clipboard.parse([]), isA()); + expect(Osc52Clipboard.parse(['c']), isA()); + }); + }); +} From e37d96454e05609c3e42da1a33e137bd1ba7476f Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 14 Jun 2026 20:35:11 +0700 Subject: [PATCH 4/6] feat(osc52): add Host.osc52Clipboard opt-in field --- app/lib/models/host.dart | 6 ++++++ app/test/models/host_osc52_test.dart | 29 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 app/test/models/host_osc52_test.dart diff --git a/app/lib/models/host.dart b/app/lib/models/host.dart index 0a64813e..634406b7 100644 --- a/app/lib/models/host.dart +++ b/app/lib/models/host.dart @@ -46,6 +46,7 @@ class Host { List jumpHostIds; bool shellIntegration; bool agentForwarding; + bool osc52Clipboard; SftpMode sftpMode; String? sftpServerCommand; @@ -83,6 +84,7 @@ class Host { Iterable jumpHostIds = const [], this.shellIntegration = true, this.agentForwarding = false, + this.osc52Clipboard = false, this.sftpMode = SftpMode.normal, this.sftpServerCommand, this.protocol = HostProtocol.ssh, @@ -133,6 +135,7 @@ class Host { 'jumpHostId': jumpHostId, 'shellIntegration': shellIntegration, 'agentForwarding': agentForwarding, + 'osc52Clipboard': osc52Clipboard, 'sftpMode': sftpMode.name, 'sftpServerCommand': sftpServerCommand, 'protocol': protocol.name, @@ -223,6 +226,7 @@ class Host { jumpHostIds: parseJumpHostIds(), shellIntegration: (json['shellIntegration'] as bool?) ?? true, agentForwarding: (json['agentForwarding'] as bool?) ?? false, + osc52Clipboard: (json['osc52Clipboard'] as bool?) ?? false, sftpMode: parseSftpMode(), sftpServerCommand: json['sftpServerCommand'] as String?, protocol: parseProtocol(), @@ -253,6 +257,7 @@ class Host { List? jumpHostIds, bool? shellIntegration, bool? agentForwarding, + bool? osc52Clipboard, SftpMode? sftpMode, Object? sftpServerCommand = const _Unset(), HostProtocol? protocol, @@ -284,6 +289,7 @@ class Host { jumpHostIds: jumpHostIds ?? this.jumpHostIds, shellIntegration: shellIntegration ?? this.shellIntegration, agentForwarding: agentForwarding ?? this.agentForwarding, + osc52Clipboard: osc52Clipboard ?? this.osc52Clipboard, sftpMode: sftpMode ?? this.sftpMode, sftpServerCommand: sftpServerCommand is _Unset ? this.sftpServerCommand diff --git a/app/test/models/host_osc52_test.dart b/app/test/models/host_osc52_test.dart new file mode 100644 index 00000000..559d796f --- /dev/null +++ b/app/test/models/host_osc52_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/host.dart'; + +void main() { + group('Host.osc52Clipboard', () { + test('defaults to false', () { + final h = Host(label: 'a', host: 'h', username: 'u'); + expect(h.osc52Clipboard, isFalse); + }); + + test('round-trips through toJson/fromJson', () { + final h = Host(label: 'a', host: 'h', username: 'u', osc52Clipboard: true); + final back = Host.fromJson(h.toJson()); + expect(back.osc52Clipboard, isTrue); + }); + + test('absent key in json defaults to false', () { + final json = Host(label: 'a', host: 'h', username: 'u').toJson() + ..remove('osc52Clipboard'); + expect(Host.fromJson(json).osc52Clipboard, isFalse); + }); + + test('copyWith overrides the field', () { + final h = Host(label: 'a', host: 'h', username: 'u'); + expect(h.copyWith(osc52Clipboard: true).osc52Clipboard, isTrue); + expect(h.copyWith().osc52Clipboard, isFalse); + }); + }); +} From 8e512a51a92496f71b4624f4c122519728a2e4cb Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 14 Jun 2026 20:36:04 +0700 Subject: [PATCH 5/6] feat(osc52): dispatch OSC 52 writes to the system clipboard --- app/lib/services/ssh_service.dart | 40 +++++++++++-- app/test/services/ssh_service_osc52_test.dart | 60 +++++++++++++++++++ 2 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 app/test/services/ssh_service_osc52_test.dart diff --git a/app/lib/services/ssh_service.dart b/app/lib/services/ssh_service.dart index e7cc1fdc..5a923316 100644 --- a/app/lib/services/ssh_service.dart +++ b/app/lib/services/ssh_service.dart @@ -3,7 +3,9 @@ import 'dart:convert'; import 'dart:io'; import 'package:dartssh2/dartssh2.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:yourssh_script_engine/yourssh_script_engine.dart'; +import 'osc52_clipboard.dart'; import '../providers/shell_integration_provider.dart'; import '../models/agent_forwarding_state.dart'; import '../models/host.dart'; @@ -103,8 +105,35 @@ class SshService { Future<({String password, bool remember})?> Function(Host host)? sudoPasswordPrompt; + /// Writes [text] to the system clipboard for an OSC 52 write request. + /// Injectable for tests; defaults to the platform clipboard. + Future Function(String text) clipboardWriter = + (text) => Clipboard.setData(ClipboardData(text: text)); + SshService(this._storage, {this.hookBus, this.shellIntegration}); + /// Routes a private-OSC event. OSC 52 (when [osc52On]) writes the local + /// clipboard; everything else falls through to shell integration when + /// [siOn]. Extracted so the routing is unit-testable without a live session. + @visibleForTesting + void dispatchPrivateOsc( + String code, + List args, { + required bool osc52On, + required bool siOn, + required String sessionId, + required int absoluteCursorY, + }) { + if (code == '52' && osc52On) { + final r = Osc52Clipboard.parse(args); + if (r is Osc52Write) clipboardWriter(r.text); + return; + } + if (siOn) { + shellIntegration?.handleOsc(sessionId, code, args, absoluteCursorY); + } + } + /// Test-only: register a (fake) client so shell/exec paths can run without /// a real network connection. @visibleForTesting @@ -605,12 +634,15 @@ class SshService { final siOn = shellIntegration != null && session.host.shellIntegration && (isShellIntegrationEnabled?.call() ?? true); - if (siOn) { - session.terminal.onPrivateOSC = (code, args) => shellIntegration!.handleOsc( - session.id, + final osc52On = session.host.osc52Clipboard; + if (siOn || osc52On) { + session.terminal.onPrivateOSC = (code, args) => dispatchPrivateOsc( code, args, - session.terminal.buffer.absoluteCursorY, + osc52On: osc52On, + siOn: siOn, + sessionId: session.id, + absoluteCursorY: session.terminal.buffer.absoluteCursorY, ); } diff --git a/app/test/services/ssh_service_osc52_test.dart b/app/test/services/ssh_service_osc52_test.dart new file mode 100644 index 00000000..68899d67 --- /dev/null +++ b/app/test/services/ssh_service_osc52_test.dart @@ -0,0 +1,60 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/providers/shell_integration_provider.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; + +String b64(String s) => base64.encode(utf8.encode(s)); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('code 52 with osc52 on writes decoded text to the clipboard', () { + final svc = SshService(StorageService()); + final written = []; + svc.clipboardWriter = (t) async => written.add(t); + + svc.dispatchPrivateOsc('52', ['c', b64('copied!')], + osc52On: true, siOn: false, sessionId: 's1', absoluteCursorY: 0); + + expect(written, ['copied!']); + }); + + test('code 52 with osc52 off does not write', () { + final svc = SshService(StorageService()); + final written = []; + svc.clipboardWriter = (t) async => written.add(t); + + svc.dispatchPrivateOsc('52', ['c', b64('nope')], + osc52On: false, siOn: false, sessionId: 's1', absoluteCursorY: 0); + + expect(written, isEmpty); + }); + + test('OSC 52 read query is never written', () { + final svc = SshService(StorageService()); + final written = []; + svc.clipboardWriter = (t) async => written.add(t); + + svc.dispatchPrivateOsc('52', ['c', '?'], + osc52On: true, siOn: false, sessionId: 's1', absoluteCursorY: 0); + + expect(written, isEmpty); + }); + + test('code 7 routes to shell integration (cwd), not the clipboard', () { + final si = ShellIntegrationProvider(); + final svc = SshService(StorageService(), shellIntegration: si); + final written = []; + svc.clipboardWriter = (t) async => written.add(t); + + svc.dispatchPrivateOsc('7', ['file://host/home/user'], + osc52On: true, siOn: true, sessionId: 's1', absoluteCursorY: 0); + + expect(written, isEmpty); + expect(si.cwdFor('s1'), '/home/user'); + }); +} From 7ebb055503984565282106d0f5536b5184a167ba Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 14 Jun 2026 20:37:03 +0700 Subject: [PATCH 6/6] feat(osc52): per-host opt-in toggle in the host panel --- app/lib/widgets/host_detail_panel.dart | 23 ++++++ .../widgets/host_detail_panel_osc52_test.dart | 76 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 app/test/widgets/host_detail_panel_osc52_test.dart diff --git a/app/lib/widgets/host_detail_panel.dart b/app/lib/widgets/host_detail_panel.dart index 376d8944..3b093634 100644 --- a/app/lib/widgets/host_detail_panel.dart +++ b/app/lib/widgets/host_detail_panel.dart @@ -68,6 +68,7 @@ class _HostDetailPanelState extends State { bool _recordingRedaction = true; bool _shellIntegration = true; bool _agentForwarding = false; + bool _osc52Clipboard = false; late final TextEditingController _workingDirCtrl; late final TextEditingController _startupSnippetCtrl; late final TextEditingController _fontSizeCtrl; @@ -108,6 +109,7 @@ class _HostDetailPanelState extends State { _recordingRedaction = h?.recordingRedaction ?? true; _shellIntegration = h?.shellIntegration ?? true; _agentForwarding = h?.agentForwarding ?? false; + _osc52Clipboard = h?.osc52Clipboard ?? false; _workingDirCtrl = TextEditingController(text: h?.workingDir ?? ''); _startupSnippetCtrl = TextEditingController(text: h?.startupSnippet ?? ''); _fontSizeCtrl = TextEditingController(text: _fmtFontSize(h?.fontSize)); @@ -229,6 +231,7 @@ class _HostDetailPanelState extends State { recordingRedaction: _recordingRedaction, shellIntegration: _shellIntegration, agentForwarding: !_isRdp && _agentForwarding, + osc52Clipboard: !_isRdp && _osc52Clipboard, jumpHostIds: _jumpHostIds, sftpMode: _isRdp ? SftpMode.normal : _sftpMode, sftpServerCommand: !_isRdp && _sftpMode == SftpMode.custom @@ -801,6 +804,26 @@ class _HostDetailPanelState extends State { horizontal: 12, vertical: 2), activeThumbColor: AppColors.accent, ), + SwitchListTile( + value: _osc52Clipboard, + onChanged: (v) => setState(() => _osc52Clipboard = v), + title: const Text( + 'OSC 52 clipboard', + style: TextStyle( + color: AppColors.textPrimary, fontSize: 13), + ), + subtitle: const Text( + 'Let remote apps (tmux, vim) set your local clipboard. ' + 'Write-only. Off by default — only enable for hosts you ' + 'trust.', + style: TextStyle( + color: AppColors.textTertiary, fontSize: 11), + ), + dense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 2), + activeThumbColor: AppColors.accent, + ), // Zero-click feedback: probes on appearance. The auth // section owns the line when auth = SSH Agent (spec: one // probe, no duplicate row). diff --git a/app/test/widgets/host_detail_panel_osc52_test.dart b/app/test/widgets/host_detail_panel_osc52_test.dart new file mode 100644 index 00000000..68d27711 --- /dev/null +++ b/app/test/widgets/host_detail_panel_osc52_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/providers/host_provider.dart'; +import 'package:yourssh/providers/key_provider.dart'; +import 'package:yourssh/services/agent_probe.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/widgets/host_detail_panel.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + Host? saved; + + Future pumpPanel(WidgetTester tester, {Host? existing}) async { + saved = null; + await tester.binding.setSurfaceSize(const Size(500, 2400)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => KeyProvider()), + ChangeNotifierProvider( + create: (_) => HostProvider(StorageService())), + Provider(create: (_) => SshService(StorageService())), + ], + child: MaterialApp( + home: Scaffold( + body: HostDetailPanel( + existing: existing, + agentProbe: () async => const AgentProbeSystem(1), + onClose: () {}, + onSave: (host, _) async => saved = host, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + Host hostWith({bool osc52 = false}) => Host( + label: 'srv', host: '1.2.3.4', username: 'root', osc52Clipboard: osc52); + + testWidgets('toggle defaults off and saves true after switching on', + (tester) async { + await pumpPanel(tester, existing: hostWith()); + + final toggle = find.widgetWithText(SwitchListTile, 'OSC 52 clipboard'); + await tester.ensureVisible(toggle); + expect(tester.widget(toggle).value, isFalse); + + await tester.tap(toggle); + await tester.pumpAndSettle(); + + final save = find.text('SAVE ONLY'); + await tester.ensureVisible(save); + await tester.tap(save); + await tester.pumpAndSettle(); + + expect(saved, isNotNull); + expect(saved!.osc52Clipboard, isTrue); + }); + + testWidgets('editing a host with osc52 on shows the switch on', + (tester) async { + await pumpPanel(tester, existing: hostWith(osc52: true)); + final toggle = find.widgetWithText(SwitchListTile, 'OSC 52 clipboard'); + await tester.ensureVisible(toggle); + expect(tester.widget(toggle).value, isTrue); + }); +}