feat: refactor browser extension communication to use IPC (#1130)

This commit is contained in:
Levi
2025-10-12 21:17:07 +08:00
committed by GitHub
parent 75a8a3a9ac
commit 9a5680fb45
17 changed files with 353 additions and 116 deletions
+1
View File
@@ -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"
+19
View File
@@ -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"))
}
+11
View File
@@ -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)
}
+69 -24
View File
@@ -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)
}
+5 -6
View File
@@ -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")
}
+9 -5
View File
@@ -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
@@ -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<void> _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<void> _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));
}
}
@@ -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<String, dynamic> json,
) =>
_$CreateRouterParamsFromJson(json);
Map<String, dynamic> toJson() => _$CreateRouterParamsToJson(this);
}
@@ -1,31 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'create_router_params.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CreateRouterParams _$CreateRouterParamsFromJson(Map<String, dynamic> json) =>
CreateRouterParams(
req: json['req'] == null
? null
: Request.fromJson(json['req'] as Map<String, dynamic>),
opt: json['opt'] == null
? null
: Options.fromJson(json['opt'] as Map<String, dynamic>),
);
Map<String, dynamic> _$CreateRouterParamsToJson(CreateRouterParams instance) {
final val = <String, dynamic>{};
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('req', instance.req?.toJson());
writeNotNull('opt', instance.opt?.toJson());
return val;
}
@@ -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<CreateController> {
final _confirmFormKey = GlobalKey<FormState>();
@@ -76,7 +75,7 @@ class CreateView extends GetView<CreateController> {
_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;
+133
View File
@@ -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<Map<String, dynamic>> readJSON() async {
if (_bodyCache == null) {
final content = <int>[];
await for (var data in request) {
content.addAll(data);
}
_bodyCache = String.fromCharCodes(content);
}
if (_bodyCache!.isEmpty) {
return {};
}
return jsonDecode(_bodyCache!) as Map<String, dynamic>;
}
Future<String> readText() async {
if (_bodyCache == null) {
final content = <int>[];
await for (var data in request) {
content.addAll(data);
}
_bodyCache = String.fromCharCodes(content);
}
return _bodyCache ?? '';
}
/// Write JSON response
Future<void> writeJSON(Map<String, dynamic> data) async {
response.headers.contentType = ContentType.json;
response.write(jsonEncode(data));
await response.close();
}
/// Write error response
Future<void> writeError(String message, [int statusCode = 500]) async {
response.statusCode = statusCode;
await writeJSON({'error': message});
}
}
/// Route handler type definition
typedef RouteHandler = Future<void> Function(RpcContext ctx);
/// Route registry
class RouteRegistry {
final Map<String, RouteHandler> _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<String> get routes => _routes.keys.toList();
}
/// Start RPC server
Future<void> startRpcServer([Map<String, RouteHandler>? 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();
}
});
}
+18 -1
View File
@@ -24,6 +24,8 @@ class Args {
bool hidden = false;
Args();
Args.parse(List<String> args) {
final parser = ArgParser();
parser.addFlag(flagHidden);
@@ -33,7 +35,22 @@ class Args {
}
void main(List<String> 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();
@@ -26,7 +26,7 @@ const _firefoxNativeHostsKey = r'Software\Mozilla\NativeMessagingHosts';
/// Install host binary for browser extension
Future<void> 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<bool> doCheckBrowserInstalled(Browser browser) async {
Future<bool> 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<void> 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<String> _getUnixExecutablePaths(Browser browser) {
}
}
String? _getManifestPath(Browser browser) {
Future<String?> _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<bool> _checkWindowsRegistry(String keyPath) async {
}
Future<String> _getManifestContent(Browser browser) async {
final hostPath = Util.homePathJoin(_hostExecName);
final hostPath = await Util.homePathJoin(_hostExecName);
final manifest = {
'name': _hostName,
'description': 'Gopeed browser extension host',
+6 -2
View File
@@ -52,7 +52,7 @@ final _updaterBin = "updater${Util.isWindows() ? ".exe" : ""}";
Future<void> 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<void> _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<void> _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<void> _update(String version, Function(int, int) onProgress) async {
_channel!.name,
"-asset",
newVersionAssetPath,
"-exeDir",
path.dirname(Platform.resolvedExecutable),
"-log",
path.join(logsDir(), "updater.log")
]);
+9 -4
View File
@@ -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<String> 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<void> installAsset(String assetPath, String targetPath,
+10 -2
View File
@@ -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:
+1
View File
@@ -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: