initwshrpc in electron (#391)

This commit is contained in:
Mike Sawka 2024-09-17 23:10:09 -07:00 committed by GitHub
parent dae72e7009
commit c7a60a80f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 296 additions and 67 deletions

View File

@ -6,7 +6,6 @@ package cmd
import (
"fmt"
"io"
"log"
"os"
"regexp"
"runtime/debug"
@ -177,7 +176,6 @@ func Execute() {
}()
err := rootCmd.Execute()
if err != nil {
log.Printf("[error] %v\n", err)
wshutil.DoShutdown("", 1, true)
return
}

View File

@ -4,12 +4,14 @@
package cmd
import (
"encoding/json"
"fmt"
"github.com/spf13/cobra"
"github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
"github.com/wavetermdev/waveterm/pkg/wshutil"
)
var webCmd = &cobra.Command{
@ -26,15 +28,16 @@ var webOpenCmd = &cobra.Command{
}
var webGetCmd = &cobra.Command{
Use: "get [--inner] [--all] css-selector",
Use: "get [--inner] [--all] [--json] blockid css-selector",
Short: "get the html for a css selector",
Args: cobra.ExactArgs(1),
Args: cobra.ExactArgs(2),
Hidden: true,
RunE: webGetRun,
}
var webGetInner bool
var webGetAll bool
var webGetJson bool
var webOpenMagnified bool
func init() {
@ -42,11 +45,59 @@ func init() {
webCmd.AddCommand(webOpenCmd)
webGetCmd.Flags().BoolVarP(&webGetInner, "inner", "", false, "get inner html (instead of outer)")
webGetCmd.Flags().BoolVarP(&webGetAll, "all", "", false, "get all matches (querySelectorAll)")
webGetCmd.Flags().BoolVarP(&webGetJson, "json", "", false, "output as json")
webCmd.AddCommand(webGetCmd)
rootCmd.AddCommand(webCmd)
}
func webGetRun(cmd *cobra.Command, args []string) error {
oref := args[0]
if oref == "" {
return fmt.Errorf("blockid not specified")
}
err := validateEasyORef(oref)
if err != nil {
return err
}
fullORef, err := resolveSimpleId(oref)
if err != nil {
return fmt.Errorf("resolving blockid: %w", err)
}
blockInfo, err := wshclient.BlockInfoCommand(RpcClient, fullORef.OID, nil)
if err != nil {
return fmt.Errorf("getting block info: %w", err)
}
if blockInfo.Meta.GetString(waveobj.MetaKey_View, "") != "web" {
return fmt.Errorf("block %s is not a web block", fullORef.OID)
}
data := wshrpc.CommandWebSelectorData{
WindowId: blockInfo.WindowId,
BlockId: fullORef.OID,
TabId: blockInfo.TabId,
Selector: args[1],
Opts: &wshrpc.WebSelectorOpts{
Inner: webGetInner,
All: webGetAll,
},
}
output, err := wshclient.WebSelectorCommand(RpcClient, data, &wshrpc.RpcOpts{
Route: wshutil.ElectronRoute,
Timeout: 5000,
})
if err != nil {
return err
}
if webGetJson {
barr, err := json.MarshalIndent(output, "", " ")
if err != nil {
return fmt.Errorf("json encoding: %w", err)
}
WriteStdout("%s\n", string(barr))
} else {
for _, item := range output {
WriteStdout("%s\n", item)
}
}
return nil
}

View File

@ -3,11 +3,7 @@
import { BrowserWindow, ipcMain, webContents, WebContents } from "electron";
export async function getWebContentsByBlockId(
win: BrowserWindow,
tabId: string,
blockId: string
): Promise<WebContents> {
export function getWebContentsByBlockId(win: BrowserWindow, tabId: string, blockId: string): Promise<WebContents> {
const prtn = new Promise<WebContents>((resolve, reject) => {
const randId = Math.floor(Math.random() * 1000000000).toString();
const respCh = `getWebContentsByBlockId-${randId}`;
@ -46,18 +42,23 @@ export async function webGetSelector(wc: WebContents, selector: string, opts?: W
if (!wc || !selector) {
return null;
}
try {
const escapedSelector = escapeSelector(selector);
const queryMethod = opts?.all ? "querySelectorAll" : "querySelector";
const prop = opts?.inner ? "innerHTML" : "outerHTML";
const execExpr = `
Array.from(document.${queryMethod}("${escapedSelector}") || []).map(el => el.${prop});
`;
const results = await wc.executeJavaScript(execExpr);
return results;
} catch (e) {
console.error("webGetSelector error", e);
return null;
(() => {
const toArr = x => (x instanceof NodeList) ? Array.from(x) : (x ? [x] : []);
try {
const result = document.${queryMethod}("${escapedSelector}");
const value = toArr(result).map(el => el.${prop});
return { value };
} catch (error) {
return { error: error.message };
}
})()`;
const results = await wc.executeJavaScript(execExpr);
if (results.error) {
throw new Error(results.error);
}
return results.value;
}

31
emain/emain-wsh.ts Normal file
View File

@ -0,0 +1,31 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { RpcResponseHelper, WshClient } from "@/app/store/wshclient";
import electron from "electron";
import { getWebContentsByBlockId, webGetSelector } from "emain/emain-web";
type WaveBrowserWindow = Electron.BrowserWindow & { waveWindowId: string; readyPromise: Promise<void> };
export class ElectronWshClientType extends WshClient {
constructor() {
super("electron");
}
async handle_webselector(rh: RpcResponseHelper, data: CommandWebSelectorData): Promise<string[]> {
if (!data.tabid || !data.blockid || !data.windowid) {
throw new Error("tabid and blockid are required");
}
const windows = electron.BrowserWindow.getAllWindows();
const win = windows.find((w) => (w as WaveBrowserWindow).waveWindowId === data.windowid);
if (win == null) {
throw new Error(`no window found with id ${data.windowid}`);
}
const wc = await getWebContentsByBlockId(win, data.tabid, data.blockid);
if (wc == null) {
throw new Error(`no webcontents found with blockid ${data.blockid}`);
}
const rtn = await webGetSelector(wc, data.selector, data.opts);
return rtn;
}
}

View File

@ -1,7 +1,9 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { initElectronWshrpc } from "@/app/store/wshrpcutil";
import * as electron from "electron";
import { ElectronWshClientType } from "emain/emain-wsh";
import { FastAverageColor } from "fast-average-color";
import fs from "fs";
import * as child_process from "node:child_process";
@ -33,6 +35,7 @@ import {
} from "./platform";
import { configureAutoUpdater, updater } from "./updater";
let ElectronWshClient = new ElectronWshClientType();
const electronApp = electron.app;
let WaveVersion = "unknown"; // set by WAVESRV-ESTART
let WaveBuildTime = 0; // set by WAVESRV-ESTART
@ -857,6 +860,11 @@ async function appMain() {
await relaunchBrowserWindows();
await configureAutoUpdater();
setTimeout(runActiveTimer, 5000); // start active timer, wait 5s just to be safe
try {
initElectronWshrpc(ElectronWshClient, AuthKey);
} catch (e) {
console.log("error initializing wshrpc", e);
}
globalIsStarting = false;

View File

@ -3,10 +3,22 @@
import debug from "debug";
import { sprintf } from "sprintf-js";
import type { WebSocket as ElectronWebSocketType } from "ws";
let ElectronWebSocket: typeof ElectronWebSocketType;
const AuthKeyHeader = "X-AuthKey";
if (typeof window === "undefined") {
try {
const WebSocket = require("ws") as typeof ElectronWebSocketType;
ElectronWebSocket = WebSocket;
} catch (e) {}
}
const dlog = debug("wave:ws");
const MaxWebSocketSendSize = 1024 * 1024; // 1MB
const WarnWebSocketSendSize = 1024 * 1024; // 1MB
const MaxWebSocketSendSize = 5 * 1024 * 1024; // 5MB
const reconnectHandlers: (() => void)[] = [];
function addWSReconnectHandler(handler: () => void) {
@ -23,7 +35,7 @@ function removeWSReconnectHandler(handler: () => void) {
type WSEventCallback = (arg0: WSEventType) => void;
class WSControl {
wsConn: any;
wsConn: WebSocket | ElectronWebSocketType;
open: boolean;
opening: boolean = false;
reconnectTimes: number = 0;
@ -35,12 +47,14 @@ class WSControl {
wsLog: string[] = [];
baseHostPort: string;
lastReconnectTime: number = 0;
authKey: string = null; // used only by electron
constructor(baseHostPort: string, windowId: string, messageCallback: WSEventCallback) {
constructor(baseHostPort: string, windowId: string, messageCallback: WSEventCallback, authKey?: string) {
this.baseHostPort = baseHostPort;
this.messageCallback = messageCallback;
this.windowId = windowId;
this.open = false;
this.authKey = authKey;
setInterval(this.sendPing.bind(this), 5000);
}
@ -51,7 +65,13 @@ class WSControl {
this.lastReconnectTime = Date.now();
dlog("try reconnect:", desc);
this.opening = true;
if (ElectronWebSocket) {
this.wsConn = new ElectronWebSocket(this.baseHostPort + "/ws?windowid=" + this.windowId, {
headers: { [AuthKeyHeader]: this.authKey },
});
} else {
this.wsConn = new WebSocket(this.baseHostPort + "/ws?windowid=" + this.windowId);
}
this.wsConn.onopen = this.onopen.bind(this);
this.wsConn.onmessage = this.onmessage.bind(this);
this.wsConn.onclose = this.onclose.bind(this);
@ -172,6 +192,9 @@ class WSControl {
console.log("ws message too large", byteSize, data.wscommand, msg.substring(0, 100));
return;
}
if (byteSize > WarnWebSocketSendSize) {
console.log("ws message large", byteSize, data.wscommand, msg.substring(0, 100));
}
this.wsConn.send(msg);
}

View File

@ -92,16 +92,21 @@ class WshClient {
// TODO implement a timeout (setTimeout + sendResponse)
const helper = new RpcResponseHelper(this, msg);
const handlerName = `handle_${msg.command}`;
try {
let result: any = null;
let prtn: any = null;
if (handlerName in this) {
prtn = this[handlerName](helper, msg.data);
return;
} else {
prtn = this.handle_default(helper, msg);
}
try {
if (prtn instanceof Promise) {
await prtn;
result = await prtn;
} else {
result = prtn;
}
if (!helper.done) {
helper.sendResponse({ data: result });
}
} catch (e) {
if (!helper.done) {

View File

@ -12,6 +12,11 @@ class RpcApiType {
return client.wshRpcCall("authenticate", data, opts);
}
// command "blockinfo" [call]
BlockInfoCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<BlockInfoData> {
return client.wshRpcCall("blockinfo", data, opts);
}
// command "connconnect" [call]
ConnConnectCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {
return client.wshRpcCall("connconnect", data, opts);
@ -207,6 +212,11 @@ class RpcApiType {
return client.wshRpcCall("test", data, opts);
}
// command "webselector" [call]
WebSelectorCommand(client: WshClient, data: CommandWebSelectorData, opts?: RpcOpts): Promise<string[]> {
return client.wshRpcCall("webselector", data, opts);
}
}
export const RpcApi = new RpcApiType();

View File

@ -31,6 +31,9 @@ class WshRouter {
constructor(upstreamClient: AbstractWshClient) {
this.routeMap = new Map();
this.rpcMap = new Map();
if (upstreamClient == null) {
throw new Error("upstream client cannot be null");
}
this.upstreamClient = upstreamClient;
}
@ -46,34 +49,17 @@ class WshRouter {
}
// returns true if the message was sent
_sendRoutedMessage(msg: RpcMessage, destRouteId: string): boolean {
_sendRoutedMessage(msg: RpcMessage, destRouteId: string) {
const client = this.routeMap.get(destRouteId);
if (client) {
client.recvRpcMessage(msg);
return true;
return;
}
// there should always an upstream client
if (!this.upstreamClient) {
// there should always be an upstream client
return false;
throw new Error(`no upstream client for message: ${msg}`);
}
this.upstreamClient?.recvRpcMessage(msg);
return true;
}
_handleNoRoute(msg: RpcMessage) {
dlog("no route for message", msg);
if (util.isBlank(msg.reqid)) {
// send a message instead
if (msg.command == "message") {
return;
}
const nrMsg = { command: "message", route: msg.source, data: { message: `no route for ${msg.route}` } };
this._sendRoutedMessage(nrMsg, SysRouteName);
return;
}
// send an error response
const nrMsg = { resid: msg.reqid, error: `no route for ${msg.route}` };
this._sendRoutedMessage(nrMsg, msg.source);
}
_registerRouteInfo(reqid: string, sourceRouteId: string, destRouteId: string) {
@ -102,18 +88,17 @@ class WshRouter {
}
if (!util.isBlank(msg.command)) {
// send + register routeinfo
const ok = this._sendRoutedMessage(msg, msg.route);
if (!ok) {
this._handleNoRoute(msg);
return;
}
if (!util.isBlank(msg.reqid)) {
this._registerRouteInfo(msg.reqid, msg.source, msg.route);
}
this._sendRoutedMessage(msg, msg.route);
return;
}
if (!util.isBlank(msg.reqid)) {
const routeInfo = this.rpcMap.get(msg.reqid);
if (!routeInfo) {
// no route info, discard
dlog("no route info for reqid, discarding", msg);
return;
}
this._sendRoutedMessage(msg, routeInfo.destRouteId);
@ -123,6 +108,7 @@ class WshRouter {
const routeInfo = this.rpcMap.get(msg.resid);
if (!routeInfo) {
// no route info, discard
dlog("no route info for resid, discarding", msg);
return;
}
this._sendRoutedMessage(msg, routeInfo.sourceRouteId);

View File

@ -114,11 +114,24 @@ if (globalThis.window != null) {
globalThis["consumeGenerator"] = consumeGenerator;
}
function initElectronWshrpc(electronClient: WshClient, authKey: string) {
DefaultRouter = new WshRouter(new UpstreamWshRpcProxy());
const handleFn = (event: WSEventType) => {
DefaultRouter.recvRpcMessage(event.data);
};
globalWS = new WSControl(getWSServerEndpoint(), "electron", handleFn, authKey);
globalWS.connectNow("connectWshrpc");
DefaultRouter.registerRoute(electronClient.routeId, electronClient);
addWSReconnectHandler(() => {
DefaultRouter.reannounceRoutes();
});
addWSReconnectHandler(wpsReconnectHandler);
}
function initWshrpc(windowId: string): WSControl {
DefaultRouter = new WshRouter(new UpstreamWshRpcProxy());
const handleFn = (event: WSEventType) => {
DefaultRouter.recvRpcMessage(event.data);
// handleIncomingRpcMessage(globalOpenRpcs, event);
};
globalWS = new WSControl(getWSServerEndpoint(), windowId, handleFn);
globalWS.connectNow("connectWshrpc");
@ -144,6 +157,7 @@ class UpstreamWshRpcProxy implements AbstractWshClient {
export {
DefaultRouter,
initElectronWshrpc,
initWshrpc,
sendRawRpcMessage,
sendRpcCommand,

View File

@ -25,6 +25,14 @@ declare global {
meta?: MetaType;
};
// wshrpc.BlockInfoData
type BlockInfoData = {
blockid: string;
tabid: string;
windowid: string;
meta: MetaType;
};
// webcmd.BlockInputWSCommand
type BlockInputWSCommand = {
wscommand: "blockinput";
@ -147,6 +155,15 @@ declare global {
meta: MetaType;
};
// wshrpc.CommandWebSelectorData
type CommandWebSelectorData = {
windowid: string;
blockid: string;
tabid: string;
selector: string;
opts?: WebSelectorOpts;
};
// wconfig.ConfigError
type ConfigError = {
file: string;
@ -641,6 +658,12 @@ declare global {
updates?: WaveObjUpdate[];
};
// wshrpc.WebSelectorOpts
type WebSelectorOpts = {
all?: boolean;
inner?: boolean;
};
// wconfig.WidgetConfigType
type WidgetConfigType = {
"display:order"?: number;

View File

@ -51,6 +51,7 @@
"@types/throttle-debounce": "^5",
"@types/tinycolor2": "^1",
"@types/uuid": "^10.0.0",
"@types/ws": "^8",
"@vitejs/plugin-react-swc": "^3.7.0",
"@vitest/coverage-istanbul": "^2.1.1",
"electron": "^32.1.0",
@ -127,7 +128,8 @@
"throttle-debounce": "^5.0.2",
"tinycolor2": "^1.6.0",
"use-device-pixel-ratio": "^1.1.2",
"winston": "^3.14.2"
"winston": "^3.14.2",
"ws": "^8.18.0"
},
"packageManager": "yarn@4.4.1"
}

View File

@ -262,12 +262,18 @@ func HandleWsInternal(w http.ResponseWriter, r *http.Request) error {
outputCh := make(chan any, 100)
closeCh := make(chan any)
eventbus.RegisterWSChannel(wsConnId, windowId, outputCh)
var routeId string
if windowId == wshutil.ElectronRoute {
routeId = wshutil.ElectronRoute
} else {
routeId = wshutil.MakeWindowRouteId(windowId)
}
defer eventbus.UnregisterWSChannel(wsConnId)
// we create a wshproxy to handle rpc messages to/from the window
wproxy := wshutil.MakeRpcProxy()
wshutil.DefaultRouter.RegisterRoute(wshutil.MakeWindowRouteId(windowId), wproxy)
wshutil.DefaultRouter.RegisterRoute(routeId, wproxy)
defer func() {
wshutil.DefaultRouter.UnregisterRoute(wshutil.MakeWindowRouteId(windowId))
wshutil.DefaultRouter.UnregisterRoute(routeId)
close(wproxy.ToRemoteCh)
}()
// WshServerFactoryFn(rpcInputCh, rpcOutputCh, wshrpc.RpcContext{})

View File

@ -19,6 +19,12 @@ func AuthenticateCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (
return resp, err
}
// command "blockinfo", wshserver.BlockInfoCommand
func BlockInfoCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (*wshrpc.BlockInfoData, error) {
resp, err := sendRpcRequestCallHelper[*wshrpc.BlockInfoData](w, "blockinfo", data, opts)
return resp, err
}
// command "connconnect", wshserver.ConnConnectCommand
func ConnConnectCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {
_, err := sendRpcRequestCallHelper[any](w, "connconnect", data, opts)
@ -248,4 +254,10 @@ func TestCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {
return err
}
// command "webselector", wshserver.WebSelectorCommand
func WebSelectorCommand(w *wshutil.WshRpc, data wshrpc.CommandWebSelectorData, opts *wshrpc.RpcOpts) ([]string, error) {
resp, err := sendRpcRequestCallHelper[[]string](w, "webselector", data, opts)
return resp, err
}

View File

@ -40,6 +40,7 @@ const (
Command_FileAppend = "fileappend"
Command_FileAppendIJson = "fileappendijson"
Command_ResolveIds = "resolveids"
Command_BlockInfo = "blockinfo"
Command_CreateBlock = "createblock"
Command_DeleteBlock = "deleteblock"
Command_FileWrite = "filewrite"
@ -65,6 +66,8 @@ const (
Command_ConnConnect = "connconnect"
Command_ConnDisconnect = "conndisconnect"
Command_ConnList = "connlist"
Command_WebSelector = "webselector"
)
type RespOrErrorUnion[T any] struct {
@ -101,6 +104,7 @@ type WshRpcInterface interface {
StreamCpuDataCommand(ctx context.Context, request CpuDataRequest) chan RespOrErrorUnion[TimeSeriesData]
TestCommand(ctx context.Context, data string) error
SetConfigCommand(ctx context.Context, data wconfig.MetaSettingsType) error
BlockInfoCommand(ctx context.Context, blockId string) (*BlockInfoData, error)
// connection functions
ConnStatusCommand(ctx context.Context) ([]ConnStatus, error)
@ -120,6 +124,8 @@ type WshRpcInterface interface {
RemoteWriteFileCommand(ctx context.Context, data CommandRemoteWriteFileData) error
RemoteFileJoinCommand(ctx context.Context, paths []string) (*FileInfo, error)
RemoteStreamCpuDataCommand(ctx context.Context) chan RespOrErrorUnion[TimeSeriesData]
WebSelectorCommand(ctx context.Context, data CommandWebSelectorData) ([]string, error)
}
// for frontend
@ -348,3 +354,23 @@ type ConnStatus struct {
ActiveConnNum int `json:"activeconnnum"`
Error string `json:"error,omitempty"`
}
type WebSelectorOpts struct {
All bool `json:"all,omitempty"`
Inner bool `json:"inner,omitempty"`
}
type CommandWebSelectorData struct {
WindowId string `json:"windowid"`
BlockId string `json:"blockid" wshcontext:"BlockId"`
TabId string `json:"tabid" wshcontext:"TabId"`
Selector string `json:"selector"`
Opts *WebSelectorOpts `json:"opts,omitempty"`
}
type BlockInfoData struct {
BlockId string `json:"blockid"`
TabId string `json:"tabid"`
WindowId string `json:"windowid"`
Meta waveobj.MetaMapType `json:"meta"`
}

View File

@ -526,3 +526,24 @@ func (ws *WshServer) ConnReinstallWshCommand(ctx context.Context, connName strin
func (ws *WshServer) ConnListCommand(ctx context.Context) ([]string, error) {
return conncontroller.GetConnectionsList()
}
func (ws *WshServer) BlockInfoCommand(ctx context.Context, blockId string) (*wshrpc.BlockInfoData, error) {
blockData, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId)
if err != nil {
return nil, fmt.Errorf("error getting block: %w", err)
}
tabId, err := wstore.DBFindTabForBlockId(ctx, blockId)
if err != nil {
return nil, fmt.Errorf("error finding tab for block: %w", err)
}
windowId, err := wstore.DBFindWindowForTabId(ctx, tabId)
if err != nil {
return nil, fmt.Errorf("error finding window for tab: %w", err)
}
return &wshrpc.BlockInfoData{
BlockId: blockId,
TabId: tabId,
WindowId: windowId,
Meta: blockData.Meta,
}, nil
}

View File

@ -18,6 +18,7 @@ import (
const DefaultRoute = "wavesrv"
const SysRoute = "sys" // this route doesn't exist, just a placeholder for system messages
const ElectronRoute = "electron"
// this works like a network switch

View File

@ -2594,6 +2594,15 @@ __metadata:
languageName: node
linkType: hard
"@types/ws@npm:^8":
version: 8.5.12
resolution: "@types/ws@npm:8.5.12"
dependencies:
"@types/node": "npm:*"
checksum: 10c0/3fd77c9e4e05c24ce42bfc7647f7506b08c40a40fe2aea236ef6d4e96fc7cb4006a81ed1b28ec9c457e177a74a72924f4768b7b4652680b42dfd52bc380e15f9
languageName: node
linkType: hard
"@types/yauzl@npm:^2.9.1":
version: 2.10.3
resolution: "@types/yauzl@npm:2.10.3"
@ -10210,6 +10219,7 @@ __metadata:
"@types/throttle-debounce": "npm:^5"
"@types/tinycolor2": "npm:^1"
"@types/uuid": "npm:^10.0.0"
"@types/ws": "npm:^8"
"@vitejs/plugin-react-swc": "npm:^3.7.0"
"@vitest/coverage-istanbul": "npm:^2.1.1"
"@xterm/addon-fit": "npm:^0.10.0"
@ -10277,6 +10287,7 @@ __metadata:
vite-tsconfig-paths: "npm:^5.0.1"
vitest: "npm:^2.1.1"
winston: "npm:^3.14.2"
ws: "npm:^8.18.0"
languageName: unknown
linkType: soft
@ -11218,7 +11229,7 @@ __metadata:
languageName: node
linkType: hard
"ws@npm:^8.2.3":
"ws@npm:^8.18.0, ws@npm:^8.2.3":
version: 8.18.0
resolution: "ws@npm:8.18.0"
peerDependencies: