diff --git a/_examples/basic/main.go b/_examples/basic/main.go index 0fbe4315..8dd6de08 100644 --- a/_examples/basic/main.go +++ b/_examples/basic/main.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/download" "github.com/GopeedLab/gopeed/pkg/protocol/http" diff --git a/cmd/host/dail_other.go b/cmd/host/dail_other.go new file mode 100644 index 00000000..9cfb8ef2 --- /dev/null +++ b/cmd/host/dail_other.go @@ -0,0 +1,19 @@ +//go:build !windows +// +build !windows + +package main + +import ( + "net" + "os" + "path/filepath" +) + +func Dial() (net.Conn, error) { + // Get binary path + exe, err := os.Executable() + if err != nil { + return nil, err + } + return net.Dial("unix", filepath.Join(filepath.Dir(exe), "gopeed_host.sock")) +} diff --git a/cmd/host/dail_windows.go b/cmd/host/dail_windows.go new file mode 100644 index 00000000..2131d660 --- /dev/null +++ b/cmd/host/dail_windows.go @@ -0,0 +1,11 @@ +package main + +import ( + "net" + + "github.com/Microsoft/go-winio" +) + +func Dial() (net.Conn, error) { + return winio.DialPipe(`\\.\pipe\gopeed_host`, nil) +} diff --git a/cmd/host/main.go b/cmd/host/main.go index b918f40a..ede8e97c 100644 --- a/cmd/host/main.go +++ b/cmd/host/main.go @@ -1,22 +1,24 @@ package main import ( + "bytes" + "context" "encoding/binary" "encoding/json" "errors" "fmt" "io" + "net" + "net/http" "os" - "strings" + "time" "github.com/pkg/browser" - "github.com/shirou/gopsutil/v4/process" ) -const identifier = "gopeed" - type Message struct { Method string `json:"method"` + Meta map[string]any `json:"meta"` Params json.RawMessage `json:"params"` } @@ -26,31 +28,74 @@ type Response struct { Message string `json:"message,omitempty"` } -var apiMap = map[string]func(params json.RawMessage) (data any, err error){ - "ping": func(params json.RawMessage) (data any, err error) { - processes, err := process.Processes() - if err != nil { - return false, err - } +func check() (data bool, err error) { + conn, err := Dial() + if err != nil { + return false, err + } + defer conn.Close() + return true, nil +} - for _, p := range processes { - name, err := p.Name() - if err != nil { - continue - } +func wakeup(hidden bool) error { + running, _ := check() + if running { + return nil + } - if strings.Contains(strings.ToLower(name), strings.ToLower(identifier)) { - return true, nil - } + uri := "gopeed:" + if hidden { + uri = uri + "?hidden=true" + } + if err := browser.OpenURL(uri); err != nil { + return err + } + + for i := 0; i < 10; i++ { + if ok, _ := check(); ok { + return nil } - return false, nil + time.Sleep(1 * time.Second) + } + return fmt.Errorf("start gopeed failed") +} + +var apiMap = map[string]func(message *Message) (data any, err error){ + "ping": func(message *Message) (data any, err error) { + return check() }, - "create": func(params json.RawMessage) (data any, err error) { - var strParams string - if err = json.Unmarshal(params, &strParams); err != nil { + "create": func(message *Message) (data any, err error) { + buf, err := message.Params.MarshalJSON() + if err != nil { return } - err = browser.OpenURL(fmt.Sprintf("%s:///create?params=%s", identifier, strParams)) + + silent := false + if v, ok := message.Meta["silent"]; ok { + silent, _ = v.(bool) + } + + if err := wakeup(silent); err != nil { + return nil, err + } + + client := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return Dial() + }, + }, + Timeout: 10 * time.Second, + } + req, err := http.NewRequest("POST", "http://127.0.0.1/create", bytes.NewBuffer(buf)) + if err != nil { + return + } + if message.Meta != nil { + metaJson, _ := json.Marshal(message.Meta) + req.Header.Set("X-Gopeed-Host-Meta", string(metaJson)) + } + _, err = client.Do(req) return }, } @@ -88,7 +133,7 @@ func main() { var data any var err error if handler, ok := apiMap[message.Method]; ok { - data, err = handler(message.Params) + data, err = handler(&message) } else { err = errors.New("Unknown method: " + message.Method) } diff --git a/cmd/updater/main.go b/cmd/updater/main.go index cdbb3afd..7ed2796a 100644 --- a/cmd/updater/main.go +++ b/cmd/updater/main.go @@ -5,7 +5,6 @@ import ( "fmt" "log" "os" - "path/filepath" "syscall" "time" @@ -19,6 +18,7 @@ func main() { pid := flag.Int("pid", 0, "PID of the process to update") updateChannel := flag.String("channel", "", "Update channel") packagePath := flag.String("asset", "", "Path to the package asset") + exeDir := flag.String("exeDir", "", "Directory of the entry executable") logPath := flag.String("log", "", "Log file path") flag.Parse() @@ -43,7 +43,7 @@ func main() { restart bool err error ) - if restart, err = update(*pid, *updateChannel, *packagePath); err != nil { + if restart, err = update(*pid, *updateChannel, *packagePath, *exeDir); err != nil { log.Printf("Update failed: %v\n", err) os.Exit(1) } @@ -61,7 +61,7 @@ func main() { os.Exit(0) } -func update(pid int, updateChannel, packagePath string) (restart bool, err error) { +func update(pid int, updateChannel, packagePath, exeDir string) (restart bool, err error) { killSignalChan := make(chan any, 1) go func() { @@ -76,9 +76,8 @@ func update(pid int, updateChannel, packagePath string) (restart bool, err error } }() - appDir := filepath.Dir(os.Args[0]) - log.Printf("Updating process updateChannel=%s packagePath=%s appDir=%s\n", updateChannel, packagePath, appDir) - if restart, err = install(killSignalChan, updateChannel, packagePath, appDir); err != nil { + log.Printf("Updating process updateChannel=%s packagePath=%s exeDir=%s\n", updateChannel, packagePath, exeDir) + if restart, err = install(killSignalChan, updateChannel, packagePath, exeDir); err != nil { return false, errors.Wrap(err, "failed to install package") } diff --git a/cmd/updater/updater_darwin.go b/cmd/updater/updater_darwin.go index db04c4d2..8d48fe1c 100644 --- a/cmd/updater/updater_darwin.go +++ b/cmd/updater/updater_darwin.go @@ -25,16 +25,20 @@ func installByDmg(killSignalChan chan<- any, packagePath, destDir string) error mountPoint := "" for _, line := range strings.Split(string(output), "\n") { if strings.Contains(line, "/Volumes/") { - fields := strings.Fields(line) - if len(fields) > 2 { - mountPoint = fields[len(fields)-1] + // Find the /Volumes/ path in the line + // hdiutil output format: /dev/disk4s1 Apple_HFS /Volumes/Gopeed + // or with sequence number: /dev/disk4s1 Apple_HFS /Volumes/Gopeed 1 + idx := strings.Index(line, "/Volumes/") + if idx != -1 { + // Extract everything from /Volumes/ onwards and trim whitespace + mountPoint = strings.TrimSpace(line[idx:]) break } } } if mountPoint == "" { - return fmt.Errorf("failed to get mount point") + return fmt.Errorf("failed to get mount point from hdiutil output: %s", string(output)) } // Detach the mounted DMG @@ -45,7 +49,7 @@ func installByDmg(killSignalChan chan<- any, packagePath, destDir string) error return err } if len(matches) == 0 { - return fmt.Errorf("no .app found in dmg") + return fmt.Errorf("no .app found in dmg, mountPoint: %s", mountPoint) } killSignalChan <- nil diff --git a/ui/flutter/lib/app/modules/app/controllers/app_controller.dart b/ui/flutter/lib/app/modules/app/controllers/app_controller.dart index 7fc966aa..239c0271 100644 --- a/ui/flutter/lib/app/modules/app/controllers/app_controller.dart +++ b/ui/flutter/lib/app/modules/app/controllers/app_controller.dart @@ -16,6 +16,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:window_manager/window_manager.dart'; import '../../../../api/api.dart'; +import '../../../../api/model/create_task.dart'; import '../../../../api/model/downloader_config.dart'; import '../../../../api/model/request.dart'; import '../../../../core/common/start_config.dart'; @@ -31,7 +32,7 @@ import '../../../../util/package_info.dart'; import '../../../../util/updater.dart'; import '../../../../util/util.dart'; import '../../../routes/app_pages.dart'; -import '../../create/dto/create_router_params.dart'; +import '../../../rpc/rpc.dart'; import '../../redirect/views/redirect_view.dart'; const unixSocketPath = 'gopeed.sock'; @@ -78,6 +79,9 @@ class AppController extends GetxController with WindowListener, TrayListener { _initTray().onError( (error, stackTrace) => logger.w("initTray error", error, stackTrace)); + _initRpcServer().onError((error, stackTrace) => + logger.w("initRpcServer error", error, stackTrace)); + _initForegroundTask().onError((error, stackTrace) => logger.w("initForegroundTask error", error, stackTrace)); @@ -271,6 +275,37 @@ class AppController extends GetxController with WindowListener, TrayListener { trayManager.addListener(this); } + Future _initRpcServer() async { + if (!Util.isDesktop()) { + return; + } + try { + await startRpcServer({ + "/create": (ctx) async { + final meta = + ctx.request.headers["X-Gopeed-Host-Meta"]?.firstOrNull ?? "{}"; + final jsonMeta = jsonDecode(meta); + final silent = jsonMeta['silent'] as bool? ?? false; + final params = await ctx.readText(); + final createTaskParams = _decodeToCreatTaskParams(params); + if (!silent) { + await windowManager.show(); + _handleToCreate0(createTaskParams); + } else { + try { + await createTask(createTaskParams); + } catch (e) { + logger.w( + "create task from extension fail", e, StackTrace.current); + } + } + }, + }); + } catch (e) { + logger.w("start rpc server fail", e, StackTrace.current); + } + } + Future _initForegroundTask() async { if (!Util.isMobile()) { return; @@ -317,13 +352,7 @@ class AppController extends GetxController with WindowListener, TrayListener { if (uri.path == "/create") { final params = uri.queryParameters["params"]; if (params?.isNotEmpty == true) { - final safeParams = params!.replaceAll(" ", "+"); - final paramsJson = - String.fromCharCodes(base64Decode(base64.normalize(safeParams))); - Get.rootDelegate.offAndToNamed(Routes.REDIRECT, - arguments: RedirectArgs(Routes.CREATE, - arguments: - CreateRouterParams.fromJson(jsonDecode(paramsJson)))); + _handleToCreate(params!); return; } Get.rootDelegate.offAndToNamed(Routes.CREATE); @@ -346,7 +375,7 @@ class AppController extends GetxController with WindowListener, TrayListener { } Get.rootDelegate.offAndToNamed(Routes.REDIRECT, arguments: RedirectArgs(Routes.CREATE, - arguments: CreateRouterParams(req: Request(url: path)))); + arguments: CreateTask(req: Request(url: path)))); } String runningAddress() { @@ -524,4 +553,21 @@ class AppController extends GetxController with WindowListener, TrayListener { apiToken: startConfig.value.apiToken)); await putConfig(downloaderConfig.value); } + + CreateTask _decodeToCreatTaskParams(String params) { + final safeParams = params.replaceAll('"', "").replaceAll(" ", "+"); + final paramsJson = + String.fromCharCodes(base64Decode(base64.normalize(safeParams))); + return CreateTask.fromJson(jsonDecode(paramsJson)); + } + + _handleToCreate(String params) { + final createTaskParams = _decodeToCreatTaskParams(params); + _handleToCreate0(createTaskParams); + } + + _handleToCreate0(CreateTask createTaskParams) { + Get.rootDelegate.offAndToNamed(Routes.REDIRECT, + arguments: RedirectArgs(Routes.CREATE, arguments: createTaskParams)); + } } diff --git a/ui/flutter/lib/app/modules/create/dto/create_router_params.dart b/ui/flutter/lib/app/modules/create/dto/create_router_params.dart deleted file mode 100644 index 63f1ea0e..00000000 --- a/ui/flutter/lib/app/modules/create/dto/create_router_params.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../../../../api/model/options.dart'; -import '../../../../api/model/request.dart'; - -part 'create_router_params.g.dart'; - -@JsonSerializable(explicitToJson: true) -class CreateRouterParams { - Request? req; - Options? opt; - - CreateRouterParams({ - this.req, - this.opt, - }); - - factory CreateRouterParams.fromJson( - Map json, - ) => - _$CreateRouterParamsFromJson(json); - - Map toJson() => _$CreateRouterParamsToJson(this); -} diff --git a/ui/flutter/lib/app/modules/create/dto/create_router_params.g.dart b/ui/flutter/lib/app/modules/create/dto/create_router_params.g.dart deleted file mode 100644 index 50fc659f..00000000 --- a/ui/flutter/lib/app/modules/create/dto/create_router_params.g.dart +++ /dev/null @@ -1,31 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'create_router_params.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -CreateRouterParams _$CreateRouterParamsFromJson(Map json) => - CreateRouterParams( - req: json['req'] == null - ? null - : Request.fromJson(json['req'] as Map), - opt: json['opt'] == null - ? null - : Options.fromJson(json['opt'] as Map), - ); - -Map _$CreateRouterParamsToJson(CreateRouterParams instance) { - final val = {}; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('req', instance.req?.toJson()); - writeNotNull('opt', instance.opt?.toJson()); - return val; -} diff --git a/ui/flutter/lib/app/modules/create/views/create_view.dart b/ui/flutter/lib/app/modules/create/views/create_view.dart index 82016acb..72e8b300 100644 --- a/ui/flutter/lib/app/modules/create/views/create_view.dart +++ b/ui/flutter/lib/app/modules/create/views/create_view.dart @@ -27,7 +27,6 @@ import '../../../views/file_tree_view.dart'; import '../../app/controllers/app_controller.dart'; import '../../history/views/history_view.dart'; import '../controllers/create_controller.dart'; -import '../dto/create_router_params.dart'; class CreateView extends GetView { final _confirmFormKey = GlobalKey(); @@ -76,7 +75,7 @@ class CreateView extends GetView { _pathController.text = appController.downloaderConfig.value.downloadDir; } - final CreateRouterParams? routerParams = Get.rootDelegate.arguments(); + final CreateTask? routerParams = Get.rootDelegate.arguments(); if (routerParams?.req?.url.isNotEmpty ?? false) { // get url from route arguments final url = routerParams!.req!.url; diff --git a/ui/flutter/lib/app/rpc/rpc.dart b/ui/flutter/lib/app/rpc/rpc.dart new file mode 100644 index 00000000..4bcfb037 --- /dev/null +++ b/ui/flutter/lib/app/rpc/rpc.dart @@ -0,0 +1,133 @@ +import 'dart:io'; +import 'dart:convert'; + +import 'package:dart_ipc/dart_ipc.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../../util/util.dart'; + +/// RPC context class that contains request information and response methods +class RpcContext { + final HttpRequest request; + final HttpResponse response; + String? _bodyCache; + + RpcContext(this.request, this.response); + + /// Read and parse JSON request body + Future> readJSON() async { + if (_bodyCache == null) { + final content = []; + await for (var data in request) { + content.addAll(data); + } + _bodyCache = String.fromCharCodes(content); + } + + if (_bodyCache!.isEmpty) { + return {}; + } + + return jsonDecode(_bodyCache!) as Map; + } + + Future readText() async { + if (_bodyCache == null) { + final content = []; + await for (var data in request) { + content.addAll(data); + } + _bodyCache = String.fromCharCodes(content); + } + + return _bodyCache ?? ''; + } + + /// Write JSON response + Future writeJSON(Map data) async { + response.headers.contentType = ContentType.json; + response.write(jsonEncode(data)); + await response.close(); + } + + /// Write error response + Future writeError(String message, [int statusCode = 500]) async { + response.statusCode = statusCode; + await writeJSON({'error': message}); + } +} + +/// Route handler type definition +typedef RouteHandler = Future Function(RpcContext ctx); + +/// Route registry +class RouteRegistry { + final Map _routes = {}; + + /// Register a route + void register(String path, RouteHandler handler) { + _routes[path] = handler; + } + + /// Get route handler + RouteHandler? getHandler(String path) { + return _routes[path]; + } + + /// Get all registered routes + List get routes => _routes.keys.toList(); +} + +/// Start RPC server +Future startRpcServer([Map? routes]) async { + String path; + if (Util.isWindows()) { + path = r'\\.\pipe\gopeed_host'; + } else { + path = await Util.homePathJoin("gopeed_host.sock"); + // try to delete existing socket file + final socketFile = File(path); + if (await socketFile.exists()) { + try { + await socketFile.delete(); + } catch (e) { + // ignore + } + } + } + + // Create route registry + final registry = RouteRegistry(); + + // Register provided routes + if (routes != null) { + routes.forEach((path, handler) { + registry.register(path, handler); + }); + } + + final serverSocket = await bind(path); + final httpServer = HttpServer.listenOn(serverSocket); + + httpServer.forEach((HttpRequest request) async { + final ctx = RpcContext(request, request.response); + + try { + // Get request path + final requestPath = request.uri.path; + // Find route handler + final handler = registry.getHandler(requestPath); + if (handler != null) { + // Execute route handler + await handler(ctx); + } else { + // 404 Route not found + await ctx.writeError('Route not found: $requestPath', 404); + } + } catch (e) { + await ctx.writeError('Internal server error: $e', 500); + } finally { + await request.response.close(); + } + }); +} diff --git a/ui/flutter/lib/main.dart b/ui/flutter/lib/main.dart index 102b3f13..81d31dc1 100644 --- a/ui/flutter/lib/main.dart +++ b/ui/flutter/lib/main.dart @@ -24,6 +24,8 @@ class Args { bool hidden = false; + Args(); + Args.parse(List args) { final parser = ArgParser(); parser.addFlag(flagHidden); @@ -33,7 +35,22 @@ class Args { } void main(List arguments) async { - final args = Args.parse(arguments); + Args args; + // Parse url scheme arguments, e.g. gopeed:?hidden=true + // TODO: macos open url handle + // TODO: macos updater test + if (arguments.firstOrNull?.startsWith("gopeed:") == true) { + try { + final uri = Uri.parse(arguments.first); + args = Args()..hidden = uri.queryParameters["hidden"] == "true"; + } catch (e) { + // ignore + args = Args.parse([]); + } + } else { + args = Args.parse(arguments); + } + await init(args); onStart(); diff --git a/ui/flutter/lib/util/browser_extension_host/entry/browser_extension_host_native.dart b/ui/flutter/lib/util/browser_extension_host/entry/browser_extension_host_native.dart index 3d5feed9..b85fa1c3 100644 --- a/ui/flutter/lib/util/browser_extension_host/entry/browser_extension_host_native.dart +++ b/ui/flutter/lib/util/browser_extension_host/entry/browser_extension_host_native.dart @@ -26,7 +26,7 @@ const _firefoxNativeHostsKey = r'Software\Mozilla\NativeMessagingHosts'; /// Install host binary for browser extension Future doInstallHost() async { - final hostPath = Util.homePathJoin(_hostExecName); + final hostPath = await Util.homePathJoin(_hostExecName); await Util.installAsset('assets/exec/$_hostExecName', hostPath, executable: true); } @@ -62,7 +62,7 @@ Future doCheckBrowserInstalled(Browser browser) async { Future doCheckManifestInstalled(Browser browser) async { if (await checkBrowserInstalled(browser) == false) return false; - final manifestPath = _getManifestPath(browser); + final manifestPath = await _getManifestPath(browser); if (manifestPath == null) return false; if (Platform.isWindows) { @@ -86,7 +86,7 @@ Future doInstallManifest(Browser browser) async { if (await checkBrowserInstalled(browser) == false) return; if (await checkManifestInstalled(browser)) return; - final manifestPath = _getManifestPath(browser)!; + final manifestPath = (await _getManifestPath(browser))!; final manifestContent = await _getManifestContent(browser); final manifestDir = path.dirname(manifestPath); await Directory(manifestDir).create(recursive: true); @@ -209,11 +209,11 @@ List _getUnixExecutablePaths(Browser browser) { } } -String? _getManifestPath(Browser browser) { +Future _getManifestPath(Browser browser) async { final manifestName = browser == Browser.firefox ? '$_hostName.moz.json' : '$_hostName.json'; if (Platform.isWindows) { - return Util.homePathJoin(manifestName); + return await Util.homePathJoin(manifestName); } final home = @@ -259,7 +259,7 @@ Future _checkWindowsRegistry(String keyPath) async { } Future _getManifestContent(Browser browser) async { - final hostPath = Util.homePathJoin(_hostExecName); + final hostPath = await Util.homePathJoin(_hostExecName); final manifest = { 'name': _hostName, 'description': 'Gopeed browser extension host', diff --git a/ui/flutter/lib/util/updater.dart b/ui/flutter/lib/util/updater.dart index d14d3412..b0d0c674 100644 --- a/ui/flutter/lib/util/updater.dart +++ b/ui/flutter/lib/util/updater.dart @@ -52,7 +52,7 @@ final _updaterBin = "updater${Util.isWindows() ? ".exe" : ""}"; Future installUpdater() async { await Util.installAsset( - 'assets/exec/$_updaterBin', Util.homePathJoin(_updaterBin), + 'assets/exec/$_updaterBin', await Util.homePathJoin(_updaterBin), executable: true); } @@ -346,7 +346,7 @@ Future _update(String version, Function(int, int) onProgress) async { case Channel.linuxFlathub: case Channel.linuxSnap: case Channel.linuxDeb: - final updaterPath = Util.homePathJoin(_updaterBin); + final updaterPath = await Util.homePathJoin(_updaterBin); // Check the updater binary is exists if (!await File(updaterPath).exists()) { await launchUrl( @@ -363,6 +363,8 @@ Future _update(String version, Function(int, int) onProgress) async { Update channel -asset string Path to the package asset + -exeDir string + Directory of the entry executable -log string Log file path */ @@ -373,6 +375,8 @@ Future _update(String version, Function(int, int) onProgress) async { _channel!.name, "-asset", newVersionAssetPath, + "-exeDir", + path.dirname(Platform.resolvedExecutable), "-log", path.join(logsDir(), "updater.log") ]); diff --git a/ui/flutter/lib/util/util.dart b/ui/flutter/lib/util/util.dart index aac3d23e..58c1f29c 100644 --- a/ui/flutter/lib/util/util.dart +++ b/ui/flutter/lib/util/util.dart @@ -157,10 +157,15 @@ class Util { }; } - static String homePathJoin(String fileName) { - final execPath = Platform.resolvedExecutable; - final execDir = path.dirname(execPath); - return path.join(execDir, fileName); + static Future homePathJoin(String fileName) async { + if (Util.isWindows()) { + final execPath = Platform.resolvedExecutable; + final execDir = path.dirname(execPath); + return path.join(execDir, fileName); + } + + final dir = await getApplicationSupportDirectory(); + return path.join(dir.path, fileName); } static Future installAsset(String assetPath, String targetPath, diff --git a/ui/flutter/pubspec.lock b/ui/flutter/pubspec.lock index 5809118d..2d7a5dda 100644 --- a/ui/flutter/pubspec.lock +++ b/ui/flutter/pubspec.lock @@ -286,6 +286,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dart_ipc: + dependency: "direct main" + description: + name: dart_ipc + sha256: "6cad558cda5304017c1f581df4c96fd4f8e4ee212aae7bfa4357716236faa9ba" + url: "https://pub.dev" + source: hosted + version: "1.0.1" dart_style: dependency: transitive description: @@ -1414,10 +1422,10 @@ packages: dependency: transitive description: name: web_socket_channel - sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.3" win32: dependency: transitive description: diff --git a/ui/flutter/pubspec.yaml b/ui/flutter/pubspec.yaml index 582346ab..6674ce97 100644 --- a/ui/flutter/pubspec.yaml +++ b/ui/flutter/pubspec.yaml @@ -82,6 +82,7 @@ dependencies: url: https://github.com/hui-z/flutter_install_plugin.git ref: cf08af829f4a4145634f8a047108f505fdbe5eaa flutter_svg: ^2.0.17 + dart_ipc: ^1.0.1 dependency_overrides: permission_handler_windows: git: