Skip to content

Commit

Permalink
Connect to VS Code using a JSON-RPC API over postMessage of WebSocket
Browse files Browse the repository at this point in the history
  • Loading branch information
DanTup committed Jun 15, 2023
1 parent 3d979b0 commit 69f30fa
Show file tree
Hide file tree
Showing 10 changed files with 338 additions and 7 deletions.
2 changes: 1 addition & 1 deletion packages/devtools_app/lib/src/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ class DevToolsAppState extends State<DevToolsApp> with AutoDisposeMixin {
Map<String, UrlParametersBuilder> get _standaloneScreens {
return {
for (final type in StandaloneScreenType.values)
type.name: (_, __, args, ___) => type.screen,
type.name: (_, __, args, ___) => type.build(embedded: isEmbedded(args)),
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright 2023 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be found
// in the LICENSE file.

export 'post_message_stub.dart' if (dart.library.html) 'post_message_web.dart';

class PostMessageEvent {
PostMessageEvent({
required this.origin,
required this.data,
});

final String origin;
final dynamic data;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Copyright 2023 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be found
// in the LICENSE file.

import 'post_message.dart';

Stream<PostMessageEvent> get onPostMessage =>
throw UnsupportedError('unsupported platform');

void postMessage(dynamic message, String targetOrigin) =>
throw UnsupportedError('unsupported platform');
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright 2023 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be found
// in the LICENSE file.

import 'dart:html' as html;

import 'post_message.dart';

Stream<PostMessageEvent> get onPostMessage {
return html.window.onMessage.map(
(message) => PostMessageEvent(
origin: message.origin,
data: message.data,
),
);
}

void postMessage(dynamic message, String targetOrigin) =>
html.window.parent?.postMessage(message, targetOrigin);
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ enum StandaloneScreenType {
return null;
}

Widget get screen {
Widget Function({required bool embedded, Key? key}) get build {
return switch (this) {
StandaloneScreenType.vsCodeFlutterPanel => const VsCodeFlutterPanel(),
StandaloneScreenType.vsCodeFlutterPanel => VsCodeFlutterPanel.new,
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright 2023 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';

import 'package:flutter/material.dart';

import 'temp_api.dart';

/// Widget showing the current selected device for Flutter apps, and a button
/// to change.
class DeviceInfo extends StatefulWidget {
const DeviceInfo(this.api, {super.key});

final VsCodeApi api;

@override
State<DeviceInfo> createState() => _DeviceInfoState();
}

class _DeviceInfoState extends State<DeviceInfo> {
Object? _currentDevice;
late Stream<Object?> _deviceChangedStream;

@override
void initState() {
super.initState();

_deviceChangedStream = widget.api.selectedDeviceChanged
.map((newDevice) => _currentDevice = newDevice['device']);

unawaited(
widget.api.getSelectedDevice().then((newDevice) {
setState(() {
_currentDevice = newDevice;
});
}),
);
}

@override
Widget build(BuildContext context) {
return StreamBuilder(
stream: _deviceChangedStream,
initialData: _currentDevice,
builder: (context, snapshot) {
return Column(
children: [
Text('Current device is $_currentDevice'),
TextButton(
onPressed: widget.api.showDeviceSelector,
child: const Text('Change Device'),
)
],
);
},
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,92 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';

import 'package:flutter/material.dart';

import '../../../devtools_app.dart';
import '../../shared/feature_flags.dart';
import 'device_info.dart';
import 'temp_api.dart';
import 'web_socket_connection_screen.dart';

/// Panel shown in the VS Code sidebar.
///
/// If [embedded] is `true`, the panel is shown directly. Otherwise, a
/// connection screen is shown asking for a WebSocket URL that can be obtained
/// by running the "Dart: Connect External Sidebar" command in VS Code to run
/// the sidebar outside of VS Code.
class VsCodeFlutterPanel extends StatefulWidget {
const VsCodeFlutterPanel({required this.embedded, super.key});

final bool embedded;

@override
State<VsCodeFlutterPanel> createState() => _VsCodeFlutterPanelState();
}

class VsCodeFlutterPanel extends StatelessWidget {
const VsCodeFlutterPanel({super.key});
class _VsCodeFlutterPanelState extends State<VsCodeFlutterPanel> {
DartApi? api;

@override
void initState() {
super.initState();

if (widget.embedded && api == null) {
api = DartApi.postMessage();
}
}

@override
void dispose() {
super.dispose();

api?.dispose();
}

@override
Widget build(BuildContext context) {
assert(FeatureFlags.vsCodeSidebarTooling);
return const Center(
child: Text('TODO: a panel for flutter actions in VS Code'),

final api = this.api;
// A null api is only valid if we're not-embedded and haven't connected yet.
assert(!widget.embedded || api != null);

return api != null
? _MainPanel(api: api)
: WebSocketConnectionScreen(
onConnected: (webSocket) {
setState(() {
this.api = DartApi.webSocket(webSocket);
});
},
);
}
}

/// The main panel shown once an API connection is available.
class _MainPanel extends StatelessWidget {
const _MainPanel({required this.api});

final DartApi api;

@override
Widget build(BuildContext context) {
return Center(
child: Column(
children: [
const Text('TODO: a panel for flutter actions in VS Code'),
FutureBuilder(
future: api.vsCode.isAvailable,
builder: (context, snapshot) => switch (snapshot.data) {
true => DeviceInfo(api.vsCode),
false => const Text('VS Code is unavailable!'),
null => const CenteredCircularProgressIndicator(),
},
),
],
),
);
}
}
98 changes: 98 additions & 0 deletions packages/devtools_app/lib/src/standalone_ui/vs_code/temp_api.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright 2023 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';

import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc_2;
import 'package:stream_channel/stream_channel.dart';
import 'package:web_socket_channel/web_socket_channel.dart';

import '../../shared/config_specific/post_message/post_message.dart';

// TODO(dantup): This should live in a package so it can be used by tools
// hosted outside of DevTools.

/// An API for interacting with Dart tooling.
class DartApi {
DartApi._(this._rpc) : vsCode = VsCodeApi(_rpc) {
unawaited(_rpc.listen());
}

/// Connects the API using 'postMessage'. This is only available when running
/// on web and embedded inside VS Code.
factory DartApi.postMessage() {
final postMessageController = StreamController();
postMessageController.stream.listen((message) => postMessage(message, '*'));
final channel = StreamChannel(
onPostMessage.map((event) => event.data),
postMessageController,
);
return DartApi._(json_rpc_2.Peer.withoutJson(channel));
}

/// Connects the API over the provided WebSocket.
factory DartApi.webSocket(WebSocketChannel socket) {
return DartApi._(json_rpc_2.Peer(socket.cast<String>()));
}

final json_rpc_2.Peer _rpc;

/// Access to APIs related to VS Code, such as executing VS Code commands or
/// interacting with the Dart/Flutter extensions.
final VsCodeApi vsCode;

void dispose() {
unawaited(_rpc.close());
}
}

/// Base class for the different APIs that may be available.
abstract base class ToolApi {
ToolApi(this.rpc);

final json_rpc_2.Peer rpc;

String get apiName;

/// Checks whether this API is available.
///
/// Calls to any other API should only be made if and when this [Future]
/// completes with `true`.
late final Future<bool> isAvailable =
_sendRequest<bool>('checkAvailable').catchError((_) => false);

Future<T> _sendRequest<T>(String method, [Object? parameters]) async {
return (await rpc.sendRequest('$apiName.$method', parameters)) as T;
}

/// Listens for an event '[apiName].[name]' that has a Map for parameters.
Stream<Map<String, Object?>> events(String name) {
final streamController = StreamController<Map<String, Object?>>.broadcast();
rpc.registerMethod('$apiName.$name', (json_rpc_2.Parameters parameters) {
streamController.add(parameters.asMap.cast<String, Object?>());
});
return streamController.stream;
}
}

final class VsCodeApi extends ToolApi {
VsCodeApi(super.rpc);

@override
final apiName = 'vsCode';

Future<Object?> getSelectedDevice() => _sendRequest('getSelectedDevice');

Future<Object?> showDeviceSelector() =>
executeCommand('flutter.selectDevice');

late final Stream<Map<String, Object?>> selectedDeviceChanged =
events('selectedDeviceChanged');

Future<Object?> executeCommand(String command, [List<Object?>? arguments]) =>
_sendRequest(
'executeCommand',
{'command': command, 'arguments': arguments},
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright 2023 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'package:flutter/material.dart';
import 'package:web_socket_channel/web_socket_channel.dart';

/// A connection screen shown when running outside of VS Code to accept a
/// WebSocket URL to connect to an instance of VS Code.
class WebSocketConnectionScreen extends StatefulWidget {
const WebSocketConnectionScreen({required this.onConnected, super.key});

final Function(WebSocketChannel) onConnected;

@override
State<WebSocketConnectionScreen> createState() =>
_WebSocketConnectionScreenState();
}

class _WebSocketConnectionScreenState extends State<WebSocketConnectionScreen> {
final _controller = TextEditingController();
String? _errorText;

@override
Widget build(BuildContext context) {
return Center(
child: Column(
children: [
const Text('Connect to VS Code'),
const Text('Enter WebSocket URL from VS Code'),
TextField(controller: _controller),
TextButton(
onPressed: () async {
try {
final socket =
WebSocketChannel.connect(Uri.parse(_controller.text));
await socket.ready;
widget.onConnected(socket);
} catch (e) {
setState(() {
_errorText = '$e';
});
}
},
child: const Text('Connect'),
),
if (_errorText != null) Text(_errorText!),
],
),
);
}
}
2 changes: 2 additions & 0 deletions packages/devtools_app/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ dependencies:
image: ^3.0.2
intl: '>=0.16.1 <0.18.0'
js: ^0.6.1+1
json_rpc_2: ^3.0.2
leak_tracker: 2.0.1
logging: ^1.1.1
mime: ^1.0.0
Expand All @@ -54,6 +55,7 @@ dependencies:
shared_preferences: ^2.0.15
sse: ^4.1.2
stack_trace: ^1.10.0
stream_channel: ^2.1.1
string_scanner: ^1.1.0
url_launcher: ^6.1.0
url_launcher_web: ^2.0.6
Expand Down

0 comments on commit 69f30fa

Please sign in to comment.