Skip to content

Commit

Permalink
Add bidirectional clipboard for iOS Simulator, support multiline past…
Browse files Browse the repository at this point in the history
…e, show toast when copying/pasting (#936)

This PR introduces bidirectional clipboard support for iOS Simulator.
The copying behaviour is similar to how cmd+v works, but instead of
transferring hosts' clipboard content to the simulator, cmd+c transfers
the simulator's clipboard to host's.

Apart from that, this PR prevents typing 'c' and 'v' when holding cmd
and fixes multiline pastes (previously, newlines were recognized as
starting separate commands, resulting in `unrecognized command` errors
from simserver). It also adds a toast notifying user when
copying/pasting is performed.


https://github.com/user-attachments/assets/c4014a7d-6023-4b30-b3ed-32c353fade02

Fixes #160, #701

### How Has This Been Tested: 

Verified that:
- command+C copies the Simulator's clipboard content to host
- command+V pastes the host's clipboard content to the Simulator
- command+C is ignored for Android Emulator (it automatically sends its
clipboard to host whenever text is copied)
- command+V pastes the hosts' clipboard content to Android Emulator's
clipboard
- typing 'c' and 'v' into Simulator/Emulator is prevented when holding
'command'
- multiline paste works properly
- toast is shown on cmd+c, cmd+v
  • Loading branch information
balins authored Feb 12, 2025
1 parent c502241 commit b039298
Show file tree
Hide file tree
Showing 11 changed files with 100 additions and 49 deletions.
2 changes: 1 addition & 1 deletion packages/simulator-server
1 change: 1 addition & 0 deletions packages/vscode-extension/src/common/Project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ export interface ProjectInterface {
dispatchTouches(touches: Array<TouchPoint>, type: "Up" | "Move" | "Down"): Promise<void>;
dispatchKeyPress(keyCode: number, direction: "Up" | "Down"): Promise<void>;
dispatchPaste(text: string): Promise<void>;
dispatchCopy(): Promise<void>;
inspectElementAt(
xRatio: number,
yRatio: number,
Expand Down
2 changes: 2 additions & 0 deletions packages/vscode-extension/src/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,6 @@ export interface UtilsInterface {
eventType: K,
listener: UtilsEventListener<UtilsEventMap[K]>
): Promise<void>;

showToast(message: string, timeout: number): Promise<void>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,11 @@ export class AndroidEmulatorDevice extends DeviceBase {
}

async sendBiometricAuthorization(isMatch: boolean) {
// TO DO: implement android biometric authorization
// TODO: implement android biometric authorization
}

async getClipboard() {
// No need to copy clipboard, Android Emulator syncs it for us whenever a user clicks on 'Copy'
}
}

Expand Down
40 changes: 37 additions & 3 deletions packages/vscode-extension/src/devices/DeviceBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,24 @@ import { AppPermissionType, DeviceSettings, TouchPoint } from "../common/Project
import { DeviceInfo, DevicePlatform } from "../common/DeviceManager";
import { tryAcquiringLock } from "../utilities/common";

const LEFT_META_HID_CODE = 0xe3;
const RIGHT_META_HID_CODE = 0xe7;
const V_KEY_HID_CODE = 0x19;
const C_KEY_HID_CODE = 0x06;

export abstract class DeviceBase implements Disposable {
protected preview: Preview | undefined;
private previewStartPromise: Promise<string> | undefined;
private acquired = false;
private pressingLeftMetaKey = false;
private pressingRightMetaKey = false;

abstract get lockFilePath(): string;

abstract bootDevice(deviceSettings: DeviceSettings): Promise<void>;
abstract changeSettings(settings: DeviceSettings): Promise<boolean>;
abstract sendBiometricAuthorization(isMatch: boolean): Promise<void>;
abstract getClipboard(): Promise<string | void>;
abstract installApp(build: BuildResult, forceReinstall: boolean): Promise<void>;
abstract launchApp(build: BuildResult, metroPort: number, devtoolsPort: number): Promise<void>;
abstract makePreview(): Preview;
Expand Down Expand Up @@ -96,11 +104,37 @@ export abstract class DeviceBase implements Disposable {
}

public sendKey(keyCode: number, direction: "Up" | "Down") {
this.preview?.sendKey(keyCode, direction);
// iOS simulator has a buggy behavior when sending cmd+V key combination.
// It sometimes triggers paste action but with a very low success rate.
// Other times it kicks in before the pasteboard is filled with the content
// therefore pasting the previously copied content instead.
// As a temporary workaround, we disable sending cmd+V as key combination
// entirely to prevent this buggy behavior. Users can still paste content
// using the context menu method as they'd do on an iOS device.
// This is not an ideal workaround as people may still trigger cmd+v by
// pressing V first and then cmd, but it is good enough to filter out
// the majority of the noisy behavior since typically you press cmd first.
// Similarly, when pasting into Android Emulator, cmd+V has results in a
// side effect of typing the letter "v" into the text field (the same
// applies to cmd+C).
if (keyCode === LEFT_META_HID_CODE) {
this.pressingLeftMetaKey = direction === "Down";
} else if (keyCode === RIGHT_META_HID_CODE) {
this.pressingRightMetaKey = direction === "Down";
}

if (
(this.pressingLeftMetaKey || this.pressingRightMetaKey) &&
(keyCode === C_KEY_HID_CODE || keyCode === V_KEY_HID_CODE)
) {
// ignore sending C and V when meta key is pressed
} else {
this.preview?.sendKey(keyCode, direction);
}
}

public async sendPaste(text: string) {
return this.preview?.sendPaste(text);
public async sendClipboard(text: string) {
return this.preview?.sendClipboard(text);
}

async startPreview() {
Expand Down
44 changes: 10 additions & 34 deletions packages/vscode-extension/src/devices/IosSimulatorDevice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,6 @@ import { AppPermissionType, DeviceSettings, Locale } from "../common/Project";
import { EXPO_GO_BUNDLE_ID, fetchExpoLaunchDeeplink } from "../builders/expoGo";
import { IOSBuildResult } from "../builders/buildIOS";

const LEFT_META_HID_CODE = 0xe3;
const RIGHT_META_HID_CODE = 0xe7;
const V_KEY_HID_CODE = 0x19;

interface SimulatorInfo {
availability?: string;
state?: string;
Expand Down Expand Up @@ -218,44 +214,24 @@ export class IosSimulatorDevice extends DeviceBase {
]);
}

private pressingLeftMetaKey = false;
private pressingRightMetaKey = false;

public sendKey(keyCode: number, direction: "Up" | "Down"): void {
// iOS simulator has a buggy behavior when sending cmd+V key combination.
// It sometimes triggers paste action but with a very low success rate.
// Other times it kicks in before the pasteboard is filled with the content
// therefore pasting the previously compied content instead.
// As a temporary workaround, we disable sending cmd+V as key combination
// entirely to prevent this buggy behavior. Users can still paste content
// using the context menu method as they'd do on an iOS device.
// This is not an ideal workaround as people may still trigger cmd+v by
// pressing V first and then cmd, but it is good enough to filter out
// the majority of the noisy behavior since typically you press cmd first.
if (keyCode === LEFT_META_HID_CODE) {
this.pressingLeftMetaKey = direction === "Down";
} else if (keyCode === RIGHT_META_HID_CODE) {
this.pressingRightMetaKey = direction === "Down";
}
if ((this.pressingLeftMetaKey || this.pressingRightMetaKey) && keyCode === V_KEY_HID_CODE) {
// ignore sending V when meta key is pressed
} else {
this.preview?.sendKey(keyCode, direction);
}
public async sendClipboard(text: string) {
const deviceSetLocation = getOrCreateDeviceSet(this.deviceUDID);
await exec("xcrun", ["simctl", "--set", deviceSetLocation, "pbcopy", this.deviceUDID], {
input: text,
});
}

public async sendPaste(text: string) {
public async getClipboard() {
const deviceSetLocation = getOrCreateDeviceSet(this.deviceUDID);
const subprocess = exec("xcrun", [
const { stdout } = await exec("xcrun", [
"simctl",
"--set",
deviceSetLocation,
"pbcopy",
"pbpaste",
this.deviceUDID,
]);
subprocess.stdin?.write(text);
subprocess.stdin?.end();
await subprocess;

return stdout;
}

private async changeLocale(newLocale: Locale): Promise<boolean> {
Expand Down
5 changes: 3 additions & 2 deletions packages/vscode-extension/src/devices/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,8 @@ export class Preview implements Disposable {
this.subprocess?.stdin?.write(`key ${direction} ${keyCode}\n`);
}

public async sendPaste(text: string) {
this.subprocess?.stdin?.write(`paste ${text}\n`);
public sendClipboard(text: string) {
// We use markers for start and end of the paste to handle multi-line pastes
this.subprocess?.stdin?.write(`paste START-SIMSERVER-PASTE>>>${text}<<<END-SIMSERVER-PASTE\n`);
}
}
8 changes: 6 additions & 2 deletions packages/vscode-extension/src/project/deviceSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,8 +310,12 @@ export class DeviceSession implements Disposable {
this.device.sendKey(keyCode, direction);
}

public sendPaste(text: string) {
return this.device.sendPaste(text);
public sendClipboard(text: string) {
return this.device.sendClipboard(text);
}

public async getClipboard() {
return this.device.getClipboard();
}

public inspectElementAt(
Expand Down
14 changes: 12 additions & 2 deletions packages/vscode-extension/src/project/project.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { EventEmitter } from "stream";
import os from "os";
import { Disposable, commands, workspace, window, DebugSessionCustomEvent } from "vscode";
import { env, Disposable, commands, workspace, window, DebugSessionCustomEvent } from "vscode";
import _ from "lodash";
import stripAnsi from "strip-ansi";
import { minimatch } from "minimatch";
Expand Down Expand Up @@ -253,7 +253,17 @@ export class Project
//#endregion

async dispatchPaste(text: string) {
await this.deviceSession?.sendPaste(text);
await this.deviceSession?.sendClipboard(text);
await this.utils.showToast("Pasted to device clipboard", 2000);
}

async dispatchCopy() {
const text = await this.deviceSession?.getClipboard();
if (text) {
env.clipboard.writeText(text);
}
// For consistency between iOS and Android, we always display toast message
await this.utils.showToast("Copied from device clipboard", 2000);
}

onBundleError(): void {
Expand Down
16 changes: 15 additions & 1 deletion packages/vscode-extension/src/utilities/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { homedir } from "node:os";
import { EventEmitter } from "stream";
import fs from "fs";
import path from "path";
import { commands, env, Uri, window } from "vscode";
import { commands, env, ProgressLocation, Uri, window } from "vscode";
import JSON5 from "json5";
import vscode from "vscode";
import { TelemetryEventProperties } from "@vscode/extension-telemetry";
Expand Down Expand Up @@ -174,4 +174,18 @@ export class Utils implements UtilsInterface {
) {
this.eventEmitter.removeListener(eventType, listener);
}

async showToast(message: string, timeout: number) {
// VSCode doesn't support auto hiding notifications, so we use a workaround with progress
await window.withProgress(
{
location: ProgressLocation.Notification,
cancellable: false,
},
async (progress) => {
progress.report({ message, increment: 100 });
await new Promise((resolve) => setTimeout(resolve, timeout));
}
);
}
}
11 changes: 8 additions & 3 deletions packages/vscode-extension/src/webview/components/Preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -347,19 +347,24 @@ function Preview({
}, []);

useEffect(() => {
function dispatchPaste(e: ClipboardEvent) {
function synchronizeClipboard(e: ClipboardEvent) {
if (document.activeElement === wrapperDivRef.current) {
e.preventDefault();

const text = e.clipboardData?.getData("text");
if (text) {
project.dispatchPaste(text);
} else {
project.dispatchCopy();
}
}
}
addEventListener("paste", dispatchPaste);

addEventListener("paste", synchronizeClipboard);
addEventListener("copy", synchronizeClipboard);
return () => {
removeEventListener("paste", dispatchPaste);
removeEventListener("paste", synchronizeClipboard);
removeEventListener("copy", synchronizeClipboard);
};
}, [project]);

Expand Down

0 comments on commit b039298

Please sign in to comment.