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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ board can be cross-checked against what actually shipped.

### Added

- Any call can be opened in an editor, changed and sent again. The editor starts from the method, address, header fields and body that were recorded. A value the redaction masked was never kept, so it is flagged and not sent unless it is typed again. What goes out is recorded as a new call that names the one it repeats, marked in the list and linked from its overview. It needs `dart:io`, so the web does not offer it (M15)
- A line above the list that says when Flutwire may be missing calls, with the reason and what to change: a client or adapter that does not use `dart:io`, an adapter that ran in another isolate, or something that replaced the capture's `HttpOverrides`. It stays out of sight until Flutwire has seen one of them happen, and it names the client or the isolate. `Flutwire.coverage` lists the notes, and an adapter that stands down for the capture reports through `Flutwire.watchStandDown`. The example app has a probe for each case (M24)
- An example for each adapter, so the package page on pub.dev has one to show (M34)

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

When an app misbehaves on a phone, the call that explains it is usually out of reach. The console shows only what a logger was told to print. A proxy needs a certificate on the device and a changed Wi-Fi setting. Every HTTP client in the app has to be wired by hand before it says anything, and when the app is killed the evidence goes with it.

Flutwire records the calls an app makes, keeps them on the device and lists the latest ones in the notification shade. Tapping it opens the inspector, and each call opens in full: URL, timings, headers, cookies, and the request and response bodies as a JSON tree, indented text, form fields, an image or a hex dump. A debug build needs one line in `main`; a release build records nothing.
Flutwire records the calls an app makes, keeps them on the device and lists the latest ones in the notification shade. Tapping it opens the inspector, and each call opens in full: URL, timings, headers, cookies, and the request and response bodies as a JSON tree, indented text, form fields, an image or a hex dump. A call can be edited and sent again from the same screen. A debug build needs one line in `main`; a release build records nothing.

```bash
flutter pub add flutwire
Expand Down Expand Up @@ -50,7 +50,7 @@ curl -X POST 'https://api.example.com/v1/orders' \
--data-raw '{"items":[{"sku":"A-12","qty":2}]}'
```

Flutwire reads traffic and never changes it. It does not proxy other apps, and it cannot see what never passes through Dart: Firestore, Auth and Storage speak their own protocols through native SDKs. When a call may have gone unseen, because a client does not use `dart:io`, because an adapter ran in another isolate or because something replaced the capture, the inspector says so above the list and says what to change. Authorization and cookie values are masked when a call is recorded, so they never reach memory, disk, a notification or an export.
Flutwire reads traffic and never changes it. It does not proxy other apps, and it cannot see what never passes through Dart: Firestore, Auth and Storage speak their own protocols through native SDKs. When a call may have gone unseen, because a client does not use `dart:io`, because an adapter ran in another isolate or because something replaced the capture, the inspector says so above the list and says what to change. Authorization and cookie values are masked when a call is recorded, so they never reach memory, disk, a notification or an export. The only request Flutwire makes is the one you edit and send again from a call, and it leaves out any value that was masked.

In a release build `Flutwire.init` does nothing: no call is recorded, no file is written and no notification is posted. The plugin's native code still ships in the app, like any other plugin's, and on Android its manifest adds the notification permission to the merged one.

Expand Down
8 changes: 7 additions & 1 deletion packages/flutwire/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

**A network inspector for Flutter that opens from the notification shade.**

Flutwire records the HTTP calls an app makes, keeps them on the device and lists the latest ones in the notification shade. Tapping it opens the inspector, and each call opens in full: URL, timings, headers, cookies, and the request and response bodies as a JSON tree, indented text, form fields, an image or a hex dump.
Flutwire records the HTTP calls an app makes, keeps them on the device and lists the latest ones in the notification shade. Tapping it opens the inspector, and each call opens in full: URL, timings, headers, cookies, and the request and response bodies as a JSON tree, indented text, form fields, an image or a hex dump. A call can be edited and sent again from the same screen.

## Getting started

Expand Down Expand Up @@ -37,6 +37,12 @@ MaterialApp(
);
```

## Sending a call again

Open a call and tap the replay button in the top bar. The method, the address, the header fields and the body can all be changed, and Send makes the request. What goes out is recorded as a new call that names the one it repeats.

A value the redaction masked was never kept, so it is flagged and sent only if you type it again. A body that is not text goes out as it was recorded, unless only part of it was kept. Sending needs `dart:io`, so the web does not offer it.

## Configuration

```dart
Expand Down
3 changes: 3 additions & 0 deletions packages/flutwire/lib/src/capture/wire/send_again.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export 'send_again_stub.dart'
if (dart.library.io) 'send_again_io.dart'
show canSendAgain, sendAgain;
92 changes: 92 additions & 0 deletions packages/flutwire/lib/src/capture/wire/send_again_io.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import 'dart:async';
import 'dart:io';

import '../../engine.dart';
import '../../resend.dart';
import '../recorder.dart';
import 'wire_io.dart';

/// How long a request may wait for the connection.
const Duration _connectTimeout = Duration(seconds: 30);

/// How long a request may run, from the moment its connection is open.
const Duration _defaultDeadline = Duration(minutes: 2);

/// Whether this platform can send a request again.
const bool canSendAgain = true;

/// Sends [request] through `dart:io` and records it as a call that repeats the
/// one it names. Returns the id of the recorded call as soon as the request is
/// on its way, or null when nothing was recorded. It does not wait for the
/// answer: the call fills in as the answer arrives, like any other.
///
/// The request goes out the way the app's own would, through the overrides the
/// app installed, so a proxy or a certificate rule it set applies here too. The
/// capture is taken out of the way, because this call is recorded once, by
/// itself, with the link to the call it repeats.
///
/// A request that has not been answered after [deadline] is aborted and
/// recorded as failed.
Future<int?> sendAgain(
FlutwireEngine engine,
FlutwireResend request, {
Duration deadline = _defaultDeadline,
}) async {
var inner = HttpClient();
if (inner is FlutwireHttpClient) inner = inner.unwrapped;
inner.connectionTimeout = _connectTimeout;
final client = FlutwireHttpClient(inner, engine, resentFrom: request.from);
final HttpClientRequest open;
try {
open = await client.openUrl(request.method, request.uri);
} on Object {
// The wrapper has already recorded the call as failed, with the reason.
client.close();
return client.lastRecorder?.id;
}
final recorder = client.lastRecorder;
unawaited(_send(client, open, request, recorder, deadline));
return recorder?.id;
}

Future<void> _send(
FlutwireHttpClient client,
HttpClientRequest open,
FlutwireResend request,
FlutwireCallRecorder? recorder,
Duration deadline,
) async {
final timer = Timer(deadline, () {
recorder?.fail(
TimeoutException('No answer within ${deadline.inSeconds} s', deadline),
);
open.abort();
});
try {
// The first value of a field replaces what the client set by default, such
// as the user agent. Any further value is added next to it.
final seen = <String>{};
for (final field in request.headers.entries) {
if (seen.add(field.key.toLowerCase())) {
open.headers.set(field.key, field.value, preserveHeaderCase: true);
} else {
open.headers.add(field.key, field.value, preserveHeaderCase: true);
}
}
final body = request.body;
if (body != null) {
open.contentLength = body.length;
open.add(body);
}
final response = await open.close();
await response.drain<void>();
} on Object catch (error, stack) {
// A failure on the wire is already on record. What is left is a field the
// client refuses and the abort above, and `fail` ignores a call that has
// already ended.
recorder?.fail(error, stack);
} finally {
timer.cancel();
client.close();
}
}
13 changes: 13 additions & 0 deletions packages/flutwire/lib/src/capture/wire/send_again_stub.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import '../../engine.dart';
import '../../resend.dart';

/// There is no `dart:io` on this platform, so a request cannot be sent again.
const bool canSendAgain = false;

/// Never called where [canSendAgain] is false.
Future<int?> sendAgain(
FlutwireEngine engine,
FlutwireResend request, {
Duration deadline = const Duration(minutes: 2),
}) =>
throw UnsupportedError('A request cannot be sent again on this platform.');
19 changes: 17 additions & 2 deletions packages/flutwire/lib/src/capture/wire/wire_io.dart
Original file line number Diff line number Diff line change
Expand Up @@ -86,16 +86,29 @@ bool _stillCaptures(FlutwireEngine engine) {
/// An `HttpClient` that records the calls made through it and delegates the
/// work to the client it wraps.
final class FlutwireHttpClient implements HttpClient {
/// Wraps [_inner], reporting to [_engine].
FlutwireHttpClient(this._inner, this._engine);
/// Wraps [_inner], reporting to [_engine]. Every request made through the
/// client is recorded as a repeat of the call with the id [resentFrom] when
/// one is given.
FlutwireHttpClient(this._inner, this._engine, {this.resentFrom});

final HttpClient _inner;
final FlutwireEngine _engine;
final Set<FlutwireCallRecorder> _open = {};
FlutwireCallRecorder? _last;

/// The engine this client reports to.
FlutwireEngine get engine => _engine;

/// The call this client's requests repeat, if they do.
final int? resentFrom;

/// The client this one wraps, without the recording.
HttpClient get unwrapped => _inner;

/// The recorder of the latest request opened through this client, which is
/// null when that request was not recorded.
FlutwireCallRecorder? get lastRecorder => _last;

@override
bool get autoUncompress => _inner.autoUncompress;
@override
Expand Down Expand Up @@ -174,7 +187,9 @@ final class FlutwireHttpClient implements HttpClient {
method: method,
uri: url,
source: FlutwireSource.io,
resentFrom: resentFrom,
);
_last = recorder;
final HttpClientRequest request;
try {
request = await open();
Expand Down
10 changes: 9 additions & 1 deletion packages/flutwire/lib/src/engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,22 @@ final class FlutwireEngine implements FlutwireRecorderHost {

/// Starts recording a call, or returns null when it should not be recorded:
/// recording is off or paused, or an ignore rule matches.
///
/// A call that repeats the call with the id [resentFrom] is recorded
/// whatever the pause and the ignore rules say, because somebody asked for it
/// and is waiting to read the answer.
FlutwireCallRecorder? begin({
required String method,
required Uri uri,
required FlutwireSource source,
FlutwireHeaders requestHeaders = FlutwireHeaders.empty,
int? resentFrom,
}) {
try {
final upper = method.toUpperCase();
if (!_shouldRecord(upper, uri)) return null;
if (resentFrom == null ? !_shouldRecord(upper, uri) : !config.isActive) {
return null;
}
final redaction = config.redaction;
final call = FlutwireCall(
id: _nextId(),
Expand All @@ -88,6 +95,7 @@ final class FlutwireEngine implements FlutwireRecorderHost {
headers: redaction.applyToHeaders(requestHeaders),
body: FlutwireBody.empty(),
),
resentFrom: resentFrom,
);
if (source == FlutwireSource.io) _noteIoCall(call);
return FlutwireCallRecorder(this, call);
Expand Down
70 changes: 70 additions & 0 deletions packages/flutwire/lib/src/l10n/strings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -482,4 +482,74 @@ abstract class FlutwireStrings {

/// How often a blind spot was seen.
String coverageSeen(int count);

// Edit and resend

/// The action that opens a call in an editor to send it again, and the
/// title of that editor.
String get resend;

/// Sends the edited request.
String get send;

/// What the send button says while the request leaves.
String get sending;

/// Adds a row for a header field.
String get addHeader;

/// The label of the name of a header field.
String get headerName;

/// The label of the value of a header field.
String get headerValue;

/// Takes a header field out of the request.
String get removeHeader;

/// Marks a header field whose value Flutwire hid when it recorded the call.
String get hiddenValue;

/// What sending does and where it goes.
String resendTarget(String host);

/// The header fields that are not sent because their values were hidden,
/// named in [names].
String resendHiddenNote(String names);

/// A text body that was recorded only in part, with the sizes written out.
String resendBodyCut(String kept, String total);

/// A body that is not text, which is sent exactly as it was recorded.
String resendBinaryBody(String size);

/// A body that is not text and was recorded only in part, so it is not sent.
String get resendBinaryCut;

/// The switch that sends the recorded body of a call, or leaves it out.
String get resendSendBody;

/// Values in the body that the redaction hid, which show as [mask].
String resendBodyHidden(String mask);

/// Values in the address that the redaction hid, which show as [mask].
String resendUrlHidden(String mask);

/// The address in the editor cannot be sent to.
String get resendBadUrl;

/// The request left but could not be put in the history.
String get resendNotRecorded;

/// The request could not leave.
String get resendFailed;

/// The label of the call that a resent call repeats.
String get resentFrom;

/// What stands in for a repeated call that is no longer in the history.
String get resentFromGone;

/// What a screen reader says about a call that was sent again.
String get resentBadge;
}
76 changes: 76 additions & 0 deletions packages/flutwire/lib/src/l10n/strings_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -505,4 +505,80 @@ class FlutwireStringsEn extends FlutwireStrings {
@override
String coverageSeen(int count) =>
count == 1 ? 'Seen once' : 'Seen $count times';

@override
String get resend => 'Edit and resend';

@override
String get send => 'Send';

@override
String get sending => 'Sending';

@override
String get addHeader => 'Add header';

@override
String get headerName => 'Name';

@override
String get headerValue => 'Value';

@override
String get removeHeader => 'Remove header';

@override
String get hiddenValue => 'Hidden';

@override
String resendTarget(String host) => 'Sends a real request to $host.';

@override
String resendHiddenNote(String names) =>
'Not sent, because Flutwire hid their values when it recorded the call: '
'$names. Type a value to send one.';

@override
String resendBodyCut(String kept, String total) =>
'The body was cut at $kept of $total. This is only the start of it.';

@override
String resendBinaryBody(String size) =>
'A body that is not text, $size. It is sent as it was recorded.';

@override
String get resendBinaryCut =>
'Only part of this body was recorded, so it is not sent.';

@override
String get resendSendBody => 'Send the recorded body';

@override
String resendBodyHidden(String mask) =>
'Some values in the body were hidden when the call was recorded and show '
'as $mask. Replace them to send the real ones.';

@override
String resendUrlHidden(String mask) =>
'A value in the address was hidden when the call was recorded and shows '
'as $mask. Replace it to send the real one.';

@override
String get resendBadUrl =>
'Enter a full address, such as https://example.com/path.';

@override
String get resendNotRecorded => 'Sent, but Flutwire could not record it.';

@override
String get resendFailed => 'The request could not be sent.';

@override
String get resentFrom => 'Sent again from';

@override
String get resentFromGone => 'A call that is no longer in the history';

@override
String get resentBadge => 'Sent again';
}
Loading
Loading