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
6 changes: 6 additions & 0 deletions app/lib/models/host.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class Host {
List<String> jumpHostIds;
bool shellIntegration;
bool agentForwarding;
bool osc52Clipboard;
SftpMode sftpMode;
String? sftpServerCommand;

Expand Down Expand Up @@ -83,6 +84,7 @@ class Host {
Iterable<String> jumpHostIds = const [],
this.shellIntegration = true,
this.agentForwarding = false,
this.osc52Clipboard = false,
this.sftpMode = SftpMode.normal,
this.sftpServerCommand,
this.protocol = HostProtocol.ssh,
Expand Down Expand Up @@ -133,6 +135,7 @@ class Host {
'jumpHostId': jumpHostId,
'shellIntegration': shellIntegration,
'agentForwarding': agentForwarding,
'osc52Clipboard': osc52Clipboard,
'sftpMode': sftpMode.name,
'sftpServerCommand': sftpServerCommand,
'protocol': protocol.name,
Expand Down Expand Up @@ -223,6 +226,7 @@ class Host {
jumpHostIds: parseJumpHostIds(),
shellIntegration: (json['shellIntegration'] as bool?) ?? true,
agentForwarding: (json['agentForwarding'] as bool?) ?? false,
osc52Clipboard: (json['osc52Clipboard'] as bool?) ?? false,
sftpMode: parseSftpMode(),
sftpServerCommand: json['sftpServerCommand'] as String?,
protocol: parseProtocol(),
Expand Down Expand Up @@ -253,6 +257,7 @@ class Host {
List<String>? jumpHostIds,
bool? shellIntegration,
bool? agentForwarding,
bool? osc52Clipboard,
SftpMode? sftpMode,
Object? sftpServerCommand = const _Unset(),
HostProtocol? protocol,
Expand Down Expand Up @@ -284,6 +289,7 @@ class Host {
jumpHostIds: jumpHostIds ?? this.jumpHostIds,
shellIntegration: shellIntegration ?? this.shellIntegration,
agentForwarding: agentForwarding ?? this.agentForwarding,
osc52Clipboard: osc52Clipboard ?? this.osc52Clipboard,
sftpMode: sftpMode ?? this.sftpMode,
sftpServerCommand: sftpServerCommand is _Unset
? this.sftpServerCommand
Expand Down
43 changes: 43 additions & 0 deletions app/lib/services/osc52_clipboard.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import 'dart:convert';

/// Maximum decoded clipboard payload accepted from an OSC 52 write (1 MiB).
const int kOsc52MaxBytes = 1 << 20;

/// Result of parsing an OSC 52 argument list.
sealed class Osc52Result {
const Osc52Result();
}

/// A clipboard-write request carrying the decoded [text].
class Osc52Write extends Osc52Result {
final String text;
const Osc52Write(this.text);
}

/// Not a write we honor: a read query (`?`), invalid base64, an oversized
/// payload, or a malformed argument list.
class Osc52Ignored extends Osc52Result {
const Osc52Ignored();
}

class Osc52Clipboard {
/// Parses the OSC 52 argument tail (everything after the `52` code).
///
/// For `OSC 52 ; c ; <base64>` the caller hands us `['c', '<base64>']`.
/// The selection target (first element) is ignored — desktop has a single
/// system clipboard. Returns [Osc52Write] only for a valid, in-cap payload;
/// every other case is [Osc52Ignored] (fail-soft — never throws).
static Osc52Result parse(List<String> args) {
if (args.length < 2) return const Osc52Ignored();
final data = args.last;
if (data == '?') return const Osc52Ignored(); // read query — never honored
final List<int> bytes;
try {
bytes = base64.decode(base64.normalize(data));
} on FormatException {
return const Osc52Ignored();
}
if (bytes.length > kOsc52MaxBytes) return const Osc52Ignored();
return Osc52Write(utf8.decode(bytes, allowMalformed: true));
}
}
40 changes: 36 additions & 4 deletions app/lib/services/ssh_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import 'dart:convert';
import 'dart:io';
import 'package:dartssh2/dartssh2.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:yourssh_script_engine/yourssh_script_engine.dart';
import 'osc52_clipboard.dart';
import '../providers/shell_integration_provider.dart';
import '../models/agent_forwarding_state.dart';
import '../models/host.dart';
Expand Down Expand Up @@ -103,8 +105,35 @@ class SshService {
Future<({String password, bool remember})?> Function(Host host)?
sudoPasswordPrompt;

/// Writes [text] to the system clipboard for an OSC 52 write request.
/// Injectable for tests; defaults to the platform clipboard.
Future<void> Function(String text) clipboardWriter =
(text) => Clipboard.setData(ClipboardData(text: text));

SshService(this._storage, {this.hookBus, this.shellIntegration});

/// Routes a private-OSC event. OSC 52 (when [osc52On]) writes the local
/// clipboard; everything else falls through to shell integration when
/// [siOn]. Extracted so the routing is unit-testable without a live session.
@visibleForTesting
void dispatchPrivateOsc(
String code,
List<String> args, {
required bool osc52On,
required bool siOn,
required String sessionId,
required int absoluteCursorY,
}) {
if (code == '52' && osc52On) {
final r = Osc52Clipboard.parse(args);
if (r is Osc52Write) clipboardWriter(r.text);
return;
}
if (siOn) {
shellIntegration?.handleOsc(sessionId, code, args, absoluteCursorY);
}
}

/// Test-only: register a (fake) client so shell/exec paths can run without
/// a real network connection.
@visibleForTesting
Expand Down Expand Up @@ -605,12 +634,15 @@ class SshService {
final siOn = shellIntegration != null &&
session.host.shellIntegration &&
(isShellIntegrationEnabled?.call() ?? true);
if (siOn) {
session.terminal.onPrivateOSC = (code, args) => shellIntegration!.handleOsc(
session.id,
final osc52On = session.host.osc52Clipboard;
if (siOn || osc52On) {
session.terminal.onPrivateOSC = (code, args) => dispatchPrivateOsc(
code,
args,
session.terminal.buffer.absoluteCursorY,
osc52On: osc52On,
siOn: siOn,
sessionId: session.id,
absoluteCursorY: session.terminal.buffer.absoluteCursorY,
);
}

Expand Down
23 changes: 23 additions & 0 deletions app/lib/widgets/host_detail_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ class _HostDetailPanelState extends State<HostDetailPanel> {
bool _recordingRedaction = true;
bool _shellIntegration = true;
bool _agentForwarding = false;
bool _osc52Clipboard = false;
late final TextEditingController _workingDirCtrl;
late final TextEditingController _startupSnippetCtrl;
late final TextEditingController _fontSizeCtrl;
Expand Down Expand Up @@ -108,6 +109,7 @@ class _HostDetailPanelState extends State<HostDetailPanel> {
_recordingRedaction = h?.recordingRedaction ?? true;
_shellIntegration = h?.shellIntegration ?? true;
_agentForwarding = h?.agentForwarding ?? false;
_osc52Clipboard = h?.osc52Clipboard ?? false;
_workingDirCtrl = TextEditingController(text: h?.workingDir ?? '');
_startupSnippetCtrl = TextEditingController(text: h?.startupSnippet ?? '');
_fontSizeCtrl = TextEditingController(text: _fmtFontSize(h?.fontSize));
Expand Down Expand Up @@ -229,6 +231,7 @@ class _HostDetailPanelState extends State<HostDetailPanel> {
recordingRedaction: _recordingRedaction,
shellIntegration: _shellIntegration,
agentForwarding: !_isRdp && _agentForwarding,
osc52Clipboard: !_isRdp && _osc52Clipboard,
jumpHostIds: _jumpHostIds,
sftpMode: _isRdp ? SftpMode.normal : _sftpMode,
sftpServerCommand: !_isRdp && _sftpMode == SftpMode.custom
Expand Down Expand Up @@ -801,6 +804,26 @@ class _HostDetailPanelState extends State<HostDetailPanel> {
horizontal: 12, vertical: 2),
activeThumbColor: AppColors.accent,
),
SwitchListTile(
value: _osc52Clipboard,
onChanged: (v) => setState(() => _osc52Clipboard = v),
title: const Text(
'OSC 52 clipboard',
style: TextStyle(
color: AppColors.textPrimary, fontSize: 13),
),
subtitle: const Text(
'Let remote apps (tmux, vim) set your local clipboard. '
'Write-only. Off by default — only enable for hosts you '
'trust.',
style: TextStyle(
color: AppColors.textTertiary, fontSize: 11),
),
dense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 2),
activeThumbColor: AppColors.accent,
),
// Zero-click feedback: probes on appearance. The auth
// section owns the line when auth = SSH Agent (spec: one
// probe, no duplicate row).
Expand Down
29 changes: 29 additions & 0 deletions app/test/models/host_osc52_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:yourssh/models/host.dart';

void main() {
group('Host.osc52Clipboard', () {
test('defaults to false', () {
final h = Host(label: 'a', host: 'h', username: 'u');
expect(h.osc52Clipboard, isFalse);
});

test('round-trips through toJson/fromJson', () {
final h = Host(label: 'a', host: 'h', username: 'u', osc52Clipboard: true);
final back = Host.fromJson(h.toJson());
expect(back.osc52Clipboard, isTrue);
});

test('absent key in json defaults to false', () {
final json = Host(label: 'a', host: 'h', username: 'u').toJson()
..remove('osc52Clipboard');
expect(Host.fromJson(json).osc52Clipboard, isFalse);
});

test('copyWith overrides the field', () {
final h = Host(label: 'a', host: 'h', username: 'u');
expect(h.copyWith(osc52Clipboard: true).osc52Clipboard, isTrue);
expect(h.copyWith().osc52Clipboard, isFalse);
});
});
}
45 changes: 45 additions & 0 deletions app/test/services/osc52_clipboard_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import 'dart:convert';

import 'package:flutter_test/flutter_test.dart';
import 'package:yourssh/services/osc52_clipboard.dart';

String b64(String s) => base64.encode(utf8.encode(s));

void main() {
group('Osc52Clipboard.parse', () {
test('valid write decodes base64 text', () {
final r = Osc52Clipboard.parse(['c', b64('hello world')]);
expect(r, isA<Osc52Write>());
expect((r as Osc52Write).text, 'hello world');
});

test('empty selection target still writes', () {
final r = Osc52Clipboard.parse(['', b64('x')]);
expect((r as Osc52Write).text, 'x');
});

test('read query (?) is ignored', () {
expect(Osc52Clipboard.parse(['c', '?']), isA<Osc52Ignored>());
});

test('invalid base64 is ignored', () {
expect(Osc52Clipboard.parse(['c', '!!!not base64!!!']),
isA<Osc52Ignored>());
});

test('payload over the cap is ignored', () {
final big = base64.encode(List<int>.filled(kOsc52MaxBytes + 1, 65));
expect(Osc52Clipboard.parse(['c', big]), isA<Osc52Ignored>());
});

test('non-utf8 bytes decode without throwing', () {
final raw = base64.encode([0xff, 0xfe, 0x41]);
expect(Osc52Clipboard.parse(['c', raw]), isA<Osc52Write>());
});

test('malformed arg lists are ignored', () {
expect(Osc52Clipboard.parse(<String>[]), isA<Osc52Ignored>());
expect(Osc52Clipboard.parse(['c']), isA<Osc52Ignored>());
});
});
}
60 changes: 60 additions & 0 deletions app/test/services/ssh_service_osc52_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import 'dart:convert';

import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:yourssh/providers/shell_integration_provider.dart';
import 'package:yourssh/services/ssh_service.dart';
import 'package:yourssh/services/storage_service.dart';

String b64(String s) => base64.encode(utf8.encode(s));

void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => SharedPreferences.setMockInitialValues({}));

test('code 52 with osc52 on writes decoded text to the clipboard', () {
final svc = SshService(StorageService());
final written = <String>[];
svc.clipboardWriter = (t) async => written.add(t);

svc.dispatchPrivateOsc('52', ['c', b64('copied!')],
osc52On: true, siOn: false, sessionId: 's1', absoluteCursorY: 0);

expect(written, ['copied!']);
});

test('code 52 with osc52 off does not write', () {
final svc = SshService(StorageService());
final written = <String>[];
svc.clipboardWriter = (t) async => written.add(t);

svc.dispatchPrivateOsc('52', ['c', b64('nope')],
osc52On: false, siOn: false, sessionId: 's1', absoluteCursorY: 0);

expect(written, isEmpty);
});

test('OSC 52 read query is never written', () {
final svc = SshService(StorageService());
final written = <String>[];
svc.clipboardWriter = (t) async => written.add(t);

svc.dispatchPrivateOsc('52', ['c', '?'],
osc52On: true, siOn: false, sessionId: 's1', absoluteCursorY: 0);

expect(written, isEmpty);
});

test('code 7 routes to shell integration (cwd), not the clipboard', () {
final si = ShellIntegrationProvider();
final svc = SshService(StorageService(), shellIntegration: si);
final written = <String>[];
svc.clipboardWriter = (t) async => written.add(t);

svc.dispatchPrivateOsc('7', ['file://host/home/user'],
osc52On: true, siOn: true, sessionId: 's1', absoluteCursorY: 0);

expect(written, isEmpty);
expect(si.cwdFor('s1'), '/home/user');
});
}
Loading
Loading