diff --git a/README.md b/README.md index df37052..abb70c5 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ This extension also installs an MCP server (`flutter_launcher`) that provides to - `launch_app`: Launches a Flutter application on a specified device. - `stop_app`: Stops a running Flutter application. +- `hot_restart`: Performs a hot restart on a running Flutter application, resetting the app state while maintaining the current session. - `list_devices`: Lists all available devices that can run Flutter applications. - `get_app_logs`: Retrieves the logs from a running Flutter application. - `list_running_apps`: Lists all Flutter applications currently running that were started by this extension. diff --git a/flutter_launcher_mcp/README.md b/flutter_launcher_mcp/README.md index 2018eb4..79b1e66 100644 --- a/flutter_launcher_mcp/README.md +++ b/flutter_launcher_mcp/README.md @@ -89,6 +89,40 @@ Launches a Flutter application with specified arguments and returns its DTD URI } ``` +#### `hot_restart` + +Performs a hot restart on a running Flutter application. This restarts the app while maintaining the current session. + +- **Input Schema:** + + ```json + { + "type": "object", + "properties": { + "pid": { + "type": "integer", + "description": "The process ID of the flutter run process to hot restart." + } + }, + "required": ["pid"] + } + ``` + +- **Output Schema:** + + ```json + { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the hot restart was successful." + } + }, + "required": ["success"] + } + ``` + #### `stop_app` Kills a running Flutter process started by the `launch_app` tool. diff --git a/flutter_launcher_mcp/lib/src/mixins/flutter_launcher.dart b/flutter_launcher_mcp/lib/src/mixins/flutter_launcher.dart index 294034b..f5b535a 100644 --- a/flutter_launcher_mcp/lib/src/mixins/flutter_launcher.dart +++ b/flutter_launcher_mcp/lib/src/mixins/flutter_launcher.dart @@ -39,6 +39,7 @@ base mixin FlutterLauncherSupport registerTool(listDevicesTool, _listDevices); registerTool(getAppLogsTool, _getAppLogs); registerTool(listRunningAppsTool, _listRunningApps); + registerTool(hotRestartTool, _hotRestart); return super.initialize(request); } @@ -461,6 +462,73 @@ base mixin FlutterLauncherSupport ); } + /// A tool to perform a hot restart on a running Flutter application. + final hotRestartTool = Tool( + name: 'hot_restart', + description: + 'Performs a hot restart on a running Flutter application. This restarts the app while maintaining the current session.', + inputSchema: Schema.object( + properties: { + 'pid': Schema.int( + description: + 'The process ID of the flutter run process to hot restart.', + ), + }, + required: ['pid'], + ), + outputSchema: Schema.object( + properties: { + 'success': Schema.bool( + description: 'Whether the hot restart was successful.', + ), + }, + required: ['success'], + ), + ); + + Future _hotRestart(CallToolRequest request) async { + final pid = request.arguments!['pid'] as int; + log(LoggingLevel.info, 'Attempting hot restart for application PID: $pid'); + final app = _runningApps[pid]; + + if (app == null) { + log(LoggingLevel.error, 'Application with PID $pid not found.'); + return CallToolResult( + isError: true, + content: [TextContent(text: 'Application with PID $pid not found.')], + ); + } + + try { + app.process.stdin.writeln('R'); + await app.process.stdin.flush(); + log(LoggingLevel.info, 'Hot restart command sent to application $pid.'); + + return CallToolResult( + content: [ + TextContent( + text: 'Hot restart initiated for application with PID $pid.', + ), + ], + structuredContent: {'success': true}, + ); + } catch (e, s) { + log( + LoggingLevel.error, + 'Error performing hot restart for application $pid: $e\n$s', + ); + return CallToolResult( + isError: true, + content: [ + TextContent( + text: 'Failed to perform hot restart for application $pid: $e', + ), + ], + structuredContent: {'success': false}, + ); + } + } + @override Future shutdown() { log(LoggingLevel.info, 'Shutting down server, killing all processes.'); diff --git a/flutter_launcher_mcp/test/sdk_test.dart b/flutter_launcher_mcp/test/sdk_test.dart index 6564ce5..4c0c159 100644 --- a/flutter_launcher_mcp/test/sdk_test.dart +++ b/flutter_launcher_mcp/test/sdk_test.dart @@ -245,6 +245,9 @@ class MockProcess implements Process { bool killed = false; + @override + late final IOSink stdin = _MockIOSink(); + MockProcess({ required this.stdout, required this.stderr, @@ -268,7 +271,61 @@ class MockProcess implements Process { } return true; } +} + +class _MockIOSink implements IOSink { + final List writtenLines = []; + bool _closed = false; + + @override + Encoding encoding = utf8; + + @override + void add(List data) { + if (_closed) throw StateError('IOSink is closed'); + } @override - late final IOSink stdin = throw UnimplementedError(); + void write(Object? object) { + if (_closed) throw StateError('IOSink is closed'); + } + + @override + void writeln([Object? object = '']) { + if (_closed) throw StateError('IOSink is closed'); + writtenLines.add(object.toString()); + } + + @override + Future flush() async { + if (_closed) throw StateError('IOSink is closed'); + } + + @override + Future close() async { + _closed = true; + } + + @override + void addError(Object error, [StackTrace? stackTrace]) { + throw UnimplementedError(); + } + + @override + Future addStream(Stream> stream) { + throw UnimplementedError(); + } + + @override + Future get done => Future.value(); + + @override + void writeAll(Iterable objects, [String separator = '']) { + throw UnimplementedError(); + } + + @override + void writeCharCode(int charCode) { + throw UnimplementedError(); + } } diff --git a/flutter_launcher_mcp/test/server_test.dart b/flutter_launcher_mcp/test/server_test.dart index acc9fee..712ab2d 100644 --- a/flutter_launcher_mcp/test/server_test.dart +++ b/flutter_launcher_mcp/test/server_test.dart @@ -155,5 +155,91 @@ void main() { await client.shutdown(); }, ); + + test.test( + 'hot_restart tool sends restart command to running app', + () async { + final dtdUri = 'ws://127.0.0.1:12345/abcdefg='; + final processPid = 54321; + final mockProcessManager = MockProcessManager(); + mockProcessManager.addCommand( + Command( + [ + '/path/to/flutter/sdk/bin/flutter', + 'run', + '--print-dtd', + '--device-id', + 'test-device', + ], + stdout: 'The Dart Tooling Daemon is available at: $dtdUri\n', + pid: processPid, + ), + ); + final serverAndClient = await createServerAndClient( + processManager: mockProcessManager, + fileSystem: fileSystem, + ); + final server = serverAndClient.server; + final client = serverAndClient.client; + + // Initialize + await client.initialize( + InitializeRequest( + protocolVersion: ProtocolVersion.latestSupported, + capabilities: ClientCapabilities(), + clientInfo: Implementation(name: 'test_client', version: '1.0.0'), + ), + ); + client.notifyInitialized(); + + // Launch the app first + final launchResult = await client.callTool( + CallToolRequest( + name: 'launch_app', + arguments: {'root': '/test/project', 'device': 'test-device'}, + ), + ); + test.expect(launchResult.isError, test.isNot(true)); + + // Perform hot restart + final restartResult = await client.callTool( + CallToolRequest(name: 'hot_restart', arguments: {'pid': processPid}), + ); + + test.expect(restartResult.isError, test.isNot(true)); + test.expect(restartResult.structuredContent, {'success': true}); + await server.shutdown(); + await client.shutdown(); + }, + ); + + test.test('hot_restart tool returns error for non-existent app', () async { + final mockProcessManager = MockProcessManager(); + final serverAndClient = await createServerAndClient( + processManager: mockProcessManager, + fileSystem: fileSystem, + ); + final server = serverAndClient.server; + final client = serverAndClient.client; + + // Initialize + await client.initialize( + InitializeRequest( + protocolVersion: ProtocolVersion.latestSupported, + capabilities: ClientCapabilities(), + clientInfo: Implementation(name: 'test_client', version: '1.0.0'), + ), + ); + client.notifyInitialized(); + + // Try to hot restart a non-existent app + final restartResult = await client.callTool( + CallToolRequest(name: 'hot_restart', arguments: {'pid': 99999}), + ); + + test.expect(restartResult.isError, true); + await server.shutdown(); + await client.shutdown(); + }); }); }