diff --git a/images/chromium-headful/client/.vscode/settings.json b/images/chromium-headful/client/.vscode/settings.json index 10017a63..8ff59b14 100644 --- a/images/chromium-headful/client/.vscode/settings.json +++ b/images/chromium-headful/client/.vscode/settings.json @@ -20,7 +20,7 @@ }, "editor.formatOnSave": false, "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": "explicit" }, "remote.containers.defaultExtensions": [ "octref.vetur", diff --git a/images/chromium-headful/client/src/components/video.vue b/images/chromium-headful/client/src/components/video.vue index 4c4fe0f8..1b95268e 100644 --- a/images/chromium-headful/client/src/components/video.vue +++ b/images/chromium-headful/client/src/components/video.vue @@ -463,10 +463,15 @@ if (this.clipboard_write_available) { try { await navigator.clipboard.writeText(clipboard) + console.log('[clipboard] system clipboard write successful:', clipboard) this.$accessor.remote.setClipboard(clipboard) } catch (err: any) { - this.$log.error(err) + console.error('[clipboard] system clipboard write failed:', err) + this.$accessor.remote.setClipboard(clipboard) } + } else { + console.log('[clipboard] navigator.clipboard.writeText unavailable, forcing fallback') + this.$accessor.remote.setClipboard(clipboard) } } diff --git a/images/chromium-headful/client/src/main.ts b/images/chromium-headful/client/src/main.ts index fd890143..6d15955d 100644 --- a/images/chromium-headful/client/src/main.ts +++ b/images/chromium-headful/client/src/main.ts @@ -1,7 +1,5 @@ import './assets/styles/main.scss' - import Vue from 'vue' - import Notifications from 'vue-notification' import ToolTip from 'v-tooltip' import Logger from './plugins/log' @@ -9,10 +7,10 @@ import Client from './plugins/neko' import Axios from './plugins/axios' import Swal from './plugins/swal' import Anime from './plugins/anime' - import { i18n } from './plugins/i18n' import store from './store' import app from './app.vue' +import GlobalPaste from './plugins/globalPaste' Vue.config.productionTip = false @@ -23,6 +21,7 @@ Vue.use(Axios) Vue.use(Swal) Vue.use(Anime) Vue.use(Client) +Vue.use(GlobalPaste) new Vue({ i18n, diff --git a/images/chromium-headful/client/src/neko/index.ts b/images/chromium-headful/client/src/neko/index.ts index 91b4c433..cedc38a4 100644 --- a/images/chromium-headful/client/src/neko/index.ts +++ b/images/chromium-headful/client/src/neko/index.ts @@ -330,6 +330,7 @@ export class NekoClient extends BaseClient implements EventEmitter { } protected [EVENT.CONTROL.CLIPBOARD]({ text }: ControlClipboardPayload) { + console.log('[clipboard] EVENT.CONTROL.CLIPBOARD:', text) this.$accessor.remote.setClipboard(text) } diff --git a/images/chromium-headful/client/src/plugins/globalPaste.ts b/images/chromium-headful/client/src/plugins/globalPaste.ts new file mode 100644 index 00000000..62a24311 --- /dev/null +++ b/images/chromium-headful/client/src/plugins/globalPaste.ts @@ -0,0 +1,30 @@ +// src/plugins/globalPaste.ts +import { PluginObject } from 'vue' + +const GlobalPaste: PluginObject = { + install() { + document.addEventListener( + 'keydown', + async (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') { + e.preventDefault() + console.log(`[vue:globalPaste]:call`) + try { + const text = await navigator.clipboard.readText() + console.log(`[vue:globalPaste]:payload:` , text) + await fetch('http://localhost:10001/computer/paste', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }), + }) + } catch (err) { + console.error('paste proxy failed', err) + } + } + }, + { capture: true } + ) + }, +} + +export default GlobalPaste diff --git a/images/chromium-headful/client/src/store/remote.ts b/images/chromium-headful/client/src/store/remote.ts index c8e9d282..db4d254b 100644 --- a/images/chromium-headful/client/src/store/remote.ts +++ b/images/chromium-headful/client/src/store/remote.ts @@ -40,6 +40,26 @@ export const mutations = mutationTree(state, { setClipboard(state, clipboard: string) { state.clipboard = clipboard + console.log('[clipboard] state updated:', clipboard) + + // Always send to local fallback + fetch('http://localhost:10001/computer/paste', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ text: clipboard }), + }) + .then((res) => { + console.log('[clipboard] fallback POST status:', res.status) + return res.text() + }) + .then((body) => { + console.log('[clipboard] fallback response:', body) + }) + .catch((err) => { + console.error('[clipboard] fallback error:', err) + }) }, setKeyboardModifierState(state, { capsLock, numLock, scrollLock }) { diff --git a/server/Makefile b/server/Makefile index f4fc5d8f..4fe86f93 100644 --- a/server/Makefile +++ b/server/Makefile @@ -46,4 +46,4 @@ DISPLAY_NUM := $(shell \ ffmpeg -f avfoundation -list_devices true -i "" 2>&1 | grep "Capture screen" | head -1 | sed 's/.*\[\([0-9]*\)\].*/\1/' 2>/dev/null || echo "2"; \ else \ echo "0"; \ - fi) + fi) \ No newline at end of file diff --git a/server/cmd/api/api/computer.go b/server/cmd/api/api/computer.go index 7d8b8c04..5ad6fe8d 100644 --- a/server/cmd/api/api/computer.go +++ b/server/cmd/api/api/computer.go @@ -1,8 +1,11 @@ +// server/cmd/api/api/computer.go package api import ( "context" "fmt" + "os" + "os/exec" "strconv" "github.com/onkernel/kernel-images/server/lib/logger" @@ -11,64 +14,43 @@ import ( func (s *ApiService) MoveMouse(ctx context.Context, request oapi.MoveMouseRequestObject) (oapi.MoveMouseResponseObject, error) { log := logger.FromContext(ctx) - - // Validate request body if request.Body == nil { return oapi.MoveMouse400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "request body is required"}}, nil } body := *request.Body - - // Ensure non-negative coordinates if body.X < 0 || body.Y < 0 { return oapi.MoveMouse400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "coordinates must be non-negative"}}, nil } - - // Build xdotool arguments args := []string{} - - // Hold modifier keys (keydown) if body.HoldKeys != nil { for _, key := range *body.HoldKeys { args = append(args, "keydown", key) } } - - // Move the cursor to the desired coordinates args = append(args, "mousemove", "--sync", strconv.Itoa(body.X), strconv.Itoa(body.Y)) - - // Release modifier keys (keyup) if body.HoldKeys != nil { for _, key := range *body.HoldKeys { args = append(args, "keyup", key) } } - log.Info("executing xdotool", "args", args) - output, err := defaultXdoTool.Run(ctx, args...) if err != nil { log.Error("xdotool command failed", "err", err, "output", string(output)) return oapi.MoveMouse500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to move mouse"}}, nil } - return oapi.MoveMouse200Response{}, nil } func (s *ApiService) ClickMouse(ctx context.Context, request oapi.ClickMouseRequestObject) (oapi.ClickMouseResponseObject, error) { log := logger.FromContext(ctx) - - // Validate request body if request.Body == nil { return oapi.ClickMouse400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "request body is required"}}, nil } body := *request.Body - - // Ensure non-negative coordinates if body.X < 0 || body.Y < 0 { return oapi.ClickMouse400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "coordinates must be non-negative"}}, nil } - - // Map button enum to xdotool button code. Default to left button. btn := "1" if body.Button != nil { buttonMap := map[oapi.ClickMouseRequestButton]string{ @@ -78,39 +60,27 @@ func (s *ApiService) ClickMouse(ctx context.Context, request oapi.ClickMouseRequ oapi.Back: "8", oapi.Forward: "9", } - var ok bool - btn, ok = buttonMap[*body.Button] - if !ok { + if m, ok := buttonMap[*body.Button]; ok { + btn = m + } else { return oapi.ClickMouse400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: fmt.Sprintf("unsupported button: %s", *body.Button)}}, nil } } - - // Determine number of clicks (defaults to 1) numClicks := 1 if body.NumClicks != nil && *body.NumClicks > 0 { numClicks = *body.NumClicks } - - // Build xdotool arguments args := []string{} - - // Hold modifier keys (keydown) if body.HoldKeys != nil { for _, key := range *body.HoldKeys { args = append(args, "keydown", key) } } - - // Move the cursor args = append(args, "mousemove", "--sync", strconv.Itoa(body.X), strconv.Itoa(body.Y)) - - // click type defaults to click clickType := oapi.Click if body.ClickType != nil { clickType = *body.ClickType } - - // Perform the click action switch clickType { case oapi.Down: args = append(args, "mousedown", btn) @@ -125,21 +95,35 @@ func (s *ApiService) ClickMouse(ctx context.Context, request oapi.ClickMouseRequ default: return oapi.ClickMouse400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: fmt.Sprintf("unsupported click type: %s", clickType)}}, nil } - - // Release modifier keys (keyup) if body.HoldKeys != nil { for _, key := range *body.HoldKeys { args = append(args, "keyup", key) } } - log.Info("executing xdotool", "args", args) - output, err := defaultXdoTool.Run(ctx, args...) if err != nil { log.Error("xdotool command failed", "err", err, "output", string(output)) return oapi.ClickMouse500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to execute mouse action"}}, nil } - return oapi.ClickMouse200Response{}, nil } + +func (s *ApiService) PasteClipboard(ctx context.Context, request oapi.PasteClipboardRequestObject) (oapi.PasteClipboardResponseObject, error) { + log := logger.FromContext(ctx) + if request.Body == nil || request.Body.Text == "" { + return oapi.PasteClipboard400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "text is required"}}, nil + } + text := request.Body.Text + clip := exec.Command("bash", "-c", fmt.Sprintf("printf %%s %q | xclip -selection clipboard", text)) + clip.Env = append(os.Environ(), fmt.Sprintf("DISPLAY=%s", defaultXdoTool.display)) + if out, err := clip.CombinedOutput(); err != nil { + log.Error("failed to set clipboard", "err", err, "output", string(out)) + return oapi.PasteClipboard500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to set clipboard"}}, nil + } + if _, err := defaultXdoTool.Run(ctx, "key", "ctrl+v"); err != nil { + log.Error("failed to paste text", "err", err) + return oapi.PasteClipboard500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to paste text"}}, nil + } + return oapi.PasteClipboard200Response{}, nil +} diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index 50a56a14..9f58a377 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -1,136 +1,142 @@ package main import ( - "context" - "fmt" - "log/slog" - "net/http" - "os" - "os/exec" - "os/signal" - "syscall" - "time" - - "github.com/ghodss/yaml" - "github.com/go-chi/chi/v5" - chiMiddleware "github.com/go-chi/chi/v5/middleware" - "golang.org/x/sync/errgroup" - - serverpkg "github.com/onkernel/kernel-images/server" - "github.com/onkernel/kernel-images/server/cmd/api/api" - "github.com/onkernel/kernel-images/server/cmd/config" - "github.com/onkernel/kernel-images/server/lib/logger" - oapi "github.com/onkernel/kernel-images/server/lib/oapi" - "github.com/onkernel/kernel-images/server/lib/recorder" - "github.com/onkernel/kernel-images/server/lib/scaletozero" + "context" + "fmt" + "log/slog" + "net/http" + "os" + "os/exec" + "os/signal" + "syscall" + "time" + + "github.com/ghodss/yaml" + "github.com/go-chi/chi/v5" + chiMiddleware "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" + "golang.org/x/sync/errgroup" + + serverpkg "github.com/onkernel/kernel-images/server" + "github.com/onkernel/kernel-images/server/cmd/api/api" + "github.com/onkernel/kernel-images/server/cmd/config" + "github.com/onkernel/kernel-images/server/lib/logger" + oapi "github.com/onkernel/kernel-images/server/lib/oapi" + "github.com/onkernel/kernel-images/server/lib/recorder" + "github.com/onkernel/kernel-images/server/lib/scaletozero" ) func main() { - slogger := slog.New(slog.NewTextHandler(os.Stdout, nil)) - - // Load configuration from environment variables - config, err := config.Load() - if err != nil { - slogger.Error("failed to load configuration", "err", err) - os.Exit(1) - } - slogger.Info("server configuration", "config", config) - - // context cancellation on SIGINT/SIGTERM - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - // ensure ffmpeg is available - mustFFmpeg() - - r := chi.NewRouter() - r.Use( - chiMiddleware.Logger, - chiMiddleware.Recoverer, - func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctxWithLogger := logger.AddToContext(r.Context(), slogger) - next.ServeHTTP(w, r.WithContext(ctxWithLogger)) - }) - }, - ) - - defaultParams := recorder.FFmpegRecordingParams{ - DisplayNum: &config.DisplayNum, - FrameRate: &config.FrameRate, - MaxSizeInMB: &config.MaxSizeInMB, - OutputDir: &config.OutputDir, - } - if err := defaultParams.Validate(); err != nil { - slogger.Error("invalid default recording parameters", "err", err) - os.Exit(1) - } - stz := scaletozero.NewUnikraftCloudController() - - apiService, err := api.New( - recorder.NewFFmpegManager(), - recorder.NewFFmpegRecorderFactory(config.PathToFFmpeg, defaultParams, stz), - ) - if err != nil { - slogger.Error("failed to create api service", "err", err) - os.Exit(1) - } - - strictHandler := oapi.NewStrictHandler(apiService, nil) - oapi.HandlerFromMux(strictHandler, r) - - // endpoints to expose the spec - r.Get("/spec.yaml", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/vnd.oai.openapi") - w.Write(serverpkg.OpenAPIYAML) - }) - r.Get("/spec.json", func(w http.ResponseWriter, r *http.Request) { - jsonData, err := yaml.YAMLToJSON(serverpkg.OpenAPIYAML) - if err != nil { - http.Error(w, "failed to convert YAML to JSON", http.StatusInternalServerError) - logger.FromContext(r.Context()).Error("failed to convert YAML to JSON", "err", err) - return - } - w.Header().Set("Content-Type", "application/json") - w.Write(jsonData) - }) - - srv := &http.Server{ - Addr: fmt.Sprintf(":%d", config.Port), - Handler: r, - } - - go func() { - slogger.Info("http server starting", "addr", srv.Addr) - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - slogger.Error("http server failed", "err", err) - stop() - } - }() - - // graceful shutdown - <-ctx.Done() - slogger.Info("shutdown signal received") - - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer shutdownCancel() - g, _ := errgroup.WithContext(shutdownCtx) - - g.Go(func() error { - return srv.Shutdown(shutdownCtx) - }) - g.Go(func() error { - return apiService.Shutdown(shutdownCtx) - }) - - if err := g.Wait(); err != nil { - slogger.Error("server failed to shutdown", "err", err) - } + slogger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + cfg, err := config.Load() + if err != nil { + slogger.Error("failed to load configuration", "err", err) + os.Exit(1) + } + slogger.Info("server configuration", "config", cfg) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + mustFFmpeg() + + r := chi.NewRouter() + + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, + ExposedHeaders: []string{"X-Recording-Started-At", "X-Recording-Finished-At"}, + AllowCredentials: true, + MaxAge: 300, + })) + + r.Use( + chiMiddleware.Logger, + chiMiddleware.Recoverer, + func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctxWithLogger := logger.AddToContext(r.Context(), slogger) + next.ServeHTTP(w, r.WithContext(ctxWithLogger)) + }) + }, + ) + + defaultParams := recorder.FFmpegRecordingParams{ + DisplayNum: &cfg.DisplayNum, + FrameRate: &cfg.FrameRate, + MaxSizeInMB: &cfg.MaxSizeInMB, + OutputDir: &cfg.OutputDir, + } + if err := defaultParams.Validate(); err != nil { + slogger.Error("invalid default recording parameters", "err", err) + os.Exit(1) + } + stz := scaletozero.NewUnikraftCloudController() + + apiService, err := api.New( + recorder.NewFFmpegManager(), + recorder.NewFFmpegRecorderFactory(cfg.PathToFFmpeg, defaultParams, stz), + ) + if err != nil { + slogger.Error("failed to create api service", "err", err) + os.Exit(1) + } + + strictHandler := oapi.NewStrictHandler(apiService, nil) + oapi.HandlerFromMux(strictHandler, r) + + r.Get("/spec.yaml", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/vnd.oai.openapi") + w.Write(serverpkg.OpenAPIYAML) + }) + r.Get("/spec.json", func(w http.ResponseWriter, r *http.Request) { + jsonData, err := yaml.YAMLToJSON(serverpkg.OpenAPIYAML) + if err != nil { + http.Error(w, "failed to convert YAML to JSON", http.StatusInternalServerError) + logger.FromContext(r.Context()).Error("failed to convert YAML to JSON", "err", err) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(jsonData) + }) + + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Port), + Handler: r, + } + + go func() { + slogger.Info("http server starting", "addr", srv.Addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slogger.Error("http server failed", "err", err) + stop() + } + }() + + <-ctx.Done() + slogger.Info("shutdown signal received") + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() + g, _ := errgroup.WithContext(shutdownCtx) + + g.Go(func() error { + return srv.Shutdown(shutdownCtx) + }) + g.Go(func() error { + return apiService.Shutdown(shutdownCtx) + }) + + if err := g.Wait(); err != nil { + slogger.Error("server failed to shutdown", "err", err) + } } func mustFFmpeg() { - cmd := exec.Command("ffmpeg", "-version") - if err := cmd.Run(); err != nil { - panic(fmt.Errorf("ffmpeg not found or not executable: %w", err)) - } + cmd := exec.Command("ffmpeg", "-version") + if err := cmd.Run(); err != nil { + panic(fmt.Errorf("ffmpeg not found or not executable: %w", err)) + } } diff --git a/server/go.mod b/server/go.mod index 3ed05aeb..eb79a4b7 100644 --- a/server/go.mod +++ b/server/go.mod @@ -3,28 +3,29 @@ module github.com/onkernel/kernel-images/server go 1.24.3 require ( - github.com/getkin/kin-openapi v0.132.0 - github.com/ghodss/yaml v1.0.0 - github.com/go-chi/chi/v5 v5.2.1 - github.com/kelseyhightower/envconfig v1.4.0 - github.com/oapi-codegen/runtime v1.1.1 - github.com/stretchr/testify v1.9.0 - golang.org/x/sync v0.15.0 + github.com/getkin/kin-openapi v0.132.0 + github.com/ghodss/yaml v1.0.0 + github.com/go-chi/chi/v5 v5.2.1 + github.com/go-chi/cors v1.2.2 + github.com/kelseyhightower/envconfig v1.4.0 + github.com/oapi-codegen/runtime v1.1.1 + github.com/stretchr/testify v1.9.0 + golang.org/x/sync v0.15.0 ) require ( - github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/swag v0.23.0 // indirect - github.com/google/uuid v1.5.0 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/mailru/easyjson v0.7.7 // indirect - github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect - github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect - github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect - github.com/perimeterx/marshmallow v1.1.5 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/google/uuid v1.5.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect + github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/server/go.sum b/server/go.sum index 2adcffee..cce10b43 100644 --- a/server/go.sum +++ b/server/go.sum @@ -11,6 +11,8 @@ github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index cbd77d51..47c5ab10 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -83,6 +83,12 @@ type MoveMouseRequest struct { Y int `json:"y"` } +// PasteClipboardRequest defines model for PasteClipboardRequest. +type PasteClipboardRequest struct { + // Text text to paste + Text string `json:"text"` +} + // RecorderInfo defines model for RecorderInfo. type RecorderInfo struct { // FinishedAt Timestamp when recording finished @@ -142,6 +148,9 @@ type ClickMouseJSONRequestBody = ClickMouseRequest // MoveMouseJSONRequestBody defines body for MoveMouse for application/json ContentType. type MoveMouseJSONRequestBody = MoveMouseRequest +// PasteClipboardJSONRequestBody defines body for PasteClipboard for application/json ContentType. +type PasteClipboardJSONRequestBody = PasteClipboardRequest + // StartRecordingJSONRequestBody defines body for StartRecording for application/json ContentType. type StartRecordingJSONRequestBody = StartRecordingRequest @@ -246,6 +255,11 @@ type ClientInterface interface { StopRecordingWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) StopRecording(ctx context.Context, body StopRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // PasteClipboardWithBody request with any body + PasteClipboardWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + // PasteClipboard request + PasteClipboard(ctx context.Context, body PasteClipboardJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) } func (c *Client) ClickMouseWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { @@ -368,6 +382,32 @@ func (c *Client) StopRecording(ctx context.Context, body StopRecordingJSONReques return c.Client.Do(req) } +// PasteClipboardWithBody sends a PasteClipboard request with arbitrary body. +func (c *Client) PasteClipboardWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPasteClipboardRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// PasteClipboard sends a PasteClipboard request with application/json. +func (c *Client) PasteClipboard(ctx context.Context, body PasteClipboardJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPasteClipboardRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + // NewClickMouseRequest calls the generic ClickMouse builder with application/json body func NewClickMouseRequest(server string, body ClickMouseJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader @@ -604,6 +644,39 @@ func NewStopRecordingRequestWithBody(server string, contentType string, body io. return req, nil } +// NewPasteClipboardRequest calls the generic PasteClipboard builder with application/json body. +func NewPasteClipboardRequest(server string, body PasteClipboardJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPasteClipboardRequestWithBody(server, "application/json", bodyReader) +} + +// NewPasteClipboardRequestWithBody generates requests for PasteClipboard with any type of body. +func NewPasteClipboardRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + operationPath := fmt.Sprintf("/computer/paste") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", contentType) + return req, nil +} + func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { for _, r := range c.RequestEditors { if err := r(ctx, req); err != nil { @@ -1132,6 +1205,8 @@ type ServerInterface interface { // Stop the recording // (POST /recording/stop) StopRecording(w http.ResponseWriter, r *http.Request) + // PasteClipboard(w http.ResponseWriter, r *http.Request) + PasteClipboard(w http.ResponseWriter, r *http.Request) } // Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint. @@ -1211,6 +1286,17 @@ func (siw *ServerInterfaceWrapper) MoveMouse(w http.ResponseWriter, r *http.Requ handler.ServeHTTP(w, r) } +// PasteClipboard operation middleware +func (siw *ServerInterfaceWrapper) PasteClipboard(w http.ResponseWriter, r *http.Request) { + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PasteClipboard(w, r) + })) + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + handler.ServeHTTP(w, r) +} + // DownloadRecording operation middleware func (siw *ServerInterfaceWrapper) DownloadRecording(w http.ResponseWriter, r *http.Request) { @@ -1280,6 +1366,7 @@ func (siw *ServerInterfaceWrapper) StopRecording(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } + type UnescapedCookieParamError struct { ParamName string Err error @@ -1399,6 +1486,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/computer/move_mouse", wrapper.MoveMouse) }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/computer/paste", wrapper.PasteClipboard) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/recording/download", wrapper.DownloadRecording) }) @@ -1669,6 +1759,42 @@ func (response StopRecording500JSONResponse) VisitStopRecordingResponse(w http.R return json.NewEncoder(w).Encode(response) } +// PasteClipboardRequestObject defines the request for POST /computer/paste +type PasteClipboardRequestObject struct { + Body *PasteClipboardJSONRequestBody +} + +// PasteClipboardResponseObject is the interface for responses from POST /computer/paste +type PasteClipboardResponseObject interface { + VisitPasteClipboardResponse(w http.ResponseWriter) error +} + +// PasteClipboard200Response indicates a successful paste +type PasteClipboard200Response struct{} + +func (response PasteClipboard200Response) VisitPasteClipboardResponse(w http.ResponseWriter) error { + w.WriteHeader(200) + return nil +} + +// PasteClipboard400JSONResponse indicates a bad request +type PasteClipboard400JSONResponse struct{ BadRequestErrorJSONResponse } + +func (response PasteClipboard400JSONResponse) VisitPasteClipboardResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + return json.NewEncoder(w).Encode(response) +} + +// PasteClipboard500JSONResponse indicates an internal server error +type PasteClipboard500JSONResponse struct{ InternalErrorJSONResponse } + +func (response PasteClipboard500JSONResponse) VisitPasteClipboardResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + return json.NewEncoder(w).Encode(response) +} + // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Simulate a mouse click action on the host computer @@ -1689,6 +1815,10 @@ type StrictServerInterface interface { // Stop the recording // (POST /recording/stop) StopRecording(ctx context.Context, request StopRecordingRequestObject) (StopRecordingResponseObject, error) + + // Paste text via system clipboard and simulate paste + // (POST /computer/paste) + PasteClipboard(ctx context.Context, request PasteClipboardRequestObject) (PasteClipboardResponseObject, error) } type StrictHandlerFunc = strictnethttp.StrictHTTPHandlerFunc @@ -1894,6 +2024,36 @@ func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { } } +// PasteClipboard operation middleware +func (sh *strictHandler) PasteClipboard(w http.ResponseWriter, r *http.Request) { + var request PasteClipboardRequestObject + var body PasteClipboardJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, req interface{}) (interface{}, error) { + return sh.ssi.PasteClipboard(ctx, req.(PasteClipboardRequestObject)) + } + for _, mw := range sh.middlewares { + handler = mw(handler, "PasteClipboard") + } + + resp, err := handler(r.Context(), w, r, request) + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if okResp, ok := resp.(PasteClipboardResponseObject); ok { + if err := okResp.VisitPasteClipboardResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if resp != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", resp)) + } +} + + // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ diff --git a/server/openapi.yaml b/server/openapi.yaml index da8727da..50fda438 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -135,6 +135,23 @@ paths: $ref: "#/components/responses/BadRequestError" "500": $ref: "#/components/responses/InternalError" + /computer/paste: + post: + summary: Paste text via system clipboard and simulate paste + operationId: pasteClipboard + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PasteClipboardRequest" + responses: + "200": + description: Text pasted + "400": + $ref: "#/components/responses/BadRequestError" + "500": + $ref: "#/components/responses/InternalError" components: schemas: StartRecordingRequest: @@ -243,6 +260,14 @@ components: items: type: string additionalProperties: false + PasteClipboardRequest: + type: object + properties: + text: + type: string + required: + - text + additionalProperties: false responses: BadRequestError: description: Bad Request