Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Load extensions from DevTools server instead of using stubs. #6097

Merged
merged 5 commits into from
Jul 26, 2023
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'dart:html' as html;
import 'dart:ui' as ui;

import 'package:devtools_extensions/api.dart';
import 'package:path/path.dart' as path;

import 'controller.dart';

Expand All @@ -32,8 +33,12 @@ class EmbeddedExtensionControllerImpl extends EmbeddedExtensionController {
late final viewId = 'ext-${extensionConfig.name}-${_viewIdIncrementer++}';

String get extensionUrl {
// TODO(kenz): load the extension url being served by devtools server.
return 'https://flutter.dev/';
return path.join(
html.window.location.origin,
'devtools_extensions',
extensionConfig.name,
'index.html',
);
}

html.IFrameElement get extensionIFrame => _extensionIFrame;
Expand Down
67 changes: 33 additions & 34 deletions packages/devtools_app/lib/src/extensions/extension_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import 'package:devtools_shared/devtools_extensions.dart';
import 'package:flutter/foundation.dart';

import '../shared/config_specific/server/server.dart' as server;
import '../shared/globals.dart';
import '../shared/primitives/auto_dispose.dart';

Expand All @@ -14,42 +15,40 @@ class ExtensionService extends DisposableController
_availableExtensions;
final _availableExtensions = ValueNotifier<List<DevToolsExtensionConfig>>([]);

void initialize() {
addAutoDisposeListener(serviceManager.connectedState, () {
_refreshAvailableExtensions();
});
Future<void> initialize() async {
await maybeRefreshExtensions();
addAutoDisposeListener(
serviceManager.connectedState,
maybeRefreshExtensions,
);

// TODO(kenz): we should also refresh the available extensions on some event
// from the analysis server that is watching the
// .dart_tool/package_config.json file for changes.
}

// TODO(kenz): actually look up the available extensions from devtools server,
// based on the root path(s) from the available isolate(s).
int _count = 0;
void _refreshAvailableExtensions() {
_availableExtensions.value =
debugExtensions.sublist(0, _count++ % (debugExtensions.length + 1));
Future<void> maybeRefreshExtensions() async {
final appRootPath = await _connectedAppRootPath();
if (appRootPath != null) {
_availableExtensions.value =
await server.refreshAvailableExtensions(appRootPath);
}
}
}

// TODO(kenz): remove these once the DevTools extensions feature has shipped.
final List<DevToolsExtensionConfig> debugExtensions = [
DevToolsExtensionConfig.parse({
DevToolsExtensionConfig.nameKey: 'foo',
DevToolsExtensionConfig.issueTrackerKey: 'www.google.com',
DevToolsExtensionConfig.versionKey: '1.0.0',
DevToolsExtensionConfig.pathKey: '/path/to/foo',
}),
DevToolsExtensionConfig.parse({
DevToolsExtensionConfig.nameKey: 'bar',
DevToolsExtensionConfig.issueTrackerKey: 'www.google.com',
DevToolsExtensionConfig.versionKey: '2.0.0',
DevToolsExtensionConfig.materialIconCodePointKey: 0xe638,
DevToolsExtensionConfig.pathKey: '/path/to/bar',
}),
DevToolsExtensionConfig.parse({
DevToolsExtensionConfig.nameKey: 'provider',
DevToolsExtensionConfig.issueTrackerKey:
'https://github.com/rrousselGit/provider/issues',
DevToolsExtensionConfig.versionKey: '3.0.0',
DevToolsExtensionConfig.materialIconCodePointKey: 0xe50a,
DevToolsExtensionConfig.pathKey: '/path/to/provider',
}),
];
Future<String?> _connectedAppRootPath() async {
var fileUri = await serviceManager.rootLibraryForSelectedIsolate();
if (fileUri == null) return null;

// TODO(kenz): for robustness, consider sending the root library uri to the
// server and having the server look for the package folder that contains the
// `.dart_tool` directory.

// Assume that the parent folder of `lib` is the package root.
final libDirectoryRegExp = RegExp(r'\/lib\/[^\/.]*.dart');
final libDirectoryIndex = fileUri.indexOf(libDirectoryRegExp);
if (libDirectoryIndex != -1) {
fileUri = fileUri.substring(0, libDirectoryIndex);
}
return fileUri;
}
21 changes: 20 additions & 1 deletion packages/devtools_app/lib/src/service/service_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ class ServiceConnectionManager {
}

if (FeatureFlags.devToolsExtensions) {
extensionService.initialize();
await extensionService.initialize();
}

_connectedState.value = const ConnectedState(true);
Expand Down Expand Up @@ -539,6 +539,25 @@ class ServiceConnectionManager {
await whenValueNonNull(isolateManager.mainIsolate);
return libraryUriAvailableNow(uri);
}

Future<String?> rootLibraryForSelectedIsolate() async {
if (!connectedState.value.connected) return null;

final selectedIsolateRef = isolateManager.mainIsolate.value;
if (selectedIsolateRef == null) return null;

final isolateState = isolateManager.isolateState(selectedIsolateRef);
await isolateState.waitForIsolateLoad();
final rootLib = isolateState.rootInfo!.library;
if (rootLib == null) return null;

final selectedIsolateRefId = selectedIsolateRef.id!;
await resolvedUriManager.fetchFileUris(selectedIsolateRefId, [rootLib]);
return resolvedUriManager.lookupFileUri(
selectedIsolateRefId,
rootLib,
);
}
}

class VmServiceCapabilities {
Expand Down
62 changes: 37 additions & 25 deletions packages/devtools_shared/lib/src/extensions/extension_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,31 +25,43 @@ class DevToolsExtensionConfig {
codePoint = codePointFromJson as int? ?? defaultCodePoint;
}

final name = json[nameKey] as String?;
final path = json[pathKey] as String?;
final issueTrackerLink = json[issueTrackerKey] as String?;
final version = json[versionKey] as String?;

final nullFields = [
if (name == null) nameKey,
if (path == null) pathKey,
if (issueTrackerLink == null) issueTrackerKey,
if (version == null) versionKey,
];
if (nullFields.isNotEmpty) {
throw StateError(
'missing required fields ${nullFields.toString()} in the extension '
'config.json',
if (json
case {
nameKey: final String name,
pathKey: final String path,
issueTrackerKey: final String issueTracker,
versionKey: final String version,
}) {
return DevToolsExtensionConfig._(
name: name,
path: path,
issueTrackerLink: issueTracker,
version: version,
materialIconCodePoint: codePoint,
);
} else {
const requiredKeys = {nameKey, pathKey, issueTrackerKey, versionKey};
final diff = requiredKeys.difference(json.keys.toSet());
if (diff.isEmpty) {
// All the required keys are present, but the value types did not match.
final sb = StringBuffer();
for (final entry in json.entries) {
sb.writeln(
' ${entry.key}: ${entry.value} (${entry.value.runtimeType})',
);
}
throw StateError(
'Unexpected value types in the extension config.json. Expected all '
'values to be of type String, but one or more had a different type:\n'
'${sb.toString()}',
);
} else {
throw StateError(
'Missing required fields ${diff.toString()} in the extension '
'config.json.',
);
}
}

return DevToolsExtensionConfig._(
name: name!,
path: path!,
issueTrackerLink: issueTrackerLink!,
version: version!,
materialIconCodePoint: codePoint,
);
}

static const nameKey = 'name';
Expand Down Expand Up @@ -78,14 +90,14 @@ class DevToolsExtensionConfig {
final String issueTrackerLink;

/// The version for the DevTools extension.
///
///
/// This may match the version of the parent package or use a different
/// versioning system as decided by the extension author.
final String version;

/// The code point for the material icon that will parsed by Flutter's
/// [IconData] class for displaying in DevTools.
///
///
/// This code point should be part of the 'MaterialIcons' font family.
final int materialIconCodePoint;

Expand Down
141 changes: 141 additions & 0 deletions packages/devtools_shared/test/extensions/extension_model_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// 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:devtools_shared/devtools_extensions.dart';
import 'package:test/test.dart';

void main() {
group('$DevToolsExtensionConfig', () {
test('parses with a String materialIconCodePoint field', () {
final config = DevToolsExtensionConfig.parse({
'name': 'foo',
'path': 'path/to/foo/extension',
'issueTracker': 'www.google.com',
'version': '1.0.0',
'materialIconCodePoint': '0xf012',
});

expect(config.name, 'foo');
expect(config.path, 'path/to/foo/extension');
expect(config.issueTrackerLink, 'www.google.com');
expect(config.version, '1.0.0');
expect(config.materialIconCodePoint, 0xf012);
});

test('parses with an int materialIconCodePoint field', () {
final config = DevToolsExtensionConfig.parse({
'name': 'foo',
'path': 'path/to/foo/extension',
'issueTracker': 'www.google.com',
'version': '1.0.0',
'materialIconCodePoint': 0xf012,
});

expect(config.name, 'foo');
expect(config.path, 'path/to/foo/extension');
expect(config.issueTrackerLink, 'www.google.com');
expect(config.version, '1.0.0');
expect(config.materialIconCodePoint, 0xf012);
});

test('parses with a null materialIconCodePoint field', () {
final config = DevToolsExtensionConfig.parse({
'name': 'foo',
'path': 'path/to/foo/extension',
'issueTracker': 'www.google.com',
'version': '1.0.0',
});

expect(config.name, 'foo');
expect(config.path, 'path/to/foo/extension');
expect(config.issueTrackerLink, 'www.google.com');
expect(config.version, '1.0.0');
expect(config.materialIconCodePoint, 0xf03f);
});

test('parse throws when missing a required field', () {
Matcher throwsMissingRequiredFieldsError() {
return throwsA(
isA<StateError>().having(
(e) => e.message,
'missing required fields StateError',
startsWith('Missing required fields'),
),
);
}

// Missing 'name'.
expect(
() {
DevToolsExtensionConfig.parse({
'path': 'path/to/foo/extension',
'issueTracker': 'www.google.com',
'version': '1.0.0',
});
},
throwsMissingRequiredFieldsError(),
);

// Missing 'path'.
expect(
() {
DevToolsExtensionConfig.parse({
'name': 'foo',
'issueTracker': 'www.google.com',
'version': '1.0.0',
});
},
throwsMissingRequiredFieldsError(),
);

// Missing 'issueTracker'.
expect(
() {
DevToolsExtensionConfig.parse({
'name': 'foo',
'path': 'path/to/foo/extension',
'version': '1.0.0',
});
},
throwsMissingRequiredFieldsError(),
);

// Missing 'version'.
expect(
() {
DevToolsExtensionConfig.parse({
'name': 'foo',
'path': 'path/to/foo/extension',
'issueTracker': 'www.google.com',
});
},
throwsMissingRequiredFieldsError(),
);
});

test('parse throws when value has unexpected type', () {
Matcher throwsUnexpectedValueTypesError() {
return throwsA(
isA<StateError>().having(
(e) => e.message,
'unexpected value types StateError',
startsWith('Unexpected value types'),
),
);
}

expect(
() {
DevToolsExtensionConfig.parse({
'name': 23,
'path': 'path/to/foo/extension',
'issueTracker': 'www.google.com',
'version': '1.0.0',
});
},
throwsUnexpectedValueTypesError(),
);
});
});
}