Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
b33c33e
docs(vnc): design spec for VNC (RFB) protocol support
thangnm93 Jun 16, 2026
a98f9ab
feat(vnc): scaffold yourssh_vnc package
thangnm93 Jun 16, 2026
9ae0625
feat(vnc): session command registry
thangnm93 Jun 16, 2026
8181066
test(vnc): run-loop pure helpers (opaque alpha, disconnect classify)
thangnm93 Jun 16, 2026
d0226b6
fix(vnc): session registry safety fixes
thangnm93 Jun 16, 2026
7c65c1f
feat(vnc): FRB api surface (VncConfig, VncEvent, connect/disconnect)
thangnm93 Jun 16, 2026
1d74034
fix(vnc): run-loop helpers quality fixes (debug_assert alignment, dis…
thangnm93 Jun 16, 2026
476981a
feat(vnc): connect stage + event run loop
thangnm93 Jun 16, 2026
2905754
feat(vnc): generate flutter_rust_bridge bindings
thangnm93 Jun 16, 2026
3fd9ccd
fix(vnc): bias select! for cmd priority; enumerate ignorable VncEvent…
thangnm93 Jun 16, 2026
bb5cbbb
feat(vnc): native build script (macOS universal + Linux)
thangnm93 Jun 16, 2026
ac041d8
feat(vnc): Dart native loader + VncClient facade
thangnm93 Jun 16, 2026
065e0a9
fix(vnc): close event stream + cancel sub on terminal paths; guard di…
thangnm93 Jun 16, 2026
b9097e3
test(vnc): FRB roundtrip (vncLibVersion over the native bridge)
thangnm93 Jun 16, 2026
3109beb
fix(vnc): keep event stream open past terminal events (match RDP facade)
thangnm93 Jun 16, 2026
fe63d55
fix(vnc): correct disconnect mapping, skip stale refresh ticks, label…
thangnm93 Jun 16, 2026
cd63c6a
build(vnc): add Windows build script and wire crate into PR CI
thangnm93 Jun 16, 2026
ba2172a
build(vnc): track Cargo.lock for reproducible builds
thangnm93 Jun 16, 2026
5cb7b2d
feat(vnc): add HostProtocol.vnc (default port 5900)
thangnm93 Jun 17, 2026
456e04a
feat(vnc): VncSession model (framebuffer realloc + latest-wins decode)
thangnm93 Jun 17, 2026
294c292
feat(vnc): route + connect VNC sessions in SessionProvider
thangnm93 Jun 17, 2026
9931f4d
feat(vnc): minimal view-only VncWorkspace (framebuffer paint)
thangnm93 Jun 17, 2026
e21d82b
feat(vnc): render VncSession tabs via VncWorkspace in MainScreen
thangnm93 Jun 17, 2026
06b654a
feat(vnc): VNC protocol option in HostDetailPanel
thangnm93 Jun 17, 2026
a791c5b
fix(vnc): dedup live VNC tabs in CONNECT ALL
thangnm93 Jun 17, 2026
cf5c728
test(vnc): drop redundant app_session import (provided via terminal_s…
thangnm93 Jun 17, 2026
f146908
build(vnc): record yourssh_vnc path dependency in pubspec.lock
thangnm93 Jun 17, 2026
1c8bc1a
merge(vnc): VNC support (M1 core crate + M2 end-to-end view-only)
thangnm93 Jun 17, 2026
abe39b3
feat(vnc): pointer/key session commands + run-loop input dispatch
thangnm93 Jun 17, 2026
75c6820
feat(vnc): FFI send-pointer/send-key + regenerated bindings
thangnm93 Jun 17, 2026
0b71c4c
feat(vnc): VncClient.sendPointer/sendKey input methods
thangnm93 Jun 17, 2026
cf21a94
feat(vnc): X11 keysym map + pointer coordinate transform
thangnm93 Jun 17, 2026
ed92ac0
feat(vnc): mouse + keyboard capture in VncWorkspace
thangnm93 Jun 17, 2026
5a66a2c
fix(vnc): dedup pointer floods, restore waiting-for-frame cue, streng…
thangnm93 Jun 17, 2026
1c81c3d
fix(vnc): commit regenerated Rust FRB glue for send-pointer/send-key
thangnm93 Jun 17, 2026
8780d5b
merge(vnc)
thangnm93 Jun 17, 2026
fecf820
refactor(vnc): rename RdpTunnelProxy to protocol-neutral LoopbackTunn…
thangnm93 Jun 17, 2026
f212954
feat(vnc): SSH-tunnel connections via shared loopback proxy
thangnm93 Jun 17, 2026
b3f4570
feat(vnc): clipboard-send command + FFI + regenerated bindings
thangnm93 Jun 17, 2026
485fa59
feat(vnc): clipboard both directions (server cut-text + focus/button …
thangnm93 Jun 17, 2026
9529e2b
feat(vnc): generalize RdpBadge into ProtocolBadge (RDP + VNC)
thangnm93 Jun 17, 2026
8732076
feat(vnc): fullscreen with auto-hide exit pill (mirrors RDP)
thangnm93 Jun 17, 2026
fed6a5f
feat(vnc): SSH-tunnel dropdown in the host panel for VNC hosts
thangnm93 Jun 17, 2026
c9aa601
feat(vnc): vnc:// copy-url, non-SSH bulk-skip wording, resize-correct…
thangnm93 Jun 17, 2026
09db5ad
test(vnc): manual screenshots integration test + docs
thangnm93 Jun 17, 2026
bd13b33
fix(vnc): M4 review fixes — restore VNC tabs, guard per-frame rebuild…
thangnm93 Jun 17, 2026
ecc4c1d
docs(vnc): add M1-M5 implementation plans
thangnm93 Jun 18, 2026
fc7841c
docs: mark VNC M1-M5 + OSC 52 shipped in roadmap; add VNC wiki page
thangnm93 Jun 18, 2026
87929af
chore: release 0.1.37 — VNC client + OSC 52 clipboard (version, chang…
thangnm93 Jun 18, 2026
d148a37
Merge branch 'master' into develop
thangnm93 Jun 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .github/workflows/pr-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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/
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
207 changes: 207 additions & 0 deletions app/integration_test/vnc_screenshots_test.dart
Original file line number Diff line number Diff line change
@@ -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 <repo>/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<void> _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<void> _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<HostProtocol>),
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<SessionProvider>(ctx, listen: false);
VncSession vnc() => sessions.sessions.whereType<VncSession>().first;
await _waitFor(
tester,
() =>
sessions.sessions.whereType<VncSession>().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);
}
});
}
3 changes: 2 additions & 1 deletion app/lib/models/host.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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});

Expand Down
4 changes: 2 additions & 2 deletions app/lib/models/rdp_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading