From f3743f90ec1ae3310bfb89c3884476237b513b55 Mon Sep 17 00:00:00 2001 From: Sylvie Crowe <107814465+oneirocosm@users.noreply.github.com> Date: Thu, 18 Jul 2024 15:21:33 -0700 Subject: [PATCH] User Input (#119) Port the User Input feature from the previous version of the app. This is currently being used to verify a few different prompts for ssh. --- frontend/app/element/modal.less | 5 +- frontend/app/element/modal.tsx | 2 +- frontend/app/element/userinputmodal.less | 37 +++ frontend/app/element/userinputmodal.tsx | 133 +++++++++++ frontend/app/store/global.ts | 9 + frontend/app/store/services.ts | 9 + frontend/app/workspace/workspace.tsx | 3 + frontend/types/gotypes.d.ts | 22 ++ pkg/eventbus/eventbus.go | 1 + pkg/remote/sshclient.go | 212 ++++++++---------- pkg/service/service.go | 12 +- .../userinputservice/userinputservice.go | 18 ++ pkg/shellexec/shellexec.go | 3 +- pkg/tsgen/tsgen.go | 2 + pkg/userinput/userinput.go | 92 ++++++++ pkg/wconfig/filewatcher.go | 4 +- 16 files changed, 438 insertions(+), 126 deletions(-) create mode 100644 frontend/app/element/userinputmodal.less create mode 100644 frontend/app/element/userinputmodal.tsx create mode 100644 pkg/service/userinputservice/userinputservice.go create mode 100644 pkg/userinput/userinput.go diff --git a/frontend/app/element/modal.less b/frontend/app/element/modal.less index 90096721e..b93b47e43 100644 --- a/frontend/app/element/modal.less +++ b/frontend/app/element/modal.less @@ -6,7 +6,7 @@ top: 0; left: 0; width: 100vw; - height: 100vh; + height: 100%; z-index: 100; background-color: rgba(21, 23, 21, 0.7); @@ -15,8 +15,7 @@ flex-direction: column; border-radius: 10px; padding: 0; - width: 80vw; - height: 80vh; + width: 80%; margin-top: 25vh; margin-left: auto; margin-right: auto; diff --git a/frontend/app/element/modal.tsx b/frontend/app/element/modal.tsx index fc3a9d132..173ba5f5b 100644 --- a/frontend/app/element/modal.tsx +++ b/frontend/app/element/modal.tsx @@ -70,7 +70,7 @@ interface WaveModalProps { function WaveModal({ title, description, onSubmit, onCancel, buttonLabel = "Ok", children }: WaveModalProps) { return ( - + {children} diff --git a/frontend/app/element/userinputmodal.less b/frontend/app/element/userinputmodal.less new file mode 100644 index 000000000..ccb93974d --- /dev/null +++ b/frontend/app/element/userinputmodal.less @@ -0,0 +1,37 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.userinput-body { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 1rem; + + font: var(--fixed-font); + color: var(--main-text-color); + + .userinput-markdown { + color: var(--main-text-color); + } + + .userinput-text { + } + + .userinput-inputbox { + resize: none; + background-color: var(--panel-bg-color); + border-radius: 6px; + margin: 0; + border: var(--border-color); + padding: 5px 0 5px 16px; + min-height: 30px; + + &:hover { + cursor: text; + } + + &:focus { + outline-color: var(--accent-color); + } + } +} diff --git a/frontend/app/element/userinputmodal.tsx b/frontend/app/element/userinputmodal.tsx new file mode 100644 index 000000000..84b647d80 --- /dev/null +++ b/frontend/app/element/userinputmodal.tsx @@ -0,0 +1,133 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Markdown } from "@/element/markdown"; +import { WaveModal } from "@/element/modal"; +import { atoms } from "@/store/global"; +import * as keyutil from "@/util/keyutil"; +import * as jotai from "jotai"; +import * as React from "react"; +import { UserInputService } from "../store/services"; + +import "./userinputmodal.less"; + +export const UserInputModal = (userInputRequest: UserInputRequest) => { + const setModals = jotai.useSetAtom(atoms.userInput); + const [responseText, setResponseText] = React.useState(""); + const [countdown, setCountdown] = React.useState(Math.floor(userInputRequest.timeoutms / 1000)); + const checkboxStatus = React.useRef(false); + + const handleSendCancel = React.useCallback(() => { + UserInputService.SendUserInputResponse({ + type: "userinputresp", + requestid: userInputRequest.requestid, + errormsg: "Canceled by the user", + }); + setModals((prev) => { + prev.pop(); + return [...prev]; + }); + }, [responseText, userInputRequest]); + + const handleSendText = React.useCallback(() => { + UserInputService.SendUserInputResponse({ + type: "userinputresp", + requestid: userInputRequest.requestid, + text: responseText, + checkboxstat: checkboxStatus.current, + }); + setModals((prev) => { + prev.pop(); + return [...prev]; + }); + }, [responseText, userInputRequest]); + + const handleSendConfirm = React.useCallback(() => { + UserInputService.SendUserInputResponse({ + type: "userinputresp", + requestid: userInputRequest.requestid, + confirm: true, + checkboxstat: checkboxStatus.current, + }); + setModals((prev) => { + prev.pop(); + return [...prev]; + }); + }, [userInputRequest]); + + const handleSubmit = React.useCallback(() => { + switch (userInputRequest.responsetype) { + case "text": + handleSendText(); + break; + case "confirm": + handleSendConfirm(); + break; + } + }, [handleSendConfirm, handleSendText, userInputRequest.responsetype]); + + const handleKeyDown = React.useCallback( + (waveEvent: WaveKeyboardEvent): boolean => { + if (keyutil.checkKeyPressed(waveEvent, "Escape")) { + handleSendCancel(); + return; + } + if (keyutil.checkKeyPressed(waveEvent, "Enter")) { + handleSubmit(); + return true; + } + }, + [handleSendCancel, handleSubmit] + ); + + const queryText = React.useMemo(() => { + if (userInputRequest.markdown) { + return ; + } + return {userInputRequest.querytext}; + }, [userInputRequest.markdown, userInputRequest.querytext]); + + const inputBox = React.useMemo(() => { + if (userInputRequest.responsetype === "confirm") { + return <>; + } + return ( + setResponseText(e.target.value)} + value={responseText} + maxLength={400} + className="userinput-inputbox" + autoFocus={true} + onKeyDown={(e) => keyutil.keydownWrapper(handleKeyDown)(e)} + /> + ); + }, [userInputRequest.responsetype, userInputRequest.publictext, responseText, handleKeyDown, setResponseText]); + + React.useEffect(() => { + let timeout: ReturnType; + if (countdown == 0) { + timeout = setTimeout(() => { + handleSendCancel(); + }, 300); + } else { + timeout = setTimeout(() => { + setCountdown(countdown - 1); + }, 1000); + } + return () => clearTimeout(timeout); + }, [countdown]); + + return ( + handleSubmit()} + onCancel={() => handleSendCancel()} + > +
+ {queryText} + {inputBox} +
+
+ ); +}; diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index a3ab7b4a2..e8f727c57 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -81,6 +81,7 @@ const activeTabIdAtom: jotai.Atom = jotai.atom((get) => { } return windowData.activetabid; }); +const userInputAtom = jotai.atom([]) as jotai.PrimitiveAtom>; const atoms = { // initialized in wave.ts (will not be null inside of application) @@ -93,6 +94,7 @@ const atoms = { settingsConfigAtom: settingsConfigAtom, tabAtom: tabAtom, activeTabId: activeTabIdAtom, + userInput: userInputAtom, }; // key is "eventType" or "eventType|oref" @@ -208,6 +210,13 @@ function handleWSEventMessage(msg: WSEventType) { console.log("config", data); return; } + if (msg.eventtype == "userinput") { + // handle user input + const data: UserInputRequest = msg.data; + console.log(data); + globalStore.set(userInputAtom, (prev) => [...prev, data]); + return; + } if (msg.eventtype == "blockfile") { const fileData: WSFileEventData = msg.data; const fileSubject = getFileSubject(fileData.zoneid, fileData.filename); diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index 19fa33b50..63dfac9df 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -126,6 +126,15 @@ class ObjectServiceType { export const ObjectService = new ObjectServiceType(); +// userinputservice.UserInputService (userinput) +class UserInputServiceType { + SendUserInputResponse(arg1: UserInputResponse): Promise { + return WOS.callBackendService("userinput", "SendUserInputResponse", Array.from(arguments)) + } +} + +export const UserInputService = new UserInputServiceType(); + // windowservice.WindowService (window) class WindowServiceType { // @returns object updates diff --git a/frontend/app/workspace/workspace.tsx b/frontend/app/workspace/workspace.tsx index 19f333e13..2b79325e2 100644 --- a/frontend/app/workspace/workspace.tsx +++ b/frontend/app/workspace/workspace.tsx @@ -3,6 +3,7 @@ import { TabBar } from "@/app/tab/tabbar"; import { TabContent } from "@/app/tab/tabcontent"; +import { UserInputModal } from "@/element/userinputmodal"; import { atoms, createBlock } from "@/store/global"; import * as services from "@/store/services"; import * as util from "@/util/util"; @@ -106,9 +107,11 @@ const Widgets = React.memo(() => { const WorkspaceElem = React.memo(() => { const windowData = jotai.useAtomValue(atoms.waveWindow); const activeTabId = windowData?.activetabid; + const modals = jotai.useAtomValue(atoms.userInput); const ws = jotai.useAtomValue(atoms.workspace); return (
+ {modals.length > 0 && }
{activeTabId == "" ? ( diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 82053e5aa..0883d61d6 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -262,6 +262,28 @@ declare global { activetabid: string; }; + // userinput.UserInputRequest + type UserInputRequest = { + requestid: string; + querytext: string; + responsetype: string; + title: string; + markdown: boolean; + timeoutms: number; + checkboxmsg: string; + publictext: boolean; + }; + + // userinput.UserInputResponse + type UserInputResponse = { + type: string; + requestid: string; + text?: string; + confirm?: boolean; + errormsg?: string; + checkboxstat?: boolean; + }; + type WSCommandType = { wscommand: string; } & ( SetBlockTermSizeWSCommand | BlockInputWSCommand | WSRpcCommand ); diff --git a/pkg/eventbus/eventbus.go b/pkg/eventbus/eventbus.go index 54bae07ec..257c7db99 100644 --- a/pkg/eventbus/eventbus.go +++ b/pkg/eventbus/eventbus.go @@ -18,6 +18,7 @@ const ( WSEvent_WaveObjUpdate = "waveobj:update" WSEvent_BlockFile = "blockfile" WSEvent_Config = "config" + WSEvent_UserInput = "userinput" WSEvent_BlockControllerStatus = "blockcontroller:status" WSEvent_LayoutAction = "layoutaction" WSEvent_ElectronNewWindow = "electron:newwindow" diff --git a/pkg/remote/sshclient.go b/pkg/remote/sshclient.go index 7d4578add..4cf90bafc 100644 --- a/pkg/remote/sshclient.go +++ b/pkg/remote/sshclient.go @@ -1,4 +1,4 @@ -// Copyright 2023-2024, Command Line Inc. +// Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package remote @@ -19,8 +19,10 @@ import ( "strconv" "strings" "sync" + "time" "github.com/kevinburke/ssh_config" + "github.com/wavetermdev/thenextwave/pkg/userinput" "github.com/wavetermdev/thenextwave/pkg/wavebase" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/knownhosts" @@ -115,28 +117,25 @@ func createPublicKeyCallback(connCtx context.Context, sshKeywords *SshKeywords, return createDummySigner() } - return nil, fmt.Errorf("unimplemented: userinput createPublicKeyCallback") //todo - /* - request := &userinput.UserInputRequestType{ - ResponseType: "text", - QueryText: fmt.Sprintf("Enter passphrase for the SSH key: %s", identityFile), - Title: "Publickey Auth + Passphrase", - } - ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second) - defer cancelFn() - response, err := userinput.GetUserInput(ctx, scbus.MainRpcBus, request) - if err != nil { - // this is an error where we actually do want to stop - // trying keys - return nil, UserInputCancelError{Err: err} - } - signer, err = ssh.ParsePrivateKeyWithPassphrase(privateKey, []byte(response.Text)) - if err != nil { - // skip this key and try with the next - return createDummySigner() - } - return []ssh.Signer{signer}, err - */ + request := &userinput.UserInputRequest{ + ResponseType: "text", + QueryText: fmt.Sprintf("Enter passphrase for the SSH key: %s", identityFile), + Title: "Publickey Auth + Passphrase", + } + ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second) + defer cancelFn() + response, err := userinput.GetUserInput(ctx, request) + if err != nil { + // this is an error where we actually do want to stop + // trying keys + return nil, UserInputCancelError{Err: err} + } + signer, err = ssh.ParsePrivateKeyWithPassphrase(privateKey, []byte(response.Text)) + if err != nil { + // skip this key and try with the next + return createDummySigner() + } + return []ssh.Signer{signer}, err } } @@ -151,26 +150,23 @@ func createDefaultPasswordCallbackPrompt(password string) func() (secret string, func createInteractivePasswordCallbackPrompt(connCtx context.Context, remoteDisplayName string) func() (secret string, err error) { return func() (secret string, err error) { - return "", fmt.Errorf("unimplemented: userinput createInteractivePasswordCallbackPrompt") //todo - /* - ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second) - defer cancelFn() - queryText := fmt.Sprintf( - "Password Authentication requested from connection \n"+ - "%s\n\n"+ - "Password:", remoteDisplayName) - request := &userinput.UserInputRequestType{ - ResponseType: "text", - QueryText: queryText, - Markdown: true, - Title: "Password Authentication", - } - response, err := userinput.GetUserInput(ctx, scbus.MainRpcBus, request) - if err != nil { - return "", err - } - return response.Text, nil - */ + ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second) + defer cancelFn() + queryText := fmt.Sprintf( + "Password Authentication requested from connection \n"+ + "%s\n\n"+ + "Password:", remoteDisplayName) + request := &userinput.UserInputRequest{ + ResponseType: "text", + QueryText: queryText, + Markdown: true, + Title: "Password Authentication", + } + response, err := userinput.GetUserInput(ctx, request) + if err != nil { + return "", err + } + return response.Text, nil } } @@ -219,27 +215,24 @@ func createInteractiveKbdInteractiveChallenge(connCtx context.Context, remoteNam func promptChallengeQuestion(connCtx context.Context, question string, echo bool, remoteName string) (answer string, err error) { // limited to 15 seconds for some reason. this should be investigated more // in the future - return "", fmt.Errorf("unimplemented: userinput promptChallengeQuestion") //todo - /* - ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second) - defer cancelFn() - queryText := fmt.Sprintf( - "Keyboard Interactive Authentication requested from connection \n"+ - "%s\n\n"+ - "%s", remoteName, question) - request := &userinput.UserInputRequestType{ - ResponseType: "text", - QueryText: queryText, - Markdown: true, - Title: "Keyboard Interactive Authentication", - PublicText: echo, - } - response, err := userinput.GetUserInput(ctx, scbus.MainRpcBus, request) - if err != nil { - return "", err - } - return response.Text, nil - */ + ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second) + defer cancelFn() + queryText := fmt.Sprintf( + "Keyboard Interactive Authentication requested from connection \n"+ + "%s\n\n"+ + "%s", remoteName, question) + request := &userinput.UserInputRequest{ + ResponseType: "text", + QueryText: queryText, + Markdown: true, + Title: "Keyboard Interactive Authentication", + PublicText: echo, + } + response, err := userinput.GetUserInput(ctx, request) + if err != nil { + return "", err + } + return response.Text, nil } func createCombinedKbdInteractiveChallenge(connCtx context.Context, password string, remoteName string) ssh.KeyboardInteractiveChallenge { @@ -263,11 +256,10 @@ func openKnownHostsForEdit(knownHostsFilename string) (*os.File, error) { return os.OpenFile(knownHostsFilename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) } -/* -func writeToKnownHosts(knownHostsFile string, newLine string, getUserVerification func() (*userinput.UserInputResponsePacketType, error)) error { +func writeToKnownHosts(knownHostsFile string, newLine string, getUserVerification func() (*userinput.UserInputResponse, error)) error { if getUserVerification == nil { - getUserVerification = func() (*userinput.UserInputResponsePacketType, error) { - return &userinput.UserInputResponsePacketType{ + getUserVerification = func() (*userinput.UserInputResponse, error) { + return &userinput.UserInputResponse{ Type: "confirm", Confirm: true, }, nil @@ -303,10 +295,8 @@ func writeToKnownHosts(knownHostsFile string, newLine string, getUserVerificatio } return f.Close() } -*/ -/* todo -func createUnknownKeyVerifier(knownHostsFile string, hostname string, remote string, key ssh.PublicKey) func() (*userinput.UserInputResponsePacketType, error) { +func createUnknownKeyVerifier(knownHostsFile string, hostname string, remote string, key ssh.PublicKey) func() (*userinput.UserInputResponse, error) { base64Key := base64.StdEncoding.EncodeToString(key.Marshal()) queryText := fmt.Sprintf( "The authenticity of host '%s (%s)' can't be established "+ @@ -316,22 +306,20 @@ func createUnknownKeyVerifier(knownHostsFile string, hostname string, remote str "**Would you like to continue connecting?** If so, the key will be permanently "+ "added to the file %s "+ "to protect from future man-in-the-middle attacks.", hostname, remote, key.Type(), base64Key, knownHostsFile) - request := &userinput.UserInputRequestType{ + request := &userinput.UserInputRequest{ ResponseType: "confirm", QueryText: queryText, Markdown: true, Title: "Known Hosts Key Missing", } - return func() (*userinput.UserInputResponsePacketType, error) { + return func() (*userinput.UserInputResponse, error) { ctx, cancelFn := context.WithTimeout(context.Background(), 60*time.Second) defer cancelFn() - return userinput.GetUserInput(ctx, scbus.MainRpcBus, request) + return userinput.GetUserInput(ctx, request) } } -*/ -/* -func createMissingKnownHostsVerifier(knownHostsFile string, hostname string, remote string, key ssh.PublicKey) func() (*userinput.UserInputResponsePacketType, error) { +func createMissingKnownHostsVerifier(knownHostsFile string, hostname string, remote string, key ssh.PublicKey) func() (*userinput.UserInputResponse, error) { base64Key := base64.StdEncoding.EncodeToString(key.Marshal()) queryText := fmt.Sprintf( "The authenticity of host '%s (%s)' can't be established "+ @@ -342,19 +330,18 @@ func createMissingKnownHostsVerifier(knownHostsFile string, hostname string, rem "- %s will be created \n"+ "- the key will be added to %s\n\n"+ "This will protect from future man-in-the-middle attacks.", hostname, remote, key.Type(), base64Key, knownHostsFile, knownHostsFile) - request := &userinput.UserInputRequestType{ + request := &userinput.UserInputRequest{ ResponseType: "confirm", QueryText: queryText, Markdown: true, Title: "Known Hosts File Missing", } - return func() (*userinput.UserInputResponsePacketType, error) { + return func() (*userinput.UserInputResponse, error) { ctx, cancelFn := context.WithTimeout(context.Background(), 60*time.Second) defer cancelFn() - return userinput.GetUserInput(ctx, scbus.MainRpcBus, request) + return userinput.GetUserInput(ctx, request) } } -*/ func lineContainsMatch(line []byte, matches [][]byte) bool { for _, match := range matches { @@ -444,41 +431,38 @@ func createHostKeyCallback(opts *SSHOpts) (ssh.HostKeyCallback, error) { // the key was not found // try to write to a file that could be read - //err := fmt.Errorf("placeholder, should not be returned") // a null value here can cause problems with empty slice - return fmt.Errorf("unimplemented: waveHostKeyCallback key not found") //todo - /* - for _, filename := range knownHostsFiles { - newLine := knownhosts.Line([]string{knownhosts.Normalize(hostname)}, key) - getUserVerification := createUnknownKeyVerifier(filename, hostname, remote.String(), key) - err = writeToKnownHosts(filename, newLine, getUserVerification) - if err == nil { - break - } - if serr, ok := err.(UserInputCancelError); ok { - return serr - } - } + err := fmt.Errorf("placeholder, should not be returned") // a null value here can cause problems with empty slice + for _, filename := range knownHostsFiles { + newLine := knownhosts.Line([]string{knownhosts.Normalize(hostname)}, key) + getUserVerification := createUnknownKeyVerifier(filename, hostname, remote.String(), key) + err = writeToKnownHosts(filename, newLine, getUserVerification) + if err == nil { + break + } + if serr, ok := err.(UserInputCancelError); ok { + return serr + } + } - // try to write to a file that could not be read (file likely doesn't exist) - // should catch cases where there is no known_hosts file - if err != nil { - for _, filename := range unreadableFiles { - newLine := knownhosts.Line([]string{knownhosts.Normalize(hostname)}, key) - getUserVerification := createMissingKnownHostsVerifier(filename, hostname, remote.String(), key) - err = writeToKnownHosts(filename, newLine, getUserVerification) - if err == nil { - knownHostsFiles = []string{filename} - break - } - if serr, ok := err.(UserInputCancelError); ok { - return serr - } - } + // try to write to a file that could not be read (file likely doesn't exist) + // should catch cases where there is no known_hosts file + if err != nil { + for _, filename := range unreadableFiles { + newLine := knownhosts.Line([]string{knownhosts.Normalize(hostname)}, key) + getUserVerification := createMissingKnownHostsVerifier(filename, hostname, remote.String(), key) + err = writeToKnownHosts(filename, newLine, getUserVerification) + if err == nil { + knownHostsFiles = []string{filename} + break + } + if serr, ok := err.(UserInputCancelError); ok { + return serr + } } - if err != nil { - return fmt.Errorf("unable to create new knownhost key: %e", err) - } - */ + } + if err != nil { + return fmt.Errorf("unable to create new knownhost key: %e", err) + } } else { // the key changed correctKeyFingerprint := base64.StdEncoding.EncodeToString(key.Marshal()) diff --git a/pkg/service/service.go b/pkg/service/service.go index a0e7e4783..493f93971 100644 --- a/pkg/service/service.go +++ b/pkg/service/service.go @@ -13,6 +13,7 @@ import ( "github.com/wavetermdev/thenextwave/pkg/service/clientservice" "github.com/wavetermdev/thenextwave/pkg/service/fileservice" "github.com/wavetermdev/thenextwave/pkg/service/objectservice" + "github.com/wavetermdev/thenextwave/pkg/service/userinputservice" "github.com/wavetermdev/thenextwave/pkg/service/windowservice" "github.com/wavetermdev/thenextwave/pkg/tsgen/tsgenmeta" "github.com/wavetermdev/thenextwave/pkg/util/utilfn" @@ -22,11 +23,12 @@ import ( ) var ServiceMap = map[string]any{ - "block": blockservice.BlockServiceInstance, - "object": &objectservice.ObjectService{}, - "file": &fileservice.FileService{}, - "client": &clientservice.ClientService{}, - "window": &windowservice.WindowService{}, + "block": blockservice.BlockServiceInstance, + "object": &objectservice.ObjectService{}, + "file": &fileservice.FileService{}, + "client": &clientservice.ClientService{}, + "window": &windowservice.WindowService{}, + "userinput": &userinputservice.UserInputService{}, } var contextRType = reflect.TypeOf((*context.Context)(nil)).Elem() diff --git a/pkg/service/userinputservice/userinputservice.go b/pkg/service/userinputservice/userinputservice.go new file mode 100644 index 000000000..da1693bf1 --- /dev/null +++ b/pkg/service/userinputservice/userinputservice.go @@ -0,0 +1,18 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package userinputservice + +import ( + "github.com/wavetermdev/thenextwave/pkg/userinput" +) + +type UserInputService struct { +} + +func (uis *UserInputService) SendUserInputResponse(response *userinput.UserInputResponse) { + select { + case userinput.MainUserInputHandler.Channels[response.RequestId] <- response: + default: + } +} diff --git a/pkg/shellexec/shellexec.go b/pkg/shellexec/shellexec.go index db97b56c5..5ea6fd5a6 100644 --- a/pkg/shellexec/shellexec.go +++ b/pkg/shellexec/shellexec.go @@ -17,6 +17,7 @@ import ( "strings" "sync" "syscall" + "time" "github.com/creack/pty" "github.com/wavetermdev/thenextwave/pkg/remote" @@ -114,7 +115,7 @@ func checkCwd(cwd string) error { var userHostRe = regexp.MustCompile(`^([a-zA-Z0-9][a-zA-Z0-9._@\\-]*@)?([a-z0-9][a-z0-9.-]*)(?::([0-9]+))?$`) func StartRemoteShellProc(termSize TermSize, cmdStr string, cmdOpts CommandOptsType, remoteName string) (*ShellProc, error) { - ctx, cancelFunc := context.WithCancel(context.Background()) + ctx, cancelFunc := context.WithTimeout(context.Background(), 60*time.Second) defer cancelFunc() var shellPath string diff --git a/pkg/tsgen/tsgen.go b/pkg/tsgen/tsgen.go index f14aa44f5..a16cc90ea 100644 --- a/pkg/tsgen/tsgen.go +++ b/pkg/tsgen/tsgen.go @@ -14,6 +14,7 @@ import ( "github.com/wavetermdev/thenextwave/pkg/filestore" "github.com/wavetermdev/thenextwave/pkg/service" "github.com/wavetermdev/thenextwave/pkg/tsgen/tsgenmeta" + "github.com/wavetermdev/thenextwave/pkg/userinput" "github.com/wavetermdev/thenextwave/pkg/waveobj" "github.com/wavetermdev/thenextwave/pkg/wconfig" "github.com/wavetermdev/thenextwave/pkg/web/webcmd" @@ -39,6 +40,7 @@ var ExtraTypes = []any{ wconfig.WatcherUpdate{}, wshutil.RpcMessage{}, wshrpc.WshServerCommandMeta{}, + userinput.UserInputRequest{}, } // add extra type unions to generate here diff --git a/pkg/userinput/userinput.go b/pkg/userinput/userinput.go new file mode 100644 index 000000000..76bd66a3d --- /dev/null +++ b/pkg/userinput/userinput.go @@ -0,0 +1,92 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package userinput + +import ( + "context" + "fmt" + "log" + "sync" + "time" + + "github.com/google/uuid" + "github.com/wavetermdev/thenextwave/pkg/eventbus" +) + +var MainUserInputHandler = UserInputHandler{Channels: make(map[string](chan *UserInputResponse), 1)} + +type UserInputRequest struct { + RequestId string `json:"requestid"` + QueryText string `json:"querytext"` + ResponseType string `json:"responsetype"` + Title string `json:"title"` + Markdown bool `json:"markdown"` + TimeoutMs int `json:"timeoutms"` + CheckBoxMsg string `json:"checkboxmsg"` + PublicText bool `json:"publictext"` +} + +type UserInputResponse struct { + Type string `json:"type"` + RequestId string `json:"requestid"` + Text string `json:"text,omitempty"` + Confirm bool `json:"confirm,omitempty"` + ErrorMsg string `json:"errormsg,omitempty"` + CheckboxStat bool `json:"checkboxstat,omitempty"` +} + +type UserInputHandler struct { + Lock sync.Mutex + Channels map[string](chan *UserInputResponse) +} + +func (ui *UserInputHandler) registerChannel() (string, chan *UserInputResponse) { + ui.Lock.Lock() + defer ui.Lock.Unlock() + + id := uuid.New().String() + uich := make(chan *UserInputResponse, 1) + + ui.Channels[id] = uich + return id, uich +} + +func (ui *UserInputHandler) unregisterChannel(id string) { + ui.Lock.Lock() + defer ui.Lock.Unlock() + + delete(ui.Channels, id) +} + +func (ui *UserInputHandler) sendRequestToFrontend(request *UserInputRequest) { + eventbus.SendEvent(eventbus.WSEventType{ + EventType: eventbus.WSEvent_UserInput, + Data: request, + }) +} + +func GetUserInput(ctx context.Context, request *UserInputRequest) (*UserInputResponse, error) { + id, uiCh := MainUserInputHandler.registerChannel() + defer MainUserInputHandler.unregisterChannel(id) + request.RequestId = id + deadline, _ := ctx.Deadline() + request.TimeoutMs = int(time.Until(deadline).Milliseconds()) - 500 + MainUserInputHandler.sendRequestToFrontend(request) + + var response *UserInputResponse + var err error + select { + case resp := <-uiCh: + log.Printf("checking received: %v", resp.RequestId) + response = resp + case <-ctx.Done(): + return nil, fmt.Errorf("timed out waiting for user input") + } + + if response.ErrorMsg != "" { + err = fmt.Errorf(response.ErrorMsg) + } + + return response, err +} diff --git a/pkg/wconfig/filewatcher.go b/pkg/wconfig/filewatcher.go index e5db879fe..1eeb791d1 100644 --- a/pkg/wconfig/filewatcher.go +++ b/pkg/wconfig/filewatcher.go @@ -40,13 +40,13 @@ type WatcherUpdate struct { func readFileContents(filePath string, getDefaults func() SettingsConfigType) (*SettingsConfigType, error) { if getDefaults == nil { - log.Printf("oopsie") + log.Printf("should not happen") return nil, fmt.Errorf("watcher started without defaults") } content := getDefaults() data, err := os.ReadFile(filePath) if err != nil { - log.Printf("doopsie: %v", err) + log.Printf("could not read settings file: %v", err) return nil, err } if err := json.Unmarshal(data, &content); err != nil {