From 31bf30c90f0eb7241086daf222293062bdc5cf4c Mon Sep 17 00:00:00 2001 From: Hakan Or Date: Sat, 22 Aug 2026 19:14:40 +0300 Subject: [PATCH 1/3] feat: clipboard get and set commands --- cli/clipboard.go | 57 ++++++++++++++++++++++++++++ commands/clipboard.go | 64 ++++++++++++++++++++++++++++++++ devices/android.go | 37 ++++++++++++++++++ devices/common.go | 2 + devices/devicekit/clipboard.go | 33 +++++++++++++++++ devices/ios.go | 8 ++++ devices/remote.go | 15 ++++++++ devices/simulator.go | 8 ++++ docs/openrpc.json | 58 +++++++++++++++++++++++++++++ docs/openrpc.md | 68 ++++++++++++++++++++++++++++++++++ server/dispatch.go | 2 + server/server.go | 48 ++++++++++++++++++++++++ 12 files changed, 400 insertions(+) create mode 100644 cli/clipboard.go create mode 100644 commands/clipboard.go create mode 100644 devices/devicekit/clipboard.go diff --git a/cli/clipboard.go b/cli/clipboard.go new file mode 100644 index 00000000..d7464979 --- /dev/null +++ b/cli/clipboard.go @@ -0,0 +1,57 @@ +package cli + +import ( + "fmt" + + "github.com/mobile-next/mobilecli/commands" + "github.com/spf13/cobra" +) + +var clipboardCmd = &cobra.Command{ + Use: "clipboard", + Short: "Clipboard commands", + Long: `Commands for reading and writing the device clipboard.`, +} + +var clipboardGetCmd = &cobra.Command{ + Use: "get", + Short: "Read the clipboard of a device", + RunE: func(cmd *cobra.Command, args []string) error { + response := commands.ClipboardGetCommand(commands.ClipboardGetRequest{ + DeviceID: deviceId, + }) + + printJson(response) + if response.Status == "error" { + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + +var clipboardSetCmd = &cobra.Command{ + Use: "set [text]", + Short: "Replace the clipboard of a device", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + response := commands.ClipboardSetCommand(commands.ClipboardSetRequest{ + DeviceID: deviceId, + Text: args[0], + }) + + printJson(response) + if response.Status == "error" { + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + +func init() { + rootCmd.AddCommand(clipboardCmd) + clipboardCmd.AddCommand(clipboardGetCmd) + clipboardCmd.AddCommand(clipboardSetCmd) + + clipboardGetCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to read the clipboard from") + clipboardSetCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to write the clipboard on") +} diff --git a/commands/clipboard.go b/commands/clipboard.go new file mode 100644 index 00000000..b974b480 --- /dev/null +++ b/commands/clipboard.go @@ -0,0 +1,64 @@ +package commands + +import ( + "fmt" + + "github.com/mobile-next/mobilecli/devices" +) + +type ClipboardGetRequest struct { + DeviceID string `json:"deviceId"` +} + +type ClipboardSetRequest struct { + DeviceID string `json:"deviceId"` + Text string `json:"text"` +} + +type ClipboardResult struct { + Text string `json:"text"` +} + +func ClipboardGetCommand(req ClipboardGetRequest) *CommandResponse { + targetDevice, err := FindDeviceOrAutoSelect(req.DeviceID) + if err != nil { + return NewErrorResponse(fmt.Errorf("error finding device: %v", err)) + } + + err = targetDevice.StartAgent(devices.StartAgentConfig{ + Hook: GetShutdownHook(), + }) + if err != nil { + return NewErrorResponse(fmt.Errorf("failed to start agent on device %s: %v", targetDevice.ID(), err)) + } + + text, err := targetDevice.GetClipboard() + if err != nil { + return NewErrorResponse(fmt.Errorf("failed to read clipboard on device %s: %v", targetDevice.ID(), err)) + } + + return NewSuccessResponse(ClipboardResult{Text: text}) +} + +func ClipboardSetCommand(req ClipboardSetRequest) *CommandResponse { + targetDevice, err := FindDeviceOrAutoSelect(req.DeviceID) + if err != nil { + return NewErrorResponse(fmt.Errorf("error finding device: %v", err)) + } + + err = targetDevice.StartAgent(devices.StartAgentConfig{ + Hook: GetShutdownHook(), + }) + if err != nil { + return NewErrorResponse(fmt.Errorf("failed to start agent on device %s: %v", targetDevice.ID(), err)) + } + + err = targetDevice.SetClipboard(req.Text) + if err != nil { + return NewErrorResponse(fmt.Errorf("failed to write clipboard on device %s: %v", targetDevice.ID(), err)) + } + + return NewSuccessResponse(MessageResult{ + Message: fmt.Sprintf("Clipboard set on device %s", targetDevice.ID()), + }) +} diff --git a/devices/android.go b/devices/android.go index 8bfe8d3c..694dc3b2 100644 --- a/devices/android.go +++ b/devices/android.go @@ -424,6 +424,43 @@ func (d *AndroidDevice) Swipe(x1, y1, x2, y2 int) error { return nil } +const devicekitClipboardClass = "com.mobilenext.devicekit.Clipboard" + +func (d *AndroidDevice) clipboardCommand(args ...string) (string, error) { + appPath, err := d.GetAppPath("com.mobilenext.devicekit") + if err != nil || appPath == "" { + return "", fmt.Errorf("DeviceKit is not installed on device %s, it is required for clipboard access", d.ID()) + } + + cmdArgs := append([]string{"exec-out", fmt.Sprintf("CLASSPATH=%s", appPath), "app_process", "/system/bin", devicekitClipboardClass}, args...) + out, err := d.runAdbCommand(cmdArgs...) + if err != nil { + return "", err + } + + return string(out), nil +} + +func (d *AndroidDevice) GetClipboard() (string, error) { + out, err := d.clipboardCommand("get") + if err != nil { + return "", err + } + + return strings.TrimSuffix(out, "\n"), nil +} + +func (d *AndroidDevice) SetClipboard(text string) error { + if text == "" { + _, err := d.clipboardCommand("clear") + return err + } + + // base64 keeps spaces, emoji and other UTF-8 intact across the shell. + _, err := d.clipboardCommand("set", "--base64", base64.StdEncoding.EncodeToString([]byte(text))) + return err +} + // Gesture performs a sequence of touch actions on the Android device func (d *AndroidDevice) Gesture(actions []devicekit.TapAction) error { diff --git a/devices/common.go b/devices/common.go index 21e4fcaf..add255c9 100644 --- a/devices/common.go +++ b/devices/common.go @@ -130,6 +130,8 @@ type ControllableDevice interface { Gesture(actions []devicekit.TapAction) error StartAgent(config StartAgentConfig) error SendKeys(text string) error + GetClipboard() (string, error) + SetClipboard(text string) error PressKeys(combos []KeyCombo) error PressButton(key string) error LaunchApp(bundleID string, opts LaunchOptions) error diff --git a/devices/devicekit/clipboard.go b/devices/devicekit/clipboard.go new file mode 100644 index 00000000..d842451e --- /dev/null +++ b/devices/devicekit/clipboard.go @@ -0,0 +1,33 @@ +package devicekit + +import ( + "encoding/json" + "fmt" +) + +type clipboardText struct { + Text string `json:"text"` +} + +func (c *DeviceKitClient) GetClipboard() (string, error) { + result, err := c.CallRPC("device.clipboard.get", nil) + if err != nil { + return "", fmt.Errorf("failed to get clipboard: %w", err) + } + + var clipboard clipboardText + if err := json.Unmarshal(result, &clipboard); err != nil { + return "", fmt.Errorf("failed to parse clipboard: %w", err) + } + + return clipboard.Text, nil +} + +func (c *DeviceKitClient) SetClipboard(text string) error { + _, err := c.CallRPC("device.clipboard.set", map[string]any{"text": text}) + if err != nil { + return fmt.Errorf("failed to set clipboard: %w", err) + } + + return nil +} diff --git a/devices/ios.go b/devices/ios.go index 7514f5d1..fc90e974 100644 --- a/devices/ios.go +++ b/devices/ios.go @@ -223,6 +223,14 @@ func (d IOSDevice) Swipe(x1, y1, x2, y2 int) error { return d.deviceKitClient.Swipe(x1, y1, x2, y2) } +func (d IOSDevice) GetClipboard() (string, error) { + return d.deviceKitClient.GetClipboard() +} + +func (d IOSDevice) SetClipboard(text string) error { + return d.deviceKitClient.SetClipboard(text) +} + func (d IOSDevice) Gesture(actions []devicekit.TapAction) error { return d.deviceKitClient.Gesture(actions) } diff --git a/devices/remote.go b/devices/remote.go index 057894e8..df851d3d 100644 --- a/devices/remote.go +++ b/devices/remote.go @@ -127,6 +127,21 @@ func (r *RemoteDevice) Swipe(x1, y1, x2, y2 int) error { return r.fireRPC("device.io.swipe", params{"x1": x1, "y1": y1, "x2": x2, "y2": y2}) } +func (r *RemoteDevice) GetClipboard() (string, error) { + resp, err := rpcCall[struct { + Text string `json:"text"` + }](r, "device.clipboard.get", params{}) + if err != nil { + return "", err + } + + return resp.Text, nil +} + +func (r *RemoteDevice) SetClipboard(text string) error { + return r.fireRPC("device.clipboard.set", params{"text": text}) +} + func (r *RemoteDevice) Gesture(actions []devicekit.TapAction) error { return r.fireRPC("device.io.gesture", params{"actions": actions}) } diff --git a/devices/simulator.go b/devices/simulator.go index 3d0f4098..703790f2 100644 --- a/devices/simulator.go +++ b/devices/simulator.go @@ -586,6 +586,14 @@ func (s SimulatorDevice) Swipe(x1, y1, x2, y2 int) error { return s.deviceKitClient.Swipe(x1, y1, x2, y2) } +func (s SimulatorDevice) GetClipboard() (string, error) { + return s.deviceKitClient.GetClipboard() +} + +func (s SimulatorDevice) SetClipboard(text string) error { + return s.deviceKitClient.SetClipboard(text) +} + func (s SimulatorDevice) Gesture(actions []devicekit.TapAction) error { return s.deviceKitClient.Gesture(actions) } diff --git a/docs/openrpc.json b/docs/openrpc.json index 31b35c04..1ea5ea88 100644 --- a/docs/openrpc.json +++ b/docs/openrpc.json @@ -271,6 +271,64 @@ } } }, + { + "name": "device.clipboard.get", + "summary": "Read clipboard", + "description": "Reads the text currently on the device clipboard", + "params": [ + { + "name": "deviceId", + "description": "ID of the target device", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "clipboard", + "description": "Clipboard contents", + "schema": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Text on the clipboard, empty when it holds no text" + } + } + } + } + }, + { + "name": "device.clipboard.set", + "summary": "Write clipboard", + "description": "Replaces the text on the device clipboard, an empty string clears it", + "params": [ + { + "name": "deviceId", + "description": "ID of the target device", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "text", + "description": "Text to place on the clipboard", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "success", + "description": "Operation result", + "schema": { + "$ref": "#/components/schemas/SuccessResult" + } + } + }, { "name": "device.io.keys", "summary": "Press keyboard keys", diff --git a/docs/openrpc.md b/docs/openrpc.md index 8755d024..6a20b202 100644 --- a/docs/openrpc.md +++ b/docs/openrpc.md @@ -15,6 +15,8 @@ JSON-RPC API for mobile device automation and control - [device.apps.terminate](#deviceappsterminate) - [device.apps.uninstall](#deviceappsuninstall) - [device.boot](#deviceboot) +- [device.clipboard.get](#deviceclipboardget) +- [device.clipboard.set](#deviceclipboardset) - [device.crashes.get](#devicecrashesget) - [device.crashes.list](#devicecrasheslist) - [device.dump.ui](#devicedumpui) @@ -369,6 +371,72 @@ Boot operation result ``` +### device.clipboard.get + +**Read clipboard** + +Reads the text currently on the device clipboard + +#### Parameters + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `deviceId` | `string` | ✓ | ID of the target device | + +#### Response + +**Type:** `object` + +Clipboard contents + +#### Example Request + +```json +{ + "jsonrpc": "2.0", + "method": "device.clipboard.get", + "params": { + "deviceId": "string" + }, + "id": 1 +} +``` + + +### device.clipboard.set + +**Write clipboard** + +Replaces the text on the device clipboard, an empty string clears it + +#### Parameters + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `deviceId` | `string` | ✓ | ID of the target device | +| `text` | `string` | ✓ | Text to place on the clipboard | + +#### Response + +**Type:** [`SuccessResult`](#successresult) + +Operation result + +#### Example Request + +```json +{ + "jsonrpc": "2.0", + "method": "device.clipboard.set", + "params": { + "deviceId": "string", + "text": "string" + }, + "id": 1 +} +``` + + ### device.crashes.get **Get a crash report** diff --git a/server/dispatch.go b/server/dispatch.go index a22e2547..9ef7b765 100644 --- a/server/dispatch.go +++ b/server/dispatch.go @@ -23,6 +23,8 @@ func GetMethodRegistry() map[string]HandlerFunc { "device.io.keys": handleIoKeys, "device.io.button": handleIoButton, "device.io.swipe": handleIoSwipe, + "device.clipboard.get": handleClipboardGet, + "device.clipboard.set": handleClipboardSet, "device.io.gesture": handleIoGesture, "device.url": handleURL, "device.info": handleDeviceInfo, diff --git a/server/server.go b/server/server.go index 60797518..d7178b65 100644 --- a/server/server.go +++ b/server/server.go @@ -548,6 +548,54 @@ func handleIoSwipe(params json.RawMessage) (any, error) { return okResponse, nil } +type ClipboardGetParams struct { + DeviceID string `json:"deviceId"` +} + +type ClipboardSetParams struct { + DeviceID string `json:"deviceId"` + Text string `json:"text"` +} + +func handleClipboardGet(params json.RawMessage) (any, error) { + var clipboardParams ClipboardGetParams + if len(params) > 0 { + if err := json.Unmarshal(params, &clipboardParams); err != nil { + return nil, fmt.Errorf("invalid parameters: %w. Expected fields: deviceId", err) + } + } + + response := commands.ClipboardGetCommand(commands.ClipboardGetRequest{ + DeviceID: clipboardParams.DeviceID, + }) + if response.Status == "error" { + return nil, fmt.Errorf("%s", response.Error) + } + + return response.Data, nil +} + +func handleClipboardSet(params json.RawMessage) (any, error) { + if len(params) == 0 { + return nil, fmt.Errorf("'params' is required with fields: deviceId, text") + } + + var clipboardParams ClipboardSetParams + if err := json.Unmarshal(params, &clipboardParams); err != nil { + return nil, fmt.Errorf("invalid parameters: %w. Expected fields: deviceId, text", err) + } + + response := commands.ClipboardSetCommand(commands.ClipboardSetRequest{ + DeviceID: clipboardParams.DeviceID, + Text: clipboardParams.Text, + }) + if response.Status == "error" { + return nil, fmt.Errorf("%s", response.Error) + } + + return okResponse, nil +} + type IoTextParams struct { DeviceID string `json:"deviceId"` Text string `json:"text"` From b09ba738d83e5cfb2b370c3013bf9054b5e60365 Mon Sep 17 00:00:00 2001 From: Hakan Or Date: Mon, 24 Aug 2026 02:49:04 +0300 Subject: [PATCH 2/3] fix: move clipboard under io, require text on device.clipboard.set --- README.md | 6 ++++++ cli/clipboard.go | 2 +- cli/io.go | 2 +- cli/root.go | 6 ++++++ server/server.go | 10 +++++++--- skills/mobilecli/SKILL.md | 10 ++++++++++ 6 files changed, 31 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a1c49836..00cecf66 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,12 @@ mobilecli io button --device POWER # Send text mobilecli io text --device 'hello world' + +# Read device clipboard +mobilecli io clipboard get --device + +# Write device clipboard +mobilecli io clipboard set --device 'hello world' ``` ### Supported Hardware Buttons diff --git a/cli/clipboard.go b/cli/clipboard.go index d7464979..38e8d907 100644 --- a/cli/clipboard.go +++ b/cli/clipboard.go @@ -48,7 +48,7 @@ var clipboardSetCmd = &cobra.Command{ } func init() { - rootCmd.AddCommand(clipboardCmd) + ioCmd.AddCommand(clipboardCmd) clipboardCmd.AddCommand(clipboardGetCmd) clipboardCmd.AddCommand(clipboardSetCmd) diff --git a/cli/io.go b/cli/io.go index 9bb8ce6e..628b30e4 100644 --- a/cli/io.go +++ b/cli/io.go @@ -12,7 +12,7 @@ import ( var ioCmd = &cobra.Command{ Use: "io", Short: "Input/output operations with devices", - Long: `Perform input/output operations like tapping, pressing buttons, and sending text to devices.`, + Long: `Perform input/output operations like tapping, pressing buttons, sending text, and reading or writing the device clipboard.`, } var ioTapCmd = &cobra.Command{ diff --git a/cli/root.go b/cli/root.go index 03e03afb..46a7121a 100644 --- a/cli/root.go +++ b/cli/root.go @@ -91,6 +91,12 @@ INPUT/OUTPUT: # Send text input mobilecli io text --device "Hello World" + # Read device clipboard + mobilecli io clipboard get --device + + # Write device clipboard + mobilecli io clipboard set --device "Hello World" + WEBVIEW: # List embedded webviews in the foreground app mobilecli webview list --device diff --git a/server/server.go b/server/server.go index d7178b65..b862bf2d 100644 --- a/server/server.go +++ b/server/server.go @@ -553,8 +553,8 @@ type ClipboardGetParams struct { } type ClipboardSetParams struct { - DeviceID string `json:"deviceId"` - Text string `json:"text"` + DeviceID string `json:"deviceId"` + Text *string `json:"text"` } func handleClipboardGet(params json.RawMessage) (any, error) { @@ -585,9 +585,13 @@ func handleClipboardSet(params json.RawMessage) (any, error) { return nil, fmt.Errorf("invalid parameters: %w. Expected fields: deviceId, text", err) } + if clipboardParams.Text == nil { + return nil, fmt.Errorf("'text' is required") + } + response := commands.ClipboardSetCommand(commands.ClipboardSetRequest{ DeviceID: clipboardParams.DeviceID, - Text: clipboardParams.Text, + Text: *clipboardParams.Text, }) if response.Status == "error" { return nil, fmt.Errorf("%s", response.Error) diff --git a/skills/mobilecli/SKILL.md b/skills/mobilecli/SKILL.md index 11832212..d5f54953 100644 --- a/skills/mobilecli/SKILL.md +++ b/skills/mobilecli/SKILL.md @@ -104,6 +104,8 @@ Here is a quick reference table mapping standard user actions to their `mobilecl | **Swipe** | `mobilecli io swipe ` | `device.io.swipe` | Drag from start to end coordinates | | **Type Text** | `mobilecli io text ""` | `device.io.text` | Send raw text to the focused field | | **Key Press** | `mobilecli io button ` | `device.io.button` | Press hardware buttons (e.g. HOME, POWER) | +| **Read Clipboard** | `mobilecli io clipboard get` | `device.clipboard.get` | Read text from the device clipboard | +| **Write Clipboard** | `mobilecli io clipboard set ""` | `device.clipboard.set` | Replace text on the device clipboard | --- @@ -209,6 +211,14 @@ All commands support the global `--device ` flag to specify the target devic # DPAD_UP, DPAD_DOWN, DPAD_LEFT, DPAD_RIGHT, DPAD_CENTER mobilecli io button --device HOME ``` +* **Clipboard**: + ```bash + # Read the device clipboard + mobilecli io clipboard get --device + + # Write to the device clipboard + mobilecli io clipboard set --device "Hello World" + ``` ### 5. UI Inspection & Webviews * **Dump UI Tree**: From 4364f8cb7c397759074d4a0cb75101bbada232c7 Mon Sep 17 00:00:00 2001 From: Hakan Or Date: Mon, 24 Aug 2026 02:49:17 +0300 Subject: [PATCH 3/3] test: add simulator e2e coverage for io clipboard --- test/simulator.spec.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/test/simulator.spec.ts b/test/simulator.spec.ts index a320c754..1a54d31d 100644 --- a/test/simulator.spec.ts +++ b/test/simulator.spec.ts @@ -217,6 +217,28 @@ test.describe('iOS Simulator Tests', () => { verifySpringBoardIsForeground(foregroundAfterHome); }); + test('should set and read back clipboard text', async () => { + test.skip(!simulatorId, 'simulator not found'); + + const text = `mobilecli-${randomUUID()}`; + setClipboard(simulatorId, text); + expect(getClipboard(simulatorId)).toBe(text); + }); + + test('should clear the clipboard when set to an empty string', async () => { + test.skip(!simulatorId, 'simulator not found'); + + setClipboard(simulatorId, 'not empty'); + setClipboard(simulatorId, ''); + expect(getClipboard(simulatorId)).toBe(''); + }); + + test('should reject clipboard set without a text argument', async () => { + test.skip(!simulatorId, 'simulator not found'); + + expect(() => mobilecli(['io', 'clipboard', 'set', '--device', simulatorId])).toThrow(); + }); + test.skip('should test device lifecycle: boot, reboot, shutdown', async () => { // shutdown simulator using simctl to get it offline shutdownSimulator(simulatorId); @@ -550,6 +572,15 @@ function pressButton(simulatorId: string, button: string): void { mobilecli(['io', 'button', button, '--device', simulatorId]); } +function setClipboard(simulatorId: string, text: string): void { + mobilecli(['io', 'clipboard', 'set', text, '--device', simulatorId]); +} + +function getClipboard(simulatorId: string): string { + const response = mobilecli(['io', 'clipboard', 'get', '--device', simulatorId]); + return response.data.text; +} + function verifyElementExists(uiDump: UIDumpResponse, name: string): void { const elements = uiDump?.data?.elements;