Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ 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.39] — 2026-06-24

### Added
- **SSH connection screen (#82)** — while an SSH session connects, the tab now shows a host card (OS icon, label, `host:port`) with an animated node → line → terminal graphic, a **Show logs** panel over a new per-session connection log (resolve, auth method, jump-host tunnel, host-key verify, established / failed, reconnect countdown), and **Close** / **Retry** actions. Manual retry reuses the same tab and terminal and starts a fresh log. Replaces the bare "Connecting…" spinner; the step log is captured at the `SessionProvider` level so `SshService.connect`'s shared signature is untouched, and the card scrolls instead of overflowing on short / split panes (`SessionConnectingView`, `ConnectionLogLine`).

---

## [0.1.38] — 2026-06-23

### Added
Expand Down Expand Up @@ -654,6 +661,8 @@ 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.39]: https://github.com/YoursshLabs/yourssh/compare/v0.1.38...v0.1.39
[0.1.38]: https://github.com/YoursshLabs/yourssh/compare/v0.1.37...v0.1.38
[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
Expand Down
108 changes: 108 additions & 0 deletions app/integration_test/connecting_screenshots_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Captures screenshots of the SSH connection screen (SessionConnectingView)
// in its connecting and error+logs states. Pumps the widget directly (it takes
// plain callbacks, no providers) so the transient connecting state is
// deterministic, and captures from the render tree — no Screen-Recording
// permission needed.
//
// Run:
// cd app && flutter test integration_test/connecting_screenshots_test.dart -d macos
import 'dart:io';
import 'dart:ui' as ui;

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:yourssh/models/connection_log.dart';
import 'package:yourssh/models/host.dart';
import 'package:yourssh/models/ssh_session.dart';
import 'package:yourssh/theme/app_theme.dart';
import 'package:yourssh/widgets/session_connecting_view.dart';

const _outDir = '/Users/thangnguyen/Projects/Personal/yourssh/screenshots';
const _g1 = '$_outDir/01-terminal-ssh';

final _captureKey = GlobalKey();

/// Captures exactly the fixed-size RepaintBoundary around the card — independent
/// of the real macOS window size, so the framing is deterministic.
Future<void> _snap(WidgetTester tester, String path) async {
await tester.pump(const Duration(milliseconds: 200));
final boundary =
_captureKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
final image = await boundary.toImage(pixelRatio: 2.0);
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
final file = File(path);
await file.parent.create(recursive: true);
await file.writeAsBytes(bytes!.buffer.asUint8List());
// ignore: avoid_print
print('SNAP: $path');
}

SshSession _session(SessionStatus status, {String? error}) {
final s = SshSession(
host: Host(
id: 'demo',
label: 'Thang MacBook Pro',
host: 'thngs-macbook-pro',
port: 22,
username: 'thang',
detectedOs: 'macos',
),
status: status,
);
s.errorMessage = error;
return s;
}

Future<void> _pump(WidgetTester tester, SshSession session) async {
await tester.pumpWidget(MaterialApp(
theme: buildAppTheme(),
debugShowCheckedModeBanner: false,
home: Scaffold(
backgroundColor: AppColors.bg,
body: Center(
child: RepaintBoundary(
key: _captureKey,
child: SizedBox(
width: 760,
height: 520,
child: SessionConnectingView(
session: session,
onClose: () {},
onRetry: () {},
),
),
),
),
),
));
await tester.pump(const Duration(milliseconds: 300));
}

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();

testWidgets('connecting state', (tester) async {
final s = _session(SessionStatus.connecting)
..logConnection(ConnectionLogLevel.info, 'Connecting to thngs-macbook-pro:22 as thang')
..logConnection(ConnectionLogLevel.info, 'Auth method: publicKey');
await _pump(tester, s);
await _snap(tester, '$_g1/ssh-connecting.png');
await tester.pumpWidget(const SizedBox()); // unmount → dispose animation
});

testWidgets('error state with logs open', (tester) async {
final s = _session(SessionStatus.error, error: 'Auth failed: permission denied (publickey)')
..logConnection(ConnectionLogLevel.info, 'Connecting to thngs-macbook-pro:22 as thang')
..logConnection(ConnectionLogLevel.info, 'Auth method: publicKey')
..logConnection(ConnectionLogLevel.info, 'Verifying host key (ssh-ed25519) for thngs-macbook-pro:22')
..logConnection(ConnectionLogLevel.success, 'Host key accepted')
..logConnection(ConnectionLogLevel.error, 'Connection failed: permission denied (publickey)');
await _pump(tester, s);
await tester.tap(find.text('Show logs'));
await _snap(tester, '$_g1/ssh-connecting-error-logs.png');
await tester.pumpWidget(const SizedBox());
});
}
22 changes: 22 additions & 0 deletions app/lib/models/connection_log.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/// A single line in a session's connection log — the human-readable trace of a
/// connect attempt (resolve → TCP → host-key verify → auth → shell) surfaced by
/// the "Show logs" panel on the connecting screen.
library;

enum ConnectionLogLevel { info, success, warn, error }

class ConnectionLogLine {
final DateTime time;
final ConnectionLogLevel level;
final String message;

const ConnectionLogLine({
required this.time,
required this.level,
required this.message,
});
}

/// Cap on retained connection-log lines. A flapping host with verbose reconnect
/// chatter must not grow this list without bound.
const int kMaxConnectionLogLines = 200;
19 changes: 19 additions & 0 deletions app/lib/models/ssh_session.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:uuid/uuid.dart';
import 'package:xterm/xterm.dart';
import 'agent_forwarding_state.dart';
import 'connection_log.dart';
import 'host.dart';
import 'terminal_session.dart';

Expand Down Expand Up @@ -33,6 +34,24 @@ class SshSession implements TerminalSession {
/// SessionProvider.handleAgentForwardingEvent.
AgentForwardingState agentForwardingState = AgentForwardingState.off;

/// Human-readable trace of the connect attempt, shown by the "Show logs"
/// panel on the connecting screen. Bounded to [kMaxConnectionLogLines].
final List<ConnectionLogLine> connectionLog = [];

/// Appends a connection-log line, trimming the oldest entries past the cap.
void logConnection(ConnectionLogLevel level, String message, {DateTime? at}) {
connectionLog.add(ConnectionLogLine(
time: at ?? DateTime.now(),
level: level,
message: message,
));
if (connectionLog.length > kMaxConnectionLogLines) {
connectionLog.removeRange(0, connectionLog.length - kMaxConnectionLogLines);
}
}

void clearConnectionLog() => connectionLog.clear();

SshSession({
String? id,
required this.host,
Expand Down
52 changes: 50 additions & 2 deletions app/lib/providers/session_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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/connection_log.dart';
import '../models/host.dart';
import '../models/local_session.dart';
import '../models/rdp_session.dart';
Expand Down Expand Up @@ -457,18 +458,29 @@ class SessionProvider extends ChangeNotifier {
jumpLookup: (id) => jumpHostLookup?.call(id),
keyLookup: (id) => keyLookup?.call(id),
);
session.logConnection(ConnectionLogLevel.info,
'Connecting to ${host.host}:${host.port} as ${host.username}');
session.logConnection(
ConnectionLogLevel.info, 'Auth method: ${host.authType.name}');
if (jumpChain.isNotEmpty) {
session.logConnection(ConnectionLogLevel.info,
'Tunneling via ${jumpChain.map((h) => h.host.label).join(' → ')}');
}
_safeNotify();
await _ssh.connect(
host,
keyEntry: keyEntry,
jumpChain: jumpChain,
verifyHostKey: hostKeyVerifier != null
? (keyType, fp) => hostKeyVerifier!(host.host, host.port, keyType, fp)
? (keyType, fp) =>
_verifyAndLog(session, host.host, host.port, keyType, fp)
: null,
verifyHopHostKey: hostKeyVerifier != null
? (hop, keyType, fp) =>
hostKeyVerifier!(hop.host, hop.port, keyType, fp)
_verifyAndLog(session, hop.host, hop.port, keyType, fp)
: null,
);
session.logConnection(ConnectionLogLevel.success, 'Connection established');
session.status = SessionStatus.connected;
audit?.record(AuditEvent.now(
type: AuditEventType.connect, host: host, sessionId: session.id));
Expand Down Expand Up @@ -516,6 +528,7 @@ class SessionProvider extends ChangeNotifier {
}
} catch (e) {
if (!_sessions.contains(session)) return;
session.logConnection(ConnectionLogLevel.error, 'Connection failed: $e');
final maxAttempts = reconnectAttempts?.call() ?? 0;
final isUnlimited = maxAttempts == 0;
// A jump-chain config error (deleted bastion, cycle) can never succeed
Expand Down Expand Up @@ -545,10 +558,45 @@ class SessionProvider extends ChangeNotifier {
}
}

/// Wraps the host-key verifier so the TOFU check is reflected in the
/// session's connection log (shown by the "Show logs" panel).
Future<bool> _verifyAndLog(
SshSession session, String host, int port, String keyType, Uint8List fp) async {
session.logConnection(
ConnectionLogLevel.info, 'Verifying host key ($keyType) for $host:$port');
_safeNotify();
final ok = await hostKeyVerifier!(host, port, keyType, fp);
session.logConnection(
ok ? ConnectionLogLevel.success : ConnectionLogLevel.warn,
ok ? 'Host key accepted' : 'Host key rejected');
_safeNotify();
return ok;
}

/// Manual retry from the connecting screen's "Retry" button. Reuses the
/// existing session/tab (preserving its terminal and metadata) rather than
/// closing and reopening, and cancels any pending auto-reconnect first.
void reconnectSession(String sessionId) {
final session = sshSessions.where((s) => s.id == sessionId).firstOrNull;
if (session == null || session.status == SessionStatus.connecting) return;
_reconnectTimers.remove(sessionId)?.cancel();
_countdownTimers.remove(sessionId)?.cancel();
session.status = SessionStatus.connecting;
session.errorMessage = null;
// A manual retry is a fresh attempt — drop the prior attempt's trace so the
// log panel shows only the new connection.
session.clearConnectionLog();
session.logConnection(ConnectionLogLevel.info, 'Retrying connection…');
_safeNotify();
unawaited(_doConnect(session, session.host, attempt: 1));
}

void _scheduleReconnect(SshSession session, Host host, {required int attempt}) {
session.reconnectCount++;
final delay = (attempt * 2).clamp(2, 60);
session.status = SessionStatus.connecting;
session.logConnection(
ConnectionLogLevel.warn, 'Reconnecting in ${delay}s (attempt $attempt)');
_safeNotify();

_startCountdown(session, delay, attempt);
Expand Down
Loading
Loading