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
16 changes: 16 additions & 0 deletions lib/clipboard_interop/clipboard_interop.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import 'clipboard_io.dart' if (dart.library.js_interop) 'clipboard_web.dart';

/// Puts [text] on the system clipboard.
///
/// Wraps `Clipboard.setData` everywhere except the web, where it throws over
/// plain HTTP: the engine's only strategy is `navigator.clipboard`, which
/// browsers expose in a secure context alone, and `http://admin.awl` is not
/// one. The web branch keeps that path and falls back to `execCommand`, which
/// carries no such requirement.
///
/// Throws if the copy did not happen, which callers must handle: a copy button
/// that reports success it didn't have sends the user off to paste an empty
/// clipboard.
Future<void> copyText(String text) async {
return copyTextImpl(text);
}
8 changes: 8 additions & 0 deletions lib/clipboard_interop/clipboard_io.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import 'package:flutter/services.dart';

/// The default branch, and a real implementation rather than a stub: this is a
/// framework call with no `dart:io` in it, so it serves Android, iOS and
/// desktop alike.
Future<void> copyTextImpl(String text) async {
await Clipboard.setData(ClipboardData(text: text));
}
49 changes: 49 additions & 0 deletions lib/clipboard_interop/clipboard_web.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import 'dart:js_interop';

import 'package:web/web.dart' as web;

/// `navigator.clipboard` is absent outside a secure context, but package:web
/// types it non-nullable because the IDL says it is always there. Reading it
/// through a nullable view is what lets the two cases be told apart.
extension on web.Navigator {
@JS('clipboard')
external web.Clipboard? get clipboardOrNull;
}

Future<void> copyTextImpl(String text) async {
// First, because it is the one that works in a secure context (localhost in
// development, any HTTPS deployment) and execCommand is deprecated.
final clipboard = web.window.navigator.clipboardOrNull;
if (clipboard != null) {
await clipboard.writeText(text).toDart;
return;
}

// Ported from the Flutter engine's own ExecCommandCopyStrategy (BSD-3),
// which served this case from 2020 until https://github.com/flutter/flutter/pull/171427 removed it
// as redundant with the Clipboard API, counting browser versions and not
// insecure contexts. execCommand needs a user gesture, which a button is.
final textArea = web.HTMLTextAreaElement();
// Off-screen and transparent rather than hidden: `display: none` cannot be
// selected, and selection is what execCommand copies.
textArea.style
..position = 'absolute'
..top = '-99999px'
..left = '-99999px'
..opacity = '0'
..color = 'transparent'
..backgroundColor = 'transparent'
..background = 'transparent';
web.document.body!.appendChild(textArea);
textArea.value = text;
// Without preventScroll the page jumps to the invisible textarea.
textArea.focus(web.FocusOptions(preventScroll: true));
textArea.select();
try {
if (!web.document.execCommand('copy')) {
throw StateError('Clipboard copy was refused by the browser');
}
} finally {
textArea.remove();
}
}
22 changes: 16 additions & 6 deletions lib/common.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import 'dart:async';

import 'package:anywherelan/clipboard_interop/clipboard_interop.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:share_plus/share_plus.dart';

Expand Down Expand Up @@ -91,16 +92,19 @@ class ExitNodePermissionField extends StatelessWidget {
}
}

const copyFailedMessage = kIsWeb
? 'Could not copy. Select the text and press Ctrl+C.'
: 'Could not copy to clipboard.';

/// A value the user is meant to take away rather than read: a link, a peer ID.
/// Rendered as a field so that seventy-odd characters of base58 read as a value
/// with a copy button on it instead of as a paragraph.
///
/// The value stays **in full and selectable**, however many lines that takes,
/// and copying is a button beside it rather than a tap on the field. Truncating
/// to one line is tidier and must not happen: on the web build served over
/// plain HTTP `Clipboard.setData` throws (`navigator.clipboard` is
/// secure-context-only), so hand-selecting the text is the only copy path that
/// works there, and it needs the whole value on screen.
/// to one line is tidier and must not happen: a browser can refuse a
/// programmatic copy, and hand-selecting the text is then the only path left,
/// which needs the whole value on screen.
///
/// The button answers in place, the tick replacing the icon for two seconds: a
/// [SnackBar] raised inside a dialog lands at the bottom of the screen, far
Expand Down Expand Up @@ -161,7 +165,13 @@ class _CopyableFieldState extends State<CopyableField> {
}

Future<void> _copy() async {
await Clipboard.setData(ClipboardData(text: widget.value));
try {
await copyText(widget.value);
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text(copyFailedMessage)));
return;
}
if (!mounted) return;

ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(widget.copiedMessage)));
Expand Down
15 changes: 12 additions & 3 deletions lib/peer_settings_screen.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import 'dart:async';

import 'package:anywherelan/clipboard_interop/clipboard_interop.dart';
import 'package:anywherelan/common.dart';
import 'package:anywherelan/entities.dart';
import 'package:anywherelan/providers.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

/// Adapter for [PeerSettingsView] that loads the peer config from the
Expand Down Expand Up @@ -332,8 +332,17 @@ class _PeerSettingsViewState extends State<PeerSettingsView> {
suffixIcon: IconButton(
icon: Icon(Icons.content_copy),
tooltip: 'Copy Peer ID',
onPressed: () {
Clipboard.setData(ClipboardData(text: widget.peerConfig.peerId));
onPressed: () async {
try {
await copyText(widget.peerConfig.peerId);
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text(copyFailedMessage)));
return;
}
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Peer ID copied to clipboard")));
Expand Down
10 changes: 8 additions & 2 deletions lib/status_tab.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:async';

import 'package:anywherelan/clipboard_interop/clipboard_interop.dart';
import 'package:anywherelan/common.dart';
import 'package:anywherelan/connection_error.dart';
import 'package:anywherelan/entities.dart';
Expand All @@ -8,7 +9,6 @@ import 'package:anywherelan/qr_dialog.dart';
import 'package:anywherelan/server_interop/server_interop.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

/// Adapter for [StatusPageView] that reads [myPeerInfoProvider] and
Expand Down Expand Up @@ -754,7 +754,13 @@ class _AddressField extends StatelessWidget {
icon: const Icon(Icons.copy_rounded),
tooltip: 'Copy address',
onPressed: () async {
await Clipboard.setData(ClipboardData(text: address));
try {
await copyText(address);
} catch (_) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text(copyFailedMessage)));
return;
}
if (!context.mounted) return;
ScaffoldMessenger.of(
context,
Expand Down
23 changes: 23 additions & 0 deletions test/widget/copyable_field_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,29 @@ void main() {
expect(shared, 1);
});

testWidgets('says the copy failed instead of confirming one that did not happen', (tester) async {
// What the web build over plain HTTP does when the browser refuses: the
// button used to leave no trace at all, the throw going nowhere.
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'Clipboard.setData') {
throw PlatformException(code: 'copy_fail', message: 'Clipboard is not available in the context.');
}
return null;
});
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null),
);

await pumpField(tester);
await tester.tap(find.byIcon(Icons.content_copy));
await tester.pump();

expect(find.text(copyFailedMessage), findsOneWidget);
expect(find.text('Link copied to clipboard'), findsNothing);
// No tick either: it is the same claim of success in another form.
expect(find.byIcon(Icons.check), findsNothing);
});

testWidgets('copies the value and says so, share or no share', (tester) async {
String? copied;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
Expand Down
24 changes: 24 additions & 0 deletions test/widget/peer_settings_screen_test.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import 'package:anywherelan/common.dart';
import 'package:anywherelan/entities.dart';
import 'package:anywherelan/peer_settings_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';

import '../fixtures/fixture_reader.dart';
Expand Down Expand Up @@ -41,6 +43,28 @@ void main() {
expect(tester.widget<Switch>(switchFinder).value, cfg.weAllowUsingAsExitNode);
});

testWidgets('does not report a Peer ID copy that failed', (tester) async {
// This button used to announce success before the copy was even awaited,
// so on a build where the clipboard is unreachable it sent the user off
// to paste an empty clipboard.
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'Clipboard.setData') {
throw PlatformException(code: 'copy_fail');
}
return null;
});
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null),
);

await pumpAppWidget(tester, PeerSettingsView(peerConfig: _loadConfig()), size: desktopSize);
await tester.tap(find.widgetWithIcon(IconButton, Icons.content_copy));
await tester.pump();

expect(find.text('Peer ID copied to clipboard'), findsNothing);
expect(find.text(copyFailedMessage), findsOneWidget);
});

testWidgets('no invite marker for a peer added the usual way', (tester) async {
await pumpAppWidget(tester, PeerSettingsView(peerConfig: _loadConfig()), size: desktopSize);

Expand Down