diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index 77c22b56..bf1184e0 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -19,16 +19,24 @@ jobs: - uses: Swatinem/rust-cache@v2 with: - workspaces: packages/yourssh_rdp/rust + workspaces: | + packages/yourssh_rdp/rust + packages/yourssh_vnc/rust # --release so the test build and build.sh's `cargo build --release` # share one compiled profile instead of compiling the IronRDP tree twice. - name: Rust tests (yourssh_rdp) run: cargo test --release --manifest-path packages/yourssh_rdp/rust/Cargo.toml + - name: Rust tests (yourssh_vnc) + run: cargo test --release --manifest-path packages/yourssh_vnc/rust/Cargo.toml + - name: Build RDP native library for Dart tests run: bash packages/yourssh_rdp/build.sh + - name: Build VNC native library for Dart tests + run: bash packages/yourssh_vnc/build.sh + - name: Install Linux build dependencies run: | sudo apt-get update @@ -53,3 +61,7 @@ jobs: - name: Dart tests (yourssh_rdp) working-directory: packages/yourssh_rdp run: flutter test + + - name: Dart tests (yourssh_vnc) + working-directory: packages/yourssh_vnc + run: flutter test diff --git a/.gitignore b/.gitignore index cd7abe09..98d73852 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,15 @@ # Rust /core/target/ /packages/yourssh_rdp/rust/target/ +/packages/yourssh_vnc/rust/target/ # Built native RDP libraries — rebuilt locally via packages/yourssh_rdp/build.sh # (build.ps1 on Windows); CI builds them fresh in pr-test.yml / release.yml. /packages/yourssh_rdp/assets/native/ +# Built native VNC libraries — rebuilt locally via packages/yourssh_vnc/build.sh +/packages/yourssh_vnc/assets/native/ + # Xcode (scope to core/ only — app/macos/ xcodeproj must be tracked for CI) core/*.xcodeproj/ core/*.xcworkspace/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ce74ffed..c02bea08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.37] — 2026-06-18 + +### Added +- **In-app VNC client** — Linux VNC servers (TigerVNC / x11vnc / TightVNC) as first-class tabs alongside SSH and RDP. New `packages/yourssh_vnc` Rust crate over `vnc-rs` via flutter_rust_bridge v2: RFB handshake with None / VNC-password (DES) auth, framebuffer-update rendering with one-at-a-time latest-wins decode, mouse + keyboard input (X11 keysym map + pointer-coordinate transform), bidirectional clipboard (`ServerCutText` / `ClientCutText`), server-driven auto-resize, SSH tunneling through the shared loopback proxy, fullscreen with an mstsc-style auto-hide hover pill, `HostProtocol.vnc` (default port 5900) with a VNC mode in the host panel, `ProtocolBadge` (RDP + VNC), protocol-aware dashboard actions and `vnc://` copy-url, and tab parity (rename / color / pin / restore / audit / notification bell) +- **OSC 52 clipboard** — remote apps (tmux, vim, …) can write to the local system clipboard through the OSC 52 escape sequence; write-only and per-host opt-in (`Host.osc52Clipboard`) for safety + +### Changed +- **`RdpTunnelProxy` → `LoopbackTunnelProxy`** — the one-shot loopback tunnel proxy is now protocol-neutral and shared by both RDP and VNC SSH-tunneled connections + +--- + ## [0.1.36] — 2026-06-12 ### Added @@ -636,6 +647,7 @@ Initial release of YourSSH — a cross-platform SSH client for macOS, Windows, a - **Host management** — CRUD for SSH host profiles with `StorageService` - **Known hosts** — TOFU dialog for host-key verification; `KnownHostsProvider` +[0.1.37]: https://github.com/YoursshLabs/yourssh/compare/v0.1.36...v0.1.37 [0.1.36]: https://github.com/YoursshLabs/yourssh/compare/v0.1.35...v0.1.36 [0.1.35]: https://github.com/YoursshLabs/yourssh/compare/v0.1.34...v0.1.35 [0.1.34]: https://github.com/YoursshLabs/yourssh/compare/v0.1.33...v0.1.34 diff --git a/CLAUDE.md b/CLAUDE.md index c3d4e55f..8194cca7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,11 @@ cd packages/yourssh_rdp && flutter test # permission (frames captured from the Flutter render tree). Prereqs in the # test file header. cd app && flutter test integration_test/rdp_screenshots_test.dart -d macos + +# Regenerate the VNC feature screenshots — drives the real app against a local +# x11vnc/TigerVNC container on :5900 (password demo12345; see the test header). +# Manual run, not CI. +cd app && flutter test integration_test/vnc_screenshots_test.dart -d macos ``` ## Architecture diff --git a/app/integration_test/vnc_screenshots_test.dart b/app/integration_test/vnc_screenshots_test.dart new file mode 100644 index 00000000..6533ea95 --- /dev/null +++ b/app/integration_test/vnc_screenshots_test.dart @@ -0,0 +1,207 @@ +// Screenshot capture for the in-app VNC client (incl. fullscreen). +// +// Drives the REAL app against a local x11vnc/TigerVNC container and saves PNGs +// into /screenshots/. Frames are captured from the render tree, so no +// macOS Screen-Recording permission is needed. +// +// Prereqs (a password-auth VNC server on :5900, password "demo12345"): +// docker run -d --name yourssh-vnc-demo -p 5900:5900 \ +// -e VNC_PW=demo12345 dorowu/ubuntu-desktop-lxde-vnc:latest +// (or any x11vnc/TigerVNC server with password "demo12345" on 5900) +// +// Run: +// cd app && flutter test integration_test/vnc_screenshots_test.dart -d macos +// +// The user's real prefs are backed up before the run and restored afterwards. +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/main.dart' as app; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/models/vnc_session.dart'; +import 'package:yourssh/providers/session_provider.dart'; +import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/widgets/host_detail_panel.dart'; + +const _outDir = '/Users/thangnguyen/Projects/Personal/yourssh/screenshots'; +const _demoHostId = 'screenshot-vnc-demo'; + +Future _snap(WidgetTester tester, String name) async { + await tester.pump(const Duration(milliseconds: 120)); + final view = RendererBinding.instance.renderViews.first; + final layer = view.debugLayer! as OffsetLayer; + final image = await layer.toImage( + Offset.zero & view.size, + pixelRatio: view.flutterView.devicePixelRatio, + ); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + final file = File('$_outDir/$name.png'); + await file.writeAsBytes(bytes!.buffer.asUint8List()); + // ignore: avoid_print + print('SNAP saved: ${file.path}'); +} + +/// Real-time poll: integration pumps honor wall-clock durations. +Future _waitFor( + WidgetTester tester, + bool Function() cond, { + Duration timeout = const Duration(seconds: 90), + String what = 'condition', +}) async { + final end = DateTime.now().add(timeout); + while (!cond()) { + if (DateTime.now().isAfter(end)) { + throw TimeoutException('timed out waiting for $what'); + } + await tester.pump(const Duration(milliseconds: 250)); + } +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('capture VNC feature screenshots', (tester) async { + Directory(_outDir).createSync(recursive: true); + + // ── Backup user data, seed the demo host ──────────────────────────── + final prefs = await SharedPreferences.getInstance(); + final backupHosts = prefs.getString('hosts'); + final backupKnown = prefs.getString('known_hosts'); + final backupWorkspace = prefs.getString('workspace_snapshot'); + final backupUpdateCheck = prefs.getInt('last_update_check'); + + final storage = StorageService(); + final demoHost = Host( + id: _demoHostId, + label: 'Demo VNC', + host: '127.0.0.1', + port: 5900, + username: '', + authType: AuthType.password, + protocol: HostProtocol.vnc, + ); + + try { + await storage.saveHosts([demoHost]); + await storage.savePassword(_demoHostId, 'demo12345'); + await prefs.remove('workspace_snapshot'); // no auto-restore noise + // Debounce the GitHub update check so no banner pollutes the shots. + await prefs.setInt( + 'last_update_check', DateTime.now().millisecondsSinceEpoch); + + // ── Launch the real app ──────────────────────────────────────────── + // main() is `void` (async internally) — give it real time to finish + // window setup + provider wiring and render the first frames. + app.main(); + await tester.pump(const Duration(seconds: 2)); + await _waitFor( + tester, + () => find.text('Demo VNC').evaluate().isNotEmpty, + timeout: const Duration(seconds: 20), + what: 'dashboard with seeded host', + ); + + // 1. Hosts dashboard with the VNC badge. + expect(find.text('Demo VNC'), findsWidgets); + await _snap(tester, '01-dashboard-vnc-badge'); + + // 2. Host editor in VNC mode (protocol selector + VNC fields). + // Scope finds to the panel — the dashboard card's VNC badge shares the + // 'VNC' text and other widgets share the close icon. + await tester.ensureVisible(find.text('NEW HOST').first); + await tester.tap(find.text('NEW HOST').first); + await tester.pump(const Duration(milliseconds: 400)); + final panel = find.byType(HostDetailPanel); + await tester.tap(find.descendant( + of: find.byType(SegmentedButton), + matching: find.text('VNC'))); + await tester.pump(const Duration(milliseconds: 400)); + await _snap(tester, '02-host-editor-vnc-form'); + // Close the panel via its header X. + await tester.tap( + find.descendant(of: panel, matching: find.byIcon(Icons.close)).first); + await tester.pump(const Duration(milliseconds: 400)); + + // 3. Connect (double-tap the card) → VNC has no TOFU/cert dialog; + // go straight to waiting for connected. + await tester.tap(find.text('Demo VNC').first); + await tester.pump(const Duration(milliseconds: 80)); + await tester.tap(find.text('Demo VNC').first); + final ctx = tester.element(find.byType(MaterialApp).first); + final sessions = Provider.of(ctx, listen: false); + VncSession vnc() => sessions.sessions.whereType().first; + await _waitFor( + tester, + () => + sessions.sessions.whereType().isNotEmpty && + vnc().status == VncSessionStatus.connected, + what: 'VNC connected', + ); + // Wait for the first real desktop frame. + await _waitFor(tester, () => vnc().image != null, what: 'first VNC frame'); + await tester.pump(const Duration(seconds: 4)); // let the desktop settle + await _snap(tester, '03-vnc-workspace-connected'); + + // 4. Fullscreen: toolbar button → OS fullscreen, chrome collapses, + // pill flashes for 2.5 s. + await tester.tap(find.byTooltip('Fullscreen')); + await tester.pump(const Duration(milliseconds: 1500)); // window anim + await _snap(tester, '04-fullscreen-with-pill'); + + // Pill auto-hides → clean fullscreen desktop. + await tester.pump(const Duration(seconds: 3)); + + // Hover the top edge → pill reveals again. + final g = await tester.createGesture(kind: PointerDeviceKind.mouse); + await g.addPointer(location: const Offset(400, 300)); + await tester.pump(const Duration(milliseconds: 100)); + final topCenter = Offset( + RendererBinding.instance.renderViews.first.size.width / 2, 3); + await g.moveTo(topCenter); + await tester.pump(const Duration(milliseconds: 400)); + await _snap(tester, '05-fullscreen-hover-reveal'); + await g.removePointer(); + + // 5. Exit fullscreen via the pill → back to windowed chrome. + await tester.tap(find.text('Exit fullscreen')); + await tester.pump(const Duration(milliseconds: 1500)); // window anim + await _snap(tester, '06-back-to-windowed'); + + // Tear the session down so the container side closes cleanly. + sessions.closeSession(vnc().id); + await tester.pump(const Duration(seconds: 1)); + } finally { + // ── Restore the user's real data ─────────────────────────────────── + if (backupHosts != null) { + await prefs.setString('hosts', backupHosts); + } else { + await prefs.remove('hosts'); + } + if (backupKnown != null) { + await prefs.setString('known_hosts', backupKnown); + } else { + await prefs.remove('known_hosts'); + } + if (backupWorkspace != null) { + await prefs.setString('workspace_snapshot', backupWorkspace); + } else { + await prefs.remove('workspace_snapshot'); + } + if (backupUpdateCheck != null) { + await prefs.setInt('last_update_check', backupUpdateCheck); + } else { + await prefs.remove('last_update_check'); + } + await storage.deletePassword(_demoHostId); + } + }); +} diff --git a/app/lib/models/host.dart b/app/lib/models/host.dart index 2e743ce3..e71fc515 100644 --- a/app/lib/models/host.dart +++ b/app/lib/models/host.dart @@ -13,7 +13,8 @@ enum SftpMode { normal, sudo, custom } /// Transport protocol. Legacy hosts without the field parse as [ssh]. enum HostProtocol { ssh(defaultPort: 22), - rdp(defaultPort: 3389); + rdp(defaultPort: 3389), + vnc(defaultPort: 5900); const HostProtocol({required this.defaultPort}); diff --git a/app/lib/models/rdp_session.dart b/app/lib/models/rdp_session.dart index 645f146c..18894c31 100644 --- a/app/lib/models/rdp_session.dart +++ b/app/lib/models/rdp_session.dart @@ -6,7 +6,7 @@ import 'package:yourssh_rdp/yourssh_rdp.dart'; // ignore: implementation_imports import 'package:yourssh_rdp/src/generated/api.dart' as frb; -import '../services/rdp_tunnel_proxy.dart'; +import '../services/loopback_tunnel_proxy.dart'; import 'app_session.dart'; import 'host.dart'; @@ -40,7 +40,7 @@ class RdpSession extends ChangeNotifier implements AppSession { /// Non-null when this session runs through an SSH tunnel; owned by the /// session and stopped on [close]. - final RdpTunnelProxy? tunnelProxy; + final LoopbackTunnelProxy? tunnelProxy; @override String get id => _id; diff --git a/app/lib/models/vnc_session.dart b/app/lib/models/vnc_session.dart new file mode 100644 index 00000000..f9a6a5d9 --- /dev/null +++ b/app/lib/models/vnc_session.dart @@ -0,0 +1,176 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:yourssh_vnc/yourssh_vnc.dart'; +// ignore: implementation_imports +import 'package:yourssh_vnc/src/generated/api.dart' as frb; + +import '../services/loopback_tunnel_proxy.dart'; +import 'app_session.dart'; +import 'host.dart'; + +enum VncSessionStatus { connecting, connected, disconnected, error } + +/// One VNC tab. Mirrors [RdpSession] but with no TLS/cert layer (plain VNC has +/// none — security is the SSH tunnel). +class VncSession extends ChangeNotifier implements AppSession { + VncSession({required this.host, required this.client, this.tunnelProxy}); + + final Host host; + final VncClient client; + + /// Non-null when this session runs through an SSH tunnel; owned by the + /// session and stopped on [close]. + final LoopbackTunnelProxy? tunnelProxy; + bool _tunnelClosed = false; + + /// Desktop size. Starts at 0×0; replaced by the server's framebuffer size + /// from the Connected event and any later Resize (frame coordinates arrive + /// in this space). + int get width => _width; + int get height => _height; + int _width = 0; + int _height = 0; + + Uint8List framebuffer = Uint8List(0); + + @override + String get id => _id; + final String _id = 'vnc_${DateTime.now().microsecondsSinceEpoch}'; + + VncSessionStatus status = VncSessionStatus.connecting; + String? lastMessage; + bool _closed = false; + + /// Called when the remote sends a ServerCutText event. Wire this to the + /// system clipboard (e.g. [Clipboard.setData]) in the provider. + void Function(String text)? onRemoteClipboardText; + + /// Latest decoded frame for painting; rebuilt lazily after patches. + ui.Image? image; + bool _decodeInFlight = false; + bool _dirtyAgain = false; + StreamSubscription? _sub; + + @override + String? customLabel; + @override + String? colorTag; + @override + bool isPinned = false; + @override + String get tabLabel => customLabel ?? host.label; + + /// Called by the tunnel proxy when the SSH side collapsed, so the + /// disconnect message names the real cause. + void markTunnelClosed() => _tunnelClosed = true; + + void attach(Stream events) { + _sub = events.listen(_onEvent, onError: (Object e) { + status = VncSessionStatus.error; + lastMessage = '$e'; + notifyListeners(); + }); + } + + void _onEvent(frb.VncEvent ev) { + switch (ev) { + case frb.VncEvent_Started(): + return; // id captured inside VncClient.connect + case frb.VncEvent_Connected(:final width, :final height): + _applyDesktopSize(width, height); + status = VncSessionStatus.connected; + case frb.VncEvent_Resize(:final width, :final height): + _applyDesktopSize(width, height); + case frb.VncEvent_FrameUpdate( + :final x, + :final y, + :final width, + :final height, + :final rgba + ): + _patch(x, y, width, height, rgba); + case frb.VncEvent_ClipboardText(:final text): + onRemoteClipboardText?.call(text); + return; // no repaint needed + case frb.VncEvent_Bell(): + return; // no visual state change + case frb.VncEvent_Disconnected(:final reason): + status = VncSessionStatus.disconnected; + lastMessage = _tunnelClosed ? 'SSH tunnel closed' : reason; + case frb.VncEvent_Error(:final message): + status = VncSessionStatus.error; + lastMessage = _tunnelClosed ? 'SSH tunnel closed' : message; + } + notifyListeners(); + } + + void _applyDesktopSize(int w, int h) { + if (w == _width && h == _height) return; + _width = w; + _height = h; + framebuffer = Uint8List(w * h * 4); + } + + void _patch(int x, int y, int w, int h, Uint8List rgba) { + final fbStride = _width * 4; + // Defense in depth: Rust clamps regions to the negotiated size, but a + // malformed event must never crash the stream listener. + if (x + w > _width || y + h > _height || rgba.length < w * h * 4) return; + for (var row = 0; row < h; row++) { + final dst = (y + row) * fbStride + x * 4; + final src = row * w * 4; + framebuffer.setRange(dst, dst + w * 4, rgba, src); + } + _scheduleDecode(); + } + + void _scheduleDecode() { + // One decode at a time, latest-wins: patches landing while a decode is + // running set a flag and a single follow-up decode picks them all up. + if (_decodeInFlight) { + _dirtyAgain = true; + return; + } + _decodeInFlight = true; + scheduleMicrotask(_decodeLoop); + } + + Future _decodeLoop() async { + do { + _dirtyAgain = false; + // fromUint8List snapshots synchronously, so the decoded image is + // internally consistent even if a patch lands during the await. + final buf = await ui.ImmutableBuffer.fromUint8List(framebuffer); + final desc = ui.ImageDescriptor.raw(buf, + width: _width, height: _height, pixelFormat: ui.PixelFormat.rgba8888); + final codec = await desc.instantiateCodec(); + final decoded = (await codec.getNextFrame()).image; + if (_closed) { + decoded.dispose(); + break; + } + image?.dispose(); + image = decoded; + notifyListeners(); + } while (_dirtyAgain); + _decodeInFlight = false; + } + + Future close() async { + _closed = true; + await _sub?.cancel(); + try { + // A wedged transport can stall the graceful disconnect indefinitely. + await client.disconnect().timeout(const Duration(seconds: 5)); + } on TimeoutException { + // Rust side will die with the process; nothing more to do. + } finally { + client.dispose(); + await tunnelProxy?.stop(); + image?.dispose(); + image = null; + } + } +} diff --git a/app/lib/providers/session_provider.dart b/app/lib/providers/session_provider.dart index 3bcd7f7a..5acbafa5 100644 --- a/app/lib/providers/session_provider.dart +++ b/app/lib/providers/session_provider.dart @@ -2,11 +2,13 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:yourssh_rdp/yourssh_rdp.dart'; +import 'package:yourssh_vnc/yourssh_vnc.dart'; import '../models/agent_forwarding_state.dart'; import '../models/audit_event.dart'; import '../models/host.dart'; import '../models/local_session.dart'; import '../models/rdp_session.dart'; +import '../models/vnc_session.dart'; import '../models/shell_profile.dart'; import '../models/ssh_key.dart'; import '../models/ssh_session.dart'; @@ -14,7 +16,7 @@ import '../models/app_session.dart'; import '../models/terminal_session.dart'; import '../services/audit_service.dart'; import '../services/local_shell_service.dart'; -import '../services/rdp_tunnel_proxy.dart'; +import '../services/loopback_tunnel_proxy.dart'; import '../services/ssh_service.dart'; import '../services/tab_metadata_service.dart'; @@ -175,6 +177,7 @@ class SessionProvider extends ChangeNotifier { /// Routes to [connectRdp] or [connect] based on [host.protocol]. Future connectAny(Host host, {String? initialCommand}) { if (host.protocol == HostProtocol.rdp) return connectRdp(host); + if (host.protocol == HostProtocol.vnc) return connectVnc(host); return connect(host, initialCommand: initialCommand).then((_) => null); } @@ -184,7 +187,7 @@ class SessionProvider extends ChangeNotifier { var targetHost = host.host; var targetPort = host.port; - RdpTunnelProxy? proxy; + LoopbackTunnelProxy? proxy; RdpSession? session; String? setupError; @@ -195,7 +198,7 @@ class SessionProvider extends ChangeNotifier { await RdpClient.ensureInitialized(); if (host.jumpHostId != null) { - proxy = RdpTunnelProxy(onClosed: () => session?.markTunnelClosed()); + proxy = LoopbackTunnelProxy(onClosed: () => session?.markTunnelClosed()); final port = await proxy.start(() async { final sshSocket = await _ssh.openTunnelSocket( host.jumpHostId!, host.host, host.port, host.id); @@ -329,6 +332,123 @@ class SessionProvider extends ChangeNotifier { await connectRdp(old.host); } + /// Opens a VNC tab. Mirrors [connectRdp] but with no TLS/cert pinning (plain + /// VNC has none). + Future connectVnc(Host host) async { + final password = await _ssh.loadPassword(host.id) ?? ''; + + var targetHost = host.host; + var targetPort = host.port; + LoopbackTunnelProxy? proxy; + VncSession? session; + String? setupError; + + try { + // Lazy bridge init: a missing/corrupt dylib surfaces as an error tab + // instead of an uncatchable LateInitializationError in the bindings. + await VncClient.ensureInitialized(); + if (host.jumpHostId != null) { + proxy = LoopbackTunnelProxy(onClosed: () => session?.markTunnelClosed()); + final port = await proxy.start(() async { + final sshSocket = await _ssh.openTunnelSocket( + host.jumpHostId!, host.host, host.port, host.id); + return TunnelEnd( + stream: sshSocket.stream, + sink: sshSocket.sink, + close: sshSocket.destroy); + }); + targetHost = '127.0.0.1'; + targetPort = port; + } + } catch (e) { + setupError = '$e'; + } + + final config = VncConfig( + targetHost: targetHost, + targetPort: targetPort, + username: host.username, + password: password, + ); + final client = VncClient(config); + session = VncSession(host: host, client: client, tunnelProxy: proxy); + session.onRemoteClipboardText = + (t) => Clipboard.setData(ClipboardData(text: t)); + + await _applyTabMetadata(session, host.id); + + if (setupError != null) { + session.status = VncSessionStatus.error; + session.lastMessage = setupError; + } else { + session.attach(client.events); + // Failures surface through the event stream (status/lastMessage); + // swallow the future's mirror error so it can't hit the root zone. + unawaited(client.connect().then((_) {}, onError: (_) {})); + } + + _watchVncStatus(session); + session.addListener(_safeNotify); + _sessions.add(session); + _activeSessionId = session.id; + if (session.isPinned) _sortSessions(); + _safeNotify(); + return session; + } + + /// Audits VNC connect/disconnect transitions and feeds the notification + /// bell — parity with [_watchRdpStatus] (no cert flows to special-case). + void _watchVncStatus(VncSession session) { + var last = session.status; + session.addListener(() { + final now = session.status; + if (now == last) return; + final was = last; + last = now; + final host = session.host; + if (was == VncSessionStatus.connecting && + now == VncSessionStatus.connected) { + audit?.record(AuditEvent.now( + type: AuditEventType.connect, + host: host, + sessionId: session.id, + meta: const {'source': 'vnc'})); + } else if (was == VncSessionStatus.connecting && + (now == VncSessionStatus.error || + now == VncSessionStatus.disconnected)) { + audit?.record(AuditEvent.now( + type: AuditEventType.connect, + host: host, + sessionId: session.id, + meta: { + 'source': 'vnc', + 'error': session.lastMessage ?? 'connection failed', + })); + onSessionDropped?.call(session, session.lastMessage); + } else if (was == VncSessionStatus.connected) { + final userClosed = session.lastMessage == 'disconnected by user'; + audit?.record(AuditEvent.now( + type: AuditEventType.disconnect, + host: host, + sessionId: session.id, + meta: { + 'source': 'vnc', + 'reason': userClosed ? 'user-closed' : 'dropped', + })); + if (!userClosed) { + onSessionDropped?.call(session, session.lastMessage); + } + } + }); + } + + Future reconnectVnc(VncSession old) async { + // Label/color/pin are persisted on every edit and reloaded by connectVnc's + // tab-metadata pass — no manual carry-over needed. + closeSession(old.id); + await connectVnc(old.host); + } + Future _doConnect(SshSession session, Host host, {required int attempt}) async { try { final keyEntry = host.keyId != null ? keyLookup?.call(host.keyId!) : null; @@ -529,6 +649,31 @@ class SessionProvider extends ChangeNotifier { _safeNotify(); return; } + if (session is VncSession) { + final hostId = session.host.id; + // Mirror the SSH/RDP path: a live tab the user closes gets its own row + // (a dead tab was already audited on the drop/error transition). + if (session.status == VncSessionStatus.connected) { + audit?.record(AuditEvent.now( + type: AuditEventType.disconnect, + host: session.host, + sessionId: sessionId, + meta: const {'source': 'vnc', 'reason': 'user-closed'})); + } + session.removeListener(_safeNotify); + unawaited(session.close()); + _sessions.remove(session); + if (_activeSessionId == sessionId) { + _activeSessionId = _sessions.isNotEmpty ? _sessions.last.id : null; + } + // No-op for direct VNC; releases the SSH tunnel client once tunneling + // lands in a later milestone (parity with the RDP branch). + if (!_sessions.any((s) => s is VncSession && s.host.id == hostId)) { + _ssh.disconnect(hostId); + } + _safeNotify(); + return; + } if (session is LocalSession) { localShell?.closeSession(sessionId); _sessions.remove(session); @@ -611,6 +756,7 @@ class SessionProvider extends ChangeNotifier { String? _metadataHostId(AppSession s) => switch (s) { SshSession ssh => ssh.isWatch ? null : ssh.host.id, RdpSession rdp => rdp.host.id, + VncSession vnc => vnc.host.id, _ => null, }; diff --git a/app/lib/screens/main_screen.dart b/app/lib/screens/main_screen.dart index 0129bdc8..50601e7e 100644 --- a/app/lib/screens/main_screen.dart +++ b/app/lib/screens/main_screen.dart @@ -52,6 +52,8 @@ import '../widgets/notification_bell.dart'; import '../widgets/session_tab.dart'; import '../models/rdp_session.dart'; import '../widgets/rdp_workspace.dart'; +import '../models/vnc_session.dart'; +import '../widgets/vnc_workspace.dart'; import '../services/storage_service.dart'; import '../widgets/network_discovery_sheet.dart'; @@ -88,6 +90,7 @@ class _MainScreenState extends State with WidgetsBindingObserver { bool _rdpCertDialogShowing = false; bool _consentDialogShowing = false; bool _rdpFullscreen = false; + bool _vncFullscreen = false; @override void initState() { @@ -293,6 +296,7 @@ class _MainScreenState extends State with WidgetsBindingObserver { static String? _restorableHostId(AppSession s) => switch (s) { SshSession ssh => ssh.isWatch ? null : ssh.host.id, RdpSession rdp => rdp.host.id, + VncSession vnc => vnc.host.id, _ => null, }; @@ -570,6 +574,17 @@ class _MainScreenState extends State with WidgetsBindingObserver { } } + /// Enters/exits VNC fullscreen: mirrors _setRdpFullscreen exactly. + Future _setVncFullscreen(bool on) async { + if (_vncFullscreen == on || !mounted) return; + setState(() => _vncFullscreen = on); + try { + await windowManager.setFullScreen(on); + } catch (_) { + // Window manager unavailable (tests/headless) — chrome state applied. + } + } + @override Widget build(BuildContext context) { final sessionProvider = context.watch(); @@ -594,6 +609,20 @@ class _MainScreenState extends State with WidgetsBindingObserver { ); } + final vncFullscreenActive = + _vncFullscreen && _viewingTerminal && activeSession is VncSession; + if (_vncFullscreen && !vncFullscreenActive) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(_setVncFullscreen(false)); + }); + } + if (vncFullscreenActive) { + return Scaffold( + backgroundColor: AppColors.bg, + body: _buildForeground(activeSession), + ); + } + final engineProvider = context.watch(); if (engineProvider.pendingConsent != null && !_consentDialogShowing) { _consentDialogShowing = true; @@ -784,6 +813,14 @@ class _MainScreenState extends State with WidgetsBindingObserver { onFullscreenChanged: (on) => unawaited(_setRdpFullscreen(on)), ); } + if (_viewingTerminal && active is VncSession) { + return VncWorkspace( + session: active, + onReconnect: () => _retryVnc(active), + isFullscreen: _vncFullscreen, + onFullscreenChanged: (on) => unawaited(_setVncFullscreen(on)), + ); + } if (_viewingTerminal && active != null) { // Hide the AI toggle (and any open chat panel) when no AI provider // has an API key configured — it appears once a key is saved. @@ -911,6 +948,11 @@ class _MainScreenState extends State with WidgetsBindingObserver { if (!mounted) return; await provider.reconnectRdp(old); } + + Future _retryVnc(VncSession old) async { + if (!mounted) return; + await context.read().reconnectVnc(old); + } } // ── Sidebar ─────────────────────────────────────────────── diff --git a/app/lib/services/rdp_tunnel_proxy.dart b/app/lib/services/loopback_tunnel_proxy.dart similarity index 94% rename from app/lib/services/rdp_tunnel_proxy.dart rename to app/lib/services/loopback_tunnel_proxy.dart index 456a035f..8c1d34d7 100644 --- a/app/lib/services/rdp_tunnel_proxy.dart +++ b/app/lib/services/loopback_tunnel_proxy.dart @@ -13,9 +13,10 @@ class TunnelEnd { /// One-shot loopback proxy: binds 127.0.0.1 on a random port, accepts exactly /// one connection, pipes it to a freshly opened tunnel end, then refuses -/// further connections. Dies with the session ([stop]). -class RdpTunnelProxy { - RdpTunnelProxy({this.onClosed}); +/// further connections. Used for SSH-tunneled RDP/VNC connections. Dies with +/// the session ([stop]). +class LoopbackTunnelProxy { + LoopbackTunnelProxy({this.onClosed}); /// Fired when the tunnel side ends before [stop] — lets the session report /// "SSH tunnel closed" instead of a generic disconnect. diff --git a/app/lib/util/vnc_input_mapping.dart b/app/lib/util/vnc_input_mapping.dart new file mode 100644 index 00000000..993a7118 --- /dev/null +++ b/app/lib/util/vnc_input_mapping.dart @@ -0,0 +1,119 @@ +import 'package:flutter/services.dart'; + +/// Maps a physical key to an X11 keysym for RFB `KeyEvent`, or null when the +/// key is outside the pragmatic US-layout subset (printable + modifiers + +/// navigation + function keys). Letters map to their *lowercase* keysym; +/// Shift is sent as its own keysym event and combined server-side. +/// +/// Keyed by `PhysicalKeyboardKey.usbHidUsage` (mirrors rdp_input_mapping.dart). +int? vncKeysymFor(PhysicalKeyboardKey key) => _keysyms[key.usbHidUsage]; + +/// Transforms a widget-local pointer position into framebuffer pixel coords, +/// removing the centered letterbox offset and the render scale, clamped to +/// `[0, width-1] x [0, height-1]` so the result fits an unsigned 16-bit coord. +(int, int) vncSessionPoint({ + required double localX, + required double localY, + required double offX, + required double offY, + required double scale, + required int width, + required int height, +}) { + final x = ((localX - offX) / scale).round().clamp(0, width - 1); + final y = ((localY - offY) / scale).round().clamp(0, height - 1); + return (x, y); +} + +const Map _keysyms = { + // Letters (USB HID 0x04-0x1D) -> lowercase Latin-1 keysyms 0x61-0x7a + 0x00070004: 0x61, // a + 0x00070005: 0x62, // b + 0x00070006: 0x63, // c + 0x00070007: 0x64, // d + 0x00070008: 0x65, // e + 0x00070009: 0x66, // f + 0x0007000A: 0x67, // g + 0x0007000B: 0x68, // h + 0x0007000C: 0x69, // i + 0x0007000D: 0x6a, // j + 0x0007000E: 0x6b, // k + 0x0007000F: 0x6c, // l + 0x00070010: 0x6d, // m + 0x00070011: 0x6e, // n + 0x00070012: 0x6f, // o + 0x00070013: 0x70, // p + 0x00070014: 0x71, // q + 0x00070015: 0x72, // r + 0x00070016: 0x73, // s + 0x00070017: 0x74, // t + 0x00070018: 0x75, // u + 0x00070019: 0x76, // v + 0x0007001A: 0x77, // w + 0x0007001B: 0x78, // x + 0x0007001C: 0x79, // y + 0x0007001D: 0x7a, // z + // Digits (0x1E-0x27) -> ASCII '1'..'9','0' + 0x0007001E: 0x31, // 1 + 0x0007001F: 0x32, // 2 + 0x00070020: 0x33, // 3 + 0x00070021: 0x34, // 4 + 0x00070022: 0x35, // 5 + 0x00070023: 0x36, // 6 + 0x00070024: 0x37, // 7 + 0x00070025: 0x38, // 8 + 0x00070026: 0x39, // 9 + 0x00070027: 0x30, // 0 + // Control / whitespace + 0x00070028: 0xff0d, // Enter -> Return + 0x00070029: 0xff1b, // Escape + 0x0007002A: 0xff08, // Backspace + 0x0007002B: 0xff09, // Tab + 0x0007002C: 0x20, // Space + 0x00070039: 0xffe5, // CapsLock + // Punctuation -> ASCII keysyms (base/unshifted) + 0x0007002D: 0x2d, // - minus + 0x0007002E: 0x3d, // = equal + 0x0007002F: 0x5b, // [ bracketleft + 0x00070030: 0x5d, // ] bracketright + 0x00070031: 0x5c, // \ backslash + 0x00070033: 0x3b, // ; semicolon + 0x00070034: 0x27, // ' apostrophe + 0x00070035: 0x60, // ` grave + 0x00070036: 0x2c, // , comma + 0x00070037: 0x2e, // . period + 0x00070038: 0x2f, // / slash + // Function keys (0x3A-0x45) -> 0xffbe-0xffc9 + 0x0007003A: 0xffbe, // F1 + 0x0007003B: 0xffbf, // F2 + 0x0007003C: 0xffc0, // F3 + 0x0007003D: 0xffc1, // F4 + 0x0007003E: 0xffc2, // F5 + 0x0007003F: 0xffc3, // F6 + 0x00070040: 0xffc4, // F7 + 0x00070041: 0xffc5, // F8 + 0x00070042: 0xffc6, // F9 + 0x00070043: 0xffc7, // F10 + 0x00070044: 0xffc8, // F11 + 0x00070045: 0xffc9, // F12 + // Navigation + 0x00070049: 0xff63, // Insert + 0x0007004A: 0xff50, // Home + 0x0007004B: 0xff55, // PageUp + 0x0007004C: 0xffff, // Delete + 0x0007004D: 0xff57, // End + 0x0007004E: 0xff56, // PageDown + 0x0007004F: 0xff53, // ArrowRight + 0x00070050: 0xff51, // ArrowLeft + 0x00070051: 0xff54, // ArrowDown + 0x00070052: 0xff52, // ArrowUp + // Modifiers (sent as their own keysym events) + 0x000700E0: 0xffe3, // LeftControl -> Control_L + 0x000700E1: 0xffe1, // LeftShift -> Shift_L + 0x000700E2: 0xffe9, // LeftAlt -> Alt_L + 0x000700E3: 0xffeb, // LeftMeta -> Super_L + 0x000700E4: 0xffe4, // RightControl -> Control_R + 0x000700E5: 0xffe2, // RightShift -> Shift_R + 0x000700E6: 0xffea, // RightAlt -> Alt_R + 0x000700E7: 0xffec, // RightMeta -> Super_R +}; diff --git a/app/lib/widgets/host_detail_panel.dart b/app/lib/widgets/host_detail_panel.dart index 80a0454e..7b9d2879 100644 --- a/app/lib/widgets/host_detail_panel.dart +++ b/app/lib/widgets/host_detail_panel.dart @@ -14,7 +14,7 @@ import '../theme/terminal_themes.dart'; import 'agent_status_line.dart'; import 'host_chain_editor.dart'; import 'network_discovery_sheet.dart'; -import 'rdp_badge.dart'; +import 'protocol_badge.dart'; import 'terminal_appearance_controls.dart' show kBundledTerminalFonts; class HostDetailPanel extends StatefulWidget { @@ -94,6 +94,9 @@ class _HostDetailPanelState extends State { bool get _isNew => widget.existing == null; bool get _isRdp => _protocol == HostProtocol.rdp; + /// True for any graphical (non-SSH) protocol — these share password-only + /// auth and hide all SSH-only sections. + bool get _isGraphical => _protocol != HostProtocol.ssh; @override void initState() { @@ -208,8 +211,8 @@ class _HostDetailPanelState extends State { if (int.tryParse(_portCtrl.text) == old.defaultPort) { _portCtrl.text = next.defaultPort.toString(); } - if (next == HostProtocol.rdp) { - // RDP supports password auth only. + if (next != HostProtocol.ssh) { + // RDP and VNC support password auth only. _authType = AuthType.password; _selectedKeyId = null; } @@ -250,30 +253,30 @@ class _HostDetailPanelState extends State { ? _domainCtrl.text.trim() : null, rdpSecurity: _rdpSecurity, - authType: _isRdp ? AuthType.password : _authType, - keyId: !_isRdp && _authType == AuthType.privateKey ? _selectedKeyId : null, + authType: _isGraphical ? AuthType.password : _authType, + keyId: !_isGraphical && _authType == AuthType.privateKey ? _selectedKeyId : null, group: _groupCtrl.text.trim(), tags: tags, - autoRecord: !_isRdp && _autoRecord, + autoRecord: !_isGraphical && _autoRecord, recordingRedaction: _recordingRedaction, shellIntegration: _shellIntegration, - agentForwarding: !_isRdp && _agentForwarding, - osc52Clipboard: !_isRdp && _osc52Clipboard, - proxyType: _isRdp ? ProxyType.none : _proxyType, - proxyHost: _isRdp || _proxyType == ProxyType.none + agentForwarding: !_isGraphical && _agentForwarding, + osc52Clipboard: !_isGraphical && _osc52Clipboard, + proxyType: _isGraphical ? ProxyType.none : _proxyType, + proxyHost: _isGraphical || _proxyType == ProxyType.none ? null : _proxyHostCtrl.text.trim(), - proxyPort: _isRdp || _proxyType == ProxyType.none + proxyPort: _isGraphical || _proxyType == ProxyType.none ? null : int.tryParse(_proxyPortCtrl.text.trim()), - proxyUsername: _isRdp || + proxyUsername: _isGraphical || _proxyType == ProxyType.none || _proxyUsernameCtrl.text.trim().isEmpty ? null : _proxyUsernameCtrl.text.trim(), jumpHostIds: _jumpHostIds, - sftpMode: _isRdp ? SftpMode.normal : _sftpMode, - sftpServerCommand: !_isRdp && _sftpMode == SftpMode.custom + sftpMode: _isGraphical ? SftpMode.normal : _sftpMode, + sftpServerCommand: !_isGraphical && _sftpMode == SftpMode.custom ? _sftpCommand.text.trim() : null, workingDir: _workingDirCtrl.text.trim().isEmpty @@ -294,7 +297,7 @@ class _HostDetailPanelState extends State { ); final ssh = context.read(); try { - if (!_isRdp && _proxyType != ProxyType.none) { + if (!_isGraphical && _proxyType != ProxyType.none) { await ssh.saveProxyPassword(host.id, _proxyPasswordCtrl.text); } await widget.onSave(host, _passwordCtrl.text); @@ -386,6 +389,11 @@ class _HostDetailPanelState extends State { label: Text('RDP'), icon: Icon(Icons.desktop_windows_outlined, size: 14), ), + ButtonSegment( + value: HostProtocol.vnc, + label: Text('VNC'), + icon: Icon(Icons.cast_outlined, size: 14), + ), ], selected: {_protocol}, onSelectionChanged: (s) => _onProtocolChanged(s.first), @@ -476,7 +484,14 @@ class _HostDetailPanelState extends State { // Port row Row( children: [ - Text(_isRdp ? 'RDP on' : 'SSH on', style: const TextStyle(color: AppColors.textSecondary, fontSize: 13)), + Text( + switch (_protocol) { + HostProtocol.ssh => 'SSH on', + HostProtocol.rdp => 'RDP on', + HostProtocol.vnc => 'VNC on', + }, + style: const TextStyle( + color: AppColors.textSecondary, fontSize: 13)), const SizedBox(width: 10), SizedBox( width: 56, @@ -563,59 +578,60 @@ class _HostDetailPanelState extends State { ), ), ]), + ], - Builder(builder: (context) { - final sshHosts = context - .watch() - .allHosts - .where((h) => - h.protocol == HostProtocol.ssh && - h.id != widget.existing?.id) - .toList(); - if (sshHosts.isEmpty) return const SizedBox.shrink(); - // A deleted bastion leaves a stale id — show "direct" - // instead of tripping the dropdown's value assert. - final current = _jumpHostIds.firstOrNull; - final valid = - sshHosts.any((h) => h.id == current) ? current : null; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 16), - _sectionLabel('SSH TUNNEL'), - const SizedBox(height: 6), - _Card(children: [ - _DropdownRow( - icon: Icons.alt_route, - child: DropdownButton( - value: valid, - isExpanded: true, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 13), - dropdownColor: AppColors.card, - underline: const SizedBox(), - items: [ - const DropdownMenuItem( - value: null, - child: Text('Direct connection')), - for (final h in sshHosts) - DropdownMenuItem( - value: h.id, - child: Text( - 'via ${h.label.isEmpty ? h.host : h.label}')), - ], - onChanged: (v) => setState(() => - _jumpHostIds = v == null ? [] : [v]), - ), + // SSH tunnel applies to any graphical protocol (RDP + VNC). + if (_isGraphical) Builder(builder: (context) { + final sshHosts = context + .watch() + .allHosts + .where((h) => + h.protocol == HostProtocol.ssh && + h.id != widget.existing?.id) + .toList(); + if (sshHosts.isEmpty) return const SizedBox.shrink(); + // A deleted bastion leaves a stale id — show "direct" + // instead of tripping the dropdown's value assert. + final current = _jumpHostIds.firstOrNull; + final valid = + sshHosts.any((h) => h.id == current) ? current : null; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + _sectionLabel('SSH TUNNEL'), + const SizedBox(height: 6), + _Card(children: [ + _DropdownRow( + icon: Icons.alt_route, + child: DropdownButton( + value: valid, + isExpanded: true, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 13), + dropdownColor: AppColors.card, + underline: const SizedBox(), + items: [ + const DropdownMenuItem( + value: null, + child: Text('Direct connection')), + for (final h in sshHosts) + DropdownMenuItem( + value: h.id, + child: Text( + 'via ${h.label.isEmpty ? h.host : h.label}')), + ], + onChanged: (v) => setState(() => + _jumpHostIds = v == null ? [] : [v]), ), - ]), - ], - ); - }), - ], + ), + ]), + ], + ); + }), - if (!_isRdp) ...[ + if (!_isGraphical) ...[ const SizedBox(height: 16), _sectionLabel('AUTH METHOD'), const SizedBox(height: 6), @@ -1211,8 +1227,8 @@ class _HostDetailPanelState extends State { ), ), ], - ], // end !_isRdp (SSH-only sections) - if (_isRdp) const SizedBox(height: 24), + ], // end !_isGraphical (SSH-only sections) + if (_isGraphical) const SizedBox(height: 24), const SizedBox(height: 8), // Connect button GestureDetector( @@ -1269,9 +1285,9 @@ class _HostDetailPanelState extends State { _isNew ? 'New Host' : 'Edit Host', style: const TextStyle(color: AppColors.textPrimary, fontSize: 14, fontWeight: FontWeight.w600), ), - if (_isRdp) ...[ + if (_isGraphical) ...[ const SizedBox(width: 8), - const RdpBadge(), + ProtocolBadge(_protocol), ], ], ), diff --git a/app/lib/widgets/hosts_dashboard.dart b/app/lib/widgets/hosts_dashboard.dart index b217ae29..6142f242 100644 --- a/app/lib/widgets/hosts_dashboard.dart +++ b/app/lib/widgets/hosts_dashboard.dart @@ -8,6 +8,7 @@ import '../models/host.dart'; import '../models/rdp_session.dart'; import '../models/ssh_key.dart'; import '../models/ssh_session.dart'; +import '../models/vnc_session.dart'; import '../util/bulk_connect.dart'; import '../util/host_query.dart'; import '../util/host_sort.dart'; @@ -19,7 +20,7 @@ import '../services/os_detection.dart'; import '../services/ssh_service.dart'; import '../services/storage_service.dart'; import '../theme/app_theme.dart'; -import 'rdp_badge.dart'; +import 'protocol_badge.dart'; import 'bulk/bulk_action_bar.dart'; import 'bulk/bulk_push_dialog.dart'; import 'bulk/bulk_run_dialog.dart'; @@ -139,6 +140,11 @@ class _HostsDashboardState extends State { if (s.status == RdpSessionStatus.connecting || s.status == RdpSessionStatus.connected) s.host.id, + // VNC tabs likewise — same dedup as RDP. + for (final s in sessionProvider.sessions.whereType()) + if (s.status == VncSessionStatus.connecting || + s.status == VncSessionStatus.connected) + s.host.id, }; final plan = planConnectAll(selected: _selectedHosts(), liveHostIds: live); @@ -183,7 +189,7 @@ class _HostsDashboardState extends State { final skipped = hosts.length - ssh.length; if (skipped > 0) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('$skipped RDP host(s) skipped — $action is SSH-only'), + content: Text('$skipped non-SSH host(s) skipped — $action is SSH-only'), )); } return ssh; @@ -1165,9 +1171,9 @@ class _HostCardState extends State<_HostCard> { overflow: TextOverflow.ellipsis, ), ), - if (widget.host.protocol == HostProtocol.rdp) ...[ + if (widget.host.protocol != HostProtocol.ssh) ...[ const SizedBox(width: 6), - const RdpBadge(), + ProtocolBadge(widget.host.protocol), ], ], ), @@ -1221,9 +1227,9 @@ class _HostCardState extends State<_HostCard> { overflow: TextOverflow.ellipsis, ), ), - if (widget.host.protocol == HostProtocol.rdp) ...[ + if (widget.host.protocol != HostProtocol.ssh) ...[ const SizedBox(width: 6), - const RdpBadge(), + ProtocolBadge(widget.host.protocol), ], const SizedBox(width: 12), Expanded( @@ -1281,7 +1287,7 @@ class _HostCardState extends State<_HostCard> { _menuItem('edit', Icons.edit_outlined, 'Edit', () => widget.onEditHost?.call(widget.host)), const PopupMenuDivider(), _menuItem('duplicate', Icons.copy_outlined, 'Duplicate', () => _duplicate(context, hostProvider)), - _menuItem('copy_url', Icons.link_outlined, isSsh ? 'Copy SSH URL' : 'Copy RDP URL', () => _copyHostUrl(context)), + _menuItem('copy_url', Icons.link_outlined, 'Copy ${widget.host.protocol.name.toUpperCase()} URL', () => _copyHostUrl(context)), _menuItem('move_group', Icons.drive_file_move_outlined, 'Move to Group', () => _moveToGroup(context, hostProvider)), _menuItem('export', Icons.upload_outlined, 'Export', () => _export(context)), const PopupMenuDivider(), @@ -1306,7 +1312,11 @@ class _HostCardState extends State<_HostCard> { } Future _copyHostUrl(BuildContext context) async { - final scheme = widget.host.protocol == HostProtocol.rdp ? 'rdp' : 'ssh'; + final scheme = switch (widget.host.protocol) { + HostProtocol.rdp => 'rdp', + HostProtocol.vnc => 'vnc', + HostProtocol.ssh => 'ssh', + }; final url = '$scheme://${widget.host.username}@${widget.host.host}:${widget.host.port}'; await Clipboard.setData(ClipboardData(text: url)); if (!context.mounted) return; diff --git a/app/lib/widgets/protocol_badge.dart b/app/lib/widgets/protocol_badge.dart new file mode 100644 index 00000000..d0ff09de --- /dev/null +++ b/app/lib/widgets/protocol_badge.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +import '../models/host.dart'; +import '../theme/app_theme.dart'; + +/// Small pill marking a remote-desktop host's protocol (RDP / VNC). One widget +/// shared by the dashboard cards, list rows, and the host detail header so a +/// restyle can't leave the call sites visually diverged. SSH renders nothing. +class ProtocolBadge extends StatelessWidget { + const ProtocolBadge(this.protocol, {super.key}); + + final HostProtocol protocol; + + @override + Widget build(BuildContext context) { + final (label, color) = switch (protocol) { + HostProtocol.rdp => ('RDP', AppColors.blue), + HostProtocol.vnc => ('VNC', AppColors.purple), + HostProtocol.ssh => ('', AppColors.blue), + }; + if (label.isEmpty) return const SizedBox.shrink(); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withValues(alpha: 0.4), width: 0.5), + ), + child: Text( + label, + style: TextStyle( + color: color, fontSize: 9, fontWeight: FontWeight.w700), + ), + ); + } +} diff --git a/app/lib/widgets/rdp_badge.dart b/app/lib/widgets/rdp_badge.dart deleted file mode 100644 index 32e3cb57..00000000 --- a/app/lib/widgets/rdp_badge.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:flutter/material.dart'; -import '../theme/app_theme.dart'; - -/// Small "RDP" pill marking remote-desktop hosts. One widget shared by the -/// dashboard cards, the compact list rows, and the host detail header so a -/// restyle can't leave the three call sites visually diverged. -class RdpBadge extends StatelessWidget { - const RdpBadge({super.key}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), - decoration: BoxDecoration( - color: AppColors.blue.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(4), - border: Border.all( - color: AppColors.blue.withValues(alpha: 0.4), width: 0.5), - ), - child: const Text( - 'RDP', - style: TextStyle( - color: AppColors.blue, fontSize: 9, fontWeight: FontWeight.w700), - ), - ); - } -} diff --git a/app/lib/widgets/vnc_workspace.dart b/app/lib/widgets/vnc_workspace.dart new file mode 100644 index 00000000..d990bc9c --- /dev/null +++ b/app/lib/widgets/vnc_workspace.dart @@ -0,0 +1,408 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../models/vnc_session.dart'; +import '../services/hotkey_service.dart'; +import '../theme/app_theme.dart'; +import '../util/vnc_input_mapping.dart'; + +/// Full workspace for an active VNC tab: rendered remote framebuffer, +/// input capture, slim toolbar, and status overlays. +/// +/// In fullscreen ([isFullscreen]) the toolbar is replaced by an auto-hiding +/// pill revealed by hovering the top screen edge (mirrors RdpWorkspace); +/// the widget reports enter/exit intents via [onFullscreenChanged] — the +/// caller owns the actual window state and collapses the app chrome. +class VncWorkspace extends StatefulWidget { + const VncWorkspace({ + super.key, + required this.session, + this.onReconnect, + this.isFullscreen = false, + this.onFullscreenChanged, + }); + + final VncSession session; + final VoidCallback? onReconnect; + final bool isFullscreen; + final ValueChanged? onFullscreenChanged; + + @override + State createState() => _VncWorkspaceState(); +} + +class _VncWorkspaceState extends State { + VncSession get session => widget.session; + final FocusNode _focusNode = FocusNode(); + // Last pointer state sent (fb-x, fb-y, button mask). Identical consecutive + // events are dropped so a fast-moving mouse doesn't flood the session + // command channel (and starve frame decode behind the biased run-loop). + (int, int, int)? _lastPointer; + String? _lastPushedClipboard; + bool _hoverBarVisible = false; + Timer? _hoverBarTimer; + // Frame updates repaint via the painter's `repaint:` listenable; the widget + // tree only rebuilds on a status change or the first-frame transition (which + // toggles the "waiting for first frame" overlay). Without this guard the whole + // toolbar/layout/input tree would rebuild on every decoded frame. + VncSessionStatus? _builtStatus; + bool _builtHasImage = false; + + @override + void initState() { + super.initState(); + session.addListener(_onSessionChanged); + if (widget.isFullscreen) _flashHoverBar(); + } + + @override + void didUpdateWidget(VncWorkspace old) { + super.didUpdateWidget(old); + if (!identical(old.session, widget.session)) { + old.session.removeListener(_onSessionChanged); + session.addListener(_onSessionChanged); + _lastPointer = null; + _builtStatus = null; + } + // Entering fullscreen: show the pill briefly so the exit affordance is + // discoverable, then auto-hide until the user hovers the top edge. + if (widget.isFullscreen && !old.isFullscreen) _flashHoverBar(); + } + + @override + void dispose() { + _hoverBarTimer?.cancel(); + _focusNode.dispose(); + session.removeListener(_onSessionChanged); + super.dispose(); + } + + void _onSessionChanged() { + if (!mounted) return; + // Don't trap the user in a chrome-less fullscreen error screen — drop back + // to windowed the moment the session leaves connected (synchronous, like + // RDP, so there's no deferred flash through the parent-rebuild path). + if (widget.isFullscreen && + session.status != VncSessionStatus.connected && + session.status != _builtStatus) { + widget.onFullscreenChanged?.call(false); + } + if (session.status != _builtStatus || + (session.image != null) != _builtHasImage) { + setState(() {}); + } + } + + void _flashHoverBar() { + setState(() => _hoverBarVisible = true); + _hoverBarTimer?.cancel(); + _hoverBarTimer = Timer(const Duration(milliseconds: 2500), () { + if (mounted) setState(() => _hoverBarVisible = false); + }); + } + + void _showHoverBar() { + _hoverBarTimer?.cancel(); + if (!_hoverBarVisible) setState(() => _hoverBarVisible = true); + } + + void _hideHoverBarSoon() { + _hoverBarTimer?.cancel(); + _hoverBarTimer = Timer(const Duration(milliseconds: 600), () { + if (mounted) setState(() => _hoverBarVisible = false); + }); + } + + @override + Widget build(BuildContext context) { + _builtStatus = session.status; + _builtHasImage = session.image != null; + if (widget.isFullscreen) { + return Stack(children: [ + Positioned.fill(child: _buildBody()), + // Invisible reveal strip along the top screen edge. + Positioned( + top: 0, + left: 0, + right: 0, + height: 8, + child: MouseRegion( + opaque: false, + onEnter: (_) => _showHoverBar(), + child: const SizedBox.expand(), + ), + ), + Positioned( + top: 8, + left: 0, + right: 0, + child: Center( + child: AnimatedOpacity( + opacity: _hoverBarVisible ? 1 : 0, + duration: const Duration(milliseconds: 150), + child: IgnorePointer( + ignoring: !_hoverBarVisible, + child: MouseRegion( + onEnter: (_) => _showHoverBar(), + onExit: (_) => _hideHoverBarSoon(), + child: _ExitFullscreenPill( + session: session, + onExit: () => widget.onFullscreenChanged?.call(false), + ), + ), + ), + ), + ), + ), + ]); + } + return Column(children: [ + _Toolbar( + session: session, + onEnterFullscreen: widget.onFullscreenChanged == null + ? null + : () => widget.onFullscreenChanged!.call(true), + ), + Expanded(child: _buildBody()), + ]); + } + + Widget _buildBody() { + switch (session.status) { + case VncSessionStatus.connecting: + return const Center( + child: Text('Connecting…', + style: TextStyle(color: AppColors.textSecondary))); + case VncSessionStatus.error: + case VncSessionStatus.disconnected: + return Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Text(session.lastMessage ?? 'Disconnected', + style: const TextStyle(color: AppColors.textSecondary)), + const SizedBox(height: 12), + FilledButton( + onPressed: widget.onReconnect, child: const Text('Retry')), + ]), + ); + case VncSessionStatus.connected: + return LayoutBuilder(builder: (context, constraints) { + final w = session.width; + final h = session.height; + if (w == 0 || h == 0) { + return const SizedBox.expand(); + } + final scale = + math.min(constraints.maxWidth / w, constraints.maxHeight / h); + final offX = (constraints.maxWidth - w * scale) / 2; + final offY = (constraints.maxHeight - h * scale) / 2; + + (int, int) toFb(Offset local) => vncSessionPoint( + localX: local.dx, + localY: local.dy, + offX: offX, + offY: offY, + scale: scale, + width: w, + height: h); + + void sendPointer(Offset local, int mask) { + final (x, y) = toFb(local); + if (_lastPointer == (x, y, mask)) return; // drop duplicate events + _lastPointer = (x, y, mask); + session.client.sendPointer(x: x, y: y, buttonMask: mask); + } + + return Focus( + focusNode: _focusNode, + autofocus: true, + onFocusChange: (gained) async { + if (!gained) return; + final data = await Clipboard.getData(Clipboard.kTextPlain); + final text = data?.text; + if (text != null && + text.isNotEmpty && + text != _lastPushedClipboard) { + _lastPushedClipboard = text; + session.client.sendClipboardText(text); + } + }, + onKeyEvent: (node, event) { + // Let app-level hotkeys win over the remote. + if (HotkeyService().shouldSwallowKeyEvent(event)) { + return KeyEventResult.handled; + } + final keysym = vncKeysymFor(event.physicalKey); + if (keysym == null) return KeyEventResult.ignored; + if (event is KeyDownEvent || event is KeyRepeatEvent) { + session.client.sendKey(keysym: keysym, down: true); + } else if (event is KeyUpEvent) { + session.client.sendKey(keysym: keysym, down: false); + } + return KeyEventResult.handled; + }, + child: Listener( + onPointerHover: (e) => sendPointer(e.localPosition, 0), + onPointerMove: (e) => + sendPointer(e.localPosition, _vncButtonMask(e.buttons)), + onPointerDown: (e) { + _focusNode.requestFocus(); + sendPointer(e.localPosition, _vncButtonMask(e.buttons)); + }, + // On pointer up Flutter has already cleared the released button + // from e.buttons, so the derived mask is the post-release state. + onPointerUp: (e) => + sendPointer(e.localPosition, _vncButtonMask(e.buttons)), + onPointerSignal: (e) { + if (e is PointerScrollEvent && e.scrollDelta.dy != 0) { + final mask = _vncButtonMask(e.buttons); + final wheel = e.scrollDelta.dy < 0 ? 0x08 : 0x10; // up : down + final (x, y) = toFb(e.localPosition); + // A wheel notch is a press+release of the wheel "button". + session.client + .sendPointer(x: x, y: y, buttonMask: mask | wheel); + session.client.sendPointer(x: x, y: y, buttonMask: mask); + _lastPointer = (x, y, mask); + } + }, + child: Stack( + fit: StackFit.expand, + children: [ + CustomPaint( + size: Size(constraints.maxWidth, constraints.maxHeight), + painter: _FramePainter(session), + ), + // Connected but no frame decoded yet — keep the surface + // interactive while showing the cue (restored from M2). + if (session.image == null) + const Center( + child: Text('Waiting for first frame…', + style: TextStyle(color: AppColors.textSecondary))), + ], + ), + ), + ); + }); + } + } +} + +class _Toolbar extends StatelessWidget { + const _Toolbar({required this.session, this.onEnterFullscreen}); + final VncSession session; + final VoidCallback? onEnterFullscreen; + + @override + Widget build(BuildContext context) { + return Container( + height: 34, + color: AppColors.card, + child: Row(children: [ + const SizedBox(width: 8), + Text(session.tabLabel, style: Theme.of(context).textTheme.labelMedium), + const Spacer(), + IconButton( + tooltip: 'Push clipboard to remote', + icon: const Icon(Icons.content_paste_go, size: 16), + onPressed: () => _pushClipboard(session), + ), + if (onEnterFullscreen != null) + IconButton( + tooltip: 'Fullscreen', + icon: const Icon(Icons.fullscreen, size: 16), + onPressed: session.status == VncSessionStatus.connected + ? onEnterFullscreen + : null, + ), + IconButton( + tooltip: 'Disconnect', + icon: const Icon(Icons.power_settings_new, size: 16), + onPressed: () => session.client.disconnect(), + ), + const SizedBox(width: 4), + ]), + ); + } +} + +class _ExitFullscreenPill extends StatelessWidget { + const _ExitFullscreenPill({required this.session, required this.onExit}); + final VncSession session; + final VoidCallback onExit; + + @override + Widget build(BuildContext context) { + return Material( + color: AppColors.card, + borderRadius: BorderRadius.circular(8), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + IconButton( + tooltip: 'Push clipboard to remote', + icon: const Icon(Icons.content_paste_go, size: 16), + onPressed: () => _pushClipboard(session), + ), + InkWell( + borderRadius: BorderRadius.circular(8), + onTap: onExit, + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.fullscreen_exit, size: 16), + SizedBox(width: 6), + Text('Exit fullscreen'), + ]), + ), + ), + ]), + ); + } +} + +class _FramePainter extends CustomPainter { + /// `repaint: session` redraws on every decoded frame without rebuilding the + /// surrounding widget tree (the workspace only rebuilds on status changes). + _FramePainter(this.session) : super(repaint: session); + + final VncSession session; + + @override + void paint(Canvas canvas, Size size) { + final ui.Image? img = session.image; + if (img == null) return; + // Fit by the IMAGE's own dimensions, not the session-negotiated size, so a + // server resize doesn't stretch the still-old frame for a few ms. + final s = math.min(size.width / img.width, size.height / img.height); + final dw = img.width * s, dh = img.height * s; + final ox = (size.width - dw) / 2, oy = (size.height - dh) / 2; + canvas.drawImageRect( + img, + Rect.fromLTWH(0, 0, img.width.toDouble(), img.height.toDouble()), + Rect.fromLTWH(ox, oy, dw, dh), + Paint()..filterQuality = FilterQuality.medium, + ); + } + + @override + bool shouldRepaint(_FramePainter old) => !identical(old.session, session); +} + +Future _pushClipboard(VncSession session) async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + if (data?.text != null) { + session.client.sendClipboardText(data!.text!); + } +} + +/// Flutter's pressed-buttons bitfield -> RFB button bitmask +/// (bit0 left, bit1 middle, bit2 right). +int _vncButtonMask(int flutterButtons) { + var mask = 0; + if (flutterButtons & kPrimaryMouseButton != 0) mask |= 0x1; + if (flutterButtons & kMiddleMouseButton != 0) mask |= 0x2; + if (flutterButtons & kSecondaryMouseButton != 0) mask |= 0x4; + return mask; +} diff --git a/app/pubspec.lock b/app/pubspec.lock index d284e755..5d971df6 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -1356,6 +1356,13 @@ packages: relative: true source: path version: "1.0.0" + yourssh_vnc: + dependency: "direct main" + description: + path: "../packages/yourssh_vnc" + relative: true + source: path + version: "0.1.0" yourssh_web_tools: dependency: "direct main" description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index e517d82d..c6ffd6d8 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -1,7 +1,7 @@ name: yourssh description: YourSSH - A professional SSH client for macOS, Windows, and Linux with advanced features like SFTP, port forwarding, and a built-in code editor. publish_to: 'none' -version: 0.1.36+1 +version: 0.1.37+1 environment: sdk: ^3.12.0 @@ -78,6 +78,8 @@ dependencies: path: ../packages/yourssh_script_engine yourssh_rdp: path: ../packages/yourssh_rdp + yourssh_vnc: + path: ../packages/yourssh_vnc # Cryptography utilities cryptography: ^2.7.0 diff --git a/app/test/models/host_vnc_test.dart b/app/test/models/host_vnc_test.dart new file mode 100644 index 00000000..4822cdbb --- /dev/null +++ b/app/test/models/host_vnc_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/host.dart'; + +void main() { + test('vnc default port is 5900', () { + expect(HostProtocol.vnc.defaultPort, 5900); + }); + + test('vnc protocol round-trips through json', () { + final h = Host( + label: 'desktop', + host: '10.0.0.5', + port: 5900, + username: 'u', + protocol: HostProtocol.vnc, + ); + final json = h.toJson(); + expect(json['protocol'], 'vnc'); + + final back = Host.fromJson(json); + expect(back.protocol, HostProtocol.vnc); + expect(back.port, 5900); + }); + + test('unknown protocol still falls back to ssh', () { + final back = Host.fromJson({ + 'id': 'x', + 'label': 'l', + 'host': 'h', + 'port': 22, + 'username': 'u', + 'protocol': 'telnet', + }); + expect(back.protocol, HostProtocol.ssh); + }); +} diff --git a/app/test/models/vnc_session_test.dart b/app/test/models/vnc_session_test.dart new file mode 100644 index 00000000..86294ad2 --- /dev/null +++ b/app/test/models/vnc_session_test.dart @@ -0,0 +1,138 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/models/vnc_session.dart'; +import 'package:yourssh_vnc/yourssh_vnc.dart' show VncClient, VncConfig; +// ignore: implementation_imports +import 'package:yourssh_vnc/src/generated/api.dart' as frb; + +Host _host() => Host( + id: 'v1', + label: 'desktop', + host: '10.0.0.5', + port: 5900, + username: 'u', + protocol: HostProtocol.vnc); + +VncClient _client() => VncClient(VncConfig( + targetHost: '10.0.0.5', targetPort: 5900, username: 'u', password: '')); + +void main() { + test('status transitions connecting -> connected -> disconnected', () async { + final events = StreamController(); + final s = VncSession(host: _host(), client: _client()); + s.attach(events.stream); + expect(s.status, VncSessionStatus.connecting); + + events.add(const frb.VncEvent.started(sessionId: 1)); + events.add(const frb.VncEvent.connected(width: 800, height: 600)); + await Future.delayed(Duration.zero); + expect(s.status, VncSessionStatus.connected); + expect(s.width, 800); + expect(s.height, 600); + + events.add(const frb.VncEvent.disconnected(reason: 'bye')); + await Future.delayed(Duration.zero); + expect(s.status, VncSessionStatus.disconnected); + expect(s.lastMessage, 'bye'); + await events.close(); + }); + + test('error event sets error status and message', () async { + final events = StreamController(); + final s = VncSession(host: _host(), client: _client()); + s.attach(events.stream); + events.add(const frb.VncEvent.error(message: 'connection refused')); + await Future.delayed(Duration.zero); + expect(s.status, VncSessionStatus.error); + expect(s.lastMessage, 'connection refused'); + await events.close(); + }); + + test('framebuffer reallocates to server size on connected', () async { + final events = StreamController(); + final s = VncSession(host: _host(), client: _client()); + s.attach(events.stream); + events.add(const frb.VncEvent.connected(width: 4, height: 2)); + await Future.delayed(Duration.zero); + expect(s.framebuffer.length, 4 * 2 * 4); + await events.close(); + }); + + test('resize reallocates the framebuffer', () async { + final events = StreamController(); + final s = VncSession(host: _host(), client: _client()); + s.attach(events.stream); + events.add(const frb.VncEvent.connected(width: 4, height: 2)); + await Future.delayed(Duration.zero); + events.add(const frb.VncEvent.resize(width: 8, height: 8)); + await Future.delayed(Duration.zero); + expect(s.width, 8); + expect(s.height, 8); + expect(s.framebuffer.length, 8 * 8 * 4); + await events.close(); + }); + + test('out-of-bounds frame update is dropped without crashing', () async { + final events = StreamController(); + final s = VncSession(host: _host(), client: _client()); + s.attach(events.stream); + events.add(const frb.VncEvent.connected(width: 4, height: 4)); + await Future.delayed(Duration.zero); + events.add(frb.VncEvent.frameUpdate( + x: 3, y: 3, width: 4, height: 4, rgba: Uint8List(4 * 4 * 4))); + await Future.delayed(Duration.zero); + expect(s.framebuffer.length, 4 * 4 * 4); + await events.close(); + }); + + test('frame update patches the framebuffer row-by-row', () async { + final events = StreamController(); + final s = VncSession(host: _host(), client: _client()); + s.attach(events.stream); + events.add(const frb.VncEvent.connected(width: 2, height: 1)); + await Future.delayed(Duration.zero); + final px = Uint8List.fromList([10, 20, 30, 255, 40, 50, 60, 255]); + events.add( + frb.VncEvent.frameUpdate(x: 0, y: 0, width: 2, height: 1, rgba: px)); + await Future.delayed(Duration.zero); + expect(s.framebuffer.sublist(0, 8), [10, 20, 30, 255, 40, 50, 60, 255]); + await events.close(); + }); + + test('tab label falls back to host label, honours custom override', () { + final s = VncSession(host: _host(), client: _client()); + expect(s.tabLabel, 'desktop'); + s.customLabel = 'My VM'; + expect(s.tabLabel, 'My VM'); + }); + + test('server cut-text invokes the clipboard callback (no repaint)', () async { + final events = StreamController(); + final s = VncSession(host: _host(), client: _client()); + String? got; + s.onRemoteClipboardText = (t) => got = t; + var notifies = 0; + s.addListener(() => notifies++); + s.attach(events.stream); + events.add(const frb.VncEvent.clipboardText(text: 'hello')); + await Future.delayed(Duration.zero); + expect(got, 'hello'); + expect(notifies, 0, + reason: 'clipboard cut-text must not trigger a repaint'); + await events.close(); + }); + + test('tunnel-closed marks the disconnect reason', () async { + final events = StreamController(); + final s = VncSession(host: _host(), client: _client()); + s.attach(events.stream); + s.markTunnelClosed(); + events.add(const frb.VncEvent.disconnected(reason: 'connection closed')); + await Future.delayed(Duration.zero); + expect(s.lastMessage, 'SSH tunnel closed'); + await events.close(); + }); +} diff --git a/app/test/providers/session_provider_app_session_test.dart b/app/test/providers/session_provider_app_session_test.dart index 9e7c3ad4..314010d3 100644 --- a/app/test/providers/session_provider_app_session_test.dart +++ b/app/test/providers/session_provider_app_session_test.dart @@ -10,6 +10,8 @@ import 'package:yourssh/services/ssh_service.dart'; import 'package:yourssh/services/storage_service.dart'; import 'package:yourssh/services/tab_metadata_service.dart'; import 'package:yourssh_rdp/yourssh_rdp.dart' show RdpClient, RdpConfig; +import 'package:yourssh/models/vnc_session.dart'; +import 'package:yourssh_vnc/yourssh_vnc.dart' show VncClient, VncConfig; // port 1 on localhost → quick connection-refused (mirrors other provider tests) Host _sshHost() => Host( @@ -34,6 +36,17 @@ RdpClient _rdpClient() => RdpClient(RdpConfig( height: 800, security: 'auto')); +Host _vncHost() => Host( + id: 'vnc1', + label: 'desktop', + host: '10.0.0.5', + port: 5900, + username: 'u', + protocol: HostProtocol.vnc); + +VncClient _vncClient() => VncClient(VncConfig( + targetHost: '10.0.0.5', targetPort: 5900, username: 'u', password: '')); + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -62,6 +75,16 @@ void main() { host: _rdpHost(), client: _rdpClient(), width: 1280, height: 800); expect(s is TerminalSession, isFalse); }); + + test('VncSession is not a TerminalSession', () { + final s = VncSession(host: _vncHost(), client: _vncClient()); + expect(s is TerminalSession, isFalse); + }); + + test('VncSession is an AppSession', () { + final s = VncSession(host: _vncHost(), client: _vncClient()); + expect(s, isA()); + }); }); group('SessionProvider.activeSshSession with SSH session', () { diff --git a/app/test/services/rdp_tunnel_proxy_test.dart b/app/test/services/loopback_tunnel_proxy_test.dart similarity index 92% rename from app/test/services/rdp_tunnel_proxy_test.dart rename to app/test/services/loopback_tunnel_proxy_test.dart index f5c66e47..babb2284 100644 --- a/app/test/services/rdp_tunnel_proxy_test.dart +++ b/app/test/services/loopback_tunnel_proxy_test.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; -import 'package:yourssh/services/rdp_tunnel_proxy.dart'; +import 'package:yourssh/services/loopback_tunnel_proxy.dart'; void main() { test('pipes bytes both ways through one accepted connection', () async { @@ -10,7 +10,7 @@ void main() { final echo = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); echo.listen((s) => s.listen(s.add)); - final proxy = RdpTunnelProxy(); + final proxy = LoopbackTunnelProxy(); final port = await proxy.start(() async { final socket = await Socket.connect('127.0.0.1', echo.port); return TunnelEnd(stream: socket, sink: socket, close: socket.destroy); @@ -33,7 +33,7 @@ void main() { echo.listen((s) => remoteSide = s); final closed = Completer(); - final proxy = RdpTunnelProxy(onClosed: () { + final proxy = LoopbackTunnelProxy(onClosed: () { if (!closed.isCompleted) closed.complete(); }); final port = await proxy.start(() async { @@ -58,7 +58,7 @@ void main() { echo.listen((s) => s.listen(s.add)); final closed = Completer(); - final proxy = RdpTunnelProxy(onClosed: () { + final proxy = LoopbackTunnelProxy(onClosed: () { if (!closed.isCompleted) closed.complete(); }); final port = await proxy.start(() async { @@ -82,7 +82,7 @@ void main() { echo.listen((s) => s.listen(s.add)); var fired = false; - final proxy = RdpTunnelProxy(onClosed: () => fired = true); + final proxy = LoopbackTunnelProxy(onClosed: () => fired = true); final port = await proxy.start(() async { final socket = await Socket.connect('127.0.0.1', echo.port); return TunnelEnd(stream: socket, sink: socket, close: socket.destroy); diff --git a/app/test/util/vnc_input_mapping_test.dart b/app/test/util/vnc_input_mapping_test.dart new file mode 100644 index 00000000..06ff2cbc --- /dev/null +++ b/app/test/util/vnc_input_mapping_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/util/vnc_input_mapping.dart'; + +void main() { + group('vncKeysymFor', () { + test('letters map to lowercase Latin-1 keysyms', () { + expect(vncKeysymFor(PhysicalKeyboardKey.keyA), 0x61); + expect(vncKeysymFor(PhysicalKeyboardKey.keyZ), 0x7a); + }); + test('digits map to ASCII keysyms', () { + expect(vncKeysymFor(PhysicalKeyboardKey.digit1), 0x31); + expect(vncKeysymFor(PhysicalKeyboardKey.digit0), 0x30); + }); + test('common control keys', () { + expect(vncKeysymFor(PhysicalKeyboardKey.enter), 0xff0d); + expect(vncKeysymFor(PhysicalKeyboardKey.backspace), 0xff08); + expect(vncKeysymFor(PhysicalKeyboardKey.tab), 0xff09); + expect(vncKeysymFor(PhysicalKeyboardKey.escape), 0xff1b); + expect(vncKeysymFor(PhysicalKeyboardKey.space), 0x20); + }); + test('arrows and navigation', () { + expect(vncKeysymFor(PhysicalKeyboardKey.arrowUp), 0xff52); + expect(vncKeysymFor(PhysicalKeyboardKey.arrowLeft), 0xff51); + expect(vncKeysymFor(PhysicalKeyboardKey.home), 0xff50); + expect(vncKeysymFor(PhysicalKeyboardKey.delete), 0xffff); + }); + test('modifiers map to side-specific keysyms', () { + expect(vncKeysymFor(PhysicalKeyboardKey.shiftLeft), 0xffe1); + expect(vncKeysymFor(PhysicalKeyboardKey.controlLeft), 0xffe3); + expect(vncKeysymFor(PhysicalKeyboardKey.altLeft), 0xffe9); + }); + test('function keys', () { + expect(vncKeysymFor(PhysicalKeyboardKey.f1), 0xffbe); + expect(vncKeysymFor(PhysicalKeyboardKey.f12), 0xffc9); + }); + test('unmapped key returns null', () { + expect(vncKeysymFor(PhysicalKeyboardKey.f24), isNull); + }); + }); + + group('vncSessionPoint', () { + test('maps a centered letterboxed point to framebuffer coords', () { + final (x, y) = vncSessionPoint( + localX: 40 + 100 * 2, + localY: 10 + 50 * 2, + offX: 40, + offY: 10, + scale: 2, + width: 800, + height: 600); + expect(x, 100); + expect(y, 50); + }); + test('clamps out-of-frame points into bounds', () { + final (x, y) = vncSessionPoint( + localX: -50, + localY: 99999, + offX: 0, + offY: 0, + scale: 1, + width: 800, + height: 600); + expect(x, 0); + expect(y, 599); + }); + }); +} diff --git a/app/test/widgets/host_detail_panel_vnc_test.dart b/app/test/widgets/host_detail_panel_vnc_test.dart new file mode 100644 index 00000000..f89189e0 --- /dev/null +++ b/app/test/widgets/host_detail_panel_vnc_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({})); + + Future pumpPanel(WidgetTester tester, + {Host? existing, List allHosts = const []}) async { + await tester.binding.setSurfaceSize(const Size(500, 3600)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final hostProvider = HostProvider(StorageService()); + for (final h in allHosts) { + await hostProvider.addHost(h); + } + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => KeyProvider()), + ChangeNotifierProvider.value(value: hostProvider), + Provider(create: (_) => SshService(StorageService())), + ], + child: MaterialApp( + home: Scaffold( + body: HostDetailPanel( + existing: existing, + initialProtocol: existing == null ? HostProtocol.vnc : null, + agentProbe: () async => const AgentProbeSystem(1), + onClose: () {}, + onSave: (host, _) async {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + Host vncHost() => Host( + id: 'vnc-1', + label: 'desktop', + host: '10.0.0.5', + port: 5900, + username: 'u', + protocol: HostProtocol.vnc, + ); + + testWidgets('VNC mode hides SSH-only and RDP-only sections', (tester) async { + await pumpPanel(tester, existing: vncHost()); + expect(find.text('VNC on'), findsOneWidget); + expect(find.text('AUTH METHOD'), findsNothing); + expect(find.text('RDP SECURITY'), findsNothing); + }); + + testWidgets('panel exposes a VNC protocol segment', (tester) async { + await pumpPanel(tester); + expect(find.text('VNC'), findsWidgets); + }); + + testWidgets('VNC mode shows the SSH TUNNEL dropdown', (tester) async { + final bastion = Host( + id: 'b1', label: 'bastion', host: '10.0.0.1', port: 22, username: 'u'); + await pumpPanel(tester, existing: vncHost(), allHosts: [bastion]); + expect(find.text('SSH TUNNEL'), findsOneWidget); + }); +} diff --git a/app/test/widgets/protocol_badge_test.dart b/app/test/widgets/protocol_badge_test.dart new file mode 100644 index 00000000..7750d372 --- /dev/null +++ b/app/test/widgets/protocol_badge_test.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/widgets/protocol_badge.dart'; + +void main() { + testWidgets('renders RDP / VNC labels, nothing for SSH', (tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold( + body: Column(children: [ + ProtocolBadge(HostProtocol.rdp), + ProtocolBadge(HostProtocol.vnc), + ProtocolBadge(HostProtocol.ssh), + ]), + ), + )); + expect(find.text('RDP'), findsOneWidget); + expect(find.text('VNC'), findsOneWidget); + expect(find.text('SSH'), findsNothing); + }); +} diff --git a/app/test/widgets/vnc_workspace_input_test.dart b/app/test/widgets/vnc_workspace_input_test.dart new file mode 100644 index 00000000..f29a410a --- /dev/null +++ b/app/test/widgets/vnc_workspace_input_test.dart @@ -0,0 +1,63 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/models/vnc_session.dart'; +import 'package:yourssh/widgets/vnc_workspace.dart'; +import 'package:yourssh_vnc/yourssh_vnc.dart' show VncClient, VncConfig; +// ignore: implementation_imports +import 'package:yourssh_vnc/src/generated/api.dart' as frb; + +Host _host() => Host( + id: 'v1', + label: 'desktop', + host: '10.0.0.5', + port: 5900, + username: 'u', + protocol: HostProtocol.vnc); + +VncClient _client() => VncClient(VncConfig( + targetHost: '10.0.0.5', targetPort: 5900, username: 'u', password: '')); + +void main() { + testWidgets('connect swaps the status view for an interactive surface', + (tester) async { + final events = StreamController(); + final session = VncSession(host: _host(), client: _client()); + session.attach(events.stream); + + await tester.pumpWidget(MaterialApp( + home: Scaffold(body: VncWorkspace(session: session)), + )); + expect(find.textContaining('Connecting'), findsOneWidget); + + // Move to connected (server framebuffer 800x600); no frame decoded yet. + events.add(const frb.VncEvent.connected(width: 800, height: 600)); + await tester.pump(); + + // The connecting status view is gone, replaced by the interactive surface. + expect(find.textContaining('Connecting'), findsNothing); + + // The input surface is owned BY the workspace (scoped to its subtree, so + // this does NOT match the Listener widgets MaterialApp/Scaffold inject as + // ancestors — the bug the original assertion missed). + expect( + find.descendant( + of: find.byType(VncWorkspace), matching: find.byType(Listener)), + findsWidgets, + ); + + // Before the first frame the workspace shows the waiting affordance while + // staying interactive (regression guard for the M2->M3 rewrite). + expect(find.textContaining('Waiting for first frame'), findsOneWidget); + + // A key event reaches the workspace's Focus handler without throwing + // (sendKey no-ops at the bridge because the client has no live session id). + await tester.sendKeyEvent(LogicalKeyboardKey.keyA); + await tester.pump(); + + await events.close(); + }); +} diff --git a/app/test/widgets/vnc_workspace_test.dart b/app/test/widgets/vnc_workspace_test.dart new file mode 100644 index 00000000..7443f3ef --- /dev/null +++ b/app/test/widgets/vnc_workspace_test.dart @@ -0,0 +1,70 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/models/vnc_session.dart'; +import 'package:yourssh/widgets/vnc_workspace.dart'; +import 'package:yourssh_vnc/yourssh_vnc.dart' show VncClient, VncConfig; +// ignore: implementation_imports +import 'package:yourssh_vnc/src/generated/api.dart' as frb; + +Host _host() => Host( + id: 'v1', + label: 'desktop', + host: '10.0.0.5', + port: 5900, + username: 'u', + protocol: HostProtocol.vnc); + +VncClient _client() => VncClient(VncConfig( + targetHost: '10.0.0.5', targetPort: 5900, username: 'u', password: '')); + +void main() { + testWidgets('fullscreen button fires onFullscreenChanged when connected', + (tester) async { + final events = StreamController(); + final session = VncSession(host: _host(), client: _client()); + session.attach(events.stream); + var fs = false; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: VncWorkspace( + session: session, onFullscreenChanged: (v) => fs = v), + ), + )); + events.add(const frb.VncEvent.connected(width: 800, height: 600)); + await tester.pump(); + await tester.tap(find.byTooltip('Fullscreen')); + await tester.pump(); + expect(fs, isTrue); + await events.close(); + }); + + testWidgets('shows connecting overlay, then error overlay with retry', + (tester) async { + final events = StreamController(); + final session = VncSession(host: _host(), client: _client()); + session.attach(events.stream); + var retried = false; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: VncWorkspace( + session: session, + onReconnect: () => retried = true, + ), + ), + )); + expect(find.textContaining('Connecting'), findsOneWidget); + + events.add(const frb.VncEvent.error(message: 'connection refused')); + await tester.pump(); + expect(find.textContaining('connection refused'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + + await tester.tap(find.text('Retry')); + expect(retried, isTrue); + await events.close(); + }); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index a9d466b9..63128c9d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,12 +1,14 @@ # YourSSH — Roadmap > Direction: **infra workstation for DevOps/SRE managing 10–100+ hosts**, not just an SSH client. -> Current version: 0.1.36 · updated: 2026-06-12 +> Current version: 0.1.37 · updated: 2026-06-18 This document lists proposed features ordered by priority. Each item can be broken out into its own spec (`docs/superpowers/specs/`) when ready for implementation. Already shipped (not repeated in roadmap): multi-tab terminal, split view, broadcast, recording (asciicast), snippet, SFTP dual-panel, port forwarding, jump host, Supabase sync + P2P LAN, AI chat sidebar with tool calling, plugin system (DevOps / WebTools / Snippets), Cloudflare tunnel, MCP gateway, mail catcher, code editor (Monaco), customizable hotkeys, TOFU known-hosts, **Command Palette (Cmd/Ctrl+K)** — fuzzy search hosts / nav / snippets / actions, **Workspace persistence** — auto-reconnect tabs + layout on relaunch, **Search-in-scrollback (Cmd/Ctrl+F)** — regex, highlights, prev/next navigation, **Script Engine plugin system** — disk-based JS plugins via QuickJS FFI, HookBus (terminal.output / terminal.input / session events), SSH/SFTP/Storage/UI bridges, hot-reload file watcher, PermissionGuard + circuit breaker, consent dialog, manager screen + console log viewer, **Import** — paste SSH config / JSON / CSV with per-host include toggles (`parseSshConfig` in `import_panel.dart`), **Host tagging** — comma-separated tags on the `Host` model, editable in host detail and searchable from the dashboard, **Smart filter + multi-dimensional query (0.1.10)** — `HostQuery` parser with `key:value` faceted AND/OR semantics, toggleable facet chips on hosts dashboard, tag-based search, **Terminal sharing / multiplayer (0.1.13)** — share a live SSH session via Supabase Realtime; guests join with a session code, watch or interact in real time; `ShareSessionService`, `ShareProvider`, `ShareEvent`, split-view watch banner, **Advanced tab management** — rename, color tag, pin, drag reorder; all tab metadata persists per host, **Connection health badge (0.1.17)** — live latency-driven dot per session tab (green/amber/red/grey + pulse), hover tooltip with uptime / last-ping / reconnect count; `HealthMonitorService` pings the live channel (`SSHClient.ping`) as sole pinger, 5s timeout surfaces half-open silent drops, **Shell integration (0.1.18)** — injected bash/zsh prompt-hooks emit OSC 7 + OSC 133 captured via xterm `onPrivateOSC`; cwd on the session tab, per-command status gutter (green/red/grey), jump-to-prompt (`Cmd/Ctrl+↑/↓`), and cwd-aware path autocomplete in the input bar; auto-on with per-host + global opt-out, **In-app updates (0.1.19)** — checks GitHub `releases/latest` on launch (24h debounce) + manual check in Settings; since 0.1.29 also re-checks while the app stays running (6h periodic timer + window-focus check, same 24h debounce) so the notification bell picks up new releases without a restart; dismissible update banner + Settings Updates section; downloads the correct OS/arch artifact and hands off to the OS installer (assisted flow, unsigned-app friendly: macOS strips quarantine + opens the DMG, Windows runs the installer, Linux opens the package); falls back to the Releases page when no artifact matches (e.g. Intel Mac); `UpdateService` + `UpdateProvider` + `UpdateBanner`, **Sudo SFTP (0.1.20)** — per-host SFTP mode running the entire SFTP session as root through `sudo` over an exec channel (WinSCP-style); distro auto-detection, `NOPASSWD` guidance, root badge on elevated panels, **External edit + Open with… (0.1.20–0.1.21)** — open remote files with the OS default app or any installed app (hover submenu; per-OS discovery via `NSWorkspace` / XDG `.desktop` / Windows registry); the local copy is watched and auto-uploaded on every save; plain-text editor fallback where Monaco is unavailable, **SFTP View mode (0.1.21)** — read-only preview separate from Edit, **Invisible shell-integration injection (0.1.21)** — bracketed-paste readiness detection + `read -rs` two-phase handshake delivers the OSC hook installer without ever echoing into the terminal or recordings, **Terminal snippets panel (0.1.21)** — collapsible right-side panel in the terminal workspace to browse/search/copy/run snippets against the active pane, backed by the new plugin `sendInput` API, **Unified terminal tabs (0.1.24)** — local shell sessions are first-class tabs in the global top bar (`TerminalSession` model), split into panes alongside SSH, recordable to asciicast, **SFTP two-panel with switchable sources (0.1.24)** — per-panel Local/host source chip, unified headers (filter + actions), clickable breadcrumbs (#41), workspace kept alive across tab switches (#42), **Terminal copy/paste UX (0.1.24)** — selection-gated Ctrl+C copy (SIGINT preserved), Ctrl(+Shift)+V paste, right-click Copy/Paste/Select All menu, middle-click paste (#43), readable semi-transparent selection colors in all themes (#40), **Notification bell (0.1.25)** — bell in the top tab bar with unread badge + anchored popover; update-available items carry a one-click Update button, unexpected session drops (no pending auto-reconnect) are collected per session; mark-read on open, per-item dismiss, clear all; in-memory `NotificationCenterProvider` (deduped, capped at 50), **Terminal appearance side panel** — tune icon in the terminal toolbar opens a right-side panel (mutually exclusive with the snippets panel via the `SidePanel` enum; shared `WorkspaceSidePanel` frame) to change color theme, font size (live preview while dragging, persisted once on release), and font family without leaving the workspace; controls shared with Settings → Terminal via `TerminalAppearanceControls`, **Terminal theme catalog 35 → 44** — added Kanagawa Dragon/Lotus, Tokyo Night Day, Nord Light, Light Owl, Flexoki Dark/Light, Aura, and Cyberpunk from their authors' published palettes, grouped next to their families in the picker, with visibility-tuned cursor/selection/search colors on the light variants, **Port forwarding runtime (0.1.27)** — saved local/remote/dynamic SOCKS5 rules actually start and stop (`PortForwardService` over the dartssh2 forward APIs behind a testable `TunnelTransport` abstraction); tunnels reuse the host's open SSH client or auto-connect with stored credentials (no terminal tab required), auto-reconnect with 2 s → 30 s exponential backoff keeping local listeners bound across drops, per-rule edit panel, auto-start on launch, live connection counters, inline error reporting, **SSH Agent Forwarding (0.1.28, #49)** — per-host toggle (like `ssh -A`); forwarded `auth-agent@openssh.com` channels are served by the local system agent (`SSH_AUTH_SOCK` / Windows OpenSSH agent pipe) with fallback to app-Keychain keys when no system agent is running; requested on shell channels only, server refusal shows a terminal warning instead of killing the session (`AgentForwardingHandler`, `SystemAgentProxy.roundtrip`, dartssh2 fork agent-channel support), **Strict KEX (0.1.29)** — CVE-2023-48795 "Terrapin" mitigation in the dartssh2 fork: sequence numbers reset after every NEWKEYS, non-KEX messages during the initial exchange terminate the connection, KEXINIT must be the first packet, **Quick wins (0.1.29)** — middle-click closes unpinned session tabs; right-click context menu on port-forward rules with Duplicate (new id, "(copy)" label, auto-start off); distro-level OS icons (`/etc/os-release` ID → ubuntu/debian/fedora/centos/rocky/alma/alpine/amazon/arch/suse/redhat glyphs) on the hosts dashboard and SSH session tabs (`os_detection.dart`, `SessionTab` extracted from main_screen); empty-password SSH behavior locked in by tests (blank passwords are never persisted; connect sends ''), **SFTP permissions editor + unified entry context menu (0.1.29)** — chmod dialog (9-checkbox rwx grid two-way synced with a validated octal field: octal-only input, 3–4 digits, Apply gated while invalid; unknown current permissions fall back to stat() then warn and gate Apply instead of offering 000) on both the remote SFTP and local panels; `SftpFileOpsService.chmod` with a hardened recursive walk (entries with omitted modes classified via lstat, symlinks never followed — SETSTAT would chmod the target, directory modes applied post-order so a restrictive mode can't lock the walk out, file chmods batched 8-wide) sharing its `listWalkChildren` classification with recursive delete; st_mode carried on `SftpEntry`/`LocalEntry` from listing/scan time (no blocking stat at dialog-open); one shared `EntryContextMenu` for both panels (Open / Open with / View / Edit / Copy to target with up-front feasibility reasons incl. the same-folder block / Refresh / New folder / Permissions / Rename / Delete) wired through the dual-panel transfer matrix; shared app-launch helpers extracted to `util/app_launcher.dart`, **Bulk action panel (0.1.30)** — SELECT mode on the hosts dashboard (per-card checkboxes, filter-aware Select all, Esc to exit) with Connect all (skips already-connected hosts, confirms before opening more than 5 tabs), Run command in parallel (free text or snippet; bounded concurrency, 30 s per-host timeout, per-host failure isolation; per-host exit code / duration / expandable stdout+stderr; a Diff tab groups identical outputs against a promotable baseline and side-by-side compares any two hosts), and Push files to one remote path on every host (destination created if missing, per-host byte progress, cancel); closing a dialog mid-run confirms, cancels queued hosts and lets in-flight operations record their real result, **Dashboard grid & list view + sorting (0.1.30)** — card grid ↔ compact one-line list toggle and a sort dropdown (name / creation date / hostname, asc/desc), both persisted across restarts; default order Name A–Z, **Agent forwarding observability (0.1.30)** — pre-connect agent status line in the host panel (system agent identities / app-Keychain fallback / nothing to serve), live per-session key icon on the session tab (grey ready / green active / orange Keychain fallback / red refused), and a notification-bell item with tap-to-jump when the server refuses forwarding, **Connection Chain editor (0.1.30)** — Termius-style visual chain replacing the jump-host dropdown in the host panel (bastion card → arrow → destination, searchable Add-a-Host picker, agent-forwarding key icon, Clear; `HostChainEditor`, single hop), **Jump host on auto-connect paths (0.1.30)** — `ensureClient` (SFTP / exec / port forwarding) now resolves `Host.jumpHostId` via `defaultJumpHostLookup`, so hosts behind a bastion tunnel correctly outside interactive sessions; plus `tool/jump_probe.dart`, a layer-by-layer jump diagnostic CLI, **Session template / per-host preset (0.1.32)** — per-host working dir + env vars delivered invisibly via the shell-integration handshake (tty canonical mode lifted during the payload read so large templates don't truncate at the 4096-byte line cap), startup snippet typed visibly after the DONE sentinel (skipped under tmux and on handshake abort — a re-attach must not replay it), and per-host terminal theme / font / size / TERM / tmux overrides falling back to globals (out-of-range synced values rejected at render); SESSION TEMPLATE section in the host panel with env-key validation incl. duplicate-name detection, **Internal audit log (0.1.32)** — local SQLite (`sqlite3`, WAL, `/audit.db`) trail of connect/disconnect/exec/input events with per-caller source tagging (`bulk` / `devops` / `plugin:` / `input-bar` / broadcast targets each get their own row; internal polls like network stats and OS detection are exempt), secret redaction before insert (key=value incl. quoted multi-word values, Bearer tokens, sshpass/mysql-family `-p`, redis-cli `-a`, URL userinfo — applied to meta error strings too), Audit Log screen with type/time/search filters, keyset pagination, CSV/JSON export (macOS sandbox entitlement upgraded to user-selected read-write), retention pruning at launch (default 90 days, configurable in Settings → Audit) and fail-soft writes that can never break an SSH operation, **In-app SSH key generation (0.1.32)** — generate Ed25519 (pure Dart in the dartssh2 fork: `OpenSSHEd25519KeyPair.generate()` + passphrase-encrypting `toPem` via bcrypt-pbkdf + aes256-ctr, interop-verified against real `ssh-keygen -y`) / RSA-4096 / ECDSA-P256 (via system `ssh-keygen`; options gated by a binary probe) from the Keychain screen; keys land in `Documents/YourSSH/keys` mode 600 with the passphrase in secure storage; copy public key + ssh-copy-id-style **deploy-to-host dialog** (idempotent `grep -qxF`, EXISTS/ADDED markers) — includes the deploy stretch goal, **Local shell picker (0.1.32)** — choose which shell local terminal tabs run: auto-detected per platform (Windows: PowerShell, cmd, PowerShell 7, Git Bash, one profile per WSL distro; macOS/Linux: `$SHELL` + `/etc/shells`) plus user-added custom executables with arguments; default in Settings → Terminal, per-session choice in the new-tab (+) menu, Restart shell reuses the session's shell, an uninstalled default falls back to the platform default with a terminal warning (`ShellProfile`, `shell_detection.dart`), **Multi-hop jump chain (0.1.32)** — bastion → bastion → target for layered networks: `Host.jumpHostIds` ordered chain (legacy `jumpHostId` migrates and is dual-written to JSON for cross-version sync), `SshService` dials hop-over-hop via `forwardLocal` with chain-prefix-keyed client cache, deepest-first refcounted teardown and a cycle guard, per-hop host-key verification; `HostChainEditor` appends/removes hops (persistent Add a Host, per-hop ×, Clear); `ensureClient` and test-connection resolve the full chain, **Recording redaction (0.1.32)** — secrets masked before being written to `.cast`: line-buffered redaction in `RecordingService` reusing `AuditRedactor` unchanged (split at the last newline, start-once 500 ms flush timer, stop flushes the tail; per-line coalescing also strips keystroke timing), global Settings toggle AND per-host opt-out (both default on; pure `effectiveRecordingRedaction` policy sampled once at record start with a fresh `HostProvider` lookup), **In-app RDP client (0.1.33, #44)** — Windows / xrdp desktops as first-class tabs: IronRDP Rust engine via flutter_rust_bridge v2 (`packages/yourssh_rdp`), NLA/TLS/auto security, direct or SSH-tunneled through a saved jump host (full connection chain reused), TOFU certificate pinning enforced in Rust pre-CredSSP (changed cert aborts before credentials are sent), server-negotiated resolution handling, bidirectional clipboard, full keyboard/mouse incl. Ctrl+Alt+Del, OS-level fullscreen with mstsc-style hover pill + auto-exit safety, graceful server-initiated disconnect (MCS ultimatum → clean message, not a raw protocol error), protocol-aware dashboard actions, RDP badge, tab parity (rename/color/pin/restore/audit/notification bell), **Kubernetes panel (0.1.34)** — `KubernetesPanel` with context switcher, `kubectl logs -f`, and 1-click port-forward via `ContainerService.execStream`, **Keyword highlighting (0.1.34)** — user-defined regex rules with per-rule color picker (defaults: Error/Warning/Fail in red/yellow/cyan); rendered in the xterm fork at paint time (`paintKeywordForeground`, `KeywordHighlightRule`) wired through `TerminalView → RenderTerminal`; toggle + rule list + add/edit dialog in Settings → Terminal and the terminal config side panel, **Server monitor panel (0.1.34)** — per-host live dashboard (CPU/mem/disk/uptime/listening ports/firewall) via draggable bottom sheet; accessed from host card hover button and context menu; `SystemStatsService` polls every 5 s via compound SSH exec with sentinel markers (`__CPU1__`/`__CPU2__`/`__MEM__`/`__DISK__`/`__UPTIME__`/`__PORTS__`), `FirewallStatusService` polls every 30 s (ufw/iptables-save/nftables auto-detected); pure parser models `SystemSnapshot` + `FirewallStatus`; requires an active SSH session for the host, **Network discovery (0.1.34)** — scan local network for SSH/RDP hosts without typing an IP; `NetworkDiscoveryService` combines mDNS (`_ssh._tcp` / `_rdp._tcp`) and a TCP port scan on the local subnet; results in a draggable bottom sheet with one-tap Add Host; also linked from the Add Host panel; `DiscoveredHost` model + `multicast_dns` dep, **Import sources expansion (0.1.34)** — five new import formats beyond SSH config/JSON/CSV: PuTTY `.reg` (hex port, URL-decoded names), MobaXterm `.mxtsessions` (SSH type-0 only, multi-`[Bookmarks_N]` files), SecureCRT XML (recursive folder → group path), Ansible INI inventory (`ansible_host`/`ansible_user`/`ansible_port`; `:vars`/`:children` skipped), WinSCP `.ini` (URL-decoded, nested path → label + group); **Known hosts import** from `~/.ssh/known_hosts` via the Known Hosts screen (MD5(key\_blob) fingerprints, duplicate-skip, hashed-entry skip), **SFTP breadcrumb path jump (0.1.35, #62)** — inline path editor on the shared `PathBreadcrumb` (edit affordance → type path → Enter; Esc cancels) in both the remote SFTP and local panels; remote input normalized to absolute POSIX, **macOS universal build (0.1.35, #64)** — Intel + Apple Silicon from one arm64+x86_64 artifact: `build.sh` lipos both Rust dylib targets, the release workflow asserts both arches via `lipo -archs`, and the updater matches the universal DMG on both archs (browser fallback on pre-universal releases for Intel), **Perf & size pass (0.1.35, #63)** — memoized keyword-rule compilation in `SettingsProvider`, `setActive` equality guard, direct-buffer agent message build; dropped unused `local_auth` dep and MesloLGS NF italic faces (−4.8 MB per bundle); `--split-debug-info` on all desktop release builds with per-release symbols zips, **Terminal scroll & rendering overhaul (0.1.36, #65 #66 #67)** — mouse wheel works inside mouse-aware TUIs (claude CLI, htop, vim `mouse=a`, tmux mouse on; wheel buttons were reported as 68/69 instead of the spec's 64/65), decomposed (NFD) Vietnamese text composes into precomposed cells (macOS `ls` filenames render correctly), scrollback position stays pinned while reading during fast output at the `maxLines` cap, per-line picture render cache (~7× less per-frame paint work; keyword regexes run on line change instead of every frame), Shift+PageUp/PageDown scrollback paging, and a right-click **Reset Terminal** action that recovers sessions stuck in the alternate screen after a TUI died uncleanly. +**Shipped (0.1.37):** **OSC 52 clipboard** — remote apps (tmux, vim) write to the local system clipboard via the OSC 52 escape sequence; write-only, per-host opt-in (`Host.osc52Clipboard`) for safety; pure payload parser + dispatch on the xterm `onPrivateOSC` path (`osc52_clipboard.dart`). **In-app VNC client (VNC M1–M5, #44 family)** — Linux VNC servers (TigerVNC / x11vnc / TightVNC) as first-class tabs, mirroring the RDP integration one-to-one: `packages/yourssh_vnc` Rust crate over `vnc-rs` via flutter_rust_bridge v2; RFB connect + None / VNC-password auth, framebuffer-update rendering with latest-wins decode (`VncSession`), mouse + keyboard input (X11 keysym map + pointer-coordinate transform), bidirectional clipboard (Client/ServerCutText), server-driven auto-resize, SSH tunneling through the shared loopback proxy (`RdpTunnelProxy` generalized to `LoopbackTunnelProxy`), fullscreen with mstsc-style auto-hide hover pill, `ProtocolBadge` (RDP + VNC), `HostProtocol.vnc` (port 5900) with a VNC mode in the host panel, protocol-aware dashboard actions + `vnc://` copy-url, tab parity (rename / color / pin / restore / audit / notification bell), and a manual integration-screenshots test. + --- ## P0 — Must-have to retain "power" users @@ -24,11 +26,11 @@ _All P0 items shipped. See P1 for next priorities._ ### Terminal UX & protocol support - **Richer autocomplete** — _cwd-aware path completion shipped (0.1.18)._ Remaining: option / argument-aware completion sourced from snippets + built-ins, and suggesting a matching key/identity on password prompts. -- **OSC 52 clipboard** — let remote apps (tmux, vim) write to the local clipboard through the escape sequence; xterm fork addition, opt-in per host for safety. - **Grid split layouts** — beyond the current 2-pane horizontal/vertical: 2×2+, drag-to-rearrange panes, per-pane resize persistence. Extends `TerminalLayoutProvider`. - **Terminal emulation & charset per host** — selectable `TERM` type (xterm-256color / linux / vt100) and charset (UTF-8 / legacy codepages) for network gear and legacy servers. - **Zmodem (rz/sz) inline transfer** — direct file transfer inside an active SSH shell without switching to the SFTP panel. - **RDP follow-ups (issue #44)** — audio redirection, drive/printer redirect, dynamic resize on window drag; deferred from the 0.1.33 RDP client release. +- **VNC follow-ups** — VeNCrypt / TLS-tunneled auth, macOS Screen Sharing / Apple Remote Desktop (DH-based) auth, RealVNC RA2, UltraVNC MS-Logon; deferred as out-of-scope from the VNC M1–M5 release (Linux servers first). - **Telnet + serial console** — multi-protocol terminal beyond SSH for legacy/network gear. Larger lift: the dartssh2 fork is SSH-only. ### Security & identity @@ -56,7 +58,7 @@ _All P0 items shipped. See P1 for next priorities._ - Recording library: full-text search inside `.cast` content (find the session where a command was run). - Named workspace profiles: save/switch multiple tab+layout sets (extends the shipped workspace persistence). -> **Candidate gaps (needs follow-up research before promoting):** bundled X-server, terminal scripting/automation (e.g. Python), output triggers + tmux integration, and in-app VNC (RDP shipped in 0.1.33 — VNC would reuse the same Rust-bridge pattern). Unverified this pass. +> **Candidate gaps (needs follow-up research before promoting):** bundled X-server, terminal scripting/automation (e.g. Python), and output triggers + tmux integration. Unverified this pass. ## P2 — Team / Enterprise (when traction exists) @@ -72,8 +74,8 @@ _All P0 items shipped. See P1 for next priorities._ ## Top 3 suggestions for the next sprint 1. **Connection proxy support** (P1 Security) — HTTP CONNECT / SOCKS5 per host for restricted networks; the natural complement to the shipped multi-hop jump chain. -2. **OSC 52 clipboard** (P1 Terminal UX) — let remote apps (tmux, vim) write to the local clipboard through the escape sequence; xterm fork addition, opt-in per host for safety; small, focused, high-visibility with no server-side requirements. -3. **Docker panel completion** (P1 Workflow) — logs, restart/stop, Compose awareness; finishes the container story (browser + exec shipped 0.1.12, K8s logs + port-forward shipped 0.1.34). +2. **Docker panel completion** (P1 Workflow) — logs, restart/stop, Compose awareness; finishes the container story (browser + exec shipped 0.1.12, K8s logs + port-forward shipped 0.1.34). +3. **Log tail viewer** (P1 Terminal UX) — multi-file `tail -f` with highlight rules, level filter, pause/resume, save view; pairs naturally with the just-shipped keyword highlighting. --- diff --git a/docs/superpowers/plans/2026-06-16-vnc-core-crate.md b/docs/superpowers/plans/2026-06-16-vnc-core-crate.md new file mode 100644 index 00000000..8acfe24f --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-vnc-core-crate.md @@ -0,0 +1,901 @@ +# VNC Core Crate (Milestone 1) 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:** Build `packages/yourssh_vnc` — a Rust VNC (RFB) client wrapping the `vnc-rs` crate behind flutter_rust_bridge v2, exposing a `StreamSink` of connect/framebuffer/disconnect events — with no Flutter app wiring yet. + +**Architecture:** Mirror `packages/yourssh_rdp` one-to-one. A tokio run loop drives `vnc-rs`'s `recv_event()` and translates each event into a `VncEvent` pushed to Dart over a `StreamSink`. Pixel data arrives as self-contained RGBA patches (`RawImage(Rect, data)`); the Dart side (Milestone 2) assembles the framebuffer. The native lib is built via `build.sh` and gitignored, resolved at runtime by `NativeLoader`. + +**Tech Stack:** Rust (`vnc-rs` 0.5.3, tokio, flutter_rust_bridge v2), Dart/Flutter. + +**Reference reading for the executor (do before Task 2):** +- The template being mirrored: `packages/yourssh_rdp/rust/src/{api,session,connect,run_loop}.rs`, `packages/yourssh_rdp/build.sh`, `packages/yourssh_rdp/lib/src/{native_loader,rdp_client}.dart`, `packages/yourssh_rdp/test/frb_roundtrip_test.dart`, `packages/yourssh_rdp/flutter_rust_bridge.yaml`. This plan is structurally identical with RDP→VNC substitutions. +- `vnc-rs` docs: https://docs.rs/vnc-rs/latest/vnc/ — crate name is `vnc-rs`, **library name is `vnc`** (all paths are `vnc::...`). The `example/src/main.rs` in https://github.com/HsuJv/vnc-rs is the canonical connect + event-loop snippet. +- **Known FRB limitation (drives the design):** a function taking a `StreamSink` parameter is generated as a Dart function returning `Stream`; **its Rust return value is discarded** (FRB issue #2233). The session id therefore travels as the first stream event (`VncEvent::Started`), never as a return value — identical to RDP. + +**Milestone-1 scope decisions (deliberate, documented — not silent caps):** +- Encodings negotiated: **Zrle + Raw only.** Both surface as `VncEvent::RawImage` (self-contained RGBA patch), so M1 needs no Rust-side framebuffer, no CopyRect blit, and no Tight/JPEG decode. CopyRect + Tight/JPEG (which require a Rust framebuffer and a JPEG decoder) are a later milestone. +- Client-initiated resize (`SetDesktopSize`/`ExtendedDesktopSize`) is **not in `vnc-rs`** — deferred to the parity milestone (needs a local fork). M1 only *receives* server-driven size via `VncEvent::SetResolution`. +- Input (mouse/keyboard) and clipboard-send are **not** in M1 (Milestones 3/4). M1 emits inbound clipboard (`Text`) and the bell, and implements only `Disconnect` as an inbound command. + +--- + +## Phase 0 — Package scaffolding + +### Task 1: Scaffold `packages/yourssh_vnc` + +**Files:** +- Create: `packages/yourssh_vnc/pubspec.yaml` +- Create: `packages/yourssh_vnc/lib/yourssh_vnc.dart` +- Create: `packages/yourssh_vnc/lib/src/.gitkeep` +- Create: `packages/yourssh_vnc/flutter_rust_bridge.yaml` +- Create: `packages/yourssh_vnc/rust/Cargo.toml` +- Create: `packages/yourssh_vnc/rust/src/lib.rs` +- Modify: `app/pubspec.yaml` +- Modify: `.gitignore` + +- [ ] **Step 1: Create the Dart package skeleton** + +`packages/yourssh_vnc/pubspec.yaml`: + +```yaml +name: yourssh_vnc +description: In-app VNC (RFB) client for yourssh (vnc-rs via flutter_rust_bridge). +version: 0.1.0 +publish_to: none + +environment: + sdk: ^3.12.0 + flutter: ">=3.24.0" + +dependencies: + flutter: + sdk: flutter + flutter_rust_bridge: ^2.12.0 + ffi: ^2.1.3 + freezed_annotation: ^2.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + freezed: ^2.0.0 + build_runner: ^2.0.0 +``` + +`packages/yourssh_vnc/lib/yourssh_vnc.dart`: + +```dart +library yourssh_vnc; + +export 'src/vnc_client.dart'; +``` + +Create an empty `packages/yourssh_vnc/lib/src/.gitkeep` (the `generated/` and `vnc_client.dart` land in later tasks). + +- [ ] **Step 2: Create the FRB codegen config** + +`packages/yourssh_vnc/flutter_rust_bridge.yaml`: + +```yaml +rust_input: crate::api +rust_root: rust +dart_output: lib/src/generated +``` + +- [ ] **Step 3: Create the Rust crate manifest** + +`packages/yourssh_vnc/rust/Cargo.toml`: + +```toml +[package] +name = "yourssh_vnc" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "staticlib"] + +[profile.release] +strip = true +lto = "thin" +codegen-units = 1 + +[dependencies] +flutter_rust_bridge = "=2.12.0" +vnc-rs = "0.5.3" +tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "time"] } +anyhow = "1" +futures = "0.3" + +[dev-dependencies] +tokio = { version = "1", features = ["test-util"] } +``` + +- [ ] **Step 4: Create the Rust lib module list** + +`packages/yourssh_vnc/rust/src/lib.rs`: + +```rust +pub mod api; +pub mod connect; +pub mod run_loop; +pub mod session; +mod frb_generated; +``` + +(`frb_generated` does not exist yet — `cargo` will not compile until Task 6 runs codegen. That is expected; the next tasks create the modules it lists.) + +- [ ] **Step 5: Wire the app dependency** + +In `app/pubspec.yaml`, directly below the existing `yourssh_rdp` path dependency (around line 79-80), add: + +```yaml + yourssh_vnc: + path: ../packages/yourssh_vnc +``` + +- [ ] **Step 6: Gitignore the built native libs** + +In `.gitignore`, below the existing `yourssh_rdp` entries (around line 3-7), add: + +```gitignore +/packages/yourssh_vnc/rust/target/ + +# Built native VNC libraries — rebuilt locally via packages/yourssh_vnc/build.sh +/packages/yourssh_vnc/assets/native/ +``` + +- [ ] **Step 7: Commit** + +```bash +git add packages/yourssh_vnc/pubspec.yaml packages/yourssh_vnc/lib packages/yourssh_vnc/flutter_rust_bridge.yaml packages/yourssh_vnc/rust/Cargo.toml packages/yourssh_vnc/rust/src/lib.rs app/pubspec.yaml .gitignore +git commit -m "feat(vnc): scaffold yourssh_vnc package" +``` + +--- + +## Phase 1 — Rust crate + +### Task 2: Session registry + +**Files:** +- Create: `packages/yourssh_vnc/rust/src/session.rs` + +- [ ] **Step 1: Write the session module with the registry test** + +`packages/yourssh_vnc/rust/src/session.rs`: + +```rust +use std::collections::HashMap; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Mutex; + +use tokio::sync::mpsc::UnboundedSender; + +/// Commands Dart can push into a running session loop. Milestone 1 only needs +/// Disconnect; input/clipboard-send commands are added in later milestones. +pub enum SessionCmd { + Disconnect, +} + +static NEXT_ID: AtomicU32 = AtomicU32::new(1); +static SESSIONS: Mutex>>> = Mutex::new(None); + +pub mod registry { + use super::*; + + pub fn insert(tx: UnboundedSender) -> u32 { + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + SESSIONS.lock().unwrap().get_or_insert_with(HashMap::new).insert(id, tx); + id + } + + pub fn send(id: u32, cmd: SessionCmd) -> bool { + let guard = SESSIONS.lock().unwrap(); + match guard.as_ref().and_then(|m| m.get(&id)) { + Some(tx) => tx.send(cmd).is_ok(), + None => false, + } + } + + pub fn remove(id: u32) { + if let Some(m) = SESSIONS.lock().unwrap().as_mut() { + m.remove(&id); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_insert_send_remove() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let id = registry::insert(tx); + assert!(registry::send(id, SessionCmd::Disconnect)); + assert!(matches!(rx.try_recv().unwrap(), SessionCmd::Disconnect)); + registry::remove(id); + assert!(!registry::send(id, SessionCmd::Disconnect)); + } +} +``` + +- [ ] **Step 2: Verify the module compiles in isolation** + +The crate cannot build fully until codegen (Task 6), so check just this module's syntax with the standalone test in Task 3's run after `frb_generated` exists. For now, confirm there are no obvious errors by reading the file. (No command yet — `cargo` needs `frb_generated`.) + +- [ ] **Step 3: Commit** + +```bash +git add packages/yourssh_vnc/rust/src/session.rs +git commit -m "feat(vnc): session command registry" +``` + +### Task 3: Run-loop pure helpers (TDD) + +**Files:** +- Create: `packages/yourssh_vnc/rust/src/run_loop.rs` (helpers + tests only in this task; the async loop is added in Task 5) + +- [ ] **Step 1: Write the failing tests for the two pure helpers** + +`packages/yourssh_vnc/rust/src/run_loop.rs`: + +```rust +use std::time::Duration; + +use tokio::sync::mpsc::UnboundedReceiver; +use vnc::{PixelFormat, VncEncoding, VncError, VncEvent, X11Event}; + +use crate::api::{VncConfig, VncEvent as ApiEvent}; +use crate::connect::vnc_connect_stage; +use crate::session::SessionCmd; + +/// How often the loop pumps an incremental framebuffer-update request. +/// vnc-rs sends the initial full request itself; everything after that is +/// driven by us (the crate has no auto-continuous refresh). +const REFRESH_INTERVAL_MS: u64 = 16; + +/// Forces every pixel's alpha byte to 0xFF. VNC servers typically send 32bpp +/// pixels with the padding (alpha) byte zeroed; rendered as RGBA8888 that is +/// fully transparent, so the patch must be made opaque before it reaches Dart. +pub fn set_opaque(rgba: &mut [u8]) { + for i in (3..rgba.len()).step_by(4) { + rgba[i] = 0xFF; + } +} + +/// Maps a `vnc-rs` error to a graceful disconnect reason, or `None` if it is a +/// real error that should surface as `VncEvent::Error`. A closed event channel +/// (`ClientNotRunning`) is the normal end-of-session signal. +pub fn disconnect_reason(e: &VncError) -> Option { + match e { + VncError::ClientNotRunning => Some("connection closed".to_string()), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn set_opaque_sets_every_fourth_byte() { + let mut buf = vec![10, 20, 30, 0, 40, 50, 60, 0]; + set_opaque(&mut buf); + assert_eq!(buf, vec![10, 20, 30, 0xFF, 40, 50, 60, 0xFF]); + } + + #[test] + fn set_opaque_handles_empty() { + let mut buf: Vec = vec![]; + set_opaque(&mut buf); + assert!(buf.is_empty()); + } + + #[test] + fn disconnect_reason_graceful_on_not_running() { + assert_eq!( + disconnect_reason(&VncError::ClientNotRunning), + Some("connection closed".to_string()) + ); + } + + #[test] + fn disconnect_reason_none_on_real_error() { + assert!(disconnect_reason(&VncError::General("boom".into())).is_none()); + assert!(disconnect_reason(&VncError::WrongPassword).is_none()); + } +} +``` + +(The `use` lines reference `api`, `connect`, and the async loop added in Tasks 4-5. They will not compile until those exist; the tests are run together after Task 6.) + +- [ ] **Step 2: Commit** + +```bash +git add packages/yourssh_vnc/rust/src/run_loop.rs +git commit -m "test(vnc): run-loop pure helpers (opaque alpha, disconnect classify)" +``` + +### Task 4: API surface — config, events, entrypoints + +**Files:** +- Create: `packages/yourssh_vnc/rust/src/api.rs` + +- [ ] **Step 1: Write the API module** + +`packages/yourssh_vnc/rust/src/api.rs`: + +```rust +use futures::FutureExt; +use tokio::sync::mpsc; + +use crate::frb_generated::StreamSink; +use crate::session::{registry, SessionCmd}; + +#[derive(Clone)] +pub struct VncConfig { + pub target_host: String, + pub target_port: u16, + /// Unused by classic VNC auth (password-only) but carried for parity with + /// the app's Host model and future auth schemes. + pub username: String, + pub password: String, +} + +#[derive(Clone)] +pub enum VncEvent { + /// Always the first event. Carries the session id (FRB discards the Rust + /// return value of StreamSink-taking functions — issue #2233). + Started { session_id: u32 }, + /// Connection established. Carries the server's initial framebuffer size; + /// the Dart framebuffer must be sized from these values. + Connected { width: u16, height: u16 }, + /// Server-driven desktop-size change after connect (e.g. a resized X session). + Resize { width: u16, height: u16 }, + /// A self-contained RGBA patch at (x, y) of the given size. `rgba` length is + /// width * height * 4, alpha forced opaque. + FrameUpdate { x: u16, y: u16, width: u16, height: u16, rgba: Vec }, + /// Server clipboard (cut-text). Latin-1 from the server. + ClipboardText { text: String }, + /// Server bell. + Bell, + Disconnected { reason: String }, + Error { message: String }, +} + +pub fn vnc_lib_version() -> String { + format!("yourssh_vnc {}", env!("CARGO_PKG_VERSION")) +} + +static RUNTIME: std::sync::OnceLock = std::sync::OnceLock::new(); + +fn runtime() -> &'static tokio::runtime::Runtime { + RUNTIME.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .expect("tokio runtime") + }) +} + +/// Removes the registry entry even if the spawned future is dropped before +/// completing (runtime shutdown, cancellation). +struct RemoveOnDrop(u32); + +impl Drop for RemoveOnDrop { + fn drop(&mut self) { + registry::remove(self.0); + } +} + +pub fn vnc_connect(config: VncConfig, sink: StreamSink) { + let (tx, rx) = mpsc::unbounded_channel::(); + let id = registry::insert(tx); + let _ = sink.add(VncEvent::Started { session_id: id }); + let emit_sink = sink.clone(); + let panic_sink = sink.clone(); + let emit = move |ev: VncEvent| { + let _ = emit_sink.add(ev); + }; + runtime().spawn(async move { + let _guard = RemoveOnDrop(id); + let result = std::panic::AssertUnwindSafe(crate::run_loop::run_session(config, rx, emit)) + .catch_unwind() + .await; + if let Err(_panic) = result { + let _ = panic_sink.add(VncEvent::Error { + message: "vnc session panicked".into(), + }); + } + }); +} + +pub fn vnc_disconnect(session_id: u32) { + registry::send(session_id, SessionCmd::Disconnect); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/yourssh_vnc/rust/src/api.rs +git commit -m "feat(vnc): FRB api surface (VncConfig, VncEvent, connect/disconnect)" +``` + +### Task 5: Connect stage + run loop + +**Files:** +- Create: `packages/yourssh_vnc/rust/src/connect.rs` +- Modify: `packages/yourssh_vnc/rust/src/run_loop.rs` (append the async loop below the helpers) + +- [ ] **Step 1: Write the connect stage** + +`packages/yourssh_vnc/rust/src/connect.rs`: + +```rust +use anyhow::Context; +use tokio::net::TcpStream; +use vnc::{PixelFormat, VncClient, VncConnector, VncEncoding}; + +use crate::api::VncConfig; + +/// Dials the server, performs the RFB handshake + auth, and negotiates the +/// Milestone-1 encoding set (Zrle + Raw — both surface as self-contained RGBA +/// patches). Returns a connected `VncClient`. +pub async fn vnc_connect_stage(cfg: &VncConfig) -> anyhow::Result { + let addr = format!("{}:{}", cfg.target_host, cfg.target_port); + let tcp = TcpStream::connect(&addr).await.context("TCP connect")?; + + // The password future is only polled if the server requires VNC auth; for + // a "None"-auth server vnc-rs never calls it. + let password = cfg.password.clone(); + let client = VncConnector::new(tcp) + .set_auth_method(async move { Ok::<_, vnc::VncError>(password) }) + .add_encoding(VncEncoding::Zrle) + .add_encoding(VncEncoding::Raw) + .allow_shared(true) + .set_pixel_format(PixelFormat::rgba()) + .build() + .context("build VNC connector")? + .try_start() + .await + .context("VNC handshake/auth")? + .finish() + .context("finish VNC connect")?; + + Ok(client) +} +``` + +- [ ] **Step 2: Append the async run loop to `run_loop.rs`** + +Add below the existing helpers in `packages/yourssh_vnc/rust/src/run_loop.rs` (after the `disconnect_reason` fn, before `#[cfg(test)]`): + +```rust +pub async fn run_session( + cfg: VncConfig, + mut cmd_rx: UnboundedReceiver, + sink: impl Fn(ApiEvent) + Send + Sync + 'static, +) { + match run_session_inner(cfg, &mut cmd_rx, &sink).await { + Ok(reason) => sink(ApiEvent::Disconnected { reason }), + Err(e) => sink(ApiEvent::Error { message: format!("{e:#}") }), + } +} + +async fn run_session_inner( + cfg: VncConfig, + cmd_rx: &mut UnboundedReceiver, + sink: &(impl Fn(ApiEvent) + Send + Sync), +) -> anyhow::Result { + let vnc = vnc_connect_stage(&cfg).await?; + + let mut connected = false; + let mut refresh = tokio::time::interval(Duration::from_millis(REFRESH_INTERVAL_MS)); + + loop { + tokio::select! { + ev = vnc.recv_event() => { + match ev { + Ok(VncEvent::SetResolution(screen)) => { + if !connected { + connected = true; + sink(ApiEvent::Connected { width: screen.width, height: screen.height }); + } else { + sink(ApiEvent::Resize { width: screen.width, height: screen.height }); + } + } + Ok(VncEvent::RawImage(rect, mut data)) => { + set_opaque(&mut data); + sink(ApiEvent::FrameUpdate { + x: rect.x, y: rect.y, width: rect.width, height: rect.height, rgba: data, + }); + } + Ok(VncEvent::Text(text)) => sink(ApiEvent::ClipboardText { text }), + Ok(VncEvent::Bell) => sink(ApiEvent::Bell), + // Not negotiated / not handled in Milestone 1: + // Copy (CopyRect), JpegImage (Tight), SetCursor, SetPixelFormat. + Ok(_) => {} + Err(e) => { + return match disconnect_reason(&e) { + Some(reason) => Ok(reason), + None => Err(e.into()), + }; + } + } + } + cmd = cmd_rx.recv() => { + match cmd { + None | Some(SessionCmd::Disconnect) => { + let _ = vnc.close().await; + return Ok("disconnected by user".into()); + } + } + } + _ = refresh.tick() => { + // Request the next incremental update. Ignore send errors — a + // dead client surfaces on the next recv_event(). + let _ = vnc.input(X11Event::Refresh).await; + } + } + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/yourssh_vnc/rust/src/connect.rs packages/yourssh_vnc/rust/src/run_loop.rs +git commit -m "feat(vnc): connect stage + event run loop" +``` + +### Task 6: Generate FRB bindings and verify the crate builds + tests + +**Files:** +- Create (generated): `packages/yourssh_vnc/lib/src/generated/**`, `packages/yourssh_vnc/rust/src/frb_generated.rs` + +- [ ] **Step 1: Run flutter_rust_bridge codegen** + +Run: `cd packages/yourssh_vnc && flutter_rust_bridge_codegen generate` +(The user's shell aliases this as `n generate` — either works. Install if missing: `cargo install flutter_rust_bridge_codegen --version 2.12.0 --locked`.) +Expected: creates `lib/src/generated/{api.dart,frb_generated.dart,frb_generated.io.dart,api.freezed.dart}` and `rust/src/frb_generated.rs`, with no errors. + +- [ ] **Step 2: Type-check the Rust crate** + +Run: `cd packages/yourssh_vnc/rust && cargo check` +Expected: compiles clean (warnings about the unused `username` field are acceptable). If `VncEncoding`/`VncEvent`/`X11Event`/`PixelFormat`/`Screen`/`Rect` paths differ from those used here, fix the imports against `vnc-rs` 0.5.3's actual exports (they are re-exported at the `vnc::` root). + +- [ ] **Step 3: Run the Rust unit tests** + +Run: `cd packages/yourssh_vnc/rust && cargo test` +Expected: PASS — `registry_insert_send_remove`, `set_opaque_sets_every_fourth_byte`, `set_opaque_handles_empty`, `disconnect_reason_graceful_on_not_running`, `disconnect_reason_none_on_real_error`. + +- [ ] **Step 4: Commit the generated bindings** + +```bash +git add packages/yourssh_vnc/lib/src/generated packages/yourssh_vnc/rust/src/frb_generated.rs +git commit -m "feat(vnc): generate flutter_rust_bridge bindings" +``` + +--- + +## Phase 2 — Build script + Dart facade + +### Task 7: Native build script + +**Files:** +- Create: `packages/yourssh_vnc/build.sh` + +- [ ] **Step 1: Write the build script (macOS universal + Linux)** + +`packages/yourssh_vnc/build.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/rust" +case "$(uname -s)" in + Darwin) + # Universal dylib (Apple Silicon + Intel): build both targets and lipo + # them together so the one shipped .app runs on either architecture. + rustup target add aarch64-apple-darwin x86_64-apple-darwin + cargo build --release --target aarch64-apple-darwin + cargo build --release --target x86_64-apple-darwin + cd .. + mkdir -p assets/native/macos + lipo -create \ + rust/target/aarch64-apple-darwin/release/libyourssh_vnc.dylib \ + rust/target/x86_64-apple-darwin/release/libyourssh_vnc.dylib \ + -output assets/native/macos/libyourssh_vnc.dylib ;; + Linux) + cargo build --release + cd .. + mkdir -p assets/native/linux + cp rust/target/release/libyourssh_vnc.so assets/native/linux/ ;; +esac +echo "yourssh_vnc native library built" +``` + +- [ ] **Step 2: Make it executable and commit** + +```bash +chmod +x packages/yourssh_vnc/build.sh +git add packages/yourssh_vnc/build.sh +git commit -m "feat(vnc): native build script (macOS universal + Linux)" +``` + +(Windows `build.ps1` mirrors `packages/yourssh_rdp/build.ps1` and is added when Windows support is needed; M1's target dev platform is macOS.) + +### Task 8: Dart native loader + client facade + +**Files:** +- Create: `packages/yourssh_vnc/lib/src/native_loader.dart` +- Create: `packages/yourssh_vnc/lib/src/vnc_client.dart` + +- [ ] **Step 1: Write the native loader** + +`packages/yourssh_vnc/lib/src/native_loader.dart`: + +```dart +import 'dart:io'; + +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +/// Locates the yourssh_vnc dynamic library. Search order: bundled release +/// locations (relative to the running executable), plain name (rpath / +/// system lookup), then repo-relative dev paths. +ExternalLibrary loadYoursshVncLibrary() { + Object? lastError; + for (final path in _candidates()) { + try { + return ExternalLibrary.open(path); + } catch (e) { + lastError = e; + } + } + throw StateError('yourssh_vnc native library not found: $lastError'); +} + +List _candidates() { + final exeDir = File(Platform.resolvedExecutable).parent.path; + if (Platform.isMacOS) { + return [ + '${File(Platform.resolvedExecutable).parent.parent.path}/Frameworks/libyourssh_vnc.dylib', + 'libyourssh_vnc.dylib', + '${Directory.current.path}/assets/native/macos/libyourssh_vnc.dylib', + '${Directory.current.path}/packages/yourssh_vnc/assets/native/macos/libyourssh_vnc.dylib', + '${Directory.current.path}/../packages/yourssh_vnc/assets/native/macos/libyourssh_vnc.dylib', + ]; + } + if (Platform.isLinux) { + return [ + '$exeDir/lib/libyourssh_vnc.so', + 'libyourssh_vnc.so', + '${Directory.current.path}/assets/native/linux/libyourssh_vnc.so', + '${Directory.current.path}/packages/yourssh_vnc/assets/native/linux/libyourssh_vnc.so', + '${Directory.current.path}/../packages/yourssh_vnc/assets/native/linux/libyourssh_vnc.so', + ]; + } + return [ + '$exeDir\\yourssh_vnc.dll', + 'yourssh_vnc.dll', + '${Directory.current.path}\\assets\\native\\windows\\yourssh_vnc.dll', + '${Directory.current.path}\\packages\\yourssh_vnc\\assets\\native\\windows\\yourssh_vnc.dll', + '${Directory.current.path}\\..\\packages\\yourssh_vnc\\assets\\native\\windows\\yourssh_vnc.dll', + ]; +} +``` + +- [ ] **Step 2: Write the client facade** + +`packages/yourssh_vnc/lib/src/vnc_client.dart`: + +```dart +import 'dart:async'; + +import 'generated/api.dart'; +import 'generated/frb_generated.dart'; +import 'native_loader.dart'; + +export 'generated/api.dart' show VncConfig, VncEvent; + +/// Lightweight typed facade over the generated FRB bindings. +/// +/// Usage: +/// ```dart +/// await VncClient.ensureInitialized(); +/// final client = VncClient(VncConfig(...)); +/// client.events.listen((event) { ... }); +/// await client.connect(); +/// await client.disconnect(); +/// await client.done; +/// ``` +class VncClient { + final VncConfig config; + + int? _sessionId; + bool _disconnectRequested = false; + StreamSubscription? _sub; + final _eventCtrl = StreamController.broadcast(); + final _done = Completer(); + + VncClient(this.config); + + static Future? _initFuture; + + /// Initializes the Rust bridge exactly once (loads the native library on + /// first use). Safe to call repeatedly; a failed attempt resets so the next + /// call retries. + static Future ensureInitialized() { + return _initFuture ??= + RustLib.init(externalLibrary: loadYoursshVncLibrary()).catchError((Object e) { + _initFuture = null; + throw e; + }); + } + + Stream get events => _eventCtrl.stream; + + /// Completes when the session is fully torn down (after Disconnected or Error). + Future get done => _done.future; + + bool get isConnected => _sessionId != null; + + /// Start the VNC session. The returned future completes once the first + /// [VncEvent.connected] arrives, or throws if the connection fails first. + Future connect() async { + if (_sessionId != null) throw StateError('Already connected'); + await ensureInitialized(); + + final connectedCompleter = Completer(); + + _sub = vncConnect(config: config).listen( + (event) { + switch (event) { + case VncEvent_Started(:final sessionId): + _sessionId = sessionId; + if (_disconnectRequested) { + unawaited(vncDisconnect(sessionId: sessionId)); + } + case VncEvent_Connected(): + if (!connectedCompleter.isCompleted) connectedCompleter.complete(); + case VncEvent_Disconnected(:final reason): + _finish(event, connectedCompleter, reason); + return; + case VncEvent_Error(:final message): + _finish(event, connectedCompleter, message); + return; + default: + break; + } + _eventCtrl.add(event); + }, + onError: (Object err) { + _sessionId = null; + if (!_done.isCompleted) _done.completeError(err); + if (!connectedCompleter.isCompleted) connectedCompleter.completeError(err); + }, + cancelOnError: false, + ); + + return connectedCompleter.future; + } + + void _finish(VncEvent event, Completer connectedCompleter, String reason) { + _sessionId = null; + _eventCtrl.add(event); + _sub?.cancel(); + _sub = null; + if (!_done.isCompleted) _done.complete(); + if (!connectedCompleter.isCompleted) { + connectedCompleter.completeError( + Exception(reason.isEmpty ? 'connection failed' : reason), + ); + } + } + + Future disconnect() async { + final id = _sessionId; + if (id == null) { + _disconnectRequested = true; + return; + } + await vncDisconnect(sessionId: id); + await done; + } + + void dispose() { + _sub?.cancel(); + _sub = null; + _eventCtrl.close(); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/yourssh_vnc/lib/src/native_loader.dart packages/yourssh_vnc/lib/src/vnc_client.dart +git commit -m "feat(vnc): Dart native loader + VncClient facade" +``` + +### Task 9: FRB roundtrip test + build the native lib + run it + +**Files:** +- Create: `packages/yourssh_vnc/test/frb_roundtrip_test.dart` + +- [ ] **Step 1: Write the roundtrip test** + +`packages/yourssh_vnc/test/frb_roundtrip_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh_vnc/src/generated/frb_generated.dart'; +import 'package:yourssh_vnc/src/generated/api.dart'; +import 'package:yourssh_vnc/src/native_loader.dart'; + +void main() { + setUpAll(() async { + await RustLib.init(externalLibrary: loadYoursshVncLibrary()); + }); + + test('vncLibVersion returns crate version', () async { + expect(await vncLibVersion(), startsWith('yourssh_vnc 0.1.0')); + }); +} +``` + +- [ ] **Step 2: Build the native library** + +Run: `bash packages/yourssh_vnc/build.sh` +Expected: prints `yourssh_vnc native library built`; produces `packages/yourssh_vnc/assets/native/macos/libyourssh_vnc.dylib` (or `linux/libyourssh_vnc.so`). + +- [ ] **Step 3: macOS only — rewrite the dylib to a fresh inode** + +On this Mac, a dylib produced by sandboxed Bash carries a `com.apple.provenance` record that makes macOS SIGKILL any process that `mmap()`s it (so `flutter test`'s `dlopen` dies with exit 137). Rewrite the bytes to a clean inode. Run (sandbox disabled): + +```bash +python3 - <<'PY' +import os +p = "packages/yourssh_vnc/assets/native/macos/libyourssh_vnc.dylib" +data = open(p, "rb").read() +open(p + ".clean", "wb").write(data) +os.chmod(p + ".clean", 0o755) +os.replace(p + ".clean", p) +print("rewrote", p, len(data), "bytes") +PY +``` + +(Skip this step on Linux. See the `macos-provenance-mmap-kill` project memory.) + +- [ ] **Step 4: Run the Dart roundtrip test** + +Run: `cd packages/yourssh_vnc && flutter test` +Expected: PASS — `vncLibVersion returns crate version`. (If it dies with exit code 137, re-run Step 3 — the dlopen hit the provenance kill.) + +- [ ] **Step 5: Commit** + +```bash +git add packages/yourssh_vnc/test/frb_roundtrip_test.dart +git commit -m "test(vnc): FRB roundtrip (vncLibVersion over the native bridge)" +``` + +--- + +## Done criteria for Milestone 1 + +- `cd packages/yourssh_vnc/rust && cargo test` passes (registry + opaque + disconnect-classify). +- `cd packages/yourssh_vnc && flutter test` passes (FRB roundtrip dlopens the real lib). +- `packages/yourssh_vnc` exposes `VncClient` with `ensureInitialized()` / `connect()` / `events` / `disconnect()` / `done` and emits `VncEvent.{started,connected,resize,frameUpdate,clipboardText,bell,disconnected,error}`. +- No Flutter app wiring yet (that is Milestone 2: `VncSession`, `SessionProvider.connectVnc`, `HostProtocol.vnc`, `VncWorkspace`). + +## Self-review notes (addressed) + +- **Spec coverage:** M1 spec deliverables (crate over `vnc-rs`, connect + RFB handshake + None/VNC-password auth, framebuffer-update loop, `VncEvent` bus, `native_loader`, `build.sh`, Rust unit + Dart FRB roundtrip tests) each map to Tasks 2-9. ✓ +- **Deferred-with-reason (not gaps):** CopyRect, Tight/JPEG, client-resize, and input/clipboard-send are explicitly scoped out of M1 above and assigned to later milestones in the design spec. ✓ +- **Type consistency:** `VncConfig`/`VncEvent` field and variant names are identical across `api.rs`, `run_loop.rs` (`ApiEvent` alias), and `vnc_client.dart` (`VncEvent_Started`/`_Connected`/`_Disconnected`/`_Error`). `vnc_connect_stage` is defined in `connect.rs` and consumed in `run_loop.rs`. `set_opaque`/`disconnect_reason` defined and tested in Task 3, used in Task 5. ✓ +- **`vnc-rs` API risk:** exact symbol paths (`vnc::VncConnector`, `vnc::VncEncoding`, `vnc::VncEvent`, `vnc::X11Event`, `vnc::PixelFormat`) are from the 0.5.3 source; Task 6 Step 2 calls out fixing any drift against the pinned crate before proceeding. diff --git a/docs/superpowers/plans/2026-06-17-vnc-m2-end-to-end-view-only.md b/docs/superpowers/plans/2026-06-17-vnc-m2-end-to-end-view-only.md new file mode 100644 index 00000000..852e9ccc --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-vnc-m2-end-to-end-view-only.md @@ -0,0 +1,1267 @@ +# VNC Milestone 2 — End-to-End View-Only 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:** Wire the M1 `yourssh_vnc` crate into the Flutter app so a user can create a VNC host, connect to it directly (no SSH tunnel yet), and watch the live remote framebuffer in a tab — view-only (no mouse/keyboard input). + +**Architecture:** Mirror the existing RDP app integration one-to-one. Add `HostProtocol.vnc`; a `VncSession` (`ChangeNotifier implements AppSession`) that owns a `VncClient`, reallocates its framebuffer to the server-negotiated size, and decodes frames latest-wins into a `ui.Image`; a `SessionProvider.connectVnc` reached through the existing `connectAny` router; a minimal `VncWorkspace` that paints the image via a `CustomPainter`; and a `VncSession → VncWorkspace` branch in `MainScreen`. VNC has **no TLS/cert layer**, so all RDP cert-pinning wiring is deliberately omitted. + +**Tech Stack:** Flutter (Dart), `provider`, `package:yourssh_vnc` (flutter_rust_bridge v2), `dart:ui` image decode. + +**Scope boundary (do NOT build here — later milestones):** mouse/keyboard input (M3); clipboard, auto-resize UI, SSH tunnel via loopback proxy, fullscreen, generalized protocol badge, dashboard polish (M4); integration screenshots (M5). Direct (untunneled) connections only. + +**Reference files (read for patterns, do not modify unless a task says so):** +- `app/lib/models/rdp_session.dart` — the model this mirrors +- `app/lib/providers/session_provider.dart` — `connectRdp` (lines 181–272), `_watchRdpStatus` (276–323), `connectAny` (176–179), `closeSession` RDP branch (504–530), `_metadataHostId` (611–615), `reconnectRdp` (325–330) +- `app/lib/widgets/rdp_workspace.dart` — render pipeline (`_FramePainter`, lines 410–438) +- `app/lib/widgets/host_detail_panel.dart` — protocol selector + `_isRdp` gating +- `packages/yourssh_vnc/rust/src/api.rs` — the exact `VncConfig`/`VncEvent` shapes + +**`VncEvent` / `VncConfig` shapes (from `api.rs`, what the generated Dart exposes):** +- `VncConfig(targetHost: String, targetPort: int, username: String, password: String)` — **no width/height** (the server dictates framebuffer size). +- Sealed `VncEvent` factory constructors (for tests): `VncEvent.started(sessionId: int)`, `VncEvent.connected(width: int, height: int)`, `VncEvent.resize(width: int, height: int)`, `VncEvent.frameUpdate(x: int, y: int, width: int, height: int, rgba: Uint8List)`, `VncEvent.clipboardText(text: String)`, `VncEvent.bell()`, `VncEvent.disconnected(reason: String)`, `VncEvent.error(message: String)`. +- Pattern-match subclasses (for the model `switch`): `VncEvent_Started`, `VncEvent_Connected`, `VncEvent_Resize`, `VncEvent_FrameUpdate`, `VncEvent_ClipboardText`, `VncEvent_Bell`, `VncEvent_Disconnected`, `VncEvent_Error`. These live in `package:yourssh_vnc/src/generated/api.dart` (import with `// ignore: implementation_imports`, alias `frb`), exactly like `rdp_session.dart`. + +--- + +## Task 1: Add `HostProtocol.vnc` + +**Files:** +- Modify: `app/lib/models/host.dart:12-21` (the `HostProtocol` enum) +- Test: `app/test/models/host_vnc_test.dart` (create) + +Serialization already works for any enum value: `toJson` writes `protocol.name`, and `fromJson`'s `parseProtocol()` uses `HostProtocol.values.asNameMap()[name] ?? HostProtocol.ssh`. Adding the variant is all that's needed — these tests lock that in. + +- [ ] **Step 1: Write the failing test** + +Create `app/test/models/host_vnc_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/host.dart'; + +void main() { + test('vnc default port is 5900', () { + expect(HostProtocol.vnc.defaultPort, 5900); + }); + + test('vnc protocol round-trips through json', () { + final h = Host( + label: 'desktop', + host: '10.0.0.5', + port: 5900, + username: 'u', + protocol: HostProtocol.vnc, + ); + final json = h.toJson(); + expect(json['protocol'], 'vnc'); + + final back = Host.fromJson(json); + expect(back.protocol, HostProtocol.vnc); + expect(back.port, 5900); + }); + + test('unknown protocol still falls back to ssh', () { + final back = Host.fromJson({ + 'id': 'x', + 'label': 'l', + 'host': 'h', + 'port': 22, + 'username': 'u', + 'protocol': 'telnet', + }); + expect(back.protocol, HostProtocol.ssh); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd app && flutter test test/models/host_vnc_test.dart` +Expected: FAIL — compile error, `vnc` is not a member of `HostProtocol`. + +- [ ] **Step 3: Add the enum variant** + +In `app/lib/models/host.dart`, change the enum (lines 12–21) from: + +```dart +enum HostProtocol { + ssh(defaultPort: 22), + rdp(defaultPort: 3389); +``` + +to: + +```dart +enum HostProtocol { + ssh(defaultPort: 22), + rdp(defaultPort: 3389), + vnc(defaultPort: 5900); +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd app && flutter test test/models/host_vnc_test.dart` +Expected: PASS — `All tests passed!` + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/models/host.dart app/test/models/host_vnc_test.dart +git commit -m "feat(vnc): add HostProtocol.vnc (default port 5900)" +``` + +--- + +## Task 2: `VncSession` model + +**Files:** +- Create: `app/lib/models/vnc_session.dart` +- Test: `app/test/models/vnc_session_test.dart` + +Mirrors `RdpSession` (`app/lib/models/rdp_session.dart`) minus everything cert/TLS and tunnel-related. The constructor takes **no** width/height — VNC starts at 0×0 and reallocates on the `Connected` event (and on `Resize`). + +- [ ] **Step 1: Write the failing test** + +Create `app/test/models/vnc_session_test.dart`: + +```dart +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/models/vnc_session.dart'; +import 'package:yourssh_vnc/yourssh_vnc.dart' show VncClient, VncConfig; +// ignore: implementation_imports +import 'package:yourssh_vnc/src/generated/api.dart' as frb; + +Host _host() => Host( + id: 'v1', + label: 'desktop', + host: '10.0.0.5', + port: 5900, + username: 'u', + protocol: HostProtocol.vnc); + +// The VncClient constructor only stores config + creates controllers; it does +// NOT load the native library (that happens in ensureInitialized/connect), +// so it is safe to build in a unit test. +VncClient _client() => VncClient(VncConfig( + targetHost: '10.0.0.5', targetPort: 5900, username: 'u', password: '')); + +void main() { + test('status transitions connecting -> connected -> disconnected', () async { + final events = StreamController(); + final s = VncSession(host: _host(), client: _client()); + s.attach(events.stream); + expect(s.status, VncSessionStatus.connecting); + + events.add(const frb.VncEvent.started(sessionId: 1)); + events.add(const frb.VncEvent.connected(width: 800, height: 600)); + await Future.delayed(Duration.zero); + expect(s.status, VncSessionStatus.connected); + expect(s.width, 800); + expect(s.height, 600); + + events.add(const frb.VncEvent.disconnected(reason: 'bye')); + await Future.delayed(Duration.zero); + expect(s.status, VncSessionStatus.disconnected); + expect(s.lastMessage, 'bye'); + await events.close(); + }); + + test('error event sets error status and message', () async { + final events = StreamController(); + final s = VncSession(host: _host(), client: _client()); + s.attach(events.stream); + events.add(const frb.VncEvent.error(message: 'connection refused')); + await Future.delayed(Duration.zero); + expect(s.status, VncSessionStatus.error); + expect(s.lastMessage, 'connection refused'); + await events.close(); + }); + + test('framebuffer reallocates to server size on connected', () async { + final events = StreamController(); + final s = VncSession(host: _host(), client: _client()); + s.attach(events.stream); + events.add(const frb.VncEvent.connected(width: 4, height: 2)); + await Future.delayed(Duration.zero); + expect(s.framebuffer.length, 4 * 2 * 4); + await events.close(); + }); + + test('resize reallocates the framebuffer', () async { + final events = StreamController(); + final s = VncSession(host: _host(), client: _client()); + s.attach(events.stream); + events.add(const frb.VncEvent.connected(width: 4, height: 2)); + await Future.delayed(Duration.zero); + events.add(const frb.VncEvent.resize(width: 8, height: 8)); + await Future.delayed(Duration.zero); + expect(s.width, 8); + expect(s.height, 8); + expect(s.framebuffer.length, 8 * 8 * 4); + await events.close(); + }); + + test('out-of-bounds frame update is dropped without crashing', () async { + final events = StreamController(); + final s = VncSession(host: _host(), client: _client()); + s.attach(events.stream); + events.add(const frb.VncEvent.connected(width: 4, height: 4)); + await Future.delayed(Duration.zero); + events.add(frb.VncEvent.frameUpdate( + x: 3, y: 3, width: 4, height: 4, rgba: Uint8List(4 * 4 * 4))); + await Future.delayed(Duration.zero); + expect(s.framebuffer.length, 4 * 4 * 4); // unchanged, no throw + await events.close(); + }); + + test('frame update patches the framebuffer row-by-row', () async { + final events = StreamController(); + final s = VncSession(host: _host(), client: _client()); + s.attach(events.stream); + events.add(const frb.VncEvent.connected(width: 2, height: 1)); + await Future.delayed(Duration.zero); + final px = Uint8List.fromList([10, 20, 30, 255, 40, 50, 60, 255]); + events.add( + frb.VncEvent.frameUpdate(x: 0, y: 0, width: 2, height: 1, rgba: px)); + await Future.delayed(Duration.zero); + expect(s.framebuffer.sublist(0, 8), [10, 20, 30, 255, 40, 50, 60, 255]); + await events.close(); + }); + + test('tab label falls back to host label, honours custom override', () { + final s = VncSession(host: _host(), client: _client()); + expect(s.tabLabel, 'desktop'); + s.customLabel = 'My VM'; + expect(s.tabLabel, 'My VM'); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd app && flutter test test/models/vnc_session_test.dart` +Expected: FAIL — `vnc_session.dart` does not exist (URI doesn't resolve). + +- [ ] **Step 3: Create the model** + +Create `app/lib/models/vnc_session.dart`: + +```dart +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:yourssh_vnc/yourssh_vnc.dart'; +// ignore: implementation_imports +import 'package:yourssh_vnc/src/generated/api.dart' as frb; + +import 'app_session.dart'; +import 'host.dart'; + +enum VncSessionStatus { connecting, connected, disconnected, error } + +/// One VNC tab. Mirrors [RdpSession] but with no TLS/cert layer (plain VNC has +/// none — security is the SSH tunnel, added in a later milestone) and no +/// tunnel proxy yet (direct connections only in this milestone). +class VncSession extends ChangeNotifier implements AppSession { + VncSession({required this.host, required this.client}); + + final Host host; + final VncClient client; + + /// Desktop size. Starts at 0×0; replaced by the server's framebuffer size + /// from the Connected event and any later Resize (frame coordinates arrive + /// in this space). + int get width => _width; + int get height => _height; + int _width = 0; + int _height = 0; + + Uint8List framebuffer = Uint8List(0); + + @override + String get id => _id; + final String _id = 'vnc_${DateTime.now().microsecondsSinceEpoch}'; + + VncSessionStatus status = VncSessionStatus.connecting; + String? lastMessage; + bool _closed = false; + + /// Latest decoded frame for painting; rebuilt lazily after patches. + ui.Image? image; + bool _decodeInFlight = false; + bool _dirtyAgain = false; + StreamSubscription? _sub; + + @override + String? customLabel; + @override + String? colorTag; + @override + bool isPinned = false; + @override + String get tabLabel => customLabel ?? host.label; + + void attach(Stream events) { + _sub = events.listen(_onEvent, onError: (Object e) { + status = VncSessionStatus.error; + lastMessage = '$e'; + notifyListeners(); + }); + } + + void _onEvent(frb.VncEvent ev) { + switch (ev) { + case frb.VncEvent_Started(): + return; // id captured inside VncClient.connect + case frb.VncEvent_Connected(:final width, :final height): + _applyDesktopSize(width, height); + status = VncSessionStatus.connected; + case frb.VncEvent_Resize(:final width, :final height): + _applyDesktopSize(width, height); + case frb.VncEvent_FrameUpdate( + :final x, + :final y, + :final width, + :final height, + :final rgba + ): + _patch(x, y, width, height, rgba); + case frb.VncEvent_ClipboardText(): + return; // clipboard handling is a later milestone; ignore for now + case frb.VncEvent_Bell(): + return; // no visual state change + case frb.VncEvent_Disconnected(:final reason): + status = VncSessionStatus.disconnected; + lastMessage = reason; + case frb.VncEvent_Error(:final message): + status = VncSessionStatus.error; + lastMessage = message; + } + notifyListeners(); + } + + void _applyDesktopSize(int w, int h) { + if (w == _width && h == _height) return; + _width = w; + _height = h; + framebuffer = Uint8List(w * h * 4); + } + + void _patch(int x, int y, int w, int h, Uint8List rgba) { + final fbStride = _width * 4; + // Defense in depth: Rust clamps regions to the negotiated size, but a + // malformed event must never crash the stream listener. + if (x + w > _width || y + h > _height || rgba.length < w * h * 4) return; + for (var row = 0; row < h; row++) { + final dst = (y + row) * fbStride + x * 4; + final src = row * w * 4; + framebuffer.setRange(dst, dst + w * 4, rgba, src); + } + _scheduleDecode(); + } + + void _scheduleDecode() { + // One decode at a time, latest-wins: patches landing while a decode is + // running set a flag and a single follow-up decode picks them all up. + if (_decodeInFlight) { + _dirtyAgain = true; + return; + } + _decodeInFlight = true; + scheduleMicrotask(_decodeLoop); + } + + Future _decodeLoop() async { + do { + _dirtyAgain = false; + // fromUint8List snapshots synchronously, so the decoded image is + // internally consistent even if a patch lands during the await. + final buf = await ui.ImmutableBuffer.fromUint8List(framebuffer); + final desc = ui.ImageDescriptor.raw(buf, + width: _width, height: _height, pixelFormat: ui.PixelFormat.rgba8888); + final codec = await desc.instantiateCodec(); + final decoded = (await codec.getNextFrame()).image; + if (_closed) { + decoded.dispose(); + break; + } + image?.dispose(); + image = decoded; + notifyListeners(); + } while (_dirtyAgain); + _decodeInFlight = false; + } + + Future close() async { + _closed = true; + await _sub?.cancel(); + try { + // A wedged transport can stall the graceful disconnect indefinitely. + await client.disconnect().timeout(const Duration(seconds: 5)); + } on TimeoutException { + // Rust side will die with the process; nothing more to do. + } finally { + client.dispose(); + image?.dispose(); + image = null; + } + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd app && flutter test test/models/vnc_session_test.dart` +Expected: PASS — `All tests passed!` + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/models/vnc_session.dart app/test/models/vnc_session_test.dart +git commit -m "feat(vnc): VncSession model (framebuffer realloc + latest-wins decode)" +``` + +--- + +## Task 3: `SessionProvider.connectVnc` + router + status watch + close + reconnect + +**Files:** +- Modify: `app/lib/providers/session_provider.dart` (imports; `connectAny` 176–179; add `connectVnc` + `_watchVncStatus` + `reconnectVnc`; `closeSession` 504–530; `_metadataHostId` 611–615) +- Test: `app/test/providers/session_provider_app_session_test.dart` (extend) + +**Testing note (read before writing tests):** `connectVnc` calls `VncClient.ensureInitialized()`, which `dlopen`s the native dylib — in `flutter test` that risks the macOS provenance SIGKILL and is exactly why the existing RDP provider tests never call `connectRdp`. So this task's tests cover only the native-safe surface (type hierarchy), mirroring `session_provider_app_session_test.dart`'s RDP coverage. The `connectVnc`/watch/close behavior is validated end-to-end in M5 (live VNC container), the same depth RDP has. + +- [ ] **Step 1: Write the failing test** + +Append to `app/test/providers/session_provider_app_session_test.dart`. First add imports near the top (after the existing model imports on line 5–7): + +```dart +import 'package:yourssh/models/vnc_session.dart'; +import 'package:yourssh_vnc/yourssh_vnc.dart' show VncClient, VncConfig; +``` + +Add a host + client builder next to `_rdpClient()` (after line 35): + +```dart +Host _vncHost() => Host( + id: 'vnc1', + label: 'desktop', + host: '10.0.0.5', + port: 5900, + username: 'u', + protocol: HostProtocol.vnc); + +VncClient _vncClient() => VncClient(VncConfig( + targetHost: '10.0.0.5', targetPort: 5900, username: 'u', password: '')); +``` + +Add two tests inside the `group('AppSession type hierarchy', ...)` block (after the RDP case on line 64): + +```dart + test('VncSession is not a TerminalSession', () { + final s = VncSession(host: _vncHost(), client: _vncClient()); + expect(s is TerminalSession, isFalse); + }); + + test('VncSession is an AppSession', () { + final s = VncSession(host: _vncHost(), client: _vncClient()); + expect(s, isA()); + }); +``` + +Add the `AppSession` import at the top if not already present: + +```dart +import 'package:yourssh/models/app_session.dart'; +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd app && flutter test test/providers/session_provider_app_session_test.dart` +Expected: FAIL — `VncSession` URI unresolved. + +- [ ] **Step 3: Add VNC imports to the provider** + +In `app/lib/providers/session_provider.dart`, after the existing `import 'package:yourssh_rdp/yourssh_rdp.dart';` (line 4), add: + +```dart +import 'package:yourssh_vnc/yourssh_vnc.dart'; +``` + +And with the other model imports add: + +```dart +import '../models/vnc_session.dart'; +``` + +- [ ] **Step 4: Route `vnc` in `connectAny`** + +Change `connectAny` (lines 176–179) from: + +```dart + Future connectAny(Host host, {String? initialCommand}) { + if (host.protocol == HostProtocol.rdp) return connectRdp(host); + return connect(host, initialCommand: initialCommand).then((_) => null); + } +``` + +to: + +```dart + Future connectAny(Host host, {String? initialCommand}) { + if (host.protocol == HostProtocol.rdp) return connectRdp(host); + if (host.protocol == HostProtocol.vnc) return connectVnc(host); + return connect(host, initialCommand: initialCommand).then((_) => null); + } +``` + +- [ ] **Step 5: Add `connectVnc`, `_watchVncStatus`, and `reconnectVnc`** + +Add these three methods next to `connectRdp`/`_watchRdpStatus`/`reconnectRdp` in `app/lib/providers/session_provider.dart` (e.g. right after `reconnectRdp`, line 330): + +```dart + /// Opens a VNC tab. Mirrors [connectRdp] but with no TLS/cert pinning (plain + /// VNC has none) and no SSH tunnel yet (direct connections only). + Future connectVnc(Host host) async { + final password = await _ssh.loadPassword(host.id) ?? ''; + + String? setupError; + try { + // Lazy bridge init: a missing/corrupt dylib surfaces as an error tab + // instead of an uncatchable LateInitializationError in the bindings. + await VncClient.ensureInitialized(); + } catch (e) { + setupError = '$e'; + } + + final config = VncConfig( + targetHost: host.host, + targetPort: host.port, + username: host.username, + password: password, + ); + final client = VncClient(config); + final session = VncSession(host: host, client: client); + + await _applyTabMetadata(session, host.id); + + if (setupError != null) { + session.status = VncSessionStatus.error; + session.lastMessage = setupError; + } else { + session.attach(client.events); + // Failures surface through the event stream (status/lastMessage); + // swallow the future's mirror error so it can't hit the root zone. + unawaited(client.connect().then((_) {}, onError: (_) {})); + } + + _watchVncStatus(session); + session.addListener(_safeNotify); + _sessions.add(session); + _activeSessionId = session.id; + if (session.isPinned) _sortSessions(); + _safeNotify(); + return session; + } + + /// Audits VNC connect/disconnect transitions and feeds the notification + /// bell — parity with [_watchRdpStatus] (no cert flows to special-case). + void _watchVncStatus(VncSession session) { + var last = session.status; + session.addListener(() { + final now = session.status; + if (now == last) return; + final was = last; + last = now; + final host = session.host; + if (was == VncSessionStatus.connecting && + now == VncSessionStatus.connected) { + audit?.record(AuditEvent.now( + type: AuditEventType.connect, + host: host, + sessionId: session.id, + meta: const {'source': 'vnc'})); + } else if (was == VncSessionStatus.connecting && + (now == VncSessionStatus.error || + now == VncSessionStatus.disconnected)) { + audit?.record(AuditEvent.now( + type: AuditEventType.connect, + host: host, + sessionId: session.id, + meta: { + 'source': 'vnc', + 'error': session.lastMessage ?? 'connection failed', + })); + onSessionDropped?.call(session, session.lastMessage); + } else if (was == VncSessionStatus.connected) { + final userClosed = session.lastMessage == 'disconnected by user'; + audit?.record(AuditEvent.now( + type: AuditEventType.disconnect, + host: host, + sessionId: session.id, + meta: { + 'source': 'vnc', + 'reason': userClosed ? 'user-closed' : 'dropped', + })); + if (!userClosed) { + onSessionDropped?.call(session, session.lastMessage); + } + } + }); + } + + Future reconnectVnc(VncSession old) async { + // Label/color/pin are persisted on every edit and reloaded by connectVnc's + // tab-metadata pass — no manual carry-over needed. + closeSession(old.id); + await connectVnc(old.host); + } +``` + +- [ ] **Step 6: Handle `VncSession` in `closeSession`** + +In `closeSession`, immediately after the closing `}` of the `if (session is RdpSession) { ... return; }` block (the RDP branch ends at line 530), add a parallel VNC branch: + +```dart + if (session is VncSession) { + final hostId = session.host.id; + // Mirror the SSH/RDP path: a live tab the user closes gets its own row + // (a dead tab was already audited on the drop/error transition). + if (session.status == VncSessionStatus.connected) { + audit?.record(AuditEvent.now( + type: AuditEventType.disconnect, + host: session.host, + sessionId: sessionId, + meta: const {'source': 'vnc', 'reason': 'user-closed'})); + } + session.removeListener(_safeNotify); + unawaited(session.close()); + _sessions.remove(session); + if (_activeSessionId == sessionId) { + _activeSessionId = _sessions.isNotEmpty ? _sessions.last.id : null; + } + // No-op for direct VNC; releases the SSH tunnel client once tunneling + // lands in a later milestone (parity with the RDP branch). + if (!_sessions.any((s) => s is VncSession && s.host.id == hostId)) { + _ssh.disconnect(hostId); + } + _safeNotify(); + return; + } +``` + +- [ ] **Step 7: Add `VncSession` to `_metadataHostId`** + +Change `_metadataHostId` (lines 611–615) from: + +```dart + String? _metadataHostId(AppSession s) => switch (s) { + SshSession ssh => ssh.isWatch ? null : ssh.host.id, + RdpSession rdp => rdp.host.id, + _ => null, + }; +``` + +to: + +```dart + String? _metadataHostId(AppSession s) => switch (s) { + SshSession ssh => ssh.isWatch ? null : ssh.host.id, + RdpSession rdp => rdp.host.id, + VncSession vnc => vnc.host.id, + _ => null, + }; +``` + +- [ ] **Step 8: Run the tests to verify they pass** + +Run: `cd app && flutter test test/providers/session_provider_app_session_test.dart` +Expected: PASS — `All tests passed!` + +- [ ] **Step 9: Analyze (no new warnings)** + +Run: `cd app && flutter analyze lib/providers/session_provider.dart` +Expected: `No issues found!` + +- [ ] **Step 10: Commit** + +```bash +git add app/lib/providers/session_provider.dart app/test/providers/session_provider_app_session_test.dart +git commit -m "feat(vnc): route + connect VNC sessions in SessionProvider" +``` + +--- + +## Task 4: `VncWorkspace` widget + +**Files:** +- Create: `app/lib/widgets/vnc_workspace.dart` +- Test: `app/test/widgets/vnc_workspace_test.dart` + +Minimal view-only mirror of `RdpWorkspace`: status overlays, a `CustomPaint`/`_FramePainter` that blits `session.image`, and a 34px toolbar with only a Disconnect button (no input/clipboard/fullscreen — later milestones). + +- [ ] **Step 1: Write the failing test** + +Create `app/test/widgets/vnc_workspace_test.dart`: + +```dart +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/models/vnc_session.dart'; +import 'package:yourssh/widgets/vnc_workspace.dart'; +import 'package:yourssh_vnc/yourssh_vnc.dart' show VncClient, VncConfig; +// ignore: implementation_imports +import 'package:yourssh_vnc/src/generated/api.dart' as frb; + +Host _host() => Host( + id: 'v1', + label: 'desktop', + host: '10.0.0.5', + port: 5900, + username: 'u', + protocol: HostProtocol.vnc); + +VncClient _client() => VncClient(VncConfig( + targetHost: '10.0.0.5', targetPort: 5900, username: 'u', password: '')); + +void main() { + testWidgets('shows connecting overlay, then error overlay with retry', + (tester) async { + final events = StreamController(); + final session = VncSession(host: _host(), client: _client()); + session.attach(events.stream); + var retried = false; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: VncWorkspace( + session: session, + onReconnect: () => retried = true, + ), + ), + )); + expect(find.textContaining('Connecting'), findsOneWidget); + + events.add(const frb.VncEvent.error(message: 'connection refused')); + await tester.pump(); + expect(find.textContaining('connection refused'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + + await tester.tap(find.text('Retry')); + expect(retried, isTrue); + await events.close(); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd app && flutter test test/widgets/vnc_workspace_test.dart` +Expected: FAIL — `vnc_workspace.dart` does not exist. + +- [ ] **Step 3: Create the widget** + +Create `app/lib/widgets/vnc_workspace.dart`: + +```dart +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; + +import '../models/vnc_session.dart'; +import '../theme/app_theme.dart'; + +/// View-only VNC framebuffer surface. Mirrors RdpWorkspace's render pipeline: +/// the widget rebuilds only on status change, while frames repaint through the +/// painter's `repaint: session` listenable. Input/clipboard/fullscreen are +/// later milestones. +class VncWorkspace extends StatefulWidget { + const VncWorkspace({super.key, required this.session, this.onReconnect}); + + final VncSession session; + final VoidCallback? onReconnect; + + @override + State createState() => _VncWorkspaceState(); +} + +class _VncWorkspaceState extends State { + VncSession get session => widget.session; + + @override + void initState() { + super.initState(); + session.addListener(_onSessionChanged); + } + + @override + void didUpdateWidget(VncWorkspace old) { + super.didUpdateWidget(old); + if (!identical(old.session, widget.session)) { + old.session.removeListener(_onSessionChanged); + session.addListener(_onSessionChanged); + } + } + + @override + void dispose() { + session.removeListener(_onSessionChanged); + super.dispose(); + } + + void _onSessionChanged() { + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Column(children: [ + _Toolbar(session: session), + Expanded(child: _buildBody()), + ]); + } + + Widget _buildBody() { + switch (session.status) { + case VncSessionStatus.connecting: + return const Center( + child: Text('Connecting…', + style: TextStyle(color: AppColors.textSecondary))); + case VncSessionStatus.error: + case VncSessionStatus.disconnected: + return Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Text(session.lastMessage ?? 'Disconnected', + style: const TextStyle(color: AppColors.textSecondary)), + const SizedBox(height: 12), + FilledButton( + onPressed: widget.onReconnect, child: const Text('Retry')), + ]), + ); + case VncSessionStatus.connected: + return LayoutBuilder(builder: (context, constraints) { + final img = session.image; + if (img == null) { + return const Center( + child: Text('Waiting for first frame…', + style: TextStyle(color: AppColors.textSecondary))); + } + final scale = math.min(constraints.maxWidth / img.width, + constraints.maxHeight / img.height); + final dw = img.width * scale; + final dh = img.height * scale; + final offX = (constraints.maxWidth - dw) / 2; + final offY = (constraints.maxHeight - dh) / 2; + return CustomPaint( + size: Size(constraints.maxWidth, constraints.maxHeight), + painter: _FramePainter(session, offX, offY, scale), + ); + }); + } + } +} + +class _Toolbar extends StatelessWidget { + const _Toolbar({required this.session}); + final VncSession session; + + @override + Widget build(BuildContext context) { + return Container( + height: 34, + color: AppColors.card, + child: Row(children: [ + const SizedBox(width: 8), + Text(session.tabLabel, style: Theme.of(context).textTheme.labelMedium), + const Spacer(), + IconButton( + tooltip: 'Disconnect', + icon: const Icon(Icons.power_settings_new, size: 16), + onPressed: () => session.client.disconnect(), + ), + const SizedBox(width: 4), + ]), + ); + } +} + +class _FramePainter extends CustomPainter { + /// `repaint: session` redraws on every decoded frame without rebuilding the + /// surrounding widget tree (the workspace only rebuilds on status changes). + _FramePainter(this.session, this.offX, this.offY, this.scale) + : super(repaint: session); + + final VncSession session; + final double offX, offY, scale; + + @override + void paint(Canvas canvas, Size size) { + final ui.Image? img = session.image; + if (img == null) return; + canvas.drawImageRect( + img, + Rect.fromLTWH(0, 0, img.width.toDouble(), img.height.toDouble()), + Rect.fromLTWH(offX, offY, img.width * scale, img.height * scale), + Paint()..filterQuality = FilterQuality.medium, + ); + } + + @override + bool shouldRepaint(_FramePainter old) => + !identical(old.session, session) || + old.scale != scale || + old.offX != offX || + old.offY != offY; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd app && flutter test test/widgets/vnc_workspace_test.dart` +Expected: PASS — `All tests passed!` + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/widgets/vnc_workspace.dart app/test/widgets/vnc_workspace_test.dart +git commit -m "feat(vnc): minimal view-only VncWorkspace (framebuffer paint)" +``` + +--- + +## Task 5: Route `VncSession` to `VncWorkspace` in `MainScreen` + +**Files:** +- Modify: `app/lib/screens/main_screen.dart` (imports; `_buildForeground` routing ~778–786; add `_retryVnc`) + +- [ ] **Step 1: Add imports** + +In `app/lib/screens/main_screen.dart`, with the other model/widget imports, add: + +```dart +import '../models/vnc_session.dart'; +import '../widgets/vnc_workspace.dart'; +``` + +- [ ] **Step 2: Add the routing branch** + +In `_buildForeground`, immediately after the existing RDP block (which ends with its closing `}` around line 786): + +```dart + if (_viewingTerminal && active is RdpSession) { + return RdpWorkspace( + session: active, + onReconnect: () => _retryRdp(active), + isFullscreen: _rdpFullscreen, + onFullscreenChanged: (on) => unawaited(_setRdpFullscreen(on)), + ); + } +``` + +add: + +```dart + if (_viewingTerminal && active is VncSession) { + return VncWorkspace( + session: active, + onReconnect: () => _retryVnc(active), + ); + } +``` + +- [ ] **Step 3: Add the `_retryVnc` method** + +Next to `_retryRdp` (it ends at line 913), add: + +```dart + Future _retryVnc(VncSession old) async { + if (!mounted) return; + await context.read().reconnectVnc(old); + } +``` + +- [ ] **Step 4: Analyze the screen compiles cleanly** + +Run: `cd app && flutter analyze lib/screens/main_screen.dart` +Expected: `No issues found!` + +- [ ] **Step 5: Run the full app test suite (nothing regressed)** + +Run: `cd app && flutter test` +Expected: PASS — all tests pass (this includes Tasks 1–4's tests). + +- [ ] **Step 6: Commit** + +```bash +git add app/lib/screens/main_screen.dart +git commit -m "feat(vnc): render VncSession tabs via VncWorkspace in MainScreen" +``` + +--- + +## Task 6: VNC option in `HostDetailPanel` + +**Files:** +- Modify: `app/lib/widgets/host_detail_panel.dart` +- Test: `app/test/widgets/host_detail_panel_vnc_test.dart` (create) + +Lets the user create/edit a VNC host. VNC reuses RDP's "non-SSH" behavior (password-only auth; SSH-only sections hidden) but has **no** domain or RDP-security sections. Introduce `_isGraphical` (`protocol != ssh`) for the SSH-only hiding, and keep `_isRdp` for the RDP-only sections. + +**Exact `_isRdp` site disposition** (from `grep -n _isRdp`): +- Keep `_isRdp`: line 88 (getter), 220 (`domain`), 480 (domain field), 490 (RDP SECURITY + SSH TUNNEL), 1090 (RDP spacer), 1147 (RDP badge). +- Change to `_isGraphical`: lines 224, 225, 228, 231, 233, 234 (`_save` flags), 572 (`if (!_isRdp)` SSH-only sections), and the 1089 comment. +- Line 433: replace the 2-way label with a 3-way switch. + +- [ ] **Step 1: Write the failing test** + +Create `app/test/widgets/host_detail_panel_vnc_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, 3600)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final hostProvider = HostProvider(StorageService()); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => KeyProvider()), + ChangeNotifierProvider.value(value: hostProvider), + Provider(create: (_) => SshService(StorageService())), + ], + child: MaterialApp( + home: Scaffold( + body: HostDetailPanel( + existing: existing, + initialProtocol: existing == null ? HostProtocol.vnc : null, + agentProbe: () async => const AgentProbeSystem(1), + onClose: () {}, + onSave: (host, _) async => saved = host, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + Host vncHost() => Host( + id: 'vnc-1', + label: 'desktop', + host: '10.0.0.5', + port: 5900, + username: 'u', + protocol: HostProtocol.vnc, + ); + + testWidgets('VNC mode hides SSH-only and RDP-only sections', (tester) async { + await pumpPanel(tester, existing: vncHost()); + expect(find.text('VNC on'), findsOneWidget); + // SSH-only: + expect(find.text('AUTH METHOD'), findsNothing); + // RDP-only: + expect(find.text('RDP SECURITY'), findsNothing); + expect(find.widgetWithText(TextField, 'Domain (optional)'), findsNothing); + }); + + testWidgets('panel exposes a VNC protocol segment', (tester) async { + await pumpPanel(tester); + expect(find.text('VNC'), findsWidgets); + }); +} +``` + +> If `HostDetailPanel`'s constructor does not accept `initialProtocol`, confirm against `app/lib/widgets/host_detail_panel.dart:94` (`widget.initialProtocol`) — it does. The save button label is `SAVE ONLY` (see `host_detail_panel_rdp_test.dart:52`) if a save assertion is added later. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd app && flutter test test/widgets/host_detail_panel_vnc_test.dart` +Expected: FAIL — no `VNC` segment; `'VNC on'` not found. + +- [ ] **Step 3: Add the `_isGraphical` getter** + +After line 88 (`bool get _isRdp => _protocol == HostProtocol.rdp;`), add: + +```dart + bool get _isVnc => _protocol == HostProtocol.vnc; + /// True for any graphical (non-SSH) protocol — these share password-only + /// auth and hide all SSH-only sections. + bool get _isGraphical => _protocol != HostProtocol.ssh; +``` + +- [ ] **Step 4: Add the VNC segment to the protocol selector** + +In the `SegmentedButton` `segments` list (lines 332–343), after the RDP `ButtonSegment`, add a third segment: + +```dart + ButtonSegment( + value: HostProtocol.rdp, + label: Text('RDP'), + icon: Icon(Icons.desktop_windows_outlined, size: 14), + ), + ButtonSegment( + value: HostProtocol.vnc, + label: Text('VNC'), + icon: Icon(Icons.cast_outlined, size: 14), + ), +``` + +- [ ] **Step 5: Force password auth for any graphical protocol** + +In `_onProtocolChanged` (line 183), change: + +```dart + if (next == HostProtocol.rdp) { + // RDP supports password auth only. + _authType = AuthType.password; + _selectedKeyId = null; + } +``` + +to: + +```dart + if (next != HostProtocol.ssh) { + // RDP and VNC support password auth only. + _authType = AuthType.password; + _selectedKeyId = null; + } +``` + +- [ ] **Step 6: Switch the SSH-only `_save` flags to `_isGraphical`** + +In `_save` (lines 224–234), change these six lines from `_isRdp` to `_isGraphical` (leave line 220 `domain:` and line 223 `rdpSecurity:` exactly as they are): + +```dart + authType: _isGraphical ? AuthType.password : _authType, + keyId: !_isGraphical && _authType == AuthType.privateKey ? _selectedKeyId : null, +``` +```dart + autoRecord: !_isGraphical && _autoRecord, +``` +```dart + agentForwarding: !_isGraphical && _agentForwarding, +``` +```dart + sftpMode: _isGraphical ? SftpMode.normal : _sftpMode, + sftpServerCommand: !_isGraphical && _sftpMode == SftpMode.custom + ? _sftpCommand.text.trim() + : null, +``` + +- [ ] **Step 7: Make the protocol label 3-way** + +Change line 433 from: + +```dart + Text(_isRdp ? 'RDP on' : 'SSH on', style: const TextStyle(color: AppColors.textSecondary, fontSize: 13)), +``` + +to: + +```dart + Text( + switch (_protocol) { + HostProtocol.ssh => 'SSH on', + HostProtocol.rdp => 'RDP on', + HostProtocol.vnc => 'VNC on', + }, + style: const TextStyle( + color: AppColors.textSecondary, fontSize: 13)), +``` + +- [ ] **Step 8: Hide the SSH-only sections for any graphical protocol** + +Change line 572 from: + +```dart + if (!_isRdp) ...[ +``` + +to: + +```dart + if (!_isGraphical) ...[ +``` + +And update the matching comment at line 1089 from `// end !_isRdp (SSH-only sections)` to `// end !_isGraphical (SSH-only sections)`. + +- [ ] **Step 9: Run the test to verify it passes** + +Run: `cd app && flutter test test/widgets/host_detail_panel_vnc_test.dart` +Expected: PASS — `All tests passed!` + +- [ ] **Step 10: Verify RDP panel behavior is unchanged** + +Run: `cd app && flutter test test/widgets/host_detail_panel_rdp_test.dart` +Expected: PASS — the RDP path still hides SSH sections and shows domain/security (no regression from the `_isRdp`→`_isGraphical` split). + +- [ ] **Step 11: Commit** + +```bash +git add app/lib/widgets/host_detail_panel.dart app/test/widgets/host_detail_panel_vnc_test.dart +git commit -m "feat(vnc): VNC protocol option in HostDetailPanel" +``` + +--- + +## Final verification + +- [ ] **Analyze the whole app** + +Run: `cd app && flutter analyze` +Expected: `No issues found!` + +- [ ] **Run the full test suite** + +Run: `cd app && flutter test` +Expected: all tests pass. + +- [ ] **(Optional, requires a live server) Manual smoke test** + +Start a local VNC server (e.g. `x11vnc -display :0 -rfbport 5900 -passwd secret`, or a TigerVNC/TightVNC container), create a VNC host in the app (host/port 5900/password), connect, and confirm the remote framebuffer renders and updates. Disconnect from the toolbar and confirm the tab shows "disconnected" with a working Retry. + +--- + +## Self-Review + +**1. Spec coverage** (umbrella `2026-06-16-vnc-support-design.md`, Milestone 2 = "End-to-end view-only"): +- `HostProtocol.vnc` (default 5900) → Task 1. ✓ +- `VncSession implements AppSession` (framebuffer realloc, latest-wins decode, status, `close()` timeout) → Task 2. ✓ +- `SessionProvider.connectVnc` (lazy init → error tab, tab metadata, audit `source: vnc`, drop watch) → Task 3. ✓ +- Minimal `VncWorkspace` rendering the framebuffer → Task 4. ✓ +- Reachable end-to-end: `connectAny` route (Task 3) + `MainScreen` branch (Task 5) + create-a-VNC-host UI (Task 6). ✓ +- Deliberately deferred (documented): cert pinning (N/A for VNC), SSH tunnel, input, clipboard, fullscreen, protocol badge, dashboard polish, integration screenshots. ✓ + +**2. Placeholder scan:** No TBD/"handle errors"/"similar to Task N"; every code step has complete code. ✓ + +**3. Type consistency:** `VncSessionStatus.{connecting,connected,disconnected,error}` used identically in the model, provider watch/close, and workspace. `VncSession({required host, required client})` (no width/height) used identically across model, provider, and all tests. `VncWorkspace({required session, onReconnect})` matches its test and the MainScreen call site. Event subclass names (`VncEvent_Connected(:final width, :final height)`, `VncEvent_FrameUpdate(:final x,:final y,:final width,:final height,:final rgba)`) match `api.rs`. ✓ diff --git a/docs/superpowers/plans/2026-06-17-vnc-m3-input.md b/docs/superpowers/plans/2026-06-17-vnc-m3-input.md new file mode 100644 index 00000000..02693765 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-vnc-m3-input.md @@ -0,0 +1,839 @@ +# VNC Milestone 3 — Mouse + Keyboard Input 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:** Make the view-only VNC workspace interactive — forward mouse movement/clicks/wheel and keyboard presses from the Flutter `VncWorkspace` to the remote server over RFB. + +**Architecture:** Mirror the RDP input path end-to-end. Dart `VncClient.sendPointer`/`sendKey` push commands over FRB → Rust `vnc_send_pointer`/`vnc_send_key` enqueue a `SessionCmd` on the session's MPSC channel → the run loop pops it and calls `vnc.input(X11Event::PointerEvent | KeyEvent)`. The workspace captures pointer events (translating widget coords → framebuffer coords, deriving the RFB button bitmask) and key events (mapping the physical key → X11 keysym), gated on `connected`. + +**Tech Stack:** Flutter (Dart), `package:yourssh_vnc` (flutter_rust_bridge v2), `vnc-rs` 0.5.3 (`X11Event`, `ClientMouseEvent`, `ClientKeyEvent`), `dart:ui`. + +**Key protocol facts (verified against vnc-rs 0.5.3 `src/event.rs`):** +- `vnc::VncClient::input(&self, event: X11Event) -> Result<(), VncError>` (shared `&self`; already used for `X11Event::Refresh`). +- `X11Event::PointerEvent(ClientMouseEvent { position_x: u16, position_y: u16, bottons: u8 })` — **note the field is spelled `bottons`.** `bottons` is the RFB button bitmask: bit0 left, bit1 middle, bit2 right, bit3 wheel-up, bit4 wheel-down. +- `X11Event::KeyEvent(ClientKeyEvent { keycode: u32, down: bool })` — `keycode` is an **X11 keysym** (e.g. `0xFF0D` Return, `0x0061` 'a'), not a scancode. +- Imports: `vnc::X11Event`, `vnc::ClientMouseEvent`, `vnc::ClientKeyEvent`. + +**Input model difference from RDP (important):** RDP sends `(button, action=down/up)`. **VNC sends the full button bitmask + position on every PointerEvent.** So the workspace derives the current mask from Flutter's `e.buttons` on every pointer event, and a wheel notch is a press+release pair (send `mask | wheelBit`, then `mask`). + +**Scope boundary (do NOT build here):** clipboard send/receive, auto-resize, SSH tunnel, fullscreen, protocol badge, dashboard polish (M4); integration screenshots (M5). Keysym map is the pragmatic US-layout subset (printable + modifiers + nav/function); non-US layouts iterate later. Modifiers are sent as their own keysym events (the server combines them) — we do NOT remap shifted characters. + +## Global Constraints + +- `flutter_rust_bridge` pinned at `=2.12.0` (codegen + runtime must match). Install codegen if missing: `cargo install flutter_rust_bridge_codegen --version 2.12.0 --locked`. +- Never add "Generated with Claude", co-author trailers, or any AI-tool mention anywhere (commits, code, docs). +- Branch off `develop` is already current; do all work on the existing working branch. Do NOT switch to `master`/`main`. +- After any in-session `build.sh` on macOS, the dylib must be rewritten to a fresh inode or `dlopen` (flutter test) dies with exit 137 — see Task 2 Step 5. +- The built native libs under `packages/yourssh_vnc/assets/native/` are gitignored — never `git add` them. + +--- + +## Task 1: Rust — input `SessionCmd` variants + run-loop dispatch + +**Files:** +- Modify: `packages/yourssh_vnc/rust/src/session.rs` (the `SessionCmd` enum) +- Modify: `packages/yourssh_vnc/rust/src/run_loop.rs` (the `cmd_rx` arm; add a pure `input_event` helper + test) + +**Interfaces:** +- Produces (Rust, used by Task 2): `SessionCmd::Pointer { x: u16, y: u16, button_mask: u8 }`, `SessionCmd::Key { keysym: u32, down: bool }`. + +The current `SessionCmd` (from M1) has only `Disconnect`. The current run-loop `cmd_rx` arm matches `None | Some(SessionCmd::Disconnect)`. + +- [ ] **Step 1: Add the command variants** + +In `packages/yourssh_vnc/rust/src/session.rs`, change the enum from: + +```rust +#[derive(Debug)] +pub enum SessionCmd { + Disconnect, +} +``` + +to: + +```rust +#[derive(Debug)] +pub enum SessionCmd { + /// Pointer move/press/release. `button_mask` is the RFB bitmask + /// (bit0 left, bit1 middle, bit2 right, bit3 wheel-up, bit4 wheel-down). + Pointer { x: u16, y: u16, button_mask: u8 }, + /// Key press/release. `keysym` is an X11 keysym. + Key { keysym: u32, down: bool }, + Disconnect, +} +``` + +- [ ] **Step 2: Write the failing test for the pure mapping helper** + +In `packages/yourssh_vnc/rust/src/run_loop.rs`, add to the existing `#[cfg(test)] mod tests` block: + +```rust + #[test] + fn input_event_maps_pointer() { + match input_event(&SessionCmd::Pointer { x: 10, y: 20, button_mask: 0x05 }) { + Some(vnc::X11Event::PointerEvent(m)) => { + assert_eq!(m.position_x, 10); + assert_eq!(m.position_y, 20); + assert_eq!(m.bottons, 0x05); + } + other => panic!("expected PointerEvent, got {other:?}"), + } + } + + #[test] + fn input_event_maps_key() { + match input_event(&SessionCmd::Key { keysym: 0xFF0D, down: true }) { + Some(vnc::X11Event::KeyEvent(k)) => { + assert_eq!(k.keycode, 0xFF0D); + assert!(k.down); + } + other => panic!("expected KeyEvent, got {other:?}"), + } + } + + #[test] + fn input_event_none_for_disconnect() { + assert!(input_event(&SessionCmd::Disconnect).is_none()); + } +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `cargo test --manifest-path packages/yourssh_vnc/rust/Cargo.toml input_event` +Expected: FAIL — `cannot find function input_event`. + +- [ ] **Step 4: Add the `input_event` helper + wire it into the run loop** + +In `packages/yourssh_vnc/rust/src/run_loop.rs`, add this pure helper near `set_opaque`/`disconnect_reason` (top-level fn): + +```rust +/// Translates an input `SessionCmd` into the `vnc-rs` event to feed +/// `client.input()`. Returns `None` for non-input commands (Disconnect). +pub fn input_event(cmd: &SessionCmd) -> Option { + match *cmd { + SessionCmd::Pointer { x, y, button_mask } => { + Some(vnc::X11Event::PointerEvent(vnc::ClientMouseEvent { + position_x: x, + position_y: y, + bottons: button_mask, + })) + } + SessionCmd::Key { keysym, down } => { + Some(vnc::X11Event::KeyEvent(vnc::ClientKeyEvent { keycode: keysym, down })) + } + SessionCmd::Disconnect => None, + } +} +``` + +Then change the `cmd_rx` arm of the `tokio::select!` from: + +```rust + cmd = cmd_rx.recv() => { + // `Disconnect` = Dart asked us to stop; `None` = every command + // sender was dropped (session torn down). Both close the client. + let reason = match cmd { + Some(SessionCmd::Disconnect) => "disconnected by user", + None => "session closed", + }; + // error = already closed; still emit Disconnected + let _ = vnc.close().await; + return Ok(reason.into()); + } +``` + +to: + +```rust + cmd = cmd_rx.recv() => { + match cmd { + // `Disconnect` = Dart asked us to stop; `None` = every command + // sender was dropped (session torn down). Both close the client. + None | Some(SessionCmd::Disconnect) => { + let reason = if cmd.is_none() { + "session closed" + } else { + "disconnected by user" + }; + // error = already closed; still emit Disconnected + let _ = vnc.close().await; + return Ok(reason.into()); + } + Some(input) => { + // Ignore send errors — if the client is closed, + // recv_event() surfaces it on the next iteration. + if let Some(ev) = input_event(&input) { + let _ = vnc.input(ev).await; + } + } + } + } +``` + +(Confirm `SessionCmd` is already imported in `run_loop.rs` — it is, via `use crate::session::SessionCmd;` from M1.) + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cargo test --manifest-path packages/yourssh_vnc/rust/Cargo.toml` +Expected: PASS — all tests pass (the 5 M1 tests + 3 new `input_event` tests). + +- [ ] **Step 6: Commit** + +```bash +git add packages/yourssh_vnc/rust/src/session.rs packages/yourssh_vnc/rust/src/run_loop.rs +git commit -m "feat(vnc): pointer/key session commands + run-loop input dispatch" +``` + +--- + +## Task 2: Rust — FFI send functions + regenerate FRB bindings + rebuild dylib + +**Files:** +- Modify: `packages/yourssh_vnc/rust/src/api.rs` (add `vnc_send_pointer`, `vnc_send_key`) +- Regenerate: `packages/yourssh_vnc/lib/src/generated/**` (via codegen) +- Rebuild: `packages/yourssh_vnc/assets/native/macos/libyourssh_vnc.dylib` (gitignored) + +**Interfaces:** +- Consumes (from Task 1): `SessionCmd::Pointer { x, y, button_mask }`, `SessionCmd::Key { keysym, down }`. +- Produces (generated Dart, used by Task 3): `vncSendPointer({required int sessionId, required int x, required int y, required int buttonMask})` and `vncSendKey({required int sessionId, required int keysym, required bool down})`. + +- [ ] **Step 1: Add the FFI functions** + +In `packages/yourssh_vnc/rust/src/api.rs`, after the existing `vnc_disconnect` function, add: + +```rust +pub fn vnc_send_pointer(session_id: u32, x: u16, y: u16, button_mask: u8) { + registry::send(session_id, SessionCmd::Pointer { x, y, button_mask }); +} + +pub fn vnc_send_key(session_id: u32, keysym: u32, down: bool) { + registry::send(session_id, SessionCmd::Key { keysym, down }); +} +``` + +(`registry` and `SessionCmd` are already imported in `api.rs` via `use crate::session::{registry, SessionCmd};` from M1.) + +- [ ] **Step 2: Verify the crate still builds** + +Run: `cargo build --manifest-path packages/yourssh_vnc/rust/Cargo.toml` +Expected: builds clean (warnings about unused fns are fine — they're FRB entry points). + +- [ ] **Step 3: Regenerate the FRB bindings** + +Run: `cd packages/yourssh_vnc && flutter_rust_bridge_codegen generate` +(The user's shell also aliases this as `n generate`. Install if missing: `cargo install flutter_rust_bridge_codegen --version 2.12.0 --locked`.) +Expected: regenerates `lib/src/generated/api.dart` (+ `frb_generated*`) now exposing `vncSendPointer` and `vncSendKey`. Confirm with: +`grep -n "vncSendPointer\|vncSendKey" packages/yourssh_vnc/lib/src/generated/api.dart` → both present. + +- [ ] **Step 4: Rebuild the native library** + +Run: `bash packages/yourssh_vnc/build.sh` +Expected: prints `yourssh_vnc native library built`; produces `packages/yourssh_vnc/assets/native/macos/libyourssh_vnc.dylib` (or `linux/libyourssh_vnc.so`). + +- [ ] **Step 5: macOS only — rewrite the dylib to a fresh inode** + +A dylib produced by sandboxed Bash on this Mac carries a `com.apple.provenance` record that makes macOS SIGKILL any process that `mmap()`s it (so `flutter test`'s `dlopen` dies with exit 137). Rewrite the bytes to a clean inode (run with sandbox disabled): + +```bash +python3 - <<'PY' +import os +p = "packages/yourssh_vnc/assets/native/macos/libyourssh_vnc.dylib" +data = open(p, "rb").read() +open(p + ".clean", "wb").write(data) +os.chmod(p + ".clean", 0o755) +os.replace(p + ".clean", p) +print("rewrote", p, len(data), "bytes") +PY +``` + +(Skip on Linux. See the `macos-provenance-mmap-kill` project memory.) + +- [ ] **Step 6: Verify the bridge still loads (FRB roundtrip)** + +Run: `cd packages/yourssh_vnc && flutter test` +Expected: PASS — `vncLibVersion returns crate version`. (If it dies with exit 137, re-run Step 5.) + +- [ ] **Step 7: Commit (generated Dart + Rust only — NOT the gitignored dylib)** + +```bash +git add packages/yourssh_vnc/rust/src/api.rs packages/yourssh_vnc/lib/src/generated +git commit -m "feat(vnc): FFI send-pointer/send-key + regenerated bindings" +``` + +--- + +## Task 3: Dart — `VncClient.sendPointer` / `sendKey` + +**Files:** +- Modify: `packages/yourssh_vnc/lib/src/vnc_client.dart` +- Test: `packages/yourssh_vnc/test/vnc_client_input_test.dart` (create) + +**Interfaces:** +- Consumes (from Task 2): `vncSendPointer(sessionId:, x:, y:, buttonMask:)`, `vncSendKey(sessionId:, keysym:, down:)`. +- Produces (used by Task 5): `VncClient.sendPointer({required int x, required int y, required int buttonMask})`, `VncClient.sendKey({required int keysym, required bool down})`. + +Both methods no-op until `_sessionId` is set (mirrors `RdpClient.sendMouse`/`sendKey`), so they're safe to call before connect and safe to unit-test without the native library. + +- [ ] **Step 1: Write the failing test** + +Create `packages/yourssh_vnc/test/vnc_client_input_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh_vnc/yourssh_vnc.dart'; + +void main() { + // Before connect, _sessionId is null, so input methods must no-op (never + // touch the bridge) — this runs without loading the native library. + VncClient client() => VncClient(VncConfig( + targetHost: '10.0.0.5', targetPort: 5900, username: 'u', password: '')); + + test('sendPointer is a no-op before connect', () { + expect(() => client().sendPointer(x: 1, y: 2, buttonMask: 0x1), + returnsNormally); + }); + + test('sendKey is a no-op before connect', () { + expect(() => client().sendKey(keysym: 0xFF0D, down: true), returnsNormally); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd packages/yourssh_vnc && flutter test test/vnc_client_input_test.dart` +Expected: FAIL — `sendPointer`/`sendKey` are not defined on `VncClient`. + +- [ ] **Step 3: Add the methods** + +In `packages/yourssh_vnc/lib/src/vnc_client.dart`, add inside the `VncClient` class (e.g. just before `dispose()`): + +```dart + /// Send a pointer event. [buttonMask] is the RFB bitmask + /// (bit0 left, bit1 middle, bit2 right, bit3 wheel-up, bit4 wheel-down). + /// No-op until the session has started. + void sendPointer({required int x, required int y, required int buttonMask}) { + final id = _sessionId; + if (id == null) return; + vncSendPointer(sessionId: id, x: x, y: y, buttonMask: buttonMask); + } + + /// Send a key event. [keysym] is an X11 keysym. No-op until started. + void sendKey({required int keysym, required bool down}) { + final id = _sessionId; + if (id == null) return; + vncSendKey(sessionId: id, keysym: keysym, down: down); + } +``` + +(`vncSendPointer`/`vncSendKey` come from `generated/api.dart`, which `vnc_client.dart` already imports.) + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd packages/yourssh_vnc && flutter test test/vnc_client_input_test.dart` +Expected: PASS — `All tests passed!` + +- [ ] **Step 5: Analyze** + +Run: `cd packages/yourssh_vnc && flutter analyze lib/src/vnc_client.dart` +Expected: `No issues found!` + +- [ ] **Step 6: Commit** + +```bash +git add packages/yourssh_vnc/lib/src/vnc_client.dart packages/yourssh_vnc/test/vnc_client_input_test.dart +git commit -m "feat(vnc): VncClient.sendPointer/sendKey input methods" +``` + +--- + +## Task 4: Dart — VNC input mapping (keysym table + coordinate transform) + +**Files:** +- Create: `app/lib/util/vnc_input_mapping.dart` +- Test: `app/test/util/vnc_input_mapping_test.dart` + +**Interfaces:** +- Produces (used by Task 5): `int? vncKeysymFor(PhysicalKeyboardKey key)`; `(int, int) vncSessionPoint({required double localX, required double localY, required double offX, required double offY, required double scale, required int width, required int height})`. + +Pure, no Flutter widgets or IO — fully unit-testable. The keysym table mirrors `app/lib/util/rdp_input_mapping.dart`'s structure (keyed by `PhysicalKeyboardKey.usbHidUsage`) but maps to X11 keysyms. Modifiers map to their `_L`/`_R` keysyms and are sent as their own events by the workspace (the server combines them with letter keysyms — so letters map to their *lowercase* keysym). + +- [ ] **Step 1: Write the failing test** + +Create `app/test/util/vnc_input_mapping_test.dart`: + +```dart +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/util/vnc_input_mapping.dart'; + +void main() { + group('vncKeysymFor', () { + test('letters map to lowercase Latin-1 keysyms', () { + expect(vncKeysymFor(PhysicalKeyboardKey.keyA), 0x61); + expect(vncKeysymFor(PhysicalKeyboardKey.keyZ), 0x7a); + }); + test('digits map to ASCII keysyms', () { + expect(vncKeysymFor(PhysicalKeyboardKey.digit1), 0x31); + expect(vncKeysymFor(PhysicalKeyboardKey.digit0), 0x30); + }); + test('common control keys', () { + expect(vncKeysymFor(PhysicalKeyboardKey.enter), 0xff0d); + expect(vncKeysymFor(PhysicalKeyboardKey.backspace), 0xff08); + expect(vncKeysymFor(PhysicalKeyboardKey.tab), 0xff09); + expect(vncKeysymFor(PhysicalKeyboardKey.escape), 0xff1b); + expect(vncKeysymFor(PhysicalKeyboardKey.space), 0x20); + }); + test('arrows and navigation', () { + expect(vncKeysymFor(PhysicalKeyboardKey.arrowUp), 0xff52); + expect(vncKeysymFor(PhysicalKeyboardKey.arrowLeft), 0xff51); + expect(vncKeysymFor(PhysicalKeyboardKey.home), 0xff50); + expect(vncKeysymFor(PhysicalKeyboardKey.delete), 0xffff); + }); + test('modifiers map to side-specific keysyms', () { + expect(vncKeysymFor(PhysicalKeyboardKey.shiftLeft), 0xffe1); + expect(vncKeysymFor(PhysicalKeyboardKey.controlLeft), 0xffe3); + expect(vncKeysymFor(PhysicalKeyboardKey.altLeft), 0xffe9); + }); + test('function keys', () { + expect(vncKeysymFor(PhysicalKeyboardKey.f1), 0xffbe); + expect(vncKeysymFor(PhysicalKeyboardKey.f12), 0xffc9); + }); + test('unmapped key returns null', () { + // F24 is not in the pragmatic table. + expect(vncKeysymFor(PhysicalKeyboardKey.f24), isNull); + }); + }); + + group('vncSessionPoint', () { + test('maps a centered letterboxed point to framebuffer coords', () { + // 800x600 framebuffer scaled 2x, letterboxed at (40, 10). + final (x, y) = vncSessionPoint( + localX: 40 + 100 * 2, + localY: 10 + 50 * 2, + offX: 40, + offY: 10, + scale: 2, + width: 800, + height: 600); + expect(x, 100); + expect(y, 50); + }); + test('clamps out-of-frame points into bounds', () { + final (x, y) = vncSessionPoint( + localX: -50, + localY: 99999, + offX: 0, + offY: 0, + scale: 1, + width: 800, + height: 600); + expect(x, 0); + expect(y, 599); + }); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd app && flutter test test/util/vnc_input_mapping_test.dart` +Expected: FAIL — `vnc_input_mapping.dart` does not exist. + +- [ ] **Step 3: Create the mapping** + +Create `app/lib/util/vnc_input_mapping.dart`: + +```dart +import 'package:flutter/services.dart'; + +/// Maps a physical key to an X11 keysym for RFB `KeyEvent`, or null when the +/// key is outside the pragmatic US-layout subset (printable + modifiers + +/// navigation + function keys). Letters map to their *lowercase* keysym; +/// Shift is sent as its own keysym event and combined server-side. +/// +/// Keyed by `PhysicalKeyboardKey.usbHidUsage` (mirrors rdp_input_mapping.dart). +int? vncKeysymFor(PhysicalKeyboardKey key) => _keysyms[key.usbHidUsage]; + +/// Transforms a widget-local pointer position into framebuffer pixel coords, +/// removing the centered letterbox offset and the render scale, clamped to +/// `[0, width-1] x [0, height-1]` so the result fits an unsigned 16-bit coord. +(int, int) vncSessionPoint({ + required double localX, + required double localY, + required double offX, + required double offY, + required double scale, + required int width, + required int height, +}) { + final x = ((localX - offX) / scale).round().clamp(0, width - 1); + final y = ((localY - offY) / scale).round().clamp(0, height - 1); + return (x, y); +} + +const Map _keysyms = { + // ── Letters (USB HID 0x04–0x1D) → lowercase Latin-1 keysyms 0x61–0x7a ── + 0x00070004: 0x61, // a + 0x00070005: 0x62, // b + 0x00070006: 0x63, // c + 0x00070007: 0x64, // d + 0x00070008: 0x65, // e + 0x00070009: 0x66, // f + 0x0007000A: 0x67, // g + 0x0007000B: 0x68, // h + 0x0007000C: 0x69, // i + 0x0007000D: 0x6a, // j + 0x0007000E: 0x6b, // k + 0x0007000F: 0x6c, // l + 0x00070010: 0x6d, // m + 0x00070011: 0x6e, // n + 0x00070012: 0x6f, // o + 0x00070013: 0x70, // p + 0x00070014: 0x71, // q + 0x00070015: 0x72, // r + 0x00070016: 0x73, // s + 0x00070017: 0x74, // t + 0x00070018: 0x75, // u + 0x00070019: 0x76, // v + 0x0007001A: 0x77, // w + 0x0007001B: 0x78, // x + 0x0007001C: 0x79, // y + 0x0007001D: 0x7a, // z + // ── Digits (0x1E–0x27) → ASCII '1'..'9','0' ── + 0x0007001E: 0x31, // 1 + 0x0007001F: 0x32, // 2 + 0x00070020: 0x33, // 3 + 0x00070021: 0x34, // 4 + 0x00070022: 0x35, // 5 + 0x00070023: 0x36, // 6 + 0x00070024: 0x37, // 7 + 0x00070025: 0x38, // 8 + 0x00070026: 0x39, // 9 + 0x00070027: 0x30, // 0 + // ── Control / whitespace ── + 0x00070028: 0xff0d, // Enter → Return + 0x00070029: 0xff1b, // Escape + 0x0007002A: 0xff08, // Backspace + 0x0007002B: 0xff09, // Tab + 0x0007002C: 0x20, // Space + 0x00070039: 0xffe5, // CapsLock + // ── Punctuation → ASCII keysyms (base/unshifted) ── + 0x0007002D: 0x2d, // - minus + 0x0007002E: 0x3d, // = equal + 0x0007002F: 0x5b, // [ bracketleft + 0x00070030: 0x5d, // ] bracketright + 0x00070031: 0x5c, // \ backslash + 0x00070033: 0x3b, // ; semicolon + 0x00070034: 0x27, // ' apostrophe + 0x00070035: 0x60, // ` grave + 0x00070036: 0x2c, // , comma + 0x00070037: 0x2e, // . period + 0x00070038: 0x2f, // / slash + // ── Function keys (0x3A–0x45) → 0xffbe–0xffc9 ── + 0x0007003A: 0xffbe, // F1 + 0x0007003B: 0xffbf, // F2 + 0x0007003C: 0xffc0, // F3 + 0x0007003D: 0xffc1, // F4 + 0x0007003E: 0xffc2, // F5 + 0x0007003F: 0xffc3, // F6 + 0x00070040: 0xffc4, // F7 + 0x00070041: 0xffc5, // F8 + 0x00070042: 0xffc6, // F9 + 0x00070043: 0xffc7, // F10 + 0x00070044: 0xffc8, // F11 + 0x00070045: 0xffc9, // F12 + // ── Navigation ── + 0x00070049: 0xff63, // Insert + 0x0007004A: 0xff50, // Home + 0x0007004B: 0xff55, // PageUp + 0x0007004C: 0xffff, // Delete + 0x0007004D: 0xff57, // End + 0x0007004E: 0xff56, // PageDown + 0x0007004F: 0xff53, // ArrowRight + 0x00070050: 0xff51, // ArrowLeft + 0x00070051: 0xff54, // ArrowDown + 0x00070052: 0xff52, // ArrowUp + // ── Modifiers (sent as their own keysym events) ── + 0x000700E0: 0xffe3, // LeftControl → Control_L + 0x000700E1: 0xffe1, // LeftShift → Shift_L + 0x000700E2: 0xffe9, // LeftAlt → Alt_L + 0x000700E3: 0xffeb, // LeftMeta → Super_L + 0x000700E4: 0xffe4, // RightControl → Control_R + 0x000700E5: 0xffe2, // RightShift → Shift_R + 0x000700E6: 0xffea, // RightAlt → Alt_R + 0x000700E7: 0xffec, // RightMeta → Super_R +}; +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd app && flutter test test/util/vnc_input_mapping_test.dart` +Expected: PASS — `All tests passed!` + +- [ ] **Step 5: Analyze** + +Run: `cd app && flutter analyze lib/util/vnc_input_mapping.dart` +Expected: `No issues found!` + +- [ ] **Step 6: Commit** + +```bash +git add app/lib/util/vnc_input_mapping.dart app/test/util/vnc_input_mapping_test.dart +git commit -m "feat(vnc): X11 keysym map + pointer coordinate transform" +``` + +--- + +## Task 5: Dart — wire mouse + keyboard capture into `VncWorkspace` + +**Files:** +- Modify: `app/lib/widgets/vnc_workspace.dart` +- Test: `app/test/widgets/vnc_workspace_input_test.dart` (create) + +**Interfaces:** +- Consumes: `VncClient.sendPointer`/`sendKey` (Task 3); `vncKeysymFor`/`vncSessionPoint` (Task 4); `HotkeyService().shouldSwallowKeyEvent` (existing). + +The connected branch currently shows "Waiting for first frame…" until an image arrives, with no input surface. Restructure it so the `Focus` + `Listener` + `CustomPaint` exist as soon as `connected` (the painter already no-ops on a null image), so input works from the first moment and matches RDP. + +- [ ] **Step 1: Write the failing widget test** + +Create `app/test/widgets/vnc_workspace_input_test.dart`: + +```dart +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/models/vnc_session.dart'; +import 'package:yourssh/widgets/vnc_workspace.dart'; +import 'package:yourssh_vnc/yourssh_vnc.dart' show VncClient, VncConfig; +// ignore: implementation_imports +import 'package:yourssh_vnc/src/generated/api.dart' as frb; + +Host _host() => Host( + id: 'v1', + label: 'desktop', + host: '10.0.0.5', + port: 5900, + username: 'u', + protocol: HostProtocol.vnc); + +VncClient _client() => VncClient(VncConfig( + targetHost: '10.0.0.5', targetPort: 5900, username: 'u', password: '')); + +void main() { + testWidgets('connected workspace exposes an input surface and handles keys', + (tester) async { + final events = StreamController(); + final session = VncSession(host: _host(), client: _client()); + session.attach(events.stream); + + await tester.pumpWidget(MaterialApp( + home: Scaffold(body: VncWorkspace(session: session)), + )); + + // Move to connected (server framebuffer 800x600); no image decoded yet. + events.add(const frb.VncEvent.connected(width: 800, height: 600)); + await tester.pump(); + + // The input surface (Listener) exists even before the first frame. + expect(find.byType(Listener), findsWidgets); + + // A key event reaches the handler without throwing (sendKey no-ops because + // the client has no live session id in the test). + await tester.sendKeyEvent(LogicalKeyboardKey.keyA); + await tester.pump(); + + await events.close(); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd app && flutter test test/widgets/vnc_workspace_input_test.dart` +Expected: FAIL — no `Listener` in the connected tree (current M2 workspace shows a `Text` "Waiting…" until an image arrives), so `find.byType(Listener)` finds nothing. + +- [ ] **Step 3: Add imports + a FocusNode field** + +In `app/lib/widgets/vnc_workspace.dart`, add these imports at the top (after the existing ones): + +```dart +import 'package:flutter/services.dart'; + +import '../services/hotkey_service.dart'; +import '../util/vnc_input_mapping.dart'; +``` + +In `_VncWorkspaceState`, add a focus node field and dispose it: + +```dart + final FocusNode _focusNode = FocusNode(); +``` + +Update `dispose()` to also dispose the node: + +```dart + @override + void dispose() { + _focusNode.dispose(); + session.removeListener(_onSessionChanged); + super.dispose(); + } +``` + +- [ ] **Step 4: Replace the connected branch with an interactive surface** + +In `_buildBody()`, replace the entire `case VncSessionStatus.connected:` block with: + +```dart + case VncSessionStatus.connected: + return LayoutBuilder(builder: (context, constraints) { + final w = session.width; + final h = session.height; + if (w == 0 || h == 0) { + return const SizedBox.expand(); + } + final scale = + math.min(constraints.maxWidth / w, constraints.maxHeight / h); + final offX = (constraints.maxWidth - w * scale) / 2; + final offY = (constraints.maxHeight - h * scale) / 2; + + (int, int) toFb(Offset local) => vncSessionPoint( + localX: local.dx, + localY: local.dy, + offX: offX, + offY: offY, + scale: scale, + width: w, + height: h); + + void sendPointer(Offset local, int mask) { + final (x, y) = toFb(local); + session.client.sendPointer(x: x, y: y, buttonMask: mask); + } + + return Focus( + focusNode: _focusNode, + autofocus: true, + onKeyEvent: (node, event) { + // Let app-level hotkeys win over the remote. + if (HotkeyService().shouldSwallowKeyEvent(event)) { + return KeyEventResult.handled; + } + final keysym = vncKeysymFor(event.physicalKey); + if (keysym == null) return KeyEventResult.ignored; + if (event is KeyDownEvent || event is KeyRepeatEvent) { + session.client.sendKey(keysym: keysym, down: true); + } else if (event is KeyUpEvent) { + session.client.sendKey(keysym: keysym, down: false); + } + return KeyEventResult.handled; + }, + child: Listener( + onPointerHover: (e) => sendPointer(e.localPosition, 0), + onPointerMove: (e) => + sendPointer(e.localPosition, _vncButtonMask(e.buttons)), + onPointerDown: (e) { + _focusNode.requestFocus(); + sendPointer(e.localPosition, _vncButtonMask(e.buttons)); + }, + // On pointer up Flutter has already cleared the released button + // from e.buttons, so the derived mask is the post-release state. + onPointerUp: (e) => + sendPointer(e.localPosition, _vncButtonMask(e.buttons)), + onPointerSignal: (e) { + if (e is PointerScrollEvent && e.scrollDelta.dy != 0) { + final mask = _vncButtonMask(e.buttons); + final wheel = e.scrollDelta.dy < 0 ? 0x08 : 0x10; // up : down + final (x, y) = toFb(e.localPosition); + // A wheel notch is a press+release of the wheel "button". + session.client + .sendPointer(x: x, y: y, buttonMask: mask | wheel); + session.client.sendPointer(x: x, y: y, buttonMask: mask); + } + }, + child: CustomPaint( + size: Size(constraints.maxWidth, constraints.maxHeight), + painter: _FramePainter(session, offX, offY, scale), + ), + ), + ); + }); +``` + +- [ ] **Step 5: Add the button-mask helper** + +Add this top-level function at the bottom of `app/lib/widgets/vnc_workspace.dart` (outside any class): + +```dart +/// Flutter's pressed-buttons bitfield → RFB button bitmask +/// (bit0 left, bit1 middle, bit2 right). +int _vncButtonMask(int flutterButtons) { + var mask = 0; + if (flutterButtons & kPrimaryMouseButton != 0) mask |= 0x1; + if (flutterButtons & kMiddleMouseButton != 0) mask |= 0x2; + if (flutterButtons & kSecondaryMouseButton != 0) mask |= 0x4; + return mask; +} +``` + +- [ ] **Step 6: Run the widget test to verify it passes** + +Run: `cd app && flutter test test/widgets/vnc_workspace_input_test.dart` +Expected: PASS — `All tests passed!` + +- [ ] **Step 7: Confirm no regression in the existing workspace test + analyze** + +Run: `cd app && flutter test test/widgets/vnc_workspace_test.dart` +Expected: PASS (the connecting→error→retry test is unaffected). +Run: `cd app && flutter analyze lib/widgets/vnc_workspace.dart` +Expected: `No issues found!` + +- [ ] **Step 8: Commit** + +```bash +git add app/lib/widgets/vnc_workspace.dart app/test/widgets/vnc_workspace_input_test.dart +git commit -m "feat(vnc): mouse + keyboard capture in VncWorkspace" +``` + +--- + +## Final verification + +- [ ] **Analyze the whole app** + +Run: `cd app && flutter analyze` +Expected: no new issues (pre-existing untracked `integration_test/*probe*` lints may remain). + +- [ ] **Run the full app test suite** + +Run: `cd app && flutter test` +Expected: all tests pass. + +- [ ] **Run the package tests** + +Run: `cd packages/yourssh_vnc && flutter test` and `cargo test --manifest-path packages/yourssh_vnc/rust/Cargo.toml` +Expected: all pass. + +- [ ] **(Optional, requires a live server) Manual smoke test** + +Against a local VNC server: connect, then confirm the mouse moves the remote cursor, clicks register, the wheel scrolls, and typing reaches the remote (letters, Enter, Backspace, arrows, Shift-combos). + +--- + +## Self-Review + +**1. Spec coverage** (umbrella `2026-06-16-vnc-support-design.md` Milestone 3 = "Input — mouse + keyboard (keysym mapping)"; Input mapping section): +- Pointer → RFB PointerEvent with X11 button mask (left=1, middle=2, right=4, wheel up/down=8/16), coords in server-negotiated space → Tasks 1/3/5 (`_vncButtonMask`, `vncSessionPoint`, wheel press+release). ✓ +- Keyboard → RFB KeyEvent (X11 keysym + down/up); LogicalKeyboardKey/physical map covering printable + modifiers + nav/function → Tasks 1/3/4/5. ✓ +- Latest-wins decode / view path unchanged. ✓ +- Deferred (documented): clipboard, resize, tunnel, fullscreen (M4); non-US layouts iterate later. ✓ + +**2. Placeholder scan:** every step has complete code/commands; no TBD/"handle errors"/"similar to". ✓ + +**3. Type consistency:** `SessionCmd::Pointer { x: u16, y: u16, button_mask: u8 }` and `SessionCmd::Key { keysym: u32, down: bool }` are identical across session.rs (Task 1), `input_event` (Task 1), and api.rs (Task 2). FFI `vnc_send_pointer(session_id,x,y,button_mask)`/`vnc_send_key(session_id,keysym,down)` → generated `vncSendPointer(sessionId,x,y,buttonMask)`/`vncSendKey(sessionId,keysym,down)` → consumed by `VncClient.sendPointer({x,y,buttonMask})`/`sendKey({keysym,down})` (Task 3) → called by the workspace (Task 5). `vncKeysymFor(PhysicalKeyboardKey)→int?` and `vncSessionPoint(...)→(int,int)` (Task 4) match their workspace call sites. ✓ diff --git a/docs/superpowers/plans/2026-06-17-vnc-m4-m5-parity-screenshots.md b/docs/superpowers/plans/2026-06-17-vnc-m4-m5-parity-screenshots.md new file mode 100644 index 00000000..81418d2e --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-vnc-m4-m5-parity-screenshots.md @@ -0,0 +1,1207 @@ +# VNC Milestone 4 (Parity) + Milestone 5 (Screenshots) 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:** Bring VNC to feature parity with RDP — clipboard (both directions), SSH-tunnel connections, fullscreen, a protocol badge, the host-panel SSH-tunnel dropdown, dashboard polish — and add a manual screenshots integration test. + +**Architecture:** Mirror RDP one-to-one. Generalize the protocol-neutral `RdpTunnelProxy` into a shared `LoopbackTunnelProxy` used by both protocols; add a clipboard command path (Dart → FFI → `vnc.input(X11Event::CopyText)`) and surface server cut-text to the system clipboard; mirror RDP's fullscreen (owned by `MainScreen`), generalize `RdpBadge` → `ProtocolBadge`, and extend the host panel + dashboard for VNC. + +**Tech Stack:** Flutter (Dart), `package:yourssh_vnc` (flutter_rust_bridge v2), `vnc-rs` 0.5.3, `windowManager`, `dart:ui`. + +## Global Constraints + +- `flutter_rust_bridge` pinned `=2.12.0` (codegen + runtime). Install codegen if missing: `cargo install flutter_rust_bridge_codegen --version 2.12.0 --locked`. +- Never add "Generated with Claude", co-author trailers, or any AI-tool mention anywhere (commits, code, docs). +- Work directly on `develop` (user-authorized). Commit per task. +- After any in-session `build.sh` on macOS, rewrite the dylib to a fresh inode or `dlopen`/`flutter test` dies with exit 137 — see Task 3 Step 5. +- Built native libs under `packages/yourssh_vnc/assets/native/` are gitignored — never `git add` them. +- VNC has **no TLS/cert layer** — no TOFU, no cert pinning anywhere. +- Resize is **receive-only**: the server drives size via `VncEvent.resize` (already handled in `VncSession._applyDesktopSize`); this plan does NOT add client-initiated `SetDesktopSize`. Task 8 fixes the painter so a server resize renders correctly. + +--- + +## Task 1: Generalize `RdpTunnelProxy` → `LoopbackTunnelProxy` + +**Files:** +- Rename: `app/lib/services/rdp_tunnel_proxy.dart` → `app/lib/services/loopback_tunnel_proxy.dart` +- Modify: `app/lib/models/rdp_session.dart` (import + field type), `app/lib/providers/session_provider.dart` (import + usage) +- Rename test: `app/test/services/rdp_tunnel_proxy_test.dart` → `app/test/services/loopback_tunnel_proxy_test.dart` + +**Interfaces:** +- Produces: `class LoopbackTunnelProxy { LoopbackTunnelProxy({void Function()? onClosed}); Future start(Future Function() openTunnel); Future stop(); }` and `class TunnelEnd { TunnelEnd({required Stream> stream, required StreamSink> sink, required void Function() close}); }`. + +The proxy is byte-pure (no RDP specifics) — this is a pure rename. RDP behavior must be preserved (its test still passes). + +- [ ] **Step 1: Rename the source file + class** + +```bash +git mv app/lib/services/rdp_tunnel_proxy.dart app/lib/services/loopback_tunnel_proxy.dart +``` +In `app/lib/services/loopback_tunnel_proxy.dart`, rename the class `RdpTunnelProxy` → `LoopbackTunnelProxy` (constructor name too). Update the class doc comment from "for SSH-tunneled RDP connections" to "for SSH-tunneled RDP/VNC connections". Leave `TunnelEnd` unchanged. + +- [ ] **Step 2: Rename the test file + update references** + +```bash +git mv app/test/services/rdp_tunnel_proxy_test.dart app/test/services/loopback_tunnel_proxy_test.dart +``` +In `loopback_tunnel_proxy_test.dart`: change the import to `package:yourssh/services/loopback_tunnel_proxy.dart` and replace every `RdpTunnelProxy(` with `LoopbackTunnelProxy(` (4 sites). + +- [ ] **Step 3: Update `rdp_session.dart`** + +Change the import from `import '../services/rdp_tunnel_proxy.dart';` to `import '../services/loopback_tunnel_proxy.dart';` and the field type `final RdpTunnelProxy? tunnelProxy;` → `final LoopbackTunnelProxy? tunnelProxy;`. + +- [ ] **Step 4: Update `session_provider.dart`** + +Change the import to `import '../services/loopback_tunnel_proxy.dart';`. In `connectRdp`, change the proxy type and instantiation: `LoopbackTunnelProxy? proxy;` and `proxy = LoopbackTunnelProxy(onClosed: () => session?.markTunnelClosed());`. + +- [ ] **Step 5: Verify rename is complete + tests pass** + +Run: `cd app && grep -rn "RdpTunnelProxy" lib test` → expect **no matches**. +Run: `cd app && flutter test test/services/loopback_tunnel_proxy_test.dart` → PASS. +Run: `cd app && flutter analyze lib/services/loopback_tunnel_proxy.dart lib/models/rdp_session.dart lib/providers/session_provider.dart` → `No issues found!` + +- [ ] **Step 6: Commit** + +```bash +git add -A app/lib/services app/test/services app/lib/models/rdp_session.dart app/lib/providers/session_provider.dart +git commit -m "refactor(vnc): rename RdpTunnelProxy to protocol-neutral LoopbackTunnelProxy" +``` + +--- + +## Task 2: VNC SSH-tunnel connection path + +**Files:** +- Modify: `app/lib/models/vnc_session.dart` (tunnel field, markTunnelClosed, close, disconnect message) +- Modify: `app/lib/providers/session_provider.dart` (`connectVnc` tunnel block) +- Test: `app/test/models/vnc_session_test.dart` (add a markTunnelClosed case) + +**Interfaces:** +- Consumes: `LoopbackTunnelProxy` (Task 1); `SshService.openTunnelSocket(String jumpHostId, String targetHost, int targetPort, String forHostId) -> Future`. +- Produces: `VncSession({required Host host, required VncClient client, LoopbackTunnelProxy? tunnelProxy})`; `VncSession.markTunnelClosed()`. + +- [ ] **Step 1: Write the failing test** + +Add to `app/test/models/vnc_session_test.dart` (it already imports the needed symbols): + +```dart + test('tunnel-closed marks the disconnect reason', () async { + final events = StreamController(); + final s = VncSession(host: _host(), client: _client()); + s.attach(events.stream); + s.markTunnelClosed(); + events.add(const frb.VncEvent.disconnected(reason: 'connection closed')); + await Future.delayed(Duration.zero); + expect(s.lastMessage, 'SSH tunnel closed'); + await events.close(); + }); +``` + +- [ ] **Step 2: Run to confirm FAIL** + +Run: `cd app && flutter test test/models/vnc_session_test.dart` +Expected: FAIL — `markTunnelClosed` not defined. + +- [ ] **Step 3: Add the tunnel plumbing to `VncSession`** + +In `app/lib/models/vnc_session.dart`: + +Add the import: +```dart +import '../services/loopback_tunnel_proxy.dart'; +``` + +Change the constructor + fields: +```dart + VncSession({required this.host, required this.client, this.tunnelProxy}); + + final Host host; + final VncClient client; + + /// Non-null when this session runs through an SSH tunnel; owned by the + /// session and stopped on [close]. + final LoopbackTunnelProxy? tunnelProxy; + bool _tunnelClosed = false; +``` + +Add the method (next to other methods): +```dart + /// Called by the tunnel proxy when the SSH side collapsed, so the + /// disconnect message names the real cause. + void markTunnelClosed() => _tunnelClosed = true; +``` + +In `_onEvent`, change the Disconnected/Error arms to prefer the tunnel reason: +```dart + case frb.VncEvent_Disconnected(:final reason): + status = VncSessionStatus.disconnected; + lastMessage = _tunnelClosed ? 'SSH tunnel closed' : reason; + case frb.VncEvent_Error(:final message): + status = VncSessionStatus.error; + lastMessage = _tunnelClosed ? 'SSH tunnel closed' : message; +``` + +In `close()`, stop the proxy in the `finally` (after `client.dispose()`): +```dart + } finally { + client.dispose(); + await tunnelProxy?.stop(); + image?.dispose(); + image = null; + } +``` + +- [ ] **Step 4: Add the tunnel block to `connectVnc`** + +In `app/lib/providers/session_provider.dart`, in `connectVnc`, replace the direct config build with a tunnel-aware one. Change the start of `connectVnc` from: +```dart + Future connectVnc(Host host) async { + final password = await _ssh.loadPassword(host.id) ?? ''; + + String? setupError; + try { + await VncClient.ensureInitialized(); + } catch (e) { + setupError = '$e'; + } + + final config = VncConfig( + targetHost: host.host, + targetPort: host.port, + username: host.username, + password: password, + ); + final client = VncClient(config); + final session = VncSession(host: host, client: client); +``` +to: +```dart + Future connectVnc(Host host) async { + final password = await _ssh.loadPassword(host.id) ?? ''; + + var targetHost = host.host; + var targetPort = host.port; + LoopbackTunnelProxy? proxy; + VncSession? session; + String? setupError; + + try { + await VncClient.ensureInitialized(); + if (host.jumpHostId != null) { + proxy = LoopbackTunnelProxy(onClosed: () => session?.markTunnelClosed()); + final port = await proxy.start(() async { + final sshSocket = await _ssh.openTunnelSocket( + host.jumpHostId!, host.host, host.port, host.id); + return TunnelEnd( + stream: sshSocket.stream, + sink: sshSocket.sink, + close: sshSocket.destroy); + }); + targetHost = '127.0.0.1'; + targetPort = port; + } + } catch (e) { + setupError = '$e'; + } + + final config = VncConfig( + targetHost: targetHost, + targetPort: targetPort, + username: host.username, + password: password, + ); + final client = VncClient(config); + session = VncSession(host: host, client: client, tunnelProxy: proxy); +``` +(The rest of `connectVnc` — `_applyTabMetadata`, attach/connect, `_watchVncStatus`, add to `_sessions`, etc. — is unchanged. Note `session` is now declared as `VncSession?` above and assigned here; the later `session.xxx` calls still work since it's non-null from this point. If the analyzer complains about nullable access after assignment, change them to `session!` or keep a local `final s = session;` — match whatever connectRdp does.) + +Confirm the import `import '../services/loopback_tunnel_proxy.dart';` is present (added in Task 1). + +- [ ] **Step 5: Run tests + analyze** + +Run: `cd app && flutter test test/models/vnc_session_test.dart` → PASS. +Run: `cd app && flutter analyze lib/models/vnc_session.dart lib/providers/session_provider.dart` → `No issues found!` + +- [ ] **Step 6: Commit** + +```bash +git add app/lib/models/vnc_session.dart app/lib/providers/session_provider.dart app/test/models/vnc_session_test.dart +git commit -m "feat(vnc): SSH-tunnel connections via shared loopback proxy" +``` + +--- + +## Task 3: Rust clipboard-send command + FFI + regenerate + +**Files:** +- Modify: `packages/yourssh_vnc/rust/src/session.rs`, `rust/src/run_loop.rs`, `rust/src/api.rs` +- Regenerate: `packages/yourssh_vnc/lib/src/generated/**`, `rust/src/frb_generated.rs` +- Rebuild: the native dylib (gitignored) + +**Interfaces:** +- Produces: generated `vncSendClipboardText({required int sessionId, required String text})`. + +- [ ] **Step 1: Add the command variant** + +In `packages/yourssh_vnc/rust/src/session.rs`, add to `SessionCmd`: +```rust + /// Send local clipboard text to the server (RFB ClientCutText). + ClipboardText(String), +``` +(Place it before `Disconnect`.) + +- [ ] **Step 2: Add a failing test for the mapping** + +In `packages/yourssh_vnc/rust/src/run_loop.rs`, add to the `#[cfg(test)] mod tests` block: +```rust + #[test] + fn input_event_maps_clipboard() { + match input_event(&SessionCmd::ClipboardText("hi".into())) { + Some(vnc::X11Event::CopyText(t)) => assert_eq!(t, "hi"), + other => panic!("expected CopyText, got {other:?}"), + } + } +``` + +- [ ] **Step 3: Run to confirm FAIL** + +Run: `cargo test --manifest-path packages/yourssh_vnc/rust/Cargo.toml input_event_maps_clipboard` +Expected: FAIL — the `ClipboardText` arm doesn't exist (non-exhaustive match or wrong result). + +- [ ] **Step 4: Handle ClipboardText in `input_event`** + +In `packages/yourssh_vnc/rust/src/run_loop.rs`, `input_event` currently matches `*cmd` (Copy). A `String` can't be moved out of a `&`, so switch to matching by reference. Replace the whole `input_event` fn with: +```rust +/// Translates an input `SessionCmd` into the `vnc-rs` event to feed +/// `client.input()`. Returns `None` for non-input commands (Disconnect). +pub fn input_event(cmd: &SessionCmd) -> Option { + match cmd { + SessionCmd::Pointer { x, y, button_mask } => { + Some(vnc::X11Event::PointerEvent(vnc::ClientMouseEvent { + position_x: *x, + position_y: *y, + bottons: *button_mask, + })) + } + SessionCmd::Key { keysym, down } => Some(vnc::X11Event::KeyEvent( + vnc::ClientKeyEvent { keycode: *keysym, down: *down }, + )), + SessionCmd::ClipboardText(text) => { + Some(vnc::X11Event::CopyText(text.clone())) + } + SessionCmd::Disconnect => None, + } +} +``` +(The run-loop `Some(input)` arm already routes through `input_event`, so clipboard now flows to `vnc.input(X11Event::CopyText)` automatically. No change to the select! arm.) + +- [ ] **Step 5: Add the FFI fn** + +In `packages/yourssh_vnc/rust/src/api.rs`, after `vnc_send_key`, add: +```rust +pub fn vnc_send_clipboard_text(session_id: u32, text: String) { + registry::send(session_id, SessionCmd::ClipboardText(text)); +} +``` + +- [ ] **Step 6: Build + regenerate + rebuild dylib** + +```bash +cargo test --manifest-path packages/yourssh_vnc/rust/Cargo.toml # input_event tests pass +cd packages/yourssh_vnc && flutter_rust_bridge_codegen generate +grep -n "vncSendClipboardText" packages/yourssh_vnc/lib/src/generated/api.dart # present +bash packages/yourssh_vnc/build.sh +``` +Then macOS provenance rewrite (skip on Linux): +```bash +python3 - <<'PY' +import os +p = "packages/yourssh_vnc/assets/native/macos/libyourssh_vnc.dylib" +data = open(p, "rb").read() +open(p + ".clean", "wb").write(data) +os.chmod(p + ".clean", 0o755) +os.replace(p + ".clean", p) +print("rewrote", p, len(data), "bytes") +PY +``` + +- [ ] **Step 7: Verify the bridge loads** + +Run: `cd packages/yourssh_vnc && flutter test` → PASS (roundtrip; re-run the rewrite if exit 137). + +- [ ] **Step 8: Commit (Rust + generated Dart + generated Rust glue; NOT the dylib)** + +```bash +git add packages/yourssh_vnc/rust/src/session.rs packages/yourssh_vnc/rust/src/run_loop.rs packages/yourssh_vnc/rust/src/api.rs packages/yourssh_vnc/rust/src/frb_generated.rs packages/yourssh_vnc/lib/src/generated +git status --porcelain packages/yourssh_vnc/assets # must be empty +git commit -m "feat(vnc): clipboard-send command + FFI + regenerated bindings" +``` +(Note: `rust/src/frb_generated.rs` IS regenerated by codegen and MUST be committed — the M3 omission lesson.) + +--- + +## Task 4: Dart clipboard — both directions + +**Files:** +- Modify: `packages/yourssh_vnc/lib/src/vnc_client.dart` (`sendClipboardText`) +- Modify: `app/lib/models/vnc_session.dart` (`onRemoteClipboardText` + event wiring) +- Modify: `app/lib/providers/session_provider.dart` (`connectVnc` wires the callback) +- Modify: `app/lib/widgets/vnc_workspace.dart` (focus-gain push + toolbar button) +- Test: `packages/yourssh_vnc/test/vnc_client_input_test.dart`, `app/test/models/vnc_session_test.dart` + +**Interfaces:** +- Consumes: generated `vncSendClipboardText` (Task 3). +- Produces: `VncClient.sendClipboardText(String)`; `VncSession.onRemoteClipboardText` (`void Function(String)?`). + +- [ ] **Step 1: Failing tests** + +In `packages/yourssh_vnc/test/vnc_client_input_test.dart`, add: +```dart + test('sendClipboardText is a no-op before connect', () { + expect(() => client().sendClipboardText('x'), returnsNormally); + }); +``` +In `app/test/models/vnc_session_test.dart`, add: +```dart + test('server cut-text invokes the clipboard callback (no repaint)', () async { + final events = StreamController(); + final s = VncSession(host: _host(), client: _client()); + String? got; + s.onRemoteClipboardText = (t) => got = t; + s.attach(events.stream); + events.add(const frb.VncEvent.clipboardText(text: 'hello')); + await Future.delayed(Duration.zero); + expect(got, 'hello'); + await events.close(); + }); +``` + +- [ ] **Step 2: Run to confirm FAIL** + +Run: `cd packages/yourssh_vnc && flutter test test/vnc_client_input_test.dart` and `cd app && flutter test test/models/vnc_session_test.dart` +Expected: FAIL (`sendClipboardText`/`onRemoteClipboardText` undefined). + +- [ ] **Step 3: `VncClient.sendClipboardText`** + +In `packages/yourssh_vnc/lib/src/vnc_client.dart`, add next to `sendKey`: +```dart + /// Send local clipboard text to the remote. No-op until started. + void sendClipboardText(String text) { + final id = _sessionId; + if (id == null) return; + vncSendClipboardText(sessionId: id, text: text); + } +``` + +- [ ] **Step 4: `VncSession.onRemoteClipboardText` + wire the event** + +In `app/lib/models/vnc_session.dart`, add a field (near `image`): +```dart + void Function(String text)? onRemoteClipboardText; +``` +Change the `_onEvent` ClipboardText arm from the M2 ignore: +```dart + case frb.VncEvent_ClipboardText(): + return; // clipboard handling is a later milestone; ignore for now +``` +to: +```dart + case frb.VncEvent_ClipboardText(:final text): + onRemoteClipboardText?.call(text); + return; // no repaint needed +``` + +- [ ] **Step 5: Wire the callback in `connectVnc`** + +In `app/lib/providers/session_provider.dart` `connectVnc`, after the `session = VncSession(...)` line, add: +```dart + session.onRemoteClipboardText = + (t) => Clipboard.setData(ClipboardData(text: t)); +``` +(Confirm `import 'package:flutter/services.dart';` is present in session_provider.dart — connectRdp uses `Clipboard`. If absent, add it.) + +- [ ] **Step 6: Workspace — push local clipboard on focus + toolbar button** + +In `app/lib/widgets/vnc_workspace.dart`: + +Add a State field: +```dart + String? _lastPushedClipboard; +``` +Add `onFocusChange` to the `Focus` in the connected branch (alongside `autofocus`/`onKeyEvent`): +```dart + onFocusChange: (gained) async { + if (!gained) return; + // Push local clipboard to the remote on focus gain, deduped so + // alt-tabbing doesn't re-send identical content. + final data = await Clipboard.getData(Clipboard.kTextPlain); + final text = data?.text; + if (text != null && + text.isNotEmpty && + text != _lastPushedClipboard) { + _lastPushedClipboard = text; + session.client.sendClipboardText(text); + } + }, +``` +Add a "push clipboard" button to `_Toolbar` (before the Disconnect button): +```dart + IconButton( + tooltip: 'Push clipboard to remote', + icon: const Icon(Icons.content_paste_go, size: 16), + onPressed: () => _pushClipboard(session), + ), +``` +And add the helper as a top-level function (near `_vncButtonMask`): +```dart +Future _pushClipboard(VncSession session) async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + if (data?.text != null) { + session.client.sendClipboardText(data!.text!); + } +} +``` +(`Clipboard`/`ClipboardData` come from `package:flutter/services.dart`, already imported in vnc_workspace.dart.) + +- [ ] **Step 7: Run tests + analyze** + +Run: `cd packages/yourssh_vnc && flutter test test/vnc_client_input_test.dart` → PASS. +Run: `cd app && flutter test test/models/vnc_session_test.dart` → PASS. +Run: `cd app && flutter analyze lib/models/vnc_session.dart lib/widgets/vnc_workspace.dart lib/providers/session_provider.dart` → clean. + +- [ ] **Step 8: Commit** + +```bash +git add packages/yourssh_vnc/lib/src/vnc_client.dart packages/yourssh_vnc/test/vnc_client_input_test.dart app/lib/models/vnc_session.dart app/lib/providers/session_provider.dart app/lib/widgets/vnc_workspace.dart app/test/models/vnc_session_test.dart +git commit -m "feat(vnc): clipboard both directions (server cut-text + focus/button push)" +``` + +--- + +## Task 5: `ProtocolBadge` (generalize `RdpBadge`) + +**Files:** +- Create: `app/lib/widgets/protocol_badge.dart` +- Delete: `app/lib/widgets/rdp_badge.dart` +- Modify: `app/lib/widgets/hosts_dashboard.dart` (2 sites), `app/lib/widgets/host_detail_panel.dart` (1 site) +- Test: `app/test/widgets/protocol_badge_test.dart` + +**Interfaces:** +- Produces: `class ProtocolBadge extends StatelessWidget { const ProtocolBadge(this.protocol, {super.key}); final HostProtocol protocol; }` — renders "RDP" (blue) or "VNC" (a distinct accent); renders nothing for `ssh`. + +- [ ] **Step 1: Failing test** + +Create `app/test/widgets/protocol_badge_test.dart`: +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/widgets/protocol_badge.dart'; + +void main() { + testWidgets('renders RDP / VNC labels, nothing for SSH', (tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold( + body: Column(children: [ + ProtocolBadge(HostProtocol.rdp), + ProtocolBadge(HostProtocol.vnc), + ProtocolBadge(HostProtocol.ssh), + ]), + ), + )); + expect(find.text('RDP'), findsOneWidget); + expect(find.text('VNC'), findsOneWidget); + expect(find.text('SSH'), findsNothing); + }); +} +``` + +- [ ] **Step 2: Run to confirm FAIL** + +Run: `cd app && flutter test test/widgets/protocol_badge_test.dart` → FAIL (file missing). + +- [ ] **Step 3: Create `ProtocolBadge`, delete `RdpBadge`** + +Create `app/lib/widgets/protocol_badge.dart`: +```dart +import 'package:flutter/material.dart'; + +import '../models/host.dart'; +import '../theme/app_theme.dart'; + +/// Small pill marking a remote-desktop host's protocol (RDP / VNC). One widget +/// shared by the dashboard cards, list rows, and the host detail header so a +/// restyle can't leave the call sites visually diverged. SSH renders nothing. +class ProtocolBadge extends StatelessWidget { + const ProtocolBadge(this.protocol, {super.key}); + + final HostProtocol protocol; + + @override + Widget build(BuildContext context) { + final (label, color) = switch (protocol) { + HostProtocol.rdp => ('RDP', AppColors.blue), + HostProtocol.vnc => ('VNC', AppColors.green), + HostProtocol.ssh => ('', AppColors.blue), + }; + if (label.isEmpty) return const SizedBox.shrink(); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withValues(alpha: 0.4), width: 0.5), + ), + child: Text( + label, + style: TextStyle( + color: color, fontSize: 9, fontWeight: FontWeight.w700), + ), + ); + } +} +``` +(Confirm `AppColors.green` exists in `app/lib/theme/app_theme.dart`; if not, use another defined accent — read the file and pick one that exists. Do NOT invent a color.) + +Then delete the old badge: +```bash +git rm app/lib/widgets/rdp_badge.dart +``` + +- [ ] **Step 4: Update the 3 call sites** + +In `app/lib/widgets/hosts_dashboard.dart`, replace both: +```dart + if (widget.host.protocol == HostProtocol.rdp) ...[ + const SizedBox(width: 6), + const RdpBadge(), + ], +``` +with: +```dart + if (widget.host.protocol != HostProtocol.ssh) ...[ + const SizedBox(width: 6), + ProtocolBadge(widget.host.protocol), + ], +``` +and change the import `import 'rdp_badge.dart';` → `import 'protocol_badge.dart';`. + +In `app/lib/widgets/host_detail_panel.dart`, replace: +```dart + if (_isRdp) ...[ + const SizedBox(width: 8), + const RdpBadge(), + ], +``` +with: +```dart + if (_isGraphical) ...[ + const SizedBox(width: 8), + ProtocolBadge(_protocol), + ], +``` +and change the import `import 'rdp_badge.dart';` → `import 'protocol_badge.dart';`. (`_isGraphical` exists from M2; `_protocol` is the panel's current protocol.) + +- [ ] **Step 5: Verify + analyze** + +Run: `cd app && grep -rn "RdpBadge\|rdp_badge" lib test` → no matches. +Run: `cd app && flutter test test/widgets/protocol_badge_test.dart` → PASS. +Run: `cd app && flutter analyze lib/widgets/protocol_badge.dart lib/widgets/hosts_dashboard.dart lib/widgets/host_detail_panel.dart` → clean. + +- [ ] **Step 6: Commit** + +```bash +git add -A app/lib/widgets app/test/widgets/protocol_badge_test.dart +git commit -m "feat(vnc): generalize RdpBadge into ProtocolBadge (RDP + VNC)" +``` + +--- + +## Task 6: VNC fullscreen + +**Files:** +- Modify: `app/lib/widgets/vnc_workspace.dart` (fullscreen props, pill, toolbar button) +- Modify: `app/lib/screens/main_screen.dart` (`_vncFullscreen`, `_setVncFullscreen`, force-exit, construct with props) +- Test: `app/test/widgets/vnc_workspace_test.dart` (fullscreen toggle case) + +**Interfaces:** +- Produces: `VncWorkspace({required VncSession session, VoidCallback? onReconnect, bool isFullscreen, ValueChanged? onFullscreenChanged})`. + +- [ ] **Step 1: Failing test** + +Add to `app/test/widgets/vnc_workspace_test.dart`: +```dart + testWidgets('fullscreen button fires onFullscreenChanged when connected', + (tester) async { + final events = StreamController(); + final session = VncSession(host: _host(), client: _client()); + session.attach(events.stream); + var fs = false; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: VncWorkspace( + session: session, onFullscreenChanged: (v) => fs = v), + ), + )); + events.add(const frb.VncEvent.connected(width: 800, height: 600)); + await tester.pump(); + await tester.tap(find.byTooltip('Fullscreen')); + await tester.pump(); + expect(fs, isTrue); + await events.close(); + }); +``` +(This test file needs `_host()`/`_client()` helpers + the frb import — copy them from `vnc_workspace_input_test.dart` if the file lacks them.) + +- [ ] **Step 2: Run to confirm FAIL** + +Run: `cd app && flutter test test/widgets/vnc_workspace_test.dart` → FAIL (no fullscreen param / no Fullscreen button). + +- [ ] **Step 3: Add fullscreen to `VncWorkspace`** + +In `app/lib/widgets/vnc_workspace.dart`: + +Add imports: +```dart +import 'dart:async'; +``` +Extend the widget: +```dart + const VncWorkspace({ + super.key, + required this.session, + this.onReconnect, + this.isFullscreen = false, + this.onFullscreenChanged, + }); + + final VncSession session; + final VoidCallback? onReconnect; + final bool isFullscreen; + final ValueChanged? onFullscreenChanged; +``` +Add hover-pill state + helpers to `_VncWorkspaceState`: +```dart + bool _hoverBarVisible = false; + Timer? _hoverBarTimer; + + void _flashHoverBar() { + setState(() => _hoverBarVisible = true); + _hoverBarTimer?.cancel(); + _hoverBarTimer = Timer(const Duration(milliseconds: 2500), () { + if (mounted) setState(() => _hoverBarVisible = false); + }); + } + + void _showHoverBar() { + _hoverBarTimer?.cancel(); + if (!_hoverBarVisible) setState(() => _hoverBarVisible = true); + } + + void _hideHoverBarSoon() { + _hoverBarTimer?.cancel(); + _hoverBarTimer = Timer(const Duration(milliseconds: 600), () { + if (mounted) setState(() => _hoverBarVisible = false); + }); + } +``` +In `initState`, flash on entry: +```dart + if (widget.isFullscreen) _flashHoverBar(); +``` +In `didUpdateWidget`, re-flash on entering fullscreen, and request windowed when the session leaves connected: +```dart + if (widget.isFullscreen && !old.isFullscreen) _flashHoverBar(); + if (widget.isFullscreen && session.status != VncSessionStatus.connected) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onFullscreenChanged?.call(false); + }); + } +``` +In `dispose`, cancel the timer (before `_focusNode.dispose()`): +```dart + _hoverBarTimer?.cancel(); +``` +Change `build` to branch on fullscreen: +```dart + @override + Widget build(BuildContext context) { + if (widget.isFullscreen) { + return Stack(children: [ + Positioned.fill(child: _buildBody()), + Positioned( + top: 0, left: 0, right: 0, height: 8, + child: MouseRegion( + opaque: false, + onEnter: (_) => _showHoverBar(), + child: const SizedBox.expand(), + ), + ), + Positioned( + top: 8, left: 0, right: 0, + child: Center( + child: AnimatedOpacity( + opacity: _hoverBarVisible ? 1 : 0, + duration: const Duration(milliseconds: 150), + child: IgnorePointer( + ignoring: !_hoverBarVisible, + child: MouseRegion( + onEnter: (_) => _showHoverBar(), + onExit: (_) => _hideHoverBarSoon(), + child: _ExitFullscreenPill( + onExit: () => widget.onFullscreenChanged?.call(false), + ), + ), + ), + ), + ), + ), + ]); + } + return Column(children: [ + _Toolbar( + session: session, + onEnterFullscreen: widget.onFullscreenChanged == null + ? null + : () => widget.onFullscreenChanged!.call(true), + ), + Expanded(child: _buildBody()), + ]); + } +``` +Update `_Toolbar` to take + show the fullscreen button: +```dart +class _Toolbar extends StatelessWidget { + const _Toolbar({required this.session, this.onEnterFullscreen}); + final VncSession session; + final VoidCallback? onEnterFullscreen; + + @override + Widget build(BuildContext context) { + return Container( + height: 34, + color: AppColors.card, + child: Row(children: [ + const SizedBox(width: 8), + Text(session.tabLabel, style: Theme.of(context).textTheme.labelMedium), + const Spacer(), + IconButton( + tooltip: 'Push clipboard to remote', + icon: const Icon(Icons.content_paste_go, size: 16), + onPressed: () => _pushClipboard(session), + ), + if (onEnterFullscreen != null) + IconButton( + tooltip: 'Fullscreen', + icon: const Icon(Icons.fullscreen, size: 16), + onPressed: session.status == VncSessionStatus.connected + ? onEnterFullscreen + : null, + ), + IconButton( + tooltip: 'Disconnect', + icon: const Icon(Icons.power_settings_new, size: 16), + onPressed: () => session.client.disconnect(), + ), + const SizedBox(width: 4), + ]), + ); + } +} +``` +Add the pill widget at the bottom of the file: +```dart +class _ExitFullscreenPill extends StatelessWidget { + const _ExitFullscreenPill({required this.onExit}); + final VoidCallback onExit; + + @override + Widget build(BuildContext context) { + return Material( + color: AppColors.card, + borderRadius: BorderRadius.circular(8), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: onExit, + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.fullscreen_exit, size: 16), + SizedBox(width: 6), + Text('Exit fullscreen'), + ]), + ), + ), + ); + } +} +``` +(The Task 4 clipboard button is included above; if Task 4 already added it, keep one copy.) + +- [ ] **Step 4: Wire fullscreen in `MainScreen`** + +In `app/lib/screens/main_screen.dart`: + +Add the field next to `_rdpFullscreen`: +```dart + bool _vncFullscreen = false; +``` +Add the setter next to `_setRdpFullscreen`: +```dart + Future _setVncFullscreen(bool on) async { + if (_vncFullscreen == on || !mounted) return; + setState(() => _vncFullscreen = on); + try { + await windowManager.setFullScreen(on); + } catch (_) { + // Window manager unavailable (tests/headless) — chrome state applied. + } + } +``` +In `build()`, next to the `rdpFullscreenActive` block, add the VNC equivalent (force-exit + chrome-collapsed return): +```dart + final vncFullscreenActive = + _vncFullscreen && _viewingTerminal && activeSession is VncSession; + if (_vncFullscreen && !vncFullscreenActive) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(_setVncFullscreen(false)); + }); + } + if (vncFullscreenActive) { + return Scaffold( + backgroundColor: AppColors.bg, + body: _buildForeground(activeSession), + ); + } +``` +Update the `VncWorkspace` construction in `_buildForeground`: +```dart + if (_viewingTerminal && active is VncSession) { + return VncWorkspace( + session: active, + onReconnect: () => _retryVnc(active), + isFullscreen: _vncFullscreen, + onFullscreenChanged: (on) => unawaited(_setVncFullscreen(on)), + ); + } +``` + +- [ ] **Step 5: Run tests + analyze** + +Run: `cd app && flutter test test/widgets/vnc_workspace_test.dart test/widgets/vnc_workspace_input_test.dart` → PASS. +Run: `cd app && flutter analyze lib/widgets/vnc_workspace.dart lib/screens/main_screen.dart` → clean. + +- [ ] **Step 6: Commit** + +```bash +git add app/lib/widgets/vnc_workspace.dart app/lib/screens/main_screen.dart app/test/widgets/vnc_workspace_test.dart +git commit -m "feat(vnc): fullscreen with auto-hide exit pill (mirrors RDP)" +``` + +--- + +## Task 7: Host panel — SSH-tunnel dropdown for VNC + +**Files:** +- Modify: `app/lib/widgets/host_detail_panel.dart` +- Test: `app/test/widgets/host_detail_panel_vnc_test.dart` + +The SSH-TUNNEL `Builder` block currently lives INSIDE the `if (_isRdp) ...[` block (with RDP SECURITY). Extract it so it renders for any graphical protocol, while RDP SECURITY + domain stay RDP-only. + +- [ ] **Step 1: Add a failing assertion** + +In `app/test/widgets/host_detail_panel_vnc_test.dart`, in the existing "VNC mode hides SSH-only and RDP-only sections" test, add (after seeding a second SSH host so the tunnel dropdown renders — the dropdown hides when there are no SSH hosts): +```dart + testWidgets('VNC mode shows the SSH TUNNEL dropdown', (tester) async { + final bastion = Host( + id: 'b1', label: 'bastion', host: '10.0.0.1', port: 22, username: 'u'); + await pumpPanel(tester, existing: vncHost(), allHosts: [bastion]); + expect(find.text('SSH TUNNEL'), findsOneWidget); + }); +``` +(Update `pumpPanel` to accept `allHosts` and seed them into the `HostProvider` — mirror `host_detail_panel_rdp_test.dart`'s `pumpPanel(allHosts:)`. If the existing `pumpPanel` lacks the param, add it.) + +- [ ] **Step 2: Run to confirm FAIL** + +Run: `cd app && flutter test test/widgets/host_detail_panel_vnc_test.dart` → FAIL (`SSH TUNNEL` not shown for VNC). + +- [ ] **Step 3: Extract the SSH-TUNNEL block** + +In `app/lib/widgets/host_detail_panel.dart`, the `if (_isRdp) ...[` block contains RDP SECURITY then the SSH-TUNNEL `Builder`. Move the SSH-TUNNEL `Builder(...)` OUT of that `if (_isRdp)` block into its own gate immediately after it: +```dart + ], // end of the RDP-SECURITY (still _isRdp) block — keep RDP SECURITY + domain here + + // SSH tunnel applies to any graphical protocol (RDP + VNC). + if (_isGraphical) + Builder(builder: (context) { + final sshHosts = context + .watch() + .allHosts + .where((h) => + h.protocol == HostProtocol.ssh && + h.id != widget.existing?.id) + .toList(); + if (sshHosts.isEmpty) return const SizedBox.shrink(); + final current = _jumpHostIds.firstOrNull; + final valid = + sshHosts.any((h) => h.id == current) ? current : null; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + _sectionLabel('SSH TUNNEL'), + const SizedBox(height: 6), + _Card(children: [ + _DropdownRow( + icon: Icons.alt_route, + child: DropdownButton( + value: valid, + isExpanded: true, + style: const TextStyle( + color: AppColors.textPrimary, fontSize: 13), + dropdownColor: AppColors.card, + underline: const SizedBox(), + items: [ + const DropdownMenuItem( + value: null, + child: Text('Direct connection')), + for (final h in sshHosts) + DropdownMenuItem( + value: h.id, + child: Text( + 'via ${h.label.isEmpty ? h.host : h.label}')), + ], + onChanged: (v) => setState(() => + _jumpHostIds = v == null ? [] : [v]), + ), + ), + ]), + ], + ); + }), +``` +Concretely: remove the SSH-TUNNEL `Builder` (and its leading content) from inside the `if (_isRdp) ...[ ]` list literal, close that list after RDP SECURITY, and re-add the `Builder` under the new `if (_isGraphical)`. The RDP SECURITY section and the domain field (Task references / M2) remain gated on `_isRdp`. + +- [ ] **Step 4: Run tests + analyze** + +Run: `cd app && flutter test test/widgets/host_detail_panel_vnc_test.dart test/widgets/host_detail_panel_rdp_test.dart` → both PASS (RDP still shows SSH TUNNEL; VNC now shows it; RDP SECURITY still RDP-only). +Run: `cd app && flutter analyze lib/widgets/host_detail_panel.dart` → clean. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/widgets/host_detail_panel.dart app/test/widgets/host_detail_panel_vnc_test.dart +git commit -m "feat(vnc): SSH-tunnel dropdown in the host panel for VNC hosts" +``` + +--- + +## Task 8: Dashboard cosmetics + painter resize fix + +**Files:** +- Modify: `app/lib/widgets/hosts_dashboard.dart` (Copy-URL scheme/label, bulk-skip message) +- Modify: `app/lib/widgets/vnc_workspace.dart` (`_FramePainter` self-fits to image dims) +- Test: `app/test/widgets/vnc_workspace_test.dart` (no new test required for the painter; covered by existing render); add a dashboard copy-url assertion only if a dashboard test already exists — otherwise rely on analyze. + +- [ ] **Step 1: Copy-URL scheme + label for VNC** + +In `app/lib/widgets/hosts_dashboard.dart`, change `_copyHostUrl`: +```dart + final scheme = widget.host.protocol == HostProtocol.rdp ? 'rdp' : 'ssh'; +``` +to: +```dart + final scheme = switch (widget.host.protocol) { + HostProtocol.rdp => 'rdp', + HostProtocol.vnc => 'vnc', + HostProtocol.ssh => 'ssh', + }; +``` +And the menu label: +```dart + _menuItem('copy_url', Icons.link_outlined, isSsh ? 'Copy SSH URL' : 'Copy RDP URL', () => _copyHostUrl(context)), +``` +to: +```dart + _menuItem('copy_url', Icons.link_outlined, + 'Copy ${widget.host.protocol.name.toUpperCase()} URL', + () => _copyHostUrl(context)), +``` + +- [ ] **Step 2: Bulk-skip message wording** + +In `_selectedSshHosts`, change: +```dart + content: Text('$skipped RDP host(s) skipped — $action is SSH-only'), +``` +to: +```dart + content: Text('$skipped non-SSH host(s) skipped — $action is SSH-only'), +``` + +- [ ] **Step 3: Painter self-fits to image dimensions** + +In `app/lib/widgets/vnc_workspace.dart`, make `_FramePainter` compute its own letterbox from the image's own dimensions (so a server resize renders the still-old-size frame correctly until the next decode, instead of stretching it to the new session-derived scale). Replace `_FramePainter` with: +```dart +class _FramePainter extends CustomPainter { + /// `repaint: session` redraws on every decoded frame without rebuilding the + /// surrounding widget tree (the workspace only rebuilds on status changes). + _FramePainter(this.session) : super(repaint: session); + + final VncSession session; + + @override + void paint(Canvas canvas, Size size) { + final ui.Image? img = session.image; + if (img == null) return; + // Fit by the IMAGE's own dimensions, not the session-negotiated size, so a + // server resize doesn't stretch the still-old frame for a few ms. + final s = math.min(size.width / img.width, size.height / img.height); + final dw = img.width * s, dh = img.height * s; + final ox = (size.width - dw) / 2, oy = (size.height - dh) / 2; + canvas.drawImageRect( + img, + Rect.fromLTWH(0, 0, img.width.toDouble(), img.height.toDouble()), + Rect.fromLTWH(ox, oy, dw, dh), + Paint()..filterQuality = FilterQuality.medium, + ); + } + + @override + bool shouldRepaint(_FramePainter old) => !identical(old.session, session); +} +``` +And update the construction in the connected branch (drop the offX/offY/scale args, which were only for the painter — the LayoutBuilder still computes them for the INPUT coordinate transform): +```dart + child: Stack( + fit: StackFit.expand, + children: [ + CustomPaint( + size: Size(constraints.maxWidth, constraints.maxHeight), + painter: _FramePainter(session), + ), + if (session.image == null) + const Center( + child: Text('Waiting for first frame…', + style: TextStyle(color: AppColors.textSecondary))), + ], + ), +``` +(Keep the `offX`/`offY`/`scale` locals and the `toFb`/`sendPointer` closures in the LayoutBuilder — input coordinates must still use the session-negotiated size.) + +- [ ] **Step 4: Run tests + analyze** + +Run: `cd app && flutter test test/widgets/vnc_workspace_test.dart test/widgets/vnc_workspace_input_test.dart` → PASS. +Run: `cd app && flutter analyze lib/widgets/hosts_dashboard.dart lib/widgets/vnc_workspace.dart` → clean. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/widgets/hosts_dashboard.dart app/lib/widgets/vnc_workspace.dart +git commit -m "feat(vnc): vnc:// copy-url, non-SSH bulk-skip wording, resize-correct painter" +``` + +--- + +## Task 9 (M5): VNC screenshots integration test + +**Files:** +- Create: `app/integration_test/vnc_screenshots_test.dart` +- Modify: `CLAUDE.md` (document the run command + prereqs) + +This is a manual-run test (lives under `integration_test/`, which the default `flutter test` skips; needs a live VNC container). Mirror `app/integration_test/rdp_screenshots_test.dart` exactly, swapping the RDP specifics. + +- [ ] **Step 1: Create the test** + +Create `app/integration_test/vnc_screenshots_test.dart` mirroring `rdp_screenshots_test.dart` with these substitutions (copy that file's `_snap`, `_waitFor`, backup/restore scaffolding verbatim, and adapt): +- Header prereqs: +```dart +// Screenshot capture for the in-app VNC client (incl. fullscreen). +// +// Drives the REAL app against a local x11vnc container and saves PNGs into +// /screenshots/. Frames are captured from the render tree, so no macOS +// Screen-Recording permission is needed. +// +// Prereqs (a password-auth VNC server on :5900): +// docker run -d --name yourssh-vnc-demo -p 5900:5900 \ +// -e VNC_PASSWORD=demo12345 consol/ubuntu-xfce-vnc:latest +// (or any x11vnc/TigerVNC server with password "demo12345" on 5900) +// +// Run: +// cd app && flutter test integration_test/vnc_screenshots_test.dart -d macos +``` +- `const _demoHostId = 'screenshot-vnc-demo';` +- Seed host: +```dart +final demoHost = Host( + id: _demoHostId, + label: 'Demo VNC', + host: '127.0.0.1', + port: 5900, + username: '', + authType: AuthType.password, + protocol: HostProtocol.vnc, +); +``` +- `await storage.savePassword(_demoHostId, 'demo12345');` +- NO TOFU dialog — after the double-tap to connect, go straight to waiting for connected (there is no "Trust … certificate?" dialog for VNC). Session access: +```dart +VncSession vnc() => sessions.sessions.whereType().first; +await _waitFor(tester, + () => sessions.sessions.whereType().isNotEmpty && + vnc().status == VncSessionStatus.connected, + what: 'VNC connected'); +await _waitFor(tester, () => vnc().image != null, what: 'first VNC frame'); +``` +- Screenshot names: `01-dashboard-vnc-badge`, `02-host-editor-vnc-form`, `03-vnc-workspace-connected`, `04-fullscreen-with-pill`, `05-fullscreen-hover-reveal`, `06-back-to-windowed`. +- Fullscreen steps: tap `find.byTooltip('Fullscreen')`, snap; hover the top edge / wait for the pill, snap; tap the exit pill (`find.text('Exit fullscreen')`), snap. +- Imports: add `package:yourssh/models/vnc_session.dart`; keep the rest from the RDP test (app.main(), SessionProvider, StorageService, Host, etc.). + +Use the exact `_outDir` constant from the RDP test (`/screenshots`). + +- [ ] **Step 2: Analyze the test compiles** + +Run: `cd app && flutter analyze integration_test/vnc_screenshots_test.dart` +Expected: `No issues found!` (it compiles even without a running container; it only fails at run time without one). + +- [ ] **Step 3: Document the command in CLAUDE.md** + +In `CLAUDE.md`, under the native-library / screenshots section (near the existing `rdp_screenshots_test.dart` line), add: +``` +# VNC feature screenshots (needs a local x11vnc/TigerVNC container on :5900 with +# password demo12345 — see the test header). Manual run, not CI. +cd app && flutter test integration_test/vnc_screenshots_test.dart -d macos +``` + +- [ ] **Step 4: Commit** + +```bash +git add app/integration_test/vnc_screenshots_test.dart CLAUDE.md +git commit -m "test(vnc): manual screenshots integration test + docs" +``` + +--- + +## Final verification + +- [ ] `cd app && flutter analyze` → no new issues (pre-existing untracked probe-file lints may remain). +- [ ] `cd app && flutter test` → all pass. +- [ ] `cd packages/yourssh_vnc && flutter test` and `cargo test --manifest-path packages/yourssh_vnc/rust/Cargo.toml` → pass. + +--- + +## Self-Review + +**1. Spec coverage** (umbrella M4 "Parity" + M5 "Tests/screenshots"): +- Clipboard both directions → Tasks 3 (send command) + 4 (server cut-text → system clipboard, focus/button push). ✓ +- Auto-resize → receive-only (already in `_applyDesktopSize`) + painter resize-correctness (Task 8). Client-initiated SetDesktopSize explicitly out of scope (documented). ✓ +- SSH tunnel via generalized loopback proxy → Tasks 1 (rename) + 2 (connectVnc tunnel). ✓ +- Fullscreen → Task 6. ✓ +- ProtocolBadge → Task 5. ✓ +- HostDetailPanel VNC mode (SSH-tunnel dropdown) → Task 7. ✓ +- Dashboard actions (vnc:// copy URL, bulk-skip wording; Duplicate already copies protocol) → Task 8. ✓ +- Screenshots → Task 9 (M5). ✓ + +**2. Placeholder scan:** every step has complete code/commands; no TBD/"handle errors"/"similar to". The two spots that say "confirm X exists / mirror the RDP test's helper" (AppColors.green, pumpPanel allHosts param, the RDP screenshots scaffolding) name the exact file to copy from — acceptable since the source is concrete and in-repo. + +**3. Type consistency:** `LoopbackTunnelProxy`/`TunnelEnd` (Task 1) used identically in Tasks 2. `SessionCmd::ClipboardText(String)` → `input_event` CopyText (Task 3) → `vnc_send_clipboard_text` → generated `vncSendClipboardText(sessionId,text)` → `VncClient.sendClipboardText(String)` (Task 4) → workspace/connectVnc. `VncSession({host, client, tunnelProxy})` + `markTunnelClosed()` + `onRemoteClipboardText` consistent across Tasks 2/4. `ProtocolBadge(HostProtocol)` (Task 5) used in dashboard + panel. `VncWorkspace({session, onReconnect, isFullscreen, onFullscreenChanged})` (Task 6) matches the MainScreen call site and the fullscreen test. `_FramePainter(session)` single-arg (Task 8) — its only construction site is updated in the same task. diff --git a/docs/superpowers/specs/2026-06-16-vnc-support-design.md b/docs/superpowers/specs/2026-06-16-vnc-support-design.md new file mode 100644 index 00000000..3abaeb0a --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-vnc-support-design.md @@ -0,0 +1,203 @@ +# VNC Support — Design Spec + +**Date:** 2026-06-16 +**Status:** Approved (architecture); Milestone 1 ready to plan + +## Overview + +Add a VNC (RFB) client as a first-class connection protocol alongside SSH and +RDP. The implementation mirrors the existing `yourssh_rdp` integration: a new +`packages/yourssh_vnc` Rust crate (engine: `vnc-rs`) bridged via +flutter_rust_bridge v2, a `VncSession` implementing `AppSession`, and a +`VncWorkspace` UI, with SSH tunneling reusing the existing one-shot loopback +proxy. Target servers for the first cut are **Linux VNC servers** (TigerVNC, +x11vnc, TightVNC). + +## Scope + +**In scope (full feature, decomposed into milestones below):** +- New `packages/yourssh_vnc` crate over `vnc-rs` (async/tokio, RFB client) +- Encodings: Raw, CopyRect, Tight, ZRLE (whatever `vnc-rs` negotiates) +- Auth: `None` + `VNC Authentication` (DES challenge with password) +- `HostProtocol.vnc` (default port 5900), password from secure storage +- `VncSession implements AppSession` — framebuffer + `ui.Image`, status, latest-wins decode +- `VncWorkspace` UI with fullscreen parity (hover pill, toolbar) +- Mouse + keyboard input (X11 keysym mapping) +- Clipboard (`Client/ServerCutText`) + auto-resize (`SetDesktopSize` / ExtendedDesktopSize) +- SSH tunneling via a generalized loopback proxy (shared with RDP) +- Tests: Rust unit, Dart FRB roundtrip, integration screenshots against a VNC container + +**Out of scope (initial):** +- macOS Screen Sharing / Apple Remote Desktop auth (DH-based) +- RealVNC RA2 and VeNCrypt/TLS auth +- UltraVNC MS-Logon +- TOFU certificate pinning — **N/A for plain VNC** (no TLS layer; security is the + SSH tunnel). This is a deliberate divergence from RDP, not a gap. + +## Architecture + +Parallels `yourssh_rdp` one-to-one. The RDP crate already runs on a tokio +multi-thread runtime and exposes a `StreamSink` event bus; `vnc-rs` +is async/tokio so the same run-loop shape applies. + +``` +Flutter UI (VncWorkspace) + └── SessionProvider.connectVnc → VncSession (AppSession) + └── VncClient (FRB) ── StreamSink ──┐ + └── packages/yourssh_vnc (Rust) │ + └── vnc-rs (RFB client over TCP) │ + └── loopback proxy (SSH-tunneled dials) ──────┘ +``` + +### Package layout (mirrors `yourssh_rdp`) + +``` +packages/yourssh_vnc/ + build.sh / build.ps1 # build + copy lib into assets/native/ (gitignored) + rust/Cargo.toml # vnc-rs, flutter_rust_bridge =2.12.0, tokio, anyhow + rust/src/ + lib.rs + api.rs # VncConfig, VncEvent, VncClient surface + connect.rs # TCP connect + RFB handshake + auth + run_loop.rs # framebuffer-update loop → StreamSink + input.rs # pointer/key → RFB messages + clipboard.rs # cut-text both directions + session.rs # shared session state + lib/yourssh_vnc.dart + lib/src/vnc_client.dart + lib/src/native_loader.dart + lib/src/generated/... # frb_generated + test/frb_roundtrip_test.dart +``` + +## Components + +### Rust crate (`packages/yourssh_vnc`) + +- **`VncConfig`** — host, port, username (optional for VNC), password, requested + encodings/quality, optional shared-flag. No domain, no security mode, no + expected fingerprint. +- **`VncEvent`** (FRB enum / StreamSink): `connected { width, height, name }`, + `framebuffer { x, y, width, height, pixels }` (BGRA, latest-wins on the Dart + side), `clipboard { text }`, `bell`, `disconnected`, `error { message }`, + `authFailed`. +- **`VncClient`** — `ensureInitialized()` lazy native load + FRB init on first + connect (no init in `main.dart`, matching `RdpClient`); `connect(config, sink)`; + `pointerEvent(x, y, buttonMask)`; `keyEvent(keysym, down)`; `setClipboard(text)`; + `requestResize(width, height)`; `disconnect()`. +- **`connect.rs`** — TCP dial, RFB version handshake, security-type negotiation + (None / VNC Authentication), DES challenge-response for password auth, + `ServerInit` → emit `connected` with the server-negotiated size and desktop name. +- **`run_loop.rs`** — drive `vnc-rs`, translate framebuffer updates into + `VncEvent.framebuffer` (dirty-rect, BGRA), surface cut-text/bell, map a clean + server close to `disconnected` and protocol/decoder errors to `error`. + +### App layer + +- **`Host`** — add `HostProtocol.vnc`; `HostProtocol.defaultPort` returns 5900 + for VNC. Port auto-flips between protocol defaults only when still on another + default (same rule already used for SSH↔RDP). `_save` preserves `protocol`. + Password reuses the existing `pw_` secure-storage path. No new Host + fields required for the Linux-only cut. +- **`VncSession`** (`extends ChangeNotifier implements AppSession`) — mirrors + `RdpSession`: holds `VncClient`, `Uint8List framebuffer` (reallocated to the + server-negotiated size from `connected`; out-of-bounds patches dropped), + `ui.Image? image` (latest-wins decode, old images disposed on replace/close), + `VncSessionStatus`, `close()` bounding `disconnect()` with a timeout so tunnel + teardown always runs, `markTunnelClosed()`. +- **`SessionProvider.connectVnc`** — mirrors `connectRdp`: lazy + `VncClient.ensureInitialized` (failure → error tab), persists tab metadata via + the shared `_persistTabMetadata`/`_applyTabMetadata`, writes connect/disconnect + audit rows (`source: vnc`), watches status for drops, releases the bastion + client when the last VNC tab of a host closes. +- **Loopback proxy** — generalize `RdpTunnelProxy` into a protocol-neutral + one-shot loopback proxy (rename + shared usage) so both RDP and VNC tunnel + SSH-forwarded dials through it. No new proxy implementation. +- **`VncWorkspace`** — mirrors `RdpWorkspace`: framebuffer paint, fullscreen + (`isFullscreen` + `onFullscreenChanged`, owned by `MainScreen`), auto-hide + hover pill, toolbar; force-exits fullscreen when the active tab changes or the + session leaves `connected`. +- **`ProtocolBadge`** — generalize `RdpBadge` to render an RDP **or** VNC badge + on dashboard cards, list rows, and the panel header. +- **`HostDetailPanel`** — extend the protocol `SegmentedButton` to SSH / RDP / + VNC; VNC mode shows the SSH-tunnel dropdown and hides SSH-only and RDP-only + (domain / RDP-security) sections. +- **Dashboard actions** — already protocol-aware for RDP; extend the same + branches so SFTP/Test hide for VNC, CONNECT ALL counts VNC tabs, Duplicate + keeps VNC fields, Copy URL uses `vnc://`. + +### Input mapping + +- Pointer: Flutter pointer events → RFB `PointerEvent` with an X11 button mask + (left=1, middle=2, right=4, wheel up/down=8/16), coordinates in the + server-negotiated framebuffer space. +- Keyboard: Flutter key events → RFB `KeyEvent` (X11 keysym + down/up). A + `LogicalKeyboardKey` → keysym map covers printable keys, modifiers, and the + common navigation/function keys. + +## Milestones + +Full parity is large, so it ships as five sub-projects. **Each milestone gets +its own spec → plan → implementation cycle** and its own PR. This document is +the umbrella architecture; Milestone 1 is detailed enough to plan now. + +1. **VNC core crate** — `packages/yourssh_vnc` over `vnc-rs`: connect + RFB + handshake + None/VNC-password auth + framebuffer-update loop + `VncEvent` + bus + `native_loader` + `build.sh`/`build.ps1` + Rust unit tests + Dart FRB + roundtrip test. No app wiring yet. **(Detailed below.)** +2. **End-to-end view-only** — `VncSession`, `SessionProvider.connectVnc`, + `HostProtocol.vnc`, minimal `VncWorkspace` rendering the framebuffer. +3. **Input** — mouse + keyboard (keysym mapping). +4. **Parity** — clipboard, auto-resize, SSH tunnel (generalized loopback proxy), + fullscreen, `ProtocolBadge`, `HostDetailPanel` VNC mode, dashboard actions. +5. **Tests/screenshots** — integration screenshots against a local VNC container + (mirrors `rdp_screenshots_test`). + +## Milestone 1 detail — VNC core crate + +**Deliverables:** +- `packages/yourssh_vnc` crate building `libyourssh_vnc.{dylib,so,dll}` via + `build.sh`/`build.ps1`, output copied into `assets/native/` (gitignored). +- `VncConfig`, `VncEvent`, `VncClient` FRB surface as above. +- `connect.rs`: RFB version handshake, security negotiation (None + VNC + Authentication), DES challenge-response, `ServerInit` → `connected`. +- `run_loop.rs`: framebuffer-update → `VncEvent.framebuffer` (BGRA dirty rects), + clean-close → `disconnected`, errors → `error`, auth failure → `authFailed`. +- `native_loader.dart` resolving the lib from the app bundle (release) or + `assets/native/` (dev), matching the RDP loader. + +**Tests:** +- Rust unit tests for the handshake/auth state machine and pixel-format/rect + translation (no live server needed — feed canned byte streams). +- `test/frb_roundtrip_test.dart` exercising the generated bridge (config in, + event enum out) like `yourssh_rdp/test/frb_roundtrip_test.dart`. + +**Out of milestone 1:** any Flutter app wiring (`VncSession`, providers, UI). + +## Backward compatibility + +- Additive only. `HostProtocol` gains a `vnc` variant; existing SSH/RDP hosts are + untouched. Sync payloads round-trip the new enum value (unknown-protocol hosts + from older clients already tolerate the RDP precedent). +- Generalizing `RdpTunnelProxy` and `RdpBadge` is a rename/extract; RDP behavior + is preserved (covered by existing RDP tests). +- The native lib is gitignored like the RDP one — `build.sh` must be run once + after clone; CI builds it fresh. + +## Risks / open questions + +- **`vnc-rs` API surface** — confirm during M1 planning that it exposes + incremental framebuffer requests, cut-text both directions, and + `SetDesktopSize`. If a needed hook is missing, fall back to a **local fork** + (the repo already forks `dartssh2`/`flutter_pty`/`xterm` via + `dependency_overrides` — same pattern, Rust side). +- **Keysym coverage** — X11 keysym mapping is fiddly for non-US layouts; M3 + scopes a pragmatic map (printable + modifiers + nav/function) and iterates. +- **Decode throughput** — Tight/ZRLE decode is CPU-bound; keep latest-wins + decode (no per-patch pileup) as RDP does. + +## Files changed (Milestone 1) + +- `packages/yourssh_vnc/**` (new package) +- `app/pubspec.yaml` — add the `yourssh_vnc` path dependency +- `.gitignore` — ignore `packages/yourssh_vnc/assets/native/` (mirror RDP) diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 66002bae..106fd091 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -2,7 +2,7 @@ A professional, open-source SSH client for **macOS**, **Windows**, and **Linux** — built for developers and sysadmins who manage multiple servers. -> **Current version:** 0.1.33 · [Download](https://github.com/YoursshLabs/yourssh/releases) · [Report an issue](https://github.com/YoursshLabs/yourssh/issues) +> **Current version:** 0.1.36 · [Download](https://github.com/YoursshLabs/yourssh/releases) · [Report an issue](https://github.com/YoursshLabs/yourssh/issues) --- @@ -16,6 +16,7 @@ A professional, open-source SSH client for **macOS**, **Windows**, and **Linux** | [Terminal](User-Guide-Terminal) | Tabs, health badges, shell integration, split view, broadcast, search, hotkeys, command palette | | [Terminal Sharing](User-Guide-Terminal-Sharing) | Share a live SSH session with teammates in real time | | [RDP](User-Guide-RDP) | Remote desktop for Windows/xrdp hosts — direct or SSH-tunneled, cert pinning, fullscreen | +| [VNC](User-Guide-VNC) | Remote desktop for Linux VNC servers (TigerVNC/x11vnc/TightVNC) — direct or SSH-tunneled, fullscreen | | [SFTP](User-Guide-SFTP) | Dual-panel file manager, uploads, downloads | | [Port Forwarding](User-Guide-Port-Forwarding) | Local, remote, and dynamic SOCKS5 tunnels | | [Recording](User-Guide-Recording) | Record and replay terminal sessions (Asciinema) | diff --git a/docs/wiki/User-Guide-VNC.md b/docs/wiki/User-Guide-VNC.md new file mode 100644 index 00000000..2e1bb246 --- /dev/null +++ b/docs/wiki/User-Guide-VNC.md @@ -0,0 +1,48 @@ +# VNC (Virtual Network Computing) + +Connect to Linux VNC servers — **TigerVNC**, **x11vnc**, **TightVNC** — directly from a YourSSH tab, alongside your SSH and RDP sessions. + + + +## Creating a VNC Host + +1. Open the host editor (new host, or edit an existing one). +2. Switch the **SSH / RDP / VNC** selector at the top to **VNC**. The port auto-flips to `5900` (a custom port is preserved). +3. Fill in: + - **Host / Port / Password** — the password is the VNC server password (VNC Authentication). A username is accepted but ignored by most VNC-auth servers. + - **SSH tunnel** — optionally route the VNC connection through one of your saved SSH hosts (its full connection chain, including multi-hop bastions, is reused). + +VNC hosts show a **VNC badge** on the dashboard, in list rows, and in the host panel header. + +## Connecting + +Click **Connect** like any host. + +> **Security note:** plain VNC has **no TLS/encryption layer** — there is no certificate trust dialog because there is no certificate. Raw VNC traffic (including the password challenge) is unencrypted on the wire. For anything beyond a trusted LAN, set an **SSH tunnel** on the host so the session rides inside an encrypted SSH connection. + +## Using the Desktop + +- **Keyboard** — printable keys, modifiers, and common navigation/function keys mapped to X11 keysyms; app hotkeys are swallowed so they don't also type into the remote desktop. +- **Mouse** — left/right/middle click and scroll; coordinates scale with the window into the server's framebuffer space. +- **Clipboard** — bidirectional (`ServerCutText` / `ClientCutText`). Remote copies land in your local clipboard; the local clipboard is pushed when the VNC view gains focus. +- **Resolution** — the desktop renders at the server's framebuffer size; if the server resizes (e.g. `SetDesktopSize`), the view adapts automatically. + +## Fullscreen + +Press the **fullscreen** button in the toolbar (enabled while connected). All app chrome disappears. + +To exit, move the mouse to the **top screen edge** — an mstsc-style pill appears (it also flashes for a moment when entering fullscreen) with exit / disconnect. The app automatically drops back to windowed mode if the session disconnects or you switch tabs. + +## Disconnects + +If the connection drops or the server closes it, the tab shows a clean message with a **Retry** button. Unexpected drops also land in the notification bell, and connects/disconnects are written to the [Audit Log](User-Guide-Audit-Log) (source `vnc`). + +## Tab Parity with SSH + +Rename, color tags, and pinning work on VNC tabs and persist across restarts. VNC tabs are restored on relaunch like SSH and RDP tabs. + +## What's Not Supported (yet) + +- TLS / VeNCrypt auth, macOS Screen Sharing / Apple Remote Desktop (DH-based) auth, RealVNC RA2, UltraVNC MS-Logon — the first release targets Linux VNC servers with None / VNC-password auth +- Audio redirection and file transfer +- Recording, split view, input bar, and snippets are SSH-only features and are hidden for VNC tabs diff --git a/packages/yourssh_vnc/build.ps1 b/packages/yourssh_vnc/build.ps1 new file mode 100644 index 00000000..8890c2ea --- /dev/null +++ b/packages/yourssh_vnc/build.ps1 @@ -0,0 +1,7 @@ +$ErrorActionPreference = "Stop" +Set-Location (Join-Path $PSScriptRoot "rust") +cargo build --release +Set-Location $PSScriptRoot +New-Item -ItemType Directory -Force -Path "assets/native/windows" | Out-Null +Copy-Item "rust/target/release/yourssh_vnc.dll" "assets/native/windows/" +Write-Host "yourssh_vnc native library built" diff --git a/packages/yourssh_vnc/build.sh b/packages/yourssh_vnc/build.sh new file mode 100755 index 00000000..bdf4321c --- /dev/null +++ b/packages/yourssh_vnc/build.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/rust" +case "$(uname -s)" in + Darwin) + # Universal dylib (Apple Silicon + Intel): build both targets and lipo + # them together so the one shipped .app runs on either architecture. + rustup target add aarch64-apple-darwin x86_64-apple-darwin + cargo build --release --target aarch64-apple-darwin + cargo build --release --target x86_64-apple-darwin + cd .. + mkdir -p assets/native/macos + lipo -create \ + rust/target/aarch64-apple-darwin/release/libyourssh_vnc.dylib \ + rust/target/x86_64-apple-darwin/release/libyourssh_vnc.dylib \ + -output assets/native/macos/libyourssh_vnc.dylib ;; + Linux) + cargo build --release + cd .. + mkdir -p assets/native/linux + cp rust/target/release/libyourssh_vnc.so assets/native/linux/ ;; +esac +echo "yourssh_vnc native library built" diff --git a/packages/yourssh_vnc/flutter_rust_bridge.yaml b/packages/yourssh_vnc/flutter_rust_bridge.yaml new file mode 100644 index 00000000..9deab858 --- /dev/null +++ b/packages/yourssh_vnc/flutter_rust_bridge.yaml @@ -0,0 +1,3 @@ +rust_input: crate::api +rust_root: rust +dart_output: lib/src/generated diff --git a/packages/yourssh_vnc/lib/src/.gitkeep b/packages/yourssh_vnc/lib/src/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/packages/yourssh_vnc/lib/src/generated/api.dart b/packages/yourssh_vnc/lib/src/generated/api.dart new file mode 100644 index 00000000..ca543bd8 --- /dev/null +++ b/packages/yourssh_vnc/lib/src/generated/api.dart @@ -0,0 +1,117 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import + +import 'frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; +import 'package:freezed_annotation/freezed_annotation.dart' hide protected; +part 'api.freezed.dart'; + +// These functions are ignored because they are not marked as `pub`: `runtime` +// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `RemoveOnDrop` +// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `drop` + +Future vncLibVersion() => RustLib.instance.api.crateApiVncLibVersion(); + +Stream vncConnect({required VncConfig config}) => + RustLib.instance.api.crateApiVncConnect(config: config); + +Future vncDisconnect({required int sessionId}) => + RustLib.instance.api.crateApiVncDisconnect(sessionId: sessionId); + +Future vncSendPointer({ + required int sessionId, + required int x, + required int y, + required int buttonMask, +}) => RustLib.instance.api.crateApiVncSendPointer( + sessionId: sessionId, + x: x, + y: y, + buttonMask: buttonMask, +); + +Future vncSendKey({ + required int sessionId, + required int keysym, + required bool down, +}) => RustLib.instance.api.crateApiVncSendKey( + sessionId: sessionId, + keysym: keysym, + down: down, +); + +Future vncSendClipboardText({ + required int sessionId, + required String text, +}) => RustLib.instance.api.crateApiVncSendClipboardText( + sessionId: sessionId, + text: text, +); + +class VncConfig { + final String targetHost; + final int targetPort; + final String username; + final String password; + + const VncConfig({ + required this.targetHost, + required this.targetPort, + required this.username, + required this.password, + }); + + @override + int get hashCode => + targetHost.hashCode ^ + targetPort.hashCode ^ + username.hashCode ^ + password.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is VncConfig && + runtimeType == other.runtimeType && + targetHost == other.targetHost && + targetPort == other.targetPort && + username == other.username && + password == other.password; +} + +@freezed +sealed class VncEvent with _$VncEvent { + const VncEvent._(); + + /// Always the first event. Carries the session id. + const factory VncEvent.started({required int sessionId}) = VncEvent_Started; + + /// Connection established. Server's initial framebuffer size. + const factory VncEvent.connected({required int width, required int height}) = + VncEvent_Connected; + + /// Server-driven desktop-size change after connect. + const factory VncEvent.resize({required int width, required int height}) = + VncEvent_Resize; + + /// A self-contained RGBA patch. `rgba` length is width * height * 4, alpha forced opaque. + const factory VncEvent.frameUpdate({ + required int x, + required int y, + required int width, + required int height, + required Uint8List rgba, + }) = VncEvent_FrameUpdate; + + /// Server clipboard (cut-text). + const factory VncEvent.clipboardText({required String text}) = + VncEvent_ClipboardText; + + /// Server bell. + const factory VncEvent.bell() = VncEvent_Bell; + const factory VncEvent.disconnected({required String reason}) = + VncEvent_Disconnected; + const factory VncEvent.error({required String message}) = VncEvent_Error; +} diff --git a/packages/yourssh_vnc/lib/src/generated/api.freezed.dart b/packages/yourssh_vnc/lib/src/generated/api.freezed.dart new file mode 100644 index 00000000..57b426bb --- /dev/null +++ b/packages/yourssh_vnc/lib/src/generated/api.freezed.dart @@ -0,0 +1,1696 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'api.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +/// @nodoc +mixin _$VncEvent { + @optionalTypeArgs + TResult when({ + required TResult Function(int sessionId) started, + required TResult Function(int width, int height) connected, + required TResult Function(int width, int height) resize, + required TResult Function( + int x, + int y, + int width, + int height, + Uint8List rgba, + ) + frameUpdate, + required TResult Function(String text) clipboardText, + required TResult Function() bell, + required TResult Function(String reason) disconnected, + required TResult Function(String message) error, + }) => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(int sessionId)? started, + TResult? Function(int width, int height)? connected, + TResult? Function(int width, int height)? resize, + TResult? Function(int x, int y, int width, int height, Uint8List rgba)? + frameUpdate, + TResult? Function(String text)? clipboardText, + TResult? Function()? bell, + TResult? Function(String reason)? disconnected, + TResult? Function(String message)? error, + }) => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(int sessionId)? started, + TResult Function(int width, int height)? connected, + TResult Function(int width, int height)? resize, + TResult Function(int x, int y, int width, int height, Uint8List rgba)? + frameUpdate, + TResult Function(String text)? clipboardText, + TResult Function()? bell, + TResult Function(String reason)? disconnected, + TResult Function(String message)? error, + required TResult orElse(), + }) => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(VncEvent_Started value) started, + required TResult Function(VncEvent_Connected value) connected, + required TResult Function(VncEvent_Resize value) resize, + required TResult Function(VncEvent_FrameUpdate value) frameUpdate, + required TResult Function(VncEvent_ClipboardText value) clipboardText, + required TResult Function(VncEvent_Bell value) bell, + required TResult Function(VncEvent_Disconnected value) disconnected, + required TResult Function(VncEvent_Error value) error, + }) => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(VncEvent_Started value)? started, + TResult? Function(VncEvent_Connected value)? connected, + TResult? Function(VncEvent_Resize value)? resize, + TResult? Function(VncEvent_FrameUpdate value)? frameUpdate, + TResult? Function(VncEvent_ClipboardText value)? clipboardText, + TResult? Function(VncEvent_Bell value)? bell, + TResult? Function(VncEvent_Disconnected value)? disconnected, + TResult? Function(VncEvent_Error value)? error, + }) => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(VncEvent_Started value)? started, + TResult Function(VncEvent_Connected value)? connected, + TResult Function(VncEvent_Resize value)? resize, + TResult Function(VncEvent_FrameUpdate value)? frameUpdate, + TResult Function(VncEvent_ClipboardText value)? clipboardText, + TResult Function(VncEvent_Bell value)? bell, + TResult Function(VncEvent_Disconnected value)? disconnected, + TResult Function(VncEvent_Error value)? error, + required TResult orElse(), + }) => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $VncEventCopyWith<$Res> { + factory $VncEventCopyWith(VncEvent value, $Res Function(VncEvent) then) = + _$VncEventCopyWithImpl<$Res, VncEvent>; +} + +/// @nodoc +class _$VncEventCopyWithImpl<$Res, $Val extends VncEvent> + implements $VncEventCopyWith<$Res> { + _$VncEventCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of VncEvent + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc +abstract class _$$VncEvent_StartedImplCopyWith<$Res> { + factory _$$VncEvent_StartedImplCopyWith( + _$VncEvent_StartedImpl value, + $Res Function(_$VncEvent_StartedImpl) then, + ) = __$$VncEvent_StartedImplCopyWithImpl<$Res>; + @useResult + $Res call({int sessionId}); +} + +/// @nodoc +class __$$VncEvent_StartedImplCopyWithImpl<$Res> + extends _$VncEventCopyWithImpl<$Res, _$VncEvent_StartedImpl> + implements _$$VncEvent_StartedImplCopyWith<$Res> { + __$$VncEvent_StartedImplCopyWithImpl( + _$VncEvent_StartedImpl _value, + $Res Function(_$VncEvent_StartedImpl) _then, + ) : super(_value, _then); + + /// Create a copy of VncEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? sessionId = null}) { + return _then( + _$VncEvent_StartedImpl( + sessionId: null == sessionId + ? _value.sessionId + : sessionId // ignore: cast_nullable_to_non_nullable + as int, + ), + ); + } +} + +/// @nodoc + +class _$VncEvent_StartedImpl extends VncEvent_Started { + const _$VncEvent_StartedImpl({required this.sessionId}) : super._(); + + @override + final int sessionId; + + @override + String toString() { + return 'VncEvent.started(sessionId: $sessionId)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$VncEvent_StartedImpl && + (identical(other.sessionId, sessionId) || + other.sessionId == sessionId)); + } + + @override + int get hashCode => Object.hash(runtimeType, sessionId); + + /// Create a copy of VncEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$VncEvent_StartedImplCopyWith<_$VncEvent_StartedImpl> get copyWith => + __$$VncEvent_StartedImplCopyWithImpl<_$VncEvent_StartedImpl>( + this, + _$identity, + ); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(int sessionId) started, + required TResult Function(int width, int height) connected, + required TResult Function(int width, int height) resize, + required TResult Function( + int x, + int y, + int width, + int height, + Uint8List rgba, + ) + frameUpdate, + required TResult Function(String text) clipboardText, + required TResult Function() bell, + required TResult Function(String reason) disconnected, + required TResult Function(String message) error, + }) { + return started(sessionId); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(int sessionId)? started, + TResult? Function(int width, int height)? connected, + TResult? Function(int width, int height)? resize, + TResult? Function(int x, int y, int width, int height, Uint8List rgba)? + frameUpdate, + TResult? Function(String text)? clipboardText, + TResult? Function()? bell, + TResult? Function(String reason)? disconnected, + TResult? Function(String message)? error, + }) { + return started?.call(sessionId); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(int sessionId)? started, + TResult Function(int width, int height)? connected, + TResult Function(int width, int height)? resize, + TResult Function(int x, int y, int width, int height, Uint8List rgba)? + frameUpdate, + TResult Function(String text)? clipboardText, + TResult Function()? bell, + TResult Function(String reason)? disconnected, + TResult Function(String message)? error, + required TResult orElse(), + }) { + if (started != null) { + return started(sessionId); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(VncEvent_Started value) started, + required TResult Function(VncEvent_Connected value) connected, + required TResult Function(VncEvent_Resize value) resize, + required TResult Function(VncEvent_FrameUpdate value) frameUpdate, + required TResult Function(VncEvent_ClipboardText value) clipboardText, + required TResult Function(VncEvent_Bell value) bell, + required TResult Function(VncEvent_Disconnected value) disconnected, + required TResult Function(VncEvent_Error value) error, + }) { + return started(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(VncEvent_Started value)? started, + TResult? Function(VncEvent_Connected value)? connected, + TResult? Function(VncEvent_Resize value)? resize, + TResult? Function(VncEvent_FrameUpdate value)? frameUpdate, + TResult? Function(VncEvent_ClipboardText value)? clipboardText, + TResult? Function(VncEvent_Bell value)? bell, + TResult? Function(VncEvent_Disconnected value)? disconnected, + TResult? Function(VncEvent_Error value)? error, + }) { + return started?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(VncEvent_Started value)? started, + TResult Function(VncEvent_Connected value)? connected, + TResult Function(VncEvent_Resize value)? resize, + TResult Function(VncEvent_FrameUpdate value)? frameUpdate, + TResult Function(VncEvent_ClipboardText value)? clipboardText, + TResult Function(VncEvent_Bell value)? bell, + TResult Function(VncEvent_Disconnected value)? disconnected, + TResult Function(VncEvent_Error value)? error, + required TResult orElse(), + }) { + if (started != null) { + return started(this); + } + return orElse(); + } +} + +abstract class VncEvent_Started extends VncEvent { + const factory VncEvent_Started({required final int sessionId}) = + _$VncEvent_StartedImpl; + const VncEvent_Started._() : super._(); + + int get sessionId; + + /// Create a copy of VncEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$VncEvent_StartedImplCopyWith<_$VncEvent_StartedImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$VncEvent_ConnectedImplCopyWith<$Res> { + factory _$$VncEvent_ConnectedImplCopyWith( + _$VncEvent_ConnectedImpl value, + $Res Function(_$VncEvent_ConnectedImpl) then, + ) = __$$VncEvent_ConnectedImplCopyWithImpl<$Res>; + @useResult + $Res call({int width, int height}); +} + +/// @nodoc +class __$$VncEvent_ConnectedImplCopyWithImpl<$Res> + extends _$VncEventCopyWithImpl<$Res, _$VncEvent_ConnectedImpl> + implements _$$VncEvent_ConnectedImplCopyWith<$Res> { + __$$VncEvent_ConnectedImplCopyWithImpl( + _$VncEvent_ConnectedImpl _value, + $Res Function(_$VncEvent_ConnectedImpl) _then, + ) : super(_value, _then); + + /// Create a copy of VncEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? width = null, Object? height = null}) { + return _then( + _$VncEvent_ConnectedImpl( + width: null == width + ? _value.width + : width // ignore: cast_nullable_to_non_nullable + as int, + height: null == height + ? _value.height + : height // ignore: cast_nullable_to_non_nullable + as int, + ), + ); + } +} + +/// @nodoc + +class _$VncEvent_ConnectedImpl extends VncEvent_Connected { + const _$VncEvent_ConnectedImpl({required this.width, required this.height}) + : super._(); + + @override + final int width; + @override + final int height; + + @override + String toString() { + return 'VncEvent.connected(width: $width, height: $height)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$VncEvent_ConnectedImpl && + (identical(other.width, width) || other.width == width) && + (identical(other.height, height) || other.height == height)); + } + + @override + int get hashCode => Object.hash(runtimeType, width, height); + + /// Create a copy of VncEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$VncEvent_ConnectedImplCopyWith<_$VncEvent_ConnectedImpl> get copyWith => + __$$VncEvent_ConnectedImplCopyWithImpl<_$VncEvent_ConnectedImpl>( + this, + _$identity, + ); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(int sessionId) started, + required TResult Function(int width, int height) connected, + required TResult Function(int width, int height) resize, + required TResult Function( + int x, + int y, + int width, + int height, + Uint8List rgba, + ) + frameUpdate, + required TResult Function(String text) clipboardText, + required TResult Function() bell, + required TResult Function(String reason) disconnected, + required TResult Function(String message) error, + }) { + return connected(width, height); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(int sessionId)? started, + TResult? Function(int width, int height)? connected, + TResult? Function(int width, int height)? resize, + TResult? Function(int x, int y, int width, int height, Uint8List rgba)? + frameUpdate, + TResult? Function(String text)? clipboardText, + TResult? Function()? bell, + TResult? Function(String reason)? disconnected, + TResult? Function(String message)? error, + }) { + return connected?.call(width, height); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(int sessionId)? started, + TResult Function(int width, int height)? connected, + TResult Function(int width, int height)? resize, + TResult Function(int x, int y, int width, int height, Uint8List rgba)? + frameUpdate, + TResult Function(String text)? clipboardText, + TResult Function()? bell, + TResult Function(String reason)? disconnected, + TResult Function(String message)? error, + required TResult orElse(), + }) { + if (connected != null) { + return connected(width, height); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(VncEvent_Started value) started, + required TResult Function(VncEvent_Connected value) connected, + required TResult Function(VncEvent_Resize value) resize, + required TResult Function(VncEvent_FrameUpdate value) frameUpdate, + required TResult Function(VncEvent_ClipboardText value) clipboardText, + required TResult Function(VncEvent_Bell value) bell, + required TResult Function(VncEvent_Disconnected value) disconnected, + required TResult Function(VncEvent_Error value) error, + }) { + return connected(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(VncEvent_Started value)? started, + TResult? Function(VncEvent_Connected value)? connected, + TResult? Function(VncEvent_Resize value)? resize, + TResult? Function(VncEvent_FrameUpdate value)? frameUpdate, + TResult? Function(VncEvent_ClipboardText value)? clipboardText, + TResult? Function(VncEvent_Bell value)? bell, + TResult? Function(VncEvent_Disconnected value)? disconnected, + TResult? Function(VncEvent_Error value)? error, + }) { + return connected?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(VncEvent_Started value)? started, + TResult Function(VncEvent_Connected value)? connected, + TResult Function(VncEvent_Resize value)? resize, + TResult Function(VncEvent_FrameUpdate value)? frameUpdate, + TResult Function(VncEvent_ClipboardText value)? clipboardText, + TResult Function(VncEvent_Bell value)? bell, + TResult Function(VncEvent_Disconnected value)? disconnected, + TResult Function(VncEvent_Error value)? error, + required TResult orElse(), + }) { + if (connected != null) { + return connected(this); + } + return orElse(); + } +} + +abstract class VncEvent_Connected extends VncEvent { + const factory VncEvent_Connected({ + required final int width, + required final int height, + }) = _$VncEvent_ConnectedImpl; + const VncEvent_Connected._() : super._(); + + int get width; + int get height; + + /// Create a copy of VncEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$VncEvent_ConnectedImplCopyWith<_$VncEvent_ConnectedImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$VncEvent_ResizeImplCopyWith<$Res> { + factory _$$VncEvent_ResizeImplCopyWith( + _$VncEvent_ResizeImpl value, + $Res Function(_$VncEvent_ResizeImpl) then, + ) = __$$VncEvent_ResizeImplCopyWithImpl<$Res>; + @useResult + $Res call({int width, int height}); +} + +/// @nodoc +class __$$VncEvent_ResizeImplCopyWithImpl<$Res> + extends _$VncEventCopyWithImpl<$Res, _$VncEvent_ResizeImpl> + implements _$$VncEvent_ResizeImplCopyWith<$Res> { + __$$VncEvent_ResizeImplCopyWithImpl( + _$VncEvent_ResizeImpl _value, + $Res Function(_$VncEvent_ResizeImpl) _then, + ) : super(_value, _then); + + /// Create a copy of VncEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? width = null, Object? height = null}) { + return _then( + _$VncEvent_ResizeImpl( + width: null == width + ? _value.width + : width // ignore: cast_nullable_to_non_nullable + as int, + height: null == height + ? _value.height + : height // ignore: cast_nullable_to_non_nullable + as int, + ), + ); + } +} + +/// @nodoc + +class _$VncEvent_ResizeImpl extends VncEvent_Resize { + const _$VncEvent_ResizeImpl({required this.width, required this.height}) + : super._(); + + @override + final int width; + @override + final int height; + + @override + String toString() { + return 'VncEvent.resize(width: $width, height: $height)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$VncEvent_ResizeImpl && + (identical(other.width, width) || other.width == width) && + (identical(other.height, height) || other.height == height)); + } + + @override + int get hashCode => Object.hash(runtimeType, width, height); + + /// Create a copy of VncEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$VncEvent_ResizeImplCopyWith<_$VncEvent_ResizeImpl> get copyWith => + __$$VncEvent_ResizeImplCopyWithImpl<_$VncEvent_ResizeImpl>( + this, + _$identity, + ); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(int sessionId) started, + required TResult Function(int width, int height) connected, + required TResult Function(int width, int height) resize, + required TResult Function( + int x, + int y, + int width, + int height, + Uint8List rgba, + ) + frameUpdate, + required TResult Function(String text) clipboardText, + required TResult Function() bell, + required TResult Function(String reason) disconnected, + required TResult Function(String message) error, + }) { + return resize(width, height); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(int sessionId)? started, + TResult? Function(int width, int height)? connected, + TResult? Function(int width, int height)? resize, + TResult? Function(int x, int y, int width, int height, Uint8List rgba)? + frameUpdate, + TResult? Function(String text)? clipboardText, + TResult? Function()? bell, + TResult? Function(String reason)? disconnected, + TResult? Function(String message)? error, + }) { + return resize?.call(width, height); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(int sessionId)? started, + TResult Function(int width, int height)? connected, + TResult Function(int width, int height)? resize, + TResult Function(int x, int y, int width, int height, Uint8List rgba)? + frameUpdate, + TResult Function(String text)? clipboardText, + TResult Function()? bell, + TResult Function(String reason)? disconnected, + TResult Function(String message)? error, + required TResult orElse(), + }) { + if (resize != null) { + return resize(width, height); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(VncEvent_Started value) started, + required TResult Function(VncEvent_Connected value) connected, + required TResult Function(VncEvent_Resize value) resize, + required TResult Function(VncEvent_FrameUpdate value) frameUpdate, + required TResult Function(VncEvent_ClipboardText value) clipboardText, + required TResult Function(VncEvent_Bell value) bell, + required TResult Function(VncEvent_Disconnected value) disconnected, + required TResult Function(VncEvent_Error value) error, + }) { + return resize(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(VncEvent_Started value)? started, + TResult? Function(VncEvent_Connected value)? connected, + TResult? Function(VncEvent_Resize value)? resize, + TResult? Function(VncEvent_FrameUpdate value)? frameUpdate, + TResult? Function(VncEvent_ClipboardText value)? clipboardText, + TResult? Function(VncEvent_Bell value)? bell, + TResult? Function(VncEvent_Disconnected value)? disconnected, + TResult? Function(VncEvent_Error value)? error, + }) { + return resize?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(VncEvent_Started value)? started, + TResult Function(VncEvent_Connected value)? connected, + TResult Function(VncEvent_Resize value)? resize, + TResult Function(VncEvent_FrameUpdate value)? frameUpdate, + TResult Function(VncEvent_ClipboardText value)? clipboardText, + TResult Function(VncEvent_Bell value)? bell, + TResult Function(VncEvent_Disconnected value)? disconnected, + TResult Function(VncEvent_Error value)? error, + required TResult orElse(), + }) { + if (resize != null) { + return resize(this); + } + return orElse(); + } +} + +abstract class VncEvent_Resize extends VncEvent { + const factory VncEvent_Resize({ + required final int width, + required final int height, + }) = _$VncEvent_ResizeImpl; + const VncEvent_Resize._() : super._(); + + int get width; + int get height; + + /// Create a copy of VncEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$VncEvent_ResizeImplCopyWith<_$VncEvent_ResizeImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$VncEvent_FrameUpdateImplCopyWith<$Res> { + factory _$$VncEvent_FrameUpdateImplCopyWith( + _$VncEvent_FrameUpdateImpl value, + $Res Function(_$VncEvent_FrameUpdateImpl) then, + ) = __$$VncEvent_FrameUpdateImplCopyWithImpl<$Res>; + @useResult + $Res call({int x, int y, int width, int height, Uint8List rgba}); +} + +/// @nodoc +class __$$VncEvent_FrameUpdateImplCopyWithImpl<$Res> + extends _$VncEventCopyWithImpl<$Res, _$VncEvent_FrameUpdateImpl> + implements _$$VncEvent_FrameUpdateImplCopyWith<$Res> { + __$$VncEvent_FrameUpdateImplCopyWithImpl( + _$VncEvent_FrameUpdateImpl _value, + $Res Function(_$VncEvent_FrameUpdateImpl) _then, + ) : super(_value, _then); + + /// Create a copy of VncEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? x = null, + Object? y = null, + Object? width = null, + Object? height = null, + Object? rgba = null, + }) { + return _then( + _$VncEvent_FrameUpdateImpl( + x: null == x + ? _value.x + : x // ignore: cast_nullable_to_non_nullable + as int, + y: null == y + ? _value.y + : y // ignore: cast_nullable_to_non_nullable + as int, + width: null == width + ? _value.width + : width // ignore: cast_nullable_to_non_nullable + as int, + height: null == height + ? _value.height + : height // ignore: cast_nullable_to_non_nullable + as int, + rgba: null == rgba + ? _value.rgba + : rgba // ignore: cast_nullable_to_non_nullable + as Uint8List, + ), + ); + } +} + +/// @nodoc + +class _$VncEvent_FrameUpdateImpl extends VncEvent_FrameUpdate { + const _$VncEvent_FrameUpdateImpl({ + required this.x, + required this.y, + required this.width, + required this.height, + required this.rgba, + }) : super._(); + + @override + final int x; + @override + final int y; + @override + final int width; + @override + final int height; + @override + final Uint8List rgba; + + @override + String toString() { + return 'VncEvent.frameUpdate(x: $x, y: $y, width: $width, height: $height, rgba: $rgba)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$VncEvent_FrameUpdateImpl && + (identical(other.x, x) || other.x == x) && + (identical(other.y, y) || other.y == y) && + (identical(other.width, width) || other.width == width) && + (identical(other.height, height) || other.height == height) && + const DeepCollectionEquality().equals(other.rgba, rgba)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + x, + y, + width, + height, + const DeepCollectionEquality().hash(rgba), + ); + + /// Create a copy of VncEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$VncEvent_FrameUpdateImplCopyWith<_$VncEvent_FrameUpdateImpl> + get copyWith => + __$$VncEvent_FrameUpdateImplCopyWithImpl<_$VncEvent_FrameUpdateImpl>( + this, + _$identity, + ); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(int sessionId) started, + required TResult Function(int width, int height) connected, + required TResult Function(int width, int height) resize, + required TResult Function( + int x, + int y, + int width, + int height, + Uint8List rgba, + ) + frameUpdate, + required TResult Function(String text) clipboardText, + required TResult Function() bell, + required TResult Function(String reason) disconnected, + required TResult Function(String message) error, + }) { + return frameUpdate(x, y, width, height, rgba); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(int sessionId)? started, + TResult? Function(int width, int height)? connected, + TResult? Function(int width, int height)? resize, + TResult? Function(int x, int y, int width, int height, Uint8List rgba)? + frameUpdate, + TResult? Function(String text)? clipboardText, + TResult? Function()? bell, + TResult? Function(String reason)? disconnected, + TResult? Function(String message)? error, + }) { + return frameUpdate?.call(x, y, width, height, rgba); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(int sessionId)? started, + TResult Function(int width, int height)? connected, + TResult Function(int width, int height)? resize, + TResult Function(int x, int y, int width, int height, Uint8List rgba)? + frameUpdate, + TResult Function(String text)? clipboardText, + TResult Function()? bell, + TResult Function(String reason)? disconnected, + TResult Function(String message)? error, + required TResult orElse(), + }) { + if (frameUpdate != null) { + return frameUpdate(x, y, width, height, rgba); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(VncEvent_Started value) started, + required TResult Function(VncEvent_Connected value) connected, + required TResult Function(VncEvent_Resize value) resize, + required TResult Function(VncEvent_FrameUpdate value) frameUpdate, + required TResult Function(VncEvent_ClipboardText value) clipboardText, + required TResult Function(VncEvent_Bell value) bell, + required TResult Function(VncEvent_Disconnected value) disconnected, + required TResult Function(VncEvent_Error value) error, + }) { + return frameUpdate(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(VncEvent_Started value)? started, + TResult? Function(VncEvent_Connected value)? connected, + TResult? Function(VncEvent_Resize value)? resize, + TResult? Function(VncEvent_FrameUpdate value)? frameUpdate, + TResult? Function(VncEvent_ClipboardText value)? clipboardText, + TResult? Function(VncEvent_Bell value)? bell, + TResult? Function(VncEvent_Disconnected value)? disconnected, + TResult? Function(VncEvent_Error value)? error, + }) { + return frameUpdate?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(VncEvent_Started value)? started, + TResult Function(VncEvent_Connected value)? connected, + TResult Function(VncEvent_Resize value)? resize, + TResult Function(VncEvent_FrameUpdate value)? frameUpdate, + TResult Function(VncEvent_ClipboardText value)? clipboardText, + TResult Function(VncEvent_Bell value)? bell, + TResult Function(VncEvent_Disconnected value)? disconnected, + TResult Function(VncEvent_Error value)? error, + required TResult orElse(), + }) { + if (frameUpdate != null) { + return frameUpdate(this); + } + return orElse(); + } +} + +abstract class VncEvent_FrameUpdate extends VncEvent { + const factory VncEvent_FrameUpdate({ + required final int x, + required final int y, + required final int width, + required final int height, + required final Uint8List rgba, + }) = _$VncEvent_FrameUpdateImpl; + const VncEvent_FrameUpdate._() : super._(); + + int get x; + int get y; + int get width; + int get height; + Uint8List get rgba; + + /// Create a copy of VncEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$VncEvent_FrameUpdateImplCopyWith<_$VncEvent_FrameUpdateImpl> + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$VncEvent_ClipboardTextImplCopyWith<$Res> { + factory _$$VncEvent_ClipboardTextImplCopyWith( + _$VncEvent_ClipboardTextImpl value, + $Res Function(_$VncEvent_ClipboardTextImpl) then, + ) = __$$VncEvent_ClipboardTextImplCopyWithImpl<$Res>; + @useResult + $Res call({String text}); +} + +/// @nodoc +class __$$VncEvent_ClipboardTextImplCopyWithImpl<$Res> + extends _$VncEventCopyWithImpl<$Res, _$VncEvent_ClipboardTextImpl> + implements _$$VncEvent_ClipboardTextImplCopyWith<$Res> { + __$$VncEvent_ClipboardTextImplCopyWithImpl( + _$VncEvent_ClipboardTextImpl _value, + $Res Function(_$VncEvent_ClipboardTextImpl) _then, + ) : super(_value, _then); + + /// Create a copy of VncEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? text = null}) { + return _then( + _$VncEvent_ClipboardTextImpl( + text: null == text + ? _value.text + : text // ignore: cast_nullable_to_non_nullable + as String, + ), + ); + } +} + +/// @nodoc + +class _$VncEvent_ClipboardTextImpl extends VncEvent_ClipboardText { + const _$VncEvent_ClipboardTextImpl({required this.text}) : super._(); + + @override + final String text; + + @override + String toString() { + return 'VncEvent.clipboardText(text: $text)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$VncEvent_ClipboardTextImpl && + (identical(other.text, text) || other.text == text)); + } + + @override + int get hashCode => Object.hash(runtimeType, text); + + /// Create a copy of VncEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$VncEvent_ClipboardTextImplCopyWith<_$VncEvent_ClipboardTextImpl> + get copyWith => + __$$VncEvent_ClipboardTextImplCopyWithImpl<_$VncEvent_ClipboardTextImpl>( + this, + _$identity, + ); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(int sessionId) started, + required TResult Function(int width, int height) connected, + required TResult Function(int width, int height) resize, + required TResult Function( + int x, + int y, + int width, + int height, + Uint8List rgba, + ) + frameUpdate, + required TResult Function(String text) clipboardText, + required TResult Function() bell, + required TResult Function(String reason) disconnected, + required TResult Function(String message) error, + }) { + return clipboardText(text); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(int sessionId)? started, + TResult? Function(int width, int height)? connected, + TResult? Function(int width, int height)? resize, + TResult? Function(int x, int y, int width, int height, Uint8List rgba)? + frameUpdate, + TResult? Function(String text)? clipboardText, + TResult? Function()? bell, + TResult? Function(String reason)? disconnected, + TResult? Function(String message)? error, + }) { + return clipboardText?.call(text); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(int sessionId)? started, + TResult Function(int width, int height)? connected, + TResult Function(int width, int height)? resize, + TResult Function(int x, int y, int width, int height, Uint8List rgba)? + frameUpdate, + TResult Function(String text)? clipboardText, + TResult Function()? bell, + TResult Function(String reason)? disconnected, + TResult Function(String message)? error, + required TResult orElse(), + }) { + if (clipboardText != null) { + return clipboardText(text); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(VncEvent_Started value) started, + required TResult Function(VncEvent_Connected value) connected, + required TResult Function(VncEvent_Resize value) resize, + required TResult Function(VncEvent_FrameUpdate value) frameUpdate, + required TResult Function(VncEvent_ClipboardText value) clipboardText, + required TResult Function(VncEvent_Bell value) bell, + required TResult Function(VncEvent_Disconnected value) disconnected, + required TResult Function(VncEvent_Error value) error, + }) { + return clipboardText(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(VncEvent_Started value)? started, + TResult? Function(VncEvent_Connected value)? connected, + TResult? Function(VncEvent_Resize value)? resize, + TResult? Function(VncEvent_FrameUpdate value)? frameUpdate, + TResult? Function(VncEvent_ClipboardText value)? clipboardText, + TResult? Function(VncEvent_Bell value)? bell, + TResult? Function(VncEvent_Disconnected value)? disconnected, + TResult? Function(VncEvent_Error value)? error, + }) { + return clipboardText?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(VncEvent_Started value)? started, + TResult Function(VncEvent_Connected value)? connected, + TResult Function(VncEvent_Resize value)? resize, + TResult Function(VncEvent_FrameUpdate value)? frameUpdate, + TResult Function(VncEvent_ClipboardText value)? clipboardText, + TResult Function(VncEvent_Bell value)? bell, + TResult Function(VncEvent_Disconnected value)? disconnected, + TResult Function(VncEvent_Error value)? error, + required TResult orElse(), + }) { + if (clipboardText != null) { + return clipboardText(this); + } + return orElse(); + } +} + +abstract class VncEvent_ClipboardText extends VncEvent { + const factory VncEvent_ClipboardText({required final String text}) = + _$VncEvent_ClipboardTextImpl; + const VncEvent_ClipboardText._() : super._(); + + String get text; + + /// Create a copy of VncEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$VncEvent_ClipboardTextImplCopyWith<_$VncEvent_ClipboardTextImpl> + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$VncEvent_BellImplCopyWith<$Res> { + factory _$$VncEvent_BellImplCopyWith( + _$VncEvent_BellImpl value, + $Res Function(_$VncEvent_BellImpl) then, + ) = __$$VncEvent_BellImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$VncEvent_BellImplCopyWithImpl<$Res> + extends _$VncEventCopyWithImpl<$Res, _$VncEvent_BellImpl> + implements _$$VncEvent_BellImplCopyWith<$Res> { + __$$VncEvent_BellImplCopyWithImpl( + _$VncEvent_BellImpl _value, + $Res Function(_$VncEvent_BellImpl) _then, + ) : super(_value, _then); + + /// Create a copy of VncEvent + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$VncEvent_BellImpl extends VncEvent_Bell { + const _$VncEvent_BellImpl() : super._(); + + @override + String toString() { + return 'VncEvent.bell()'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is _$VncEvent_BellImpl); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(int sessionId) started, + required TResult Function(int width, int height) connected, + required TResult Function(int width, int height) resize, + required TResult Function( + int x, + int y, + int width, + int height, + Uint8List rgba, + ) + frameUpdate, + required TResult Function(String text) clipboardText, + required TResult Function() bell, + required TResult Function(String reason) disconnected, + required TResult Function(String message) error, + }) { + return bell(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(int sessionId)? started, + TResult? Function(int width, int height)? connected, + TResult? Function(int width, int height)? resize, + TResult? Function(int x, int y, int width, int height, Uint8List rgba)? + frameUpdate, + TResult? Function(String text)? clipboardText, + TResult? Function()? bell, + TResult? Function(String reason)? disconnected, + TResult? Function(String message)? error, + }) { + return bell?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(int sessionId)? started, + TResult Function(int width, int height)? connected, + TResult Function(int width, int height)? resize, + TResult Function(int x, int y, int width, int height, Uint8List rgba)? + frameUpdate, + TResult Function(String text)? clipboardText, + TResult Function()? bell, + TResult Function(String reason)? disconnected, + TResult Function(String message)? error, + required TResult orElse(), + }) { + if (bell != null) { + return bell(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(VncEvent_Started value) started, + required TResult Function(VncEvent_Connected value) connected, + required TResult Function(VncEvent_Resize value) resize, + required TResult Function(VncEvent_FrameUpdate value) frameUpdate, + required TResult Function(VncEvent_ClipboardText value) clipboardText, + required TResult Function(VncEvent_Bell value) bell, + required TResult Function(VncEvent_Disconnected value) disconnected, + required TResult Function(VncEvent_Error value) error, + }) { + return bell(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(VncEvent_Started value)? started, + TResult? Function(VncEvent_Connected value)? connected, + TResult? Function(VncEvent_Resize value)? resize, + TResult? Function(VncEvent_FrameUpdate value)? frameUpdate, + TResult? Function(VncEvent_ClipboardText value)? clipboardText, + TResult? Function(VncEvent_Bell value)? bell, + TResult? Function(VncEvent_Disconnected value)? disconnected, + TResult? Function(VncEvent_Error value)? error, + }) { + return bell?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(VncEvent_Started value)? started, + TResult Function(VncEvent_Connected value)? connected, + TResult Function(VncEvent_Resize value)? resize, + TResult Function(VncEvent_FrameUpdate value)? frameUpdate, + TResult Function(VncEvent_ClipboardText value)? clipboardText, + TResult Function(VncEvent_Bell value)? bell, + TResult Function(VncEvent_Disconnected value)? disconnected, + TResult Function(VncEvent_Error value)? error, + required TResult orElse(), + }) { + if (bell != null) { + return bell(this); + } + return orElse(); + } +} + +abstract class VncEvent_Bell extends VncEvent { + const factory VncEvent_Bell() = _$VncEvent_BellImpl; + const VncEvent_Bell._() : super._(); +} + +/// @nodoc +abstract class _$$VncEvent_DisconnectedImplCopyWith<$Res> { + factory _$$VncEvent_DisconnectedImplCopyWith( + _$VncEvent_DisconnectedImpl value, + $Res Function(_$VncEvent_DisconnectedImpl) then, + ) = __$$VncEvent_DisconnectedImplCopyWithImpl<$Res>; + @useResult + $Res call({String reason}); +} + +/// @nodoc +class __$$VncEvent_DisconnectedImplCopyWithImpl<$Res> + extends _$VncEventCopyWithImpl<$Res, _$VncEvent_DisconnectedImpl> + implements _$$VncEvent_DisconnectedImplCopyWith<$Res> { + __$$VncEvent_DisconnectedImplCopyWithImpl( + _$VncEvent_DisconnectedImpl _value, + $Res Function(_$VncEvent_DisconnectedImpl) _then, + ) : super(_value, _then); + + /// Create a copy of VncEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? reason = null}) { + return _then( + _$VncEvent_DisconnectedImpl( + reason: null == reason + ? _value.reason + : reason // ignore: cast_nullable_to_non_nullable + as String, + ), + ); + } +} + +/// @nodoc + +class _$VncEvent_DisconnectedImpl extends VncEvent_Disconnected { + const _$VncEvent_DisconnectedImpl({required this.reason}) : super._(); + + @override + final String reason; + + @override + String toString() { + return 'VncEvent.disconnected(reason: $reason)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$VncEvent_DisconnectedImpl && + (identical(other.reason, reason) || other.reason == reason)); + } + + @override + int get hashCode => Object.hash(runtimeType, reason); + + /// Create a copy of VncEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$VncEvent_DisconnectedImplCopyWith<_$VncEvent_DisconnectedImpl> + get copyWith => + __$$VncEvent_DisconnectedImplCopyWithImpl<_$VncEvent_DisconnectedImpl>( + this, + _$identity, + ); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(int sessionId) started, + required TResult Function(int width, int height) connected, + required TResult Function(int width, int height) resize, + required TResult Function( + int x, + int y, + int width, + int height, + Uint8List rgba, + ) + frameUpdate, + required TResult Function(String text) clipboardText, + required TResult Function() bell, + required TResult Function(String reason) disconnected, + required TResult Function(String message) error, + }) { + return disconnected(reason); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(int sessionId)? started, + TResult? Function(int width, int height)? connected, + TResult? Function(int width, int height)? resize, + TResult? Function(int x, int y, int width, int height, Uint8List rgba)? + frameUpdate, + TResult? Function(String text)? clipboardText, + TResult? Function()? bell, + TResult? Function(String reason)? disconnected, + TResult? Function(String message)? error, + }) { + return disconnected?.call(reason); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(int sessionId)? started, + TResult Function(int width, int height)? connected, + TResult Function(int width, int height)? resize, + TResult Function(int x, int y, int width, int height, Uint8List rgba)? + frameUpdate, + TResult Function(String text)? clipboardText, + TResult Function()? bell, + TResult Function(String reason)? disconnected, + TResult Function(String message)? error, + required TResult orElse(), + }) { + if (disconnected != null) { + return disconnected(reason); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(VncEvent_Started value) started, + required TResult Function(VncEvent_Connected value) connected, + required TResult Function(VncEvent_Resize value) resize, + required TResult Function(VncEvent_FrameUpdate value) frameUpdate, + required TResult Function(VncEvent_ClipboardText value) clipboardText, + required TResult Function(VncEvent_Bell value) bell, + required TResult Function(VncEvent_Disconnected value) disconnected, + required TResult Function(VncEvent_Error value) error, + }) { + return disconnected(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(VncEvent_Started value)? started, + TResult? Function(VncEvent_Connected value)? connected, + TResult? Function(VncEvent_Resize value)? resize, + TResult? Function(VncEvent_FrameUpdate value)? frameUpdate, + TResult? Function(VncEvent_ClipboardText value)? clipboardText, + TResult? Function(VncEvent_Bell value)? bell, + TResult? Function(VncEvent_Disconnected value)? disconnected, + TResult? Function(VncEvent_Error value)? error, + }) { + return disconnected?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(VncEvent_Started value)? started, + TResult Function(VncEvent_Connected value)? connected, + TResult Function(VncEvent_Resize value)? resize, + TResult Function(VncEvent_FrameUpdate value)? frameUpdate, + TResult Function(VncEvent_ClipboardText value)? clipboardText, + TResult Function(VncEvent_Bell value)? bell, + TResult Function(VncEvent_Disconnected value)? disconnected, + TResult Function(VncEvent_Error value)? error, + required TResult orElse(), + }) { + if (disconnected != null) { + return disconnected(this); + } + return orElse(); + } +} + +abstract class VncEvent_Disconnected extends VncEvent { + const factory VncEvent_Disconnected({required final String reason}) = + _$VncEvent_DisconnectedImpl; + const VncEvent_Disconnected._() : super._(); + + String get reason; + + /// Create a copy of VncEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$VncEvent_DisconnectedImplCopyWith<_$VncEvent_DisconnectedImpl> + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$VncEvent_ErrorImplCopyWith<$Res> { + factory _$$VncEvent_ErrorImplCopyWith( + _$VncEvent_ErrorImpl value, + $Res Function(_$VncEvent_ErrorImpl) then, + ) = __$$VncEvent_ErrorImplCopyWithImpl<$Res>; + @useResult + $Res call({String message}); +} + +/// @nodoc +class __$$VncEvent_ErrorImplCopyWithImpl<$Res> + extends _$VncEventCopyWithImpl<$Res, _$VncEvent_ErrorImpl> + implements _$$VncEvent_ErrorImplCopyWith<$Res> { + __$$VncEvent_ErrorImplCopyWithImpl( + _$VncEvent_ErrorImpl _value, + $Res Function(_$VncEvent_ErrorImpl) _then, + ) : super(_value, _then); + + /// Create a copy of VncEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? message = null}) { + return _then( + _$VncEvent_ErrorImpl( + message: null == message + ? _value.message + : message // ignore: cast_nullable_to_non_nullable + as String, + ), + ); + } +} + +/// @nodoc + +class _$VncEvent_ErrorImpl extends VncEvent_Error { + const _$VncEvent_ErrorImpl({required this.message}) : super._(); + + @override + final String message; + + @override + String toString() { + return 'VncEvent.error(message: $message)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$VncEvent_ErrorImpl && + (identical(other.message, message) || other.message == message)); + } + + @override + int get hashCode => Object.hash(runtimeType, message); + + /// Create a copy of VncEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$VncEvent_ErrorImplCopyWith<_$VncEvent_ErrorImpl> get copyWith => + __$$VncEvent_ErrorImplCopyWithImpl<_$VncEvent_ErrorImpl>( + this, + _$identity, + ); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(int sessionId) started, + required TResult Function(int width, int height) connected, + required TResult Function(int width, int height) resize, + required TResult Function( + int x, + int y, + int width, + int height, + Uint8List rgba, + ) + frameUpdate, + required TResult Function(String text) clipboardText, + required TResult Function() bell, + required TResult Function(String reason) disconnected, + required TResult Function(String message) error, + }) { + return error(message); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(int sessionId)? started, + TResult? Function(int width, int height)? connected, + TResult? Function(int width, int height)? resize, + TResult? Function(int x, int y, int width, int height, Uint8List rgba)? + frameUpdate, + TResult? Function(String text)? clipboardText, + TResult? Function()? bell, + TResult? Function(String reason)? disconnected, + TResult? Function(String message)? error, + }) { + return error?.call(message); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(int sessionId)? started, + TResult Function(int width, int height)? connected, + TResult Function(int width, int height)? resize, + TResult Function(int x, int y, int width, int height, Uint8List rgba)? + frameUpdate, + TResult Function(String text)? clipboardText, + TResult Function()? bell, + TResult Function(String reason)? disconnected, + TResult Function(String message)? error, + required TResult orElse(), + }) { + if (error != null) { + return error(message); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(VncEvent_Started value) started, + required TResult Function(VncEvent_Connected value) connected, + required TResult Function(VncEvent_Resize value) resize, + required TResult Function(VncEvent_FrameUpdate value) frameUpdate, + required TResult Function(VncEvent_ClipboardText value) clipboardText, + required TResult Function(VncEvent_Bell value) bell, + required TResult Function(VncEvent_Disconnected value) disconnected, + required TResult Function(VncEvent_Error value) error, + }) { + return error(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(VncEvent_Started value)? started, + TResult? Function(VncEvent_Connected value)? connected, + TResult? Function(VncEvent_Resize value)? resize, + TResult? Function(VncEvent_FrameUpdate value)? frameUpdate, + TResult? Function(VncEvent_ClipboardText value)? clipboardText, + TResult? Function(VncEvent_Bell value)? bell, + TResult? Function(VncEvent_Disconnected value)? disconnected, + TResult? Function(VncEvent_Error value)? error, + }) { + return error?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(VncEvent_Started value)? started, + TResult Function(VncEvent_Connected value)? connected, + TResult Function(VncEvent_Resize value)? resize, + TResult Function(VncEvent_FrameUpdate value)? frameUpdate, + TResult Function(VncEvent_ClipboardText value)? clipboardText, + TResult Function(VncEvent_Bell value)? bell, + TResult Function(VncEvent_Disconnected value)? disconnected, + TResult Function(VncEvent_Error value)? error, + required TResult orElse(), + }) { + if (error != null) { + return error(this); + } + return orElse(); + } +} + +abstract class VncEvent_Error extends VncEvent { + const factory VncEvent_Error({required final String message}) = + _$VncEvent_ErrorImpl; + const VncEvent_Error._() : super._(); + + String get message; + + /// Create a copy of VncEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$VncEvent_ErrorImplCopyWith<_$VncEvent_ErrorImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/packages/yourssh_vnc/lib/src/generated/frb_generated.dart b/packages/yourssh_vnc/lib/src/generated/frb_generated.dart new file mode 100644 index 00000000..93626d92 --- /dev/null +++ b/packages/yourssh_vnc/lib/src/generated/frb_generated.dart @@ -0,0 +1,689 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +import 'api.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'frb_generated.dart'; +import 'frb_generated.io.dart' + if (dart.library.js_interop) 'frb_generated.web.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +/// Main entrypoint of the Rust API +class RustLib extends BaseEntrypoint { + @internal + static final instance = RustLib._(); + + RustLib._(); + + /// Initialize flutter_rust_bridge + static Future init({ + RustLibApi? api, + BaseHandler? handler, + ExternalLibrary? externalLibrary, + bool forceSameCodegenVersion = true, + }) async { + await instance.initImpl( + api: api, + handler: handler, + externalLibrary: externalLibrary, + forceSameCodegenVersion: forceSameCodegenVersion, + ); + } + + /// Initialize flutter_rust_bridge in mock mode. + /// No libraries for FFI are loaded. + static void initMock({required RustLibApi api}) { + instance.initMockImpl(api: api); + } + + /// Dispose flutter_rust_bridge + /// + /// The call to this function is optional, since flutter_rust_bridge (and everything else) + /// is automatically disposed when the app stops. + static void dispose() => instance.disposeImpl(); + + @override + ApiImplConstructor get apiImplConstructor => + RustLibApiImpl.new; + + @override + WireConstructor get wireConstructor => + RustLibWire.fromExternalLibrary; + + @override + Future executeRustInitializers() async {} + + @override + ExternalLibraryLoaderConfig get defaultExternalLibraryLoaderConfig => + kDefaultExternalLibraryLoaderConfig; + + @override + String get codegenVersion => '2.12.0'; + + @override + int get rustContentHash => -1793444694; + + static const kDefaultExternalLibraryLoaderConfig = + ExternalLibraryLoaderConfig( + stem: 'yourssh_vnc', + ioDirectory: 'rust/target/release/', + webPrefix: 'pkg/', + wasmBindgenName: 'wasm_bindgen', + ); +} + +abstract class RustLibApi extends BaseApi { + Stream crateApiVncConnect({required VncConfig config}); + + Future crateApiVncDisconnect({required int sessionId}); + + Future crateApiVncLibVersion(); + + Future crateApiVncSendClipboardText({ + required int sessionId, + required String text, + }); + + Future crateApiVncSendKey({ + required int sessionId, + required int keysym, + required bool down, + }); + + Future crateApiVncSendPointer({ + required int sessionId, + required int x, + required int y, + required int buttonMask, + }); +} + +class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { + RustLibApiImpl({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @override + Stream crateApiVncConnect({required VncConfig config}) { + final sink = RustStreamSink(); + unawaited( + handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_box_autoadd_vnc_config(config, serializer); + sse_encode_StreamSink_vnc_event_Sse(sink, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 1, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiVncConnectConstMeta, + argValues: [config, sink], + apiImpl: this, + ), + ), + ); + return sink.stream; + } + + TaskConstMeta get kCrateApiVncConnectConstMeta => const TaskConstMeta( + debugName: "vnc_connect", + argNames: ["config", "sink"], + ); + + @override + Future crateApiVncDisconnect({required int sessionId}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_32(sessionId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 2, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiVncDisconnectConstMeta, + argValues: [sessionId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiVncDisconnectConstMeta => + const TaskConstMeta(debugName: "vnc_disconnect", argNames: ["sessionId"]); + + @override + Future crateApiVncLibVersion() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 3, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: null, + ), + constMeta: kCrateApiVncLibVersionConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiVncLibVersionConstMeta => + const TaskConstMeta(debugName: "vnc_lib_version", argNames: []); + + @override + Future crateApiVncSendClipboardText({ + required int sessionId, + required String text, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_32(sessionId, serializer); + sse_encode_String(text, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 4, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiVncSendClipboardTextConstMeta, + argValues: [sessionId, text], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiVncSendClipboardTextConstMeta => + const TaskConstMeta( + debugName: "vnc_send_clipboard_text", + argNames: ["sessionId", "text"], + ); + + @override + Future crateApiVncSendKey({ + required int sessionId, + required int keysym, + required bool down, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_32(sessionId, serializer); + sse_encode_u_32(keysym, serializer); + sse_encode_bool(down, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 5, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiVncSendKeyConstMeta, + argValues: [sessionId, keysym, down], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiVncSendKeyConstMeta => const TaskConstMeta( + debugName: "vnc_send_key", + argNames: ["sessionId", "keysym", "down"], + ); + + @override + Future crateApiVncSendPointer({ + required int sessionId, + required int x, + required int y, + required int buttonMask, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_32(sessionId, serializer); + sse_encode_u_16(x, serializer); + sse_encode_u_16(y, serializer); + sse_encode_u_8(buttonMask, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 6, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiVncSendPointerConstMeta, + argValues: [sessionId, x, y, buttonMask], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiVncSendPointerConstMeta => const TaskConstMeta( + debugName: "vnc_send_pointer", + argNames: ["sessionId", "x", "y", "buttonMask"], + ); + + @protected + AnyhowException dco_decode_AnyhowException(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return AnyhowException(raw as String); + } + + @protected + RustStreamSink dco_decode_StreamSink_vnc_event_Sse(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + throw UnimplementedError(); + } + + @protected + String dco_decode_String(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as String; + } + + @protected + bool dco_decode_bool(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as bool; + } + + @protected + VncConfig dco_decode_box_autoadd_vnc_config(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dco_decode_vnc_config(raw); + } + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as Uint8List; + } + + @protected + int dco_decode_u_16(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + int dco_decode_u_32(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + int dco_decode_u_8(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + void dco_decode_unit(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return; + } + + @protected + VncConfig dco_decode_vnc_config(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 4) + throw Exception('unexpected arr length: expect 4 but see ${arr.length}'); + return VncConfig( + targetHost: dco_decode_String(arr[0]), + targetPort: dco_decode_u_16(arr[1]), + username: dco_decode_String(arr[2]), + password: dco_decode_String(arr[3]), + ); + } + + @protected + VncEvent dco_decode_vnc_event(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + switch (raw[0]) { + case 0: + return VncEvent_Started(sessionId: dco_decode_u_32(raw[1])); + case 1: + return VncEvent_Connected( + width: dco_decode_u_16(raw[1]), + height: dco_decode_u_16(raw[2]), + ); + case 2: + return VncEvent_Resize( + width: dco_decode_u_16(raw[1]), + height: dco_decode_u_16(raw[2]), + ); + case 3: + return VncEvent_FrameUpdate( + x: dco_decode_u_16(raw[1]), + y: dco_decode_u_16(raw[2]), + width: dco_decode_u_16(raw[3]), + height: dco_decode_u_16(raw[4]), + rgba: dco_decode_list_prim_u_8_strict(raw[5]), + ); + case 4: + return VncEvent_ClipboardText(text: dco_decode_String(raw[1])); + case 5: + return VncEvent_Bell(); + case 6: + return VncEvent_Disconnected(reason: dco_decode_String(raw[1])); + case 7: + return VncEvent_Error(message: dco_decode_String(raw[1])); + default: + throw Exception("unreachable"); + } + } + + @protected + AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_String(deserializer); + return AnyhowException(inner); + } + + @protected + RustStreamSink sse_decode_StreamSink_vnc_event_Sse( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + throw UnimplementedError('Unreachable ()'); + } + + @protected + String sse_decode_String(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_list_prim_u_8_strict(deserializer); + return utf8.decoder.convert(inner); + } + + @protected + bool sse_decode_bool(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint8() != 0; + } + + @protected + VncConfig sse_decode_box_autoadd_vnc_config(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return (sse_decode_vnc_config(deserializer)); + } + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var len_ = sse_decode_i_32(deserializer); + return deserializer.buffer.getUint8List(len_); + } + + @protected + int sse_decode_u_16(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint16(); + } + + @protected + int sse_decode_u_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint32(); + } + + @protected + int sse_decode_u_8(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint8(); + } + + @protected + void sse_decode_unit(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + } + + @protected + VncConfig sse_decode_vnc_config(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_targetHost = sse_decode_String(deserializer); + var var_targetPort = sse_decode_u_16(deserializer); + var var_username = sse_decode_String(deserializer); + var var_password = sse_decode_String(deserializer); + return VncConfig( + targetHost: var_targetHost, + targetPort: var_targetPort, + username: var_username, + password: var_password, + ); + } + + @protected + VncEvent sse_decode_vnc_event(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var tag_ = sse_decode_i_32(deserializer); + switch (tag_) { + case 0: + var var_sessionId = sse_decode_u_32(deserializer); + return VncEvent_Started(sessionId: var_sessionId); + case 1: + var var_width = sse_decode_u_16(deserializer); + var var_height = sse_decode_u_16(deserializer); + return VncEvent_Connected(width: var_width, height: var_height); + case 2: + var var_width = sse_decode_u_16(deserializer); + var var_height = sse_decode_u_16(deserializer); + return VncEvent_Resize(width: var_width, height: var_height); + case 3: + var var_x = sse_decode_u_16(deserializer); + var var_y = sse_decode_u_16(deserializer); + var var_width = sse_decode_u_16(deserializer); + var var_height = sse_decode_u_16(deserializer); + var var_rgba = sse_decode_list_prim_u_8_strict(deserializer); + return VncEvent_FrameUpdate( + x: var_x, + y: var_y, + width: var_width, + height: var_height, + rgba: var_rgba, + ); + case 4: + var var_text = sse_decode_String(deserializer); + return VncEvent_ClipboardText(text: var_text); + case 5: + return VncEvent_Bell(); + case 6: + var var_reason = sse_decode_String(deserializer); + return VncEvent_Disconnected(reason: var_reason); + case 7: + var var_message = sse_decode_String(deserializer); + return VncEvent_Error(message: var_message); + default: + throw UnimplementedError(''); + } + } + + @protected + int sse_decode_i_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getInt32(); + } + + @protected + void sse_encode_AnyhowException( + AnyhowException self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.message, serializer); + } + + @protected + void sse_encode_StreamSink_vnc_event_Sse( + RustStreamSink self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String( + self.setupAndSerialize( + codec: SseCodec( + decodeSuccessData: sse_decode_vnc_event, + decodeErrorData: sse_decode_AnyhowException, + ), + ), + serializer, + ); + } + + @protected + void sse_encode_String(String self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_list_prim_u_8_strict(utf8.encoder.convert(self), serializer); + } + + @protected + void sse_encode_bool(bool self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint8(self ? 1 : 0); + } + + @protected + void sse_encode_box_autoadd_vnc_config( + VncConfig self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_vnc_config(self, serializer); + } + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + serializer.buffer.putUint8List(self); + } + + @protected + void sse_encode_u_16(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint16(self); + } + + @protected + void sse_encode_u_32(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint32(self); + } + + @protected + void sse_encode_u_8(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint8(self); + } + + @protected + void sse_encode_unit(void self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + } + + @protected + void sse_encode_vnc_config(VncConfig self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.targetHost, serializer); + sse_encode_u_16(self.targetPort, serializer); + sse_encode_String(self.username, serializer); + sse_encode_String(self.password, serializer); + } + + @protected + void sse_encode_vnc_event(VncEvent self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + switch (self) { + case VncEvent_Started(sessionId: final sessionId): + sse_encode_i_32(0, serializer); + sse_encode_u_32(sessionId, serializer); + case VncEvent_Connected(width: final width, height: final height): + sse_encode_i_32(1, serializer); + sse_encode_u_16(width, serializer); + sse_encode_u_16(height, serializer); + case VncEvent_Resize(width: final width, height: final height): + sse_encode_i_32(2, serializer); + sse_encode_u_16(width, serializer); + sse_encode_u_16(height, serializer); + case VncEvent_FrameUpdate( + x: final x, + y: final y, + width: final width, + height: final height, + rgba: final rgba, + ): + sse_encode_i_32(3, serializer); + sse_encode_u_16(x, serializer); + sse_encode_u_16(y, serializer); + sse_encode_u_16(width, serializer); + sse_encode_u_16(height, serializer); + sse_encode_list_prim_u_8_strict(rgba, serializer); + case VncEvent_ClipboardText(text: final text): + sse_encode_i_32(4, serializer); + sse_encode_String(text, serializer); + case VncEvent_Bell(): + sse_encode_i_32(5, serializer); + case VncEvent_Disconnected(reason: final reason): + sse_encode_i_32(6, serializer); + sse_encode_String(reason, serializer); + case VncEvent_Error(message: final message): + sse_encode_i_32(7, serializer); + sse_encode_String(message, serializer); + } + } + + @protected + void sse_encode_i_32(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putInt32(self); + } +} diff --git a/packages/yourssh_vnc/lib/src/generated/frb_generated.io.dart b/packages/yourssh_vnc/lib/src/generated/frb_generated.io.dart new file mode 100644 index 00000000..c86492ae --- /dev/null +++ b/packages/yourssh_vnc/lib/src/generated/frb_generated.io.dart @@ -0,0 +1,163 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +import 'api.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'dart:ffi' as ffi; +import 'frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart'; + +abstract class RustLibApiImplPlatform extends BaseApiImpl { + RustLibApiImplPlatform({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @protected + AnyhowException dco_decode_AnyhowException(dynamic raw); + + @protected + RustStreamSink dco_decode_StreamSink_vnc_event_Sse(dynamic raw); + + @protected + String dco_decode_String(dynamic raw); + + @protected + bool dco_decode_bool(dynamic raw); + + @protected + VncConfig dco_decode_box_autoadd_vnc_config(dynamic raw); + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + + @protected + int dco_decode_u_16(dynamic raw); + + @protected + int dco_decode_u_32(dynamic raw); + + @protected + int dco_decode_u_8(dynamic raw); + + @protected + void dco_decode_unit(dynamic raw); + + @protected + VncConfig dco_decode_vnc_config(dynamic raw); + + @protected + VncEvent dco_decode_vnc_event(dynamic raw); + + @protected + AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer); + + @protected + RustStreamSink sse_decode_StreamSink_vnc_event_Sse( + SseDeserializer deserializer, + ); + + @protected + String sse_decode_String(SseDeserializer deserializer); + + @protected + bool sse_decode_bool(SseDeserializer deserializer); + + @protected + VncConfig sse_decode_box_autoadd_vnc_config(SseDeserializer deserializer); + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + + @protected + int sse_decode_u_16(SseDeserializer deserializer); + + @protected + int sse_decode_u_32(SseDeserializer deserializer); + + @protected + int sse_decode_u_8(SseDeserializer deserializer); + + @protected + void sse_decode_unit(SseDeserializer deserializer); + + @protected + VncConfig sse_decode_vnc_config(SseDeserializer deserializer); + + @protected + VncEvent sse_decode_vnc_event(SseDeserializer deserializer); + + @protected + int sse_decode_i_32(SseDeserializer deserializer); + + @protected + void sse_encode_AnyhowException( + AnyhowException self, + SseSerializer serializer, + ); + + @protected + void sse_encode_StreamSink_vnc_event_Sse( + RustStreamSink self, + SseSerializer serializer, + ); + + @protected + void sse_encode_String(String self, SseSerializer serializer); + + @protected + void sse_encode_bool(bool self, SseSerializer serializer); + + @protected + void sse_encode_box_autoadd_vnc_config( + VncConfig self, + SseSerializer serializer, + ); + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, + SseSerializer serializer, + ); + + @protected + void sse_encode_u_16(int self, SseSerializer serializer); + + @protected + void sse_encode_u_32(int self, SseSerializer serializer); + + @protected + void sse_encode_u_8(int self, SseSerializer serializer); + + @protected + void sse_encode_unit(void self, SseSerializer serializer); + + @protected + void sse_encode_vnc_config(VncConfig self, SseSerializer serializer); + + @protected + void sse_encode_vnc_event(VncEvent self, SseSerializer serializer); + + @protected + void sse_encode_i_32(int self, SseSerializer serializer); +} + +// Section: wire_class + +class RustLibWire implements BaseWire { + factory RustLibWire.fromExternalLibrary(ExternalLibrary lib) => + RustLibWire(lib.ffiDynamicLibrary); + + /// Holds the symbol lookup function. + final ffi.Pointer Function(String symbolName) + _lookup; + + /// The symbols are looked up in [dynamicLibrary]. + RustLibWire(ffi.DynamicLibrary dynamicLibrary) + : _lookup = dynamicLibrary.lookup; +} diff --git a/packages/yourssh_vnc/lib/src/generated/frb_generated.web.dart b/packages/yourssh_vnc/lib/src/generated/frb_generated.web.dart new file mode 100644 index 00000000..c49027f3 --- /dev/null +++ b/packages/yourssh_vnc/lib/src/generated/frb_generated.web.dart @@ -0,0 +1,163 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +// Static analysis wrongly picks the IO variant, thus ignore this +// ignore_for_file: argument_type_not_assignable + +import 'api.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart'; + +abstract class RustLibApiImplPlatform extends BaseApiImpl { + RustLibApiImplPlatform({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @protected + AnyhowException dco_decode_AnyhowException(dynamic raw); + + @protected + RustStreamSink dco_decode_StreamSink_vnc_event_Sse(dynamic raw); + + @protected + String dco_decode_String(dynamic raw); + + @protected + bool dco_decode_bool(dynamic raw); + + @protected + VncConfig dco_decode_box_autoadd_vnc_config(dynamic raw); + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + + @protected + int dco_decode_u_16(dynamic raw); + + @protected + int dco_decode_u_32(dynamic raw); + + @protected + int dco_decode_u_8(dynamic raw); + + @protected + void dco_decode_unit(dynamic raw); + + @protected + VncConfig dco_decode_vnc_config(dynamic raw); + + @protected + VncEvent dco_decode_vnc_event(dynamic raw); + + @protected + AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer); + + @protected + RustStreamSink sse_decode_StreamSink_vnc_event_Sse( + SseDeserializer deserializer, + ); + + @protected + String sse_decode_String(SseDeserializer deserializer); + + @protected + bool sse_decode_bool(SseDeserializer deserializer); + + @protected + VncConfig sse_decode_box_autoadd_vnc_config(SseDeserializer deserializer); + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + + @protected + int sse_decode_u_16(SseDeserializer deserializer); + + @protected + int sse_decode_u_32(SseDeserializer deserializer); + + @protected + int sse_decode_u_8(SseDeserializer deserializer); + + @protected + void sse_decode_unit(SseDeserializer deserializer); + + @protected + VncConfig sse_decode_vnc_config(SseDeserializer deserializer); + + @protected + VncEvent sse_decode_vnc_event(SseDeserializer deserializer); + + @protected + int sse_decode_i_32(SseDeserializer deserializer); + + @protected + void sse_encode_AnyhowException( + AnyhowException self, + SseSerializer serializer, + ); + + @protected + void sse_encode_StreamSink_vnc_event_Sse( + RustStreamSink self, + SseSerializer serializer, + ); + + @protected + void sse_encode_String(String self, SseSerializer serializer); + + @protected + void sse_encode_bool(bool self, SseSerializer serializer); + + @protected + void sse_encode_box_autoadd_vnc_config( + VncConfig self, + SseSerializer serializer, + ); + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, + SseSerializer serializer, + ); + + @protected + void sse_encode_u_16(int self, SseSerializer serializer); + + @protected + void sse_encode_u_32(int self, SseSerializer serializer); + + @protected + void sse_encode_u_8(int self, SseSerializer serializer); + + @protected + void sse_encode_unit(void self, SseSerializer serializer); + + @protected + void sse_encode_vnc_config(VncConfig self, SseSerializer serializer); + + @protected + void sse_encode_vnc_event(VncEvent self, SseSerializer serializer); + + @protected + void sse_encode_i_32(int self, SseSerializer serializer); +} + +// Section: wire_class + +class RustLibWire implements BaseWire { + RustLibWire.fromExternalLibrary(ExternalLibrary lib); +} + +@JS('wasm_bindgen') +external RustLibWasmModule get wasmModule; + +@JS() +@anonymous +extension type RustLibWasmModule._(JSObject _) implements JSObject {} diff --git a/packages/yourssh_vnc/lib/src/native_loader.dart b/packages/yourssh_vnc/lib/src/native_loader.dart new file mode 100644 index 00000000..6a3b50d8 --- /dev/null +++ b/packages/yourssh_vnc/lib/src/native_loader.dart @@ -0,0 +1,47 @@ +import 'dart:io'; + +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +/// Locates the yourssh_vnc dynamic library. Search order: bundled release +/// locations (relative to the running executable), plain name (rpath / +/// system lookup), then repo-relative dev paths. +ExternalLibrary loadYoursshVncLibrary() { + Object? lastError; + for (final path in _candidates()) { + try { + return ExternalLibrary.open(path); + } catch (e) { + lastError = e; + } + } + throw StateError('yourssh_vnc native library not found: $lastError'); +} + +List _candidates() { + final exeDir = File(Platform.resolvedExecutable).parent.path; + if (Platform.isMacOS) { + return [ + '${File(Platform.resolvedExecutable).parent.parent.path}/Frameworks/libyourssh_vnc.dylib', + 'libyourssh_vnc.dylib', + '${Directory.current.path}/assets/native/macos/libyourssh_vnc.dylib', + '${Directory.current.path}/packages/yourssh_vnc/assets/native/macos/libyourssh_vnc.dylib', + '${Directory.current.path}/../packages/yourssh_vnc/assets/native/macos/libyourssh_vnc.dylib', + ]; + } + if (Platform.isLinux) { + return [ + '$exeDir/lib/libyourssh_vnc.so', + 'libyourssh_vnc.so', + '${Directory.current.path}/assets/native/linux/libyourssh_vnc.so', + '${Directory.current.path}/packages/yourssh_vnc/assets/native/linux/libyourssh_vnc.so', + '${Directory.current.path}/../packages/yourssh_vnc/assets/native/linux/libyourssh_vnc.so', + ]; + } + return [ + '$exeDir\\yourssh_vnc.dll', + 'yourssh_vnc.dll', + '${Directory.current.path}\\assets\\native\\windows\\yourssh_vnc.dll', + '${Directory.current.path}\\packages\\yourssh_vnc\\assets\\native\\windows\\yourssh_vnc.dll', + '${Directory.current.path}\\..\\packages\\yourssh_vnc\\assets\\native\\windows\\yourssh_vnc.dll', + ]; +} diff --git a/packages/yourssh_vnc/lib/src/vnc_client.dart b/packages/yourssh_vnc/lib/src/vnc_client.dart new file mode 100644 index 00000000..08ff0b00 --- /dev/null +++ b/packages/yourssh_vnc/lib/src/vnc_client.dart @@ -0,0 +1,160 @@ +import 'dart:async'; + +import 'generated/api.dart'; +import 'generated/frb_generated.dart'; +import 'native_loader.dart'; + +export 'generated/api.dart' show VncConfig, VncEvent; + +/// Lightweight typed facade over the generated FRB bindings. +/// +/// Usage: +/// ```dart +/// await VncClient.ensureInitialized(); +/// final client = VncClient(VncConfig(...)); +/// client.events.listen((event) { ... }); +/// await client.connect(); +/// await client.disconnect(); +/// await client.done; +/// ``` +class VncClient { + final VncConfig config; + + int? _sessionId; + bool _disconnectRequested = false; + StreamSubscription? _sub; + final _eventCtrl = StreamController.broadcast(); + final _done = Completer(); + + VncClient(this.config); + + static Future? _initFuture; + + /// Initializes the Rust bridge exactly once (loads the native library on + /// first use). Safe to call repeatedly and from concurrent callers; a + /// failed attempt resets so the next call retries. + static Future ensureInitialized() { + return _initFuture ??= + RustLib.init(externalLibrary: loadYoursshVncLibrary()).catchError( + (Object e) { + _initFuture = null; + throw e; + }, + ); + } + + /// Broadcast stream of all events emitted by the Rust session. + Stream get events => _eventCtrl.stream; + + /// Completes when the session is fully torn down (after Disconnected or Error). + Future get done => _done.future; + + bool get isConnected => _sessionId != null; + + /// Start the VNC session. Returns when [VncEvent.connected] is received, or + /// throws if the connection fails before a [VncEvent.connected] arrives. + Future connect() async { + if (_sessionId != null) throw StateError('Already connected'); + await ensureInitialized(); + + final connectedCompleter = Completer(); + + _sub = vncConnect(config: config).listen( + (event) { + switch (event) { + case VncEvent_Started(:final sessionId): + _sessionId = sessionId; + // disconnect() was called before the Started event arrived — + // honor it now so the Rust session doesn't run orphaned. + if (_disconnectRequested) { + unawaited(vncDisconnect(sessionId: sessionId)); + } + + case VncEvent_Connected(): + if (!connectedCompleter.isCompleted) { + connectedCompleter.complete(); + } + + case VncEvent_Disconnected(:final reason): + _finish(event, connectedCompleter, reason); + return; + + case VncEvent_Error(:final message): + _finish(event, connectedCompleter, message); + return; + + default: + break; + } + _eventCtrl.add(event); + }, + onError: (Object err) { + _sessionId = null; + if (!_done.isCompleted) _done.completeError(err); + if (!connectedCompleter.isCompleted) connectedCompleter.completeError(err); + }, + cancelOnError: false, + ); + + return connectedCompleter.future; + } + + void _finish( + VncEvent event, + Completer connectedCompleter, + String reason, + ) { + _sessionId = null; + _eventCtrl.add(event); + _sub?.cancel(); + _sub = null; + if (!_done.isCompleted) _done.complete(); + if (!connectedCompleter.isCompleted) { + connectedCompleter.completeError( + Exception(reason.isEmpty ? 'connection failed' : reason), + ); + } + } + + Future disconnect() async { + final id = _sessionId; + if (id == null) { + if (_done.isCompleted) return; // already fully torn down + // Started not yet observed — flag the intent so the session is torn + // down as soon as its id arrives (instead of orphaning the Rust loop). + _disconnectRequested = true; + return; + } + await vncDisconnect(sessionId: id); + await done; + } + + /// Send a pointer event. [buttonMask] is the RFB bitmask + /// (bit0 left, bit1 middle, bit2 right, bit3 wheel-up, bit4 wheel-down). + /// No-op until the session has started. + void sendPointer({required int x, required int y, required int buttonMask}) { + final id = _sessionId; + if (id == null) return; + vncSendPointer(sessionId: id, x: x, y: y, buttonMask: buttonMask); + } + + /// Send a key event. [keysym] is an X11 keysym. No-op until started. + void sendKey({required int keysym, required bool down}) { + final id = _sessionId; + if (id == null) return; + vncSendKey(sessionId: id, keysym: keysym, down: down); + } + + /// Send local clipboard text to the remote. No-op until started. + void sendClipboardText(String text) { + final id = _sessionId; + if (id == null) return; + vncSendClipboardText(sessionId: id, text: text); + } + + void dispose() { + _sub?.cancel(); + _sub = null; + if (!_eventCtrl.isClosed) _eventCtrl.close(); + } +} diff --git a/packages/yourssh_vnc/lib/yourssh_vnc.dart b/packages/yourssh_vnc/lib/yourssh_vnc.dart new file mode 100644 index 00000000..76449ec4 --- /dev/null +++ b/packages/yourssh_vnc/lib/yourssh_vnc.dart @@ -0,0 +1,3 @@ +library yourssh_vnc; + +export 'src/vnc_client.dart'; diff --git a/packages/yourssh_vnc/pubspec.yaml b/packages/yourssh_vnc/pubspec.yaml new file mode 100644 index 00000000..30fea9b1 --- /dev/null +++ b/packages/yourssh_vnc/pubspec.yaml @@ -0,0 +1,22 @@ +name: yourssh_vnc +description: In-app VNC (RFB) client for yourssh (vnc-rs via flutter_rust_bridge). +version: 0.1.0 +publish_to: none + +environment: + sdk: ^3.12.0 + flutter: ">=3.24.0" + +dependencies: + flutter: + sdk: flutter + flutter_rust_bridge: ^2.12.0 + ffi: ^2.1.3 + freezed_annotation: ^2.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + freezed: ^2.0.0 + build_runner: ^2.0.0 diff --git a/packages/yourssh_vnc/rust/Cargo.lock b/packages/yourssh_vnc/rust/Cargo.lock new file mode 100644 index 00000000..c5d2801e --- /dev/null +++ b/packages/yourssh_vnc/rust/Cargo.lock @@ -0,0 +1,961 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allo-isolate" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "449e356a4864c017286dbbec0e12767ea07efba29e3b7d984194c2a7ff3c4550" +dependencies = [ + "anyhow", + "atomic", + "backtrace", +] + +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter", + "log", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "rustc_version", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "build-target" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "832133bbabbbaa9fbdba793456a2827627a7d2b8fb96032fa1e7666d7895832b" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dart-sys" +version = "4.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57967e4b200d767d091b961d6ab42cc7d0cc14fe9e052e75d0d3cf9eb732d895" +dependencies = [ + "cc", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "delegate-attr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51aac4c99b2e6775164b412ea33ae8441b2fde2dbf05a20bc0052a63d08c475b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flutter_rust_bridge" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0884853aae8a6517b5b58cf36f55da487f2fe110e1686938eb29b6640aae4a5" +dependencies = [ + "allo-isolate", + "android_logger", + "anyhow", + "build-target", + "bytemuck", + "byteorder", + "console_error_panic_hook", + "dart-sys", + "delegate-attr", + "flutter_rust_bridge_macros", + "futures", + "js-sys", + "lazy_static", + "log", + "oslog", + "portable-atomic", + "threadpool", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "flutter_rust_bridge_macros" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b5ce32f35f710ced8c5aa557f023f1a624e737b5460cee2b70fcd3a8df09e1b" +dependencies = [ + "hex", + "md-5", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oslog" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d2043d1f61d77cb2f4b1f7b7b2295f40507f5f8e9d1c8bf10a1ca5f97a3969" +dependencies = [ + "cc", + "dashmap", + "log", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vnc-rs" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5607299ce93dc285571540ee93de681a1be7a5765c35dfb2e1f049b493652145" +dependencies = [ + "async_io_stream", + "flate2", + "futures", + "thiserror", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "wasm-bindgen-futures", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "yourssh_vnc" +version = "0.1.0" +dependencies = [ + "anyhow", + "flutter_rust_bridge", + "futures", + "tokio", + "vnc-rs", +] diff --git a/packages/yourssh_vnc/rust/Cargo.toml b/packages/yourssh_vnc/rust/Cargo.toml new file mode 100644 index 00000000..a2c79774 --- /dev/null +++ b/packages/yourssh_vnc/rust/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "yourssh_vnc" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "staticlib"] + +[profile.release] +strip = true +lto = "thin" +codegen-units = 1 + +[dependencies] +flutter_rust_bridge = "=2.12.0" +vnc-rs = "0.5.3" +tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "time"] } +anyhow = "1" +futures = "0.3" + +[dev-dependencies] +tokio = { version = "1", features = ["test-util"] } diff --git a/packages/yourssh_vnc/rust/src/api.rs b/packages/yourssh_vnc/rust/src/api.rs new file mode 100644 index 00000000..78231c25 --- /dev/null +++ b/packages/yourssh_vnc/rust/src/api.rs @@ -0,0 +1,93 @@ +use futures::FutureExt; +use tokio::sync::mpsc; + +use crate::frb_generated::StreamSink; +use crate::session::{registry, SessionCmd}; + +#[derive(Clone)] +pub struct VncConfig { + pub target_host: String, + pub target_port: u16, + pub username: String, + pub password: String, +} + +#[derive(Clone)] +pub enum VncEvent { + /// Always the first event. Carries the session id. + Started { session_id: u32 }, + /// Connection established. Server's initial framebuffer size. + Connected { width: u16, height: u16 }, + /// Server-driven desktop-size change after connect. + Resize { width: u16, height: u16 }, + /// A self-contained RGBA patch. `rgba` length is width * height * 4, alpha forced opaque. + FrameUpdate { x: u16, y: u16, width: u16, height: u16, rgba: Vec }, + /// Server clipboard (cut-text). + ClipboardText { text: String }, + /// Server bell. + Bell, + Disconnected { reason: String }, + Error { message: String }, +} + +pub fn vnc_lib_version() -> String { + format!("yourssh_vnc {}", env!("CARGO_PKG_VERSION")) +} + +static RUNTIME: std::sync::OnceLock = std::sync::OnceLock::new(); + +fn runtime() -> &'static tokio::runtime::Runtime { + RUNTIME.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .expect("tokio runtime") + }) +} + +struct RemoveOnDrop(u32); + +impl Drop for RemoveOnDrop { + fn drop(&mut self) { + registry::remove(self.0); + } +} + +pub fn vnc_connect(config: VncConfig, sink: StreamSink) { + let (tx, rx) = mpsc::unbounded_channel::(); + let id = registry::insert(tx); + let _ = sink.add(VncEvent::Started { session_id: id }); + let emit_sink = sink.clone(); + let panic_sink = sink.clone(); + let emit = move |ev: VncEvent| { + let _ = emit_sink.add(ev); + }; + runtime().spawn(async move { + let _guard = RemoveOnDrop(id); + let result = std::panic::AssertUnwindSafe(crate::run_loop::run_session(config, rx, emit)) + .catch_unwind() + .await; + if let Err(_panic) = result { + let _ = panic_sink.add(VncEvent::Error { + message: "vnc session panicked".into(), + }); + } + }); +} + +pub fn vnc_disconnect(session_id: u32) { + registry::send(session_id, SessionCmd::Disconnect); +} + +pub fn vnc_send_pointer(session_id: u32, x: u16, y: u16, button_mask: u8) { + registry::send(session_id, SessionCmd::Pointer { x, y, button_mask }); +} + +pub fn vnc_send_key(session_id: u32, keysym: u32, down: bool) { + registry::send(session_id, SessionCmd::Key { keysym, down }); +} + +pub fn vnc_send_clipboard_text(session_id: u32, text: String) { + registry::send(session_id, SessionCmd::ClipboardText(text)); +} diff --git a/packages/yourssh_vnc/rust/src/connect.rs b/packages/yourssh_vnc/rust/src/connect.rs new file mode 100644 index 00000000..0b0d40b9 --- /dev/null +++ b/packages/yourssh_vnc/rust/src/connect.rs @@ -0,0 +1,32 @@ +use anyhow::Context; +use tokio::net::TcpStream; +use vnc::{PixelFormat, VncConnector, VncEncoding}; + +use crate::api::VncConfig; + +/// Dials the server, performs the RFB handshake + auth, and negotiates the +/// Milestone-1 encoding set (Zrle + Raw — both surface as self-contained RGBA +/// patches). Returns a connected `VncClient`. +pub async fn vnc_connect_stage(cfg: &VncConfig) -> anyhow::Result { + let addr = format!("{}:{}", cfg.target_host, cfg.target_port); + let tcp = TcpStream::connect(&addr).await.context("TCP connect")?; + + // The password future is only polled if the server requires VNC auth; for + // a "None"-auth server vnc-rs never calls it. + let password = cfg.password.clone(); + let client = VncConnector::new(tcp) + .set_auth_method(async move { Ok(password) }) + .add_encoding(VncEncoding::Zrle) + .add_encoding(VncEncoding::Raw) + .allow_shared(true) + .set_pixel_format(PixelFormat::rgba()) + .build() + .context("build VNC connector")? + .try_start() + .await + .context("VNC handshake/auth")? + .finish() + .context("finish VNC connect")?; + + Ok(client) +} diff --git a/packages/yourssh_vnc/rust/src/frb_generated.rs b/packages/yourssh_vnc/rust/src/frb_generated.rs new file mode 100644 index 00000000..46848f5a --- /dev/null +++ b/packages/yourssh_vnc/rust/src/frb_generated.rs @@ -0,0 +1,712 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +#![allow( + non_camel_case_types, + unused, + non_snake_case, + clippy::needless_return, + clippy::redundant_closure_call, + clippy::redundant_closure, + clippy::useless_conversion, + clippy::unit_arg, + clippy::unused_unit, + clippy::double_parens, + clippy::let_and_return, + clippy::too_many_arguments, + clippy::match_single_binding, + clippy::clone_on_copy, + clippy::let_unit_value, + clippy::deref_addrof, + clippy::explicit_auto_deref, + clippy::borrow_deref_ref, + clippy::uninlined_format_args, + clippy::needless_borrow +)] + +// Section: imports + +use flutter_rust_bridge::for_generated::byteorder::{NativeEndian, ReadBytesExt, WriteBytesExt}; +use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; +use flutter_rust_bridge::{Handler, IntoIntoDart}; + +// Section: boilerplate + +flutter_rust_bridge::frb_generated_boilerplate!( + default_stream_sink_codec = SseCodec, + default_rust_opaque = RustOpaqueMoi, + default_rust_auto_opaque = RustAutoOpaqueMoi, +); +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1793444694; + +// Section: executor + +flutter_rust_bridge::frb_generated_default_handler!(); + +// Section: wire_funcs + +fn wire__crate__api__vnc_connect_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "vnc_connect", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_config = ::sse_decode(&mut deserializer); + let api_sink = >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok({ + crate::api::vnc_connect(api_config, api_sink); + })?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__vnc_disconnect_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "vnc_disconnect", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_session_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok({ + crate::api::vnc_disconnect(api_session_id); + })?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__vnc_lib_version_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "vnc_lib_version", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok(crate::api::vnc_lib_version())?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__vnc_send_clipboard_text_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "vnc_send_clipboard_text", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_session_id = ::sse_decode(&mut deserializer); + let api_text = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok({ + crate::api::vnc_send_clipboard_text(api_session_id, api_text); + })?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__vnc_send_key_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "vnc_send_key", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_session_id = ::sse_decode(&mut deserializer); + let api_keysym = ::sse_decode(&mut deserializer); + let api_down = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok({ + crate::api::vnc_send_key(api_session_id, api_keysym, api_down); + })?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__vnc_send_pointer_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "vnc_send_pointer", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_session_id = ::sse_decode(&mut deserializer); + let api_x = ::sse_decode(&mut deserializer); + let api_y = ::sse_decode(&mut deserializer); + let api_button_mask = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok({ + crate::api::vnc_send_pointer(api_session_id, api_x, api_y, api_button_mask); + })?; + Ok(output_ok) + })()) + } + }, + ) +} + +// Section: dart2rust + +impl SseDecode for flutter_rust_bridge::for_generated::anyhow::Error { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return flutter_rust_bridge::for_generated::anyhow::anyhow!("{}", inner); + } +} + +impl SseDecode for StreamSink { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return StreamSink::deserialize(inner); + } +} + +impl SseDecode for String { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = >::sse_decode(deserializer); + return String::from_utf8(inner).unwrap(); + } +} + +impl SseDecode for bool { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u8().unwrap() != 0 + } +} + +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = Vec::with_capacity(len_ as usize); + for idx_ in 0..len_ { + ans_.push(::sse_decode(deserializer)); + } + return ans_; + } +} + +impl SseDecode for u16 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u16::().unwrap() + } +} + +impl SseDecode for u32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u32::().unwrap() + } +} + +impl SseDecode for u8 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u8().unwrap() + } +} + +impl SseDecode for () { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {} +} + +impl SseDecode for crate::api::VncConfig { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_targetHost = ::sse_decode(deserializer); + let mut var_targetPort = ::sse_decode(deserializer); + let mut var_username = ::sse_decode(deserializer); + let mut var_password = ::sse_decode(deserializer); + return crate::api::VncConfig { + target_host: var_targetHost, + target_port: var_targetPort, + username: var_username, + password: var_password, + }; + } +} + +impl SseDecode for crate::api::VncEvent { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut tag_ = ::sse_decode(deserializer); + match tag_ { + 0 => { + let mut var_sessionId = ::sse_decode(deserializer); + return crate::api::VncEvent::Started { + session_id: var_sessionId, + }; + } + 1 => { + let mut var_width = ::sse_decode(deserializer); + let mut var_height = ::sse_decode(deserializer); + return crate::api::VncEvent::Connected { + width: var_width, + height: var_height, + }; + } + 2 => { + let mut var_width = ::sse_decode(deserializer); + let mut var_height = ::sse_decode(deserializer); + return crate::api::VncEvent::Resize { + width: var_width, + height: var_height, + }; + } + 3 => { + let mut var_x = ::sse_decode(deserializer); + let mut var_y = ::sse_decode(deserializer); + let mut var_width = ::sse_decode(deserializer); + let mut var_height = ::sse_decode(deserializer); + let mut var_rgba = >::sse_decode(deserializer); + return crate::api::VncEvent::FrameUpdate { + x: var_x, + y: var_y, + width: var_width, + height: var_height, + rgba: var_rgba, + }; + } + 4 => { + let mut var_text = ::sse_decode(deserializer); + return crate::api::VncEvent::ClipboardText { text: var_text }; + } + 5 => { + return crate::api::VncEvent::Bell; + } + 6 => { + let mut var_reason = ::sse_decode(deserializer); + return crate::api::VncEvent::Disconnected { reason: var_reason }; + } + 7 => { + let mut var_message = ::sse_decode(deserializer); + return crate::api::VncEvent::Error { + message: var_message, + }; + } + _ => { + unimplemented!(""); + } + } + } +} + +impl SseDecode for i32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_i32::().unwrap() + } +} + +fn pde_ffi_dispatcher_primary_impl( + func_id: i32, + port: flutter_rust_bridge::for_generated::MessagePort, + ptr: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len: i32, + data_len: i32, +) { + // Codec=Pde (Serialization + dispatch), see doc to use other codecs + match func_id { + 1 => wire__crate__api__vnc_connect_impl(port, ptr, rust_vec_len, data_len), + 2 => wire__crate__api__vnc_disconnect_impl(port, ptr, rust_vec_len, data_len), + 3 => wire__crate__api__vnc_lib_version_impl(port, ptr, rust_vec_len, data_len), + 4 => wire__crate__api__vnc_send_clipboard_text_impl(port, ptr, rust_vec_len, data_len), + 5 => wire__crate__api__vnc_send_key_impl(port, ptr, rust_vec_len, data_len), + 6 => wire__crate__api__vnc_send_pointer_impl(port, ptr, rust_vec_len, data_len), + _ => unreachable!(), + } +} + +fn pde_ffi_dispatcher_sync_impl( + func_id: i32, + ptr: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len: i32, + data_len: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + // Codec=Pde (Serialization + dispatch), see doc to use other codecs + match func_id { + _ => unreachable!(), + } +} + +// Section: rust2dart + +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::VncConfig { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.target_host.into_into_dart().into_dart(), + self.target_port.into_into_dart().into_dart(), + self.username.into_into_dart().into_dart(), + self.password.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::VncConfig {} +impl flutter_rust_bridge::IntoIntoDart for crate::api::VncConfig { + fn into_into_dart(self) -> crate::api::VncConfig { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::VncEvent { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + match self { + crate::api::VncEvent::Started { session_id } => { + [0.into_dart(), session_id.into_into_dart().into_dart()].into_dart() + } + crate::api::VncEvent::Connected { width, height } => [ + 1.into_dart(), + width.into_into_dart().into_dart(), + height.into_into_dart().into_dart(), + ] + .into_dart(), + crate::api::VncEvent::Resize { width, height } => [ + 2.into_dart(), + width.into_into_dart().into_dart(), + height.into_into_dart().into_dart(), + ] + .into_dart(), + crate::api::VncEvent::FrameUpdate { + x, + y, + width, + height, + rgba, + } => [ + 3.into_dart(), + x.into_into_dart().into_dart(), + y.into_into_dart().into_dart(), + width.into_into_dart().into_dart(), + height.into_into_dart().into_dart(), + rgba.into_into_dart().into_dart(), + ] + .into_dart(), + crate::api::VncEvent::ClipboardText { text } => { + [4.into_dart(), text.into_into_dart().into_dart()].into_dart() + } + crate::api::VncEvent::Bell => [5.into_dart()].into_dart(), + crate::api::VncEvent::Disconnected { reason } => { + [6.into_dart(), reason.into_into_dart().into_dart()].into_dart() + } + crate::api::VncEvent::Error { message } => { + [7.into_dart(), message.into_into_dart().into_dart()].into_dart() + } + _ => { + unimplemented!(""); + } + } + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::VncEvent {} +impl flutter_rust_bridge::IntoIntoDart for crate::api::VncEvent { + fn into_into_dart(self) -> crate::api::VncEvent { + self + } +} + +impl SseEncode for flutter_rust_bridge::for_generated::anyhow::Error { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(format!("{:?}", self), serializer); + } +} + +impl SseEncode for StreamSink { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + unimplemented!("") + } +} + +impl SseEncode for String { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >::sse_encode(self.into_bytes(), serializer); + } +} + +impl SseEncode for bool { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u8(self as _).unwrap(); + } +} + +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + +impl SseEncode for u16 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u16::(self).unwrap(); + } +} + +impl SseEncode for u32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u32::(self).unwrap(); + } +} + +impl SseEncode for u8 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u8(self).unwrap(); + } +} + +impl SseEncode for () { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {} +} + +impl SseEncode for crate::api::VncConfig { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.target_host, serializer); + ::sse_encode(self.target_port, serializer); + ::sse_encode(self.username, serializer); + ::sse_encode(self.password, serializer); + } +} + +impl SseEncode for crate::api::VncEvent { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + match self { + crate::api::VncEvent::Started { session_id } => { + ::sse_encode(0, serializer); + ::sse_encode(session_id, serializer); + } + crate::api::VncEvent::Connected { width, height } => { + ::sse_encode(1, serializer); + ::sse_encode(width, serializer); + ::sse_encode(height, serializer); + } + crate::api::VncEvent::Resize { width, height } => { + ::sse_encode(2, serializer); + ::sse_encode(width, serializer); + ::sse_encode(height, serializer); + } + crate::api::VncEvent::FrameUpdate { + x, + y, + width, + height, + rgba, + } => { + ::sse_encode(3, serializer); + ::sse_encode(x, serializer); + ::sse_encode(y, serializer); + ::sse_encode(width, serializer); + ::sse_encode(height, serializer); + >::sse_encode(rgba, serializer); + } + crate::api::VncEvent::ClipboardText { text } => { + ::sse_encode(4, serializer); + ::sse_encode(text, serializer); + } + crate::api::VncEvent::Bell => { + ::sse_encode(5, serializer); + } + crate::api::VncEvent::Disconnected { reason } => { + ::sse_encode(6, serializer); + ::sse_encode(reason, serializer); + } + crate::api::VncEvent::Error { message } => { + ::sse_encode(7, serializer); + ::sse_encode(message, serializer); + } + _ => { + unimplemented!(""); + } + } + } +} + +impl SseEncode for i32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_i32::(self).unwrap(); + } +} + +#[cfg(not(target_family = "wasm"))] +mod io { + // This file is automatically generated, so please do not edit it. + // @generated by `flutter_rust_bridge`@ 2.12.0. + + // Section: imports + + use super::*; + use flutter_rust_bridge::for_generated::byteorder::{ + NativeEndian, ReadBytesExt, WriteBytesExt, + }; + use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; + use flutter_rust_bridge::{Handler, IntoIntoDart}; + + // Section: boilerplate + + flutter_rust_bridge::frb_generated_boilerplate_io!(); +} +#[cfg(not(target_family = "wasm"))] +pub use io::*; + +/// cbindgen:ignore +#[cfg(target_family = "wasm")] +mod web { + // This file is automatically generated, so please do not edit it. + // @generated by `flutter_rust_bridge`@ 2.12.0. + + // Section: imports + + use super::*; + use flutter_rust_bridge::for_generated::byteorder::{ + NativeEndian, ReadBytesExt, WriteBytesExt, + }; + use flutter_rust_bridge::for_generated::wasm_bindgen; + use flutter_rust_bridge::for_generated::wasm_bindgen::prelude::*; + use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; + use flutter_rust_bridge::{Handler, IntoIntoDart}; + + // Section: boilerplate + + flutter_rust_bridge::frb_generated_boilerplate_web!(); +} +#[cfg(target_family = "wasm")] +pub use web::*; diff --git a/packages/yourssh_vnc/rust/src/lib.rs b/packages/yourssh_vnc/rust/src/lib.rs new file mode 100644 index 00000000..39df2323 --- /dev/null +++ b/packages/yourssh_vnc/rust/src/lib.rs @@ -0,0 +1,5 @@ +pub mod api; +pub mod connect; +pub mod run_loop; +pub mod session; +mod frb_generated; diff --git a/packages/yourssh_vnc/rust/src/run_loop.rs b/packages/yourssh_vnc/rust/src/run_loop.rs new file mode 100644 index 00000000..62425623 --- /dev/null +++ b/packages/yourssh_vnc/rust/src/run_loop.rs @@ -0,0 +1,222 @@ +use std::time::Duration; + +use tokio::sync::mpsc::UnboundedReceiver; +use vnc::VncError; + +use crate::api::{VncConfig, VncEvent as ApiEvent}; +use crate::connect::vnc_connect_stage; +use crate::session::SessionCmd; + +/// How often the loop pumps an incremental framebuffer-update request. +const REFRESH_INTERVAL_MS: u64 = 16; + +/// Forces every pixel's alpha byte to 0xFF. VNC servers typically send 32bpp +/// pixels with the padding (alpha) byte zeroed; rendered as RGBA8888 that is +/// fully transparent, so the patch must be made opaque before it reaches Dart. +pub fn set_opaque(rgba: &mut [u8]) { + debug_assert!(rgba.len() % 4 == 0, "set_opaque: buffer length must be a multiple of 4 (RGBA pixels)"); + for i in (3..rgba.len()).step_by(4) { + rgba[i] = 0xFF; + } +} + +/// Maps a `vnc-rs` error to a graceful disconnect reason, or `None` if it is a +/// real error that should surface as `VncEvent::Error`. +/// +/// In vnc-rs 0.5.3, `recv_event()` only ever fails with `ClientNotRunning` once +/// the session ends: a clean server EOF is swallowed by the decode thread, which +/// then closes the event channel. Mid-stream protocol/IO failures arrive +/// separately as `Ok(VncEvent::Error(_))` and never reach this function. +pub fn disconnect_reason(e: &VncError) -> Option { + match e { + VncError::ClientNotRunning => Some("connection closed".to_string()), + _ => None, + } +} + +/// Translates an input `SessionCmd` into the `vnc-rs` event to feed +/// `client.input()`. Returns `None` for non-input commands (Disconnect). +pub fn input_event(cmd: &SessionCmd) -> Option { + match cmd { + SessionCmd::Pointer { x, y, button_mask } => { + Some(vnc::X11Event::PointerEvent(vnc::ClientMouseEvent { + position_x: *x, + position_y: *y, + bottons: *button_mask, + })) + } + SessionCmd::Key { keysym, down } => Some(vnc::X11Event::KeyEvent( + vnc::ClientKeyEvent { keycode: *keysym, down: *down }, + )), + SessionCmd::ClipboardText(text) => { + Some(vnc::X11Event::CopyText(text.clone())) + } + SessionCmd::Disconnect => None, + } +} + +pub async fn run_session( + cfg: VncConfig, + mut cmd_rx: UnboundedReceiver, + sink: impl Fn(ApiEvent) + Send + Sync + 'static, +) { + match run_session_inner(cfg, &mut cmd_rx, &sink).await { + Ok(reason) => sink(ApiEvent::Disconnected { reason }), + Err(e) => sink(ApiEvent::Error { message: format!("{e:#}") }), + } +} + +async fn run_session_inner( + cfg: VncConfig, + cmd_rx: &mut UnboundedReceiver, + sink: &(impl Fn(ApiEvent) + Send + Sync), +) -> anyhow::Result { + let vnc = vnc_connect_stage(&cfg).await?; + + let mut connected = false; + let mut refresh = tokio::time::interval(Duration::from_millis(REFRESH_INTERVAL_MS)); + // Don't replay refresh ticks that piled up while the loop was busy decoding a + // burst of frames — one incremental request on resume is enough. + refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + // `biased` ensures cmd_rx is checked first every iteration so a + // Disconnect command is never starved by a high-frequency frame stream. + biased; + + cmd = cmd_rx.recv() => { + match cmd { + None => { + let _ = vnc.close().await; + return Ok("session closed".into()); + } + Some(SessionCmd::Disconnect) => { + // error = already closed; still emit Disconnected + let _ = vnc.close().await; + return Ok("disconnected by user".into()); + } + Some(input) => { + // Ignore send errors — if the client is closed, + // recv_event() surfaces it on the next iteration. + if let Some(ev) = input_event(&input) { + let _ = vnc.input(ev).await; + } + } + } + } + ev = vnc.recv_event() => { + match ev { + Ok(vnc::VncEvent::SetResolution(screen)) => { + if !connected { + connected = true; + sink(ApiEvent::Connected { width: screen.width, height: screen.height }); + } else { + sink(ApiEvent::Resize { width: screen.width, height: screen.height }); + } + } + Ok(vnc::VncEvent::RawImage(rect, mut data)) => { + set_opaque(&mut data); + sink(ApiEvent::FrameUpdate { + x: rect.x, y: rect.y, width: rect.width, height: rect.height, rgba: data, + }); + } + Ok(vnc::VncEvent::Text(text)) => sink(ApiEvent::ClipboardText { text }), + Ok(vnc::VncEvent::Bell) => sink(ApiEvent::Bell), + Ok(vnc::VncEvent::Error(msg)) => { + return Err(anyhow::anyhow!("{msg}")); + } + // SetPixelFormat: we called set_pixel_format(rgba()) on the + // connector, so this variant should never arrive. Ignore if it does. + Ok(vnc::VncEvent::SetPixelFormat(_)) => {} + // CopyRect / Tight-JPEG / cursor: encodings not negotiated; ignore. + Ok(vnc::VncEvent::Copy(..)) + | Ok(vnc::VncEvent::JpegImage(..)) + | Ok(vnc::VncEvent::SetCursor(..)) => {} + // Catch-all for any variants added in future vnc-rs releases. + Ok(_) => {} + Err(e) => { + return match disconnect_reason(&e) { + Some(reason) => Ok(reason), + None => Err(e.into()), + }; + } + } + } + _ = refresh.tick() => { + // Ignore send errors — if the client is closed, recv_event() will + // surface it on the next iteration. + let _ = vnc.input(vnc::X11Event::Refresh).await; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn set_opaque_sets_every_fourth_byte() { + let mut buf = vec![10, 20, 30, 0, 40, 50, 60, 0]; + set_opaque(&mut buf); + assert_eq!(buf, vec![10, 20, 30, 0xFF, 40, 50, 60, 0xFF]); + } + + #[test] + fn set_opaque_handles_empty() { + let mut buf: Vec = vec![]; + set_opaque(&mut buf); + assert!(buf.is_empty()); + } + + #[test] + fn disconnect_reason_graceful_on_not_running() { + assert_eq!( + disconnect_reason(&VncError::ClientNotRunning), + Some("connection closed".to_string()) + ); + } + + #[test] + fn disconnect_reason_none_on_real_error() { + assert!(disconnect_reason(&VncError::General("boom".into())).is_none()); + assert!(disconnect_reason(&VncError::WrongPassword).is_none()); + } + + #[test] + fn input_event_maps_pointer() { + match input_event(&SessionCmd::Pointer { x: 10, y: 20, button_mask: 0x05 }) { + Some(vnc::X11Event::PointerEvent(m)) => { + assert_eq!(m.position_x, 10); + assert_eq!(m.position_y, 20); + assert_eq!(m.bottons, 0x05); + } + other => panic!("expected PointerEvent, got {other:?}"), + } + } + + #[test] + fn input_event_maps_key() { + match input_event(&SessionCmd::Key { keysym: 0xFF0D, down: true }) { + Some(vnc::X11Event::KeyEvent(k)) => { + assert_eq!(k.keycode, 0xFF0D); + assert!(k.down); + } + other => panic!("expected KeyEvent, got {other:?}"), + } + } + + #[test] + fn input_event_none_for_disconnect() { + assert!(input_event(&SessionCmd::Disconnect).is_none()); + } + + #[test] + fn input_event_maps_clipboard() { + match input_event(&SessionCmd::ClipboardText("hi".into())) { + Some(vnc::X11Event::CopyText(t)) => assert_eq!(t, "hi"), + other => panic!("expected CopyText, got {other:?}"), + } + } +} diff --git a/packages/yourssh_vnc/rust/src/session.rs b/packages/yourssh_vnc/rust/src/session.rs new file mode 100644 index 00000000..3143e2ed --- /dev/null +++ b/packages/yourssh_vnc/rust/src/session.rs @@ -0,0 +1,65 @@ +use std::collections::HashMap; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Mutex; + +use tokio::sync::mpsc::UnboundedSender; + +/// Commands Dart can push into a running session loop. +#[derive(Debug)] +pub enum SessionCmd { + /// Pointer move/press/release. `button_mask` is the RFB bitmask + /// (bit0 left, bit1 middle, bit2 right, bit3 wheel-up, bit4 wheel-down). + Pointer { x: u16, y: u16, button_mask: u8 }, + /// Key press/release. `keysym` is an X11 keysym. + Key { keysym: u32, down: bool }, + /// Send local clipboard text to the server (RFB ClientCutText). + ClipboardText(String), + Disconnect, +} + +static NEXT_ID: AtomicU32 = AtomicU32::new(1); +static SESSIONS: Mutex>>> = Mutex::new(None); + +pub mod registry { + use super::*; + + pub fn insert(tx: UnboundedSender) -> u32 { + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + SESSIONS.lock().expect("session registry lock poisoned").get_or_insert_with(HashMap::new).insert(id, tx); + id + } + + pub fn send(id: u32, cmd: SessionCmd) -> bool { + // Clone the sender out from under the lock so the channel send + // (and any future drop-handler triggered by it) cannot re-enter SESSIONS. + let tx = SESSIONS.lock().expect("session registry lock poisoned") + .as_ref() + .and_then(|m| m.get(&id)) + .cloned(); + match tx { + Some(tx) => tx.send(cmd).is_ok(), + None => false, + } + } + + pub fn remove(id: u32) { + if let Some(m) = SESSIONS.lock().expect("session registry lock poisoned").as_mut() { + m.remove(&id); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn registry_insert_send_remove() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let id = registry::insert(tx); + assert!(registry::send(id, SessionCmd::Disconnect)); + assert!(matches!(rx.try_recv().unwrap(), SessionCmd::Disconnect)); + registry::remove(id); + assert!(!registry::send(id, SessionCmd::Disconnect)); + } +} diff --git a/packages/yourssh_vnc/test/frb_roundtrip_test.dart b/packages/yourssh_vnc/test/frb_roundtrip_test.dart new file mode 100644 index 00000000..616370f1 --- /dev/null +++ b/packages/yourssh_vnc/test/frb_roundtrip_test.dart @@ -0,0 +1,14 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh_vnc/src/generated/frb_generated.dart'; +import 'package:yourssh_vnc/src/generated/api.dart'; +import 'package:yourssh_vnc/src/native_loader.dart'; + +void main() { + setUpAll(() async { + await RustLib.init(externalLibrary: loadYoursshVncLibrary()); + }); + + test('vncLibVersion returns crate version', () async { + expect(await vncLibVersion(), startsWith('yourssh_vnc 0.1.0')); + }); +} diff --git a/packages/yourssh_vnc/test/vnc_client_input_test.dart b/packages/yourssh_vnc/test/vnc_client_input_test.dart new file mode 100644 index 00000000..0af85903 --- /dev/null +++ b/packages/yourssh_vnc/test/vnc_client_input_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh_vnc/yourssh_vnc.dart'; + +void main() { + // Before connect, _sessionId is null, so input methods must no-op (never + // touch the bridge) — this runs without loading the native library. + VncClient client() => VncClient(VncConfig( + targetHost: '10.0.0.5', targetPort: 5900, username: 'u', password: '')); + + test('sendPointer is a no-op before connect', () { + expect(() => client().sendPointer(x: 1, y: 2, buttonMask: 0x1), + returnsNormally); + }); + + test('sendKey is a no-op before connect', () { + expect(() => client().sendKey(keysym: 0xFF0D, down: true), returnsNormally); + }); + + test('sendClipboardText is a no-op before connect', () { + expect(() => client().sendClipboardText('x'), returnsNormally); + }); +}