Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions apps/desktop/src/main/services/ios/iosSimulatorService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2549,6 +2549,21 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) {
});
};

const attachToChatSession = (chatSessionId: string | null): IosSimulatorSession | null => {
if (!activeSession) return null;
if (
activeSession.chatSessionId
&& chatSessionId
&& activeSession.chatSessionId !== chatSessionId
) {
throw new IosSimulatorOwnedBySessionError(activeSession);
}
if (activeSession.chatSessionId === chatSessionId) return activeSession;
activeSession = { ...activeSession, chatSessionId };
emit({ type: "session-updated", session: activeSession });
return activeSession;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
};

const launch = async (launchArgs: IosSimulatorLaunchArgs = {}): Promise<IosSimulatorSession> => {
if (process.platform !== "darwin") {
throw new Error("iOS Simulator control is only available on macOS.");
Expand Down Expand Up @@ -2604,10 +2619,21 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) {

currentStep = "open-simulator";
emitLaunchProgress(launchId, "open-simulator", "running", "Preparing Simulator.app in the background...", null, { deviceUdid: device.udid });
const openArgs = launchArgs.keepSimulatorInBackground === false
? ["-a", "Simulator"]
: ["-g", "-a", "Simulator"];
const keepInBackground = launchArgs.keepSimulatorInBackground !== false;
const openArgs = keepInBackground
? ["-gj", "-a", "Simulator"]
: ["-a", "Simulator"];
spawn("open", openArgs, { detached: true, stdio: "ignore" }).unref();
if (keepInBackground) {
// `simctl boot`/`launch` can still steal focus on some macOS versions even
// with `open -gj`. Force-hide Simulator.app via System Events so the user
// never sees it pop in front of ADE during the launch sequence.
spawn(
"osascript",
["-e", 'tell application "System Events" to if exists process "Simulator" then set visible of process "Simulator" to false'],
{ detached: true, stdio: "ignore" },
).unref();
}
emitLaunchProgress(launchId, "open-simulator", "complete", "Simulator.app is ready in the background.", null, { deviceUdid: device.udid });

currentStep = "resolve-target";
Expand Down Expand Up @@ -2683,6 +2709,15 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) {
env: childEnv,
timeoutMs: 60_000,
});
if (launchArgs.keepSimulatorInBackground !== false) {
// simctl launch frequently activates Simulator.app β€” re-hide it so ADE
// stays in front for the remainder of the session.
spawn(
"osascript",
["-e", 'tell application "System Events" to if exists process "Simulator" then set visible of process "Simulator" to false'],
{ detached: true, stdio: "ignore" },
).unref();
}
emitLaunchProgress(launchId, "launch-app", "complete", "App launched.", bundleId, { deviceUdid: device.udid, targetId: target.target.id });
emitLaunchProgress(launchId, "ready", "complete", "iOS simulator drawer is ready.", device.name, { deviceUdid: device.udid, targetId: target.target.id });
emit({ type: "session-started", session });
Expand Down Expand Up @@ -3375,6 +3410,7 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) {
listDevices,
listLaunchTargets,
launch,
attachToChatSession,
shutdown,
screenshot,
getScreenSnapshot,
Expand Down
19 changes: 19 additions & 0 deletions apps/desktop/src/main/services/ipc/registerIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2112,6 +2112,22 @@ export function registerIpc({
clipboard.writeText(text);
});

ipcMain.handle(IPC.appReadClipboardImage, async (): Promise<{ data: string; filename: string; mimeType: string } | null> => {
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
const image = clipboard.readImage();
if (image.isEmpty()) return null;
const png = image.toPNG();
if (!png.byteLength) return null;
if (png.byteLength > MAX_ATTACHMENT_BYTES) {
throw new Error("Clipboard image must be 10 MB or smaller.");
}
return {
data: png.toString("base64"),
filename: "clipboard.png",
mimeType: "image/png",
};
});

ipcMain.handle(IPC.appGetImageDataUrl, async (_event, arg: { path: string }): Promise<{ dataUrl: string }> => {
const filePath = resolveAllowedRendererPath(arg?.path);
// Use async fs APIs and a size pre-check so a 10 MB image read never
Expand Down Expand Up @@ -5043,6 +5059,9 @@ export function registerIpc({

ipcMain.handle(IPC.iosSimulatorLaunch, async (_event, arg = {}) => ensureIosSimulator().launch(arg));

ipcMain.handle(IPC.iosSimulatorAttachToChatSession, async (_event, arg: { chatSessionId: string | null } = { chatSessionId: null }) =>
ensureIosSimulator().attachToChatSession(arg.chatSessionId ?? null));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

ipcMain.handle(IPC.iosSimulatorShutdown, async (_event, arg = {}) => ensureIosSimulator().shutdown(arg));

ipcMain.handle(IPC.iosSimulatorScreenshot, async (_event, arg = {}) => ensureIosSimulator().screenshot(arg));
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src/main/services/sync/syncHostService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ const DEFAULT_SYNC_HEARTBEAT_INTERVAL_MS = 30_000;
const DEFAULT_SYNC_POLL_INTERVAL_MS = 400;
const DEFAULT_BRAIN_STATUS_INTERVAL_MS = 5_000;
const DEFAULT_TERMINAL_SNAPSHOT_BYTES = 220_000;
const PEER_BACKPRESSURE_BYTES = 4 * 1024 * 1024;
const LANE_PRESENCE_TTL_MS = 60_000;
const SYNC_MDNS_SERVICE_TYPE = "ade-sync";
export const SYNC_TAILNET_DISCOVERY_SERVICE_NAME = "svc:ade-sync";
Expand Down Expand Up @@ -753,6 +754,13 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
const sentAt = nowIso();
for (const peer of peers) {
if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue;
if (isPeerBackpressured(peer)) {
args.logger.debug("sync_host.heartbeat_deferred_backpressure", {
peerDeviceId: peer.metadata?.deviceId ?? null,
bufferedAmount: peer.ws.bufferedAmount,
});
continue;
}
if (peer.awaitingHeartbeatAt) {
peer.missedHeartbeatCount += 1;
if (peer.missedHeartbeatCount >= 2) {
Expand Down Expand Up @@ -1096,9 +1104,14 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
};

function send<TPayload>(ws: WebSocket, type: SyncEnvelope["type"], payload: TPayload, requestId?: string | null): void {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes }));
}

function isPeerBackpressured(peer: PeerState): boolean {
return peer.ws.bufferedAmount >= PEER_BACKPRESSURE_BYTES;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Backpressure policy is not centralized, so ungated sends can still grow buffers.

send only checks OPEN; it does not enforce backpressure. Because many call sites still use send without isPeerBackpressured, buffered peers can continue accumulating queued bytes from those paths.

πŸ›‘οΈ Proposed fix (centralized enforcement with opt-out)
-function send<TPayload>(ws: WebSocket, type: SyncEnvelope["type"], payload: TPayload, requestId?: string | null): void {
-  if (ws.readyState !== WebSocket.OPEN) return;
-  ws.send(encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes }));
+function send<TPayload>(
+  peer: PeerState,
+  type: SyncEnvelope["type"],
+  payload: TPayload,
+  requestId?: string | null,
+  options?: { force?: boolean },
+): void {
+  if (peer.ws.readyState !== WebSocket.OPEN) return;
+  if (!options?.force && isPeerBackpressured(peer)) return;
+  peer.ws.send(encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes }));
 }
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/services/sync/syncHostService.ts` around lines 1106 -
1113, The send function currently only checks ws.readyState and allows callers
to bypass backpressure checks; change send to centrally enforce backpressure by
using isPeerBackpressured(peer) before sending: update send to accept a
PeerState (or bufferedAmount) and an optional force/allowBypass boolean, return
early when isPeerBackpressured(peer) is true unless force is true, and keep the
existing readiness check; update all call sites to pass the PeerState (or buffer
value) and only use force=true when an explicit bypass is intended. Ensure
references to send, isPeerBackpressured, PeerState, and PEER_BACKPRESSURE_BYTES
are used so callers can be updated consistently.


function sendAndWait<TPayload>(
ws: WebSocket,
type: SyncEnvelope["type"],
Expand Down Expand Up @@ -1421,6 +1434,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) {

for (const peer of peers) {
if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue;
if (isPeerBackpressured(peer)) continue;
for (const sessionId of peer.subscribedChatSessionIds) {
const session = args.sessionService.get(sessionId);
if (!session?.transcriptPath) continue;
Expand All @@ -1441,6 +1455,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
function broadcastChatEvent(event: AgentChatEventEnvelope): void {
for (const peer of peers) {
if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue;
if (isPeerBackpressured(peer)) continue;
if (!peer.subscribedChatSessionIds.has(event.sessionId)) continue;
if (!rememberChatEventSent(peer, event)) continue;
send(peer.ws, "chat_event", event);
Expand All @@ -1452,6 +1467,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
const currentDbVersion = args.db.sync.getDbVersion();
for (const peer of peers) {
if (!peer.authenticated || !peer.metadata || peer.ws.readyState !== WebSocket.OPEN) continue;
if (isPeerBackpressured(peer)) continue;
if (currentDbVersion <= peer.lastKnownServerDbVersion) continue;
const changes = args.db.sync
.exportChangesSince(peer.lastKnownServerDbVersion)
Expand Down Expand Up @@ -2394,6 +2410,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
};
for (const peer of peers) {
if (!peer.authenticated || !peer.subscribedSessionIds.has(event.sessionId) || peer.ws.readyState !== WebSocket.OPEN) continue;
if (isPeerBackpressured(peer)) continue;
send(peer.ws, "terminal_data", payload);
}
},
Expand All @@ -2407,6 +2424,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
};
for (const peer of peers) {
if (!peer.authenticated || !peer.subscribedSessionIds.has(event.sessionId) || peer.ws.readyState !== WebSocket.OPEN) continue;
if (isPeerBackpressured(peer)) continue;
send(peer.ws, "terminal_exit", payload);
}
},
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/preload/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,7 @@ declare global {
revealPath: (path: string) => Promise<void>;
openPath: (path: string) => Promise<void>;
writeClipboardText: (text: string) => Promise<void>;
readClipboardImage: () => Promise<{ data: string; filename: string; mimeType: string } | null>;
getImageDataUrl: (path: string) => Promise<{ dataUrl: string }>;
writeClipboardImage: (path: string) => Promise<void>;
openPathInEditor: (args: {
Expand Down Expand Up @@ -1265,6 +1266,7 @@ declare global {
listDevices: () => Promise<IosSimulatorDevice[]>;
listLaunchTargets: (args?: IosSimulatorListLaunchTargetsArgs) => Promise<IosSimulatorLaunchTarget[]>;
launch: (args?: IosSimulatorLaunchArgs) => Promise<IosSimulatorSession>;
attachToChatSession: (args: { chatSessionId: string | null }) => Promise<IosSimulatorSession | null>;
shutdown: (args?: IosSimulatorShutdownArgs) => Promise<IosSimulatorShutdownResult>;
screenshot: (args?: { deviceUdid?: string | null }) => Promise<IosSimulatorScreenshot>;
getScreenSnapshot: (args?: IosScreenSnapshotArgs) => Promise<IosScreenSnapshot>;
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,8 @@ contextBridge.exposeInMainWorld("ade", {
ipcRenderer.invoke(IPC.appOpenPath, { path }),
writeClipboardText: async (text: string): Promise<void> =>
ipcRenderer.invoke(IPC.appWriteClipboardText, { text }),
readClipboardImage: async (): Promise<{ data: string; filename: string; mimeType: string } | null> =>
ipcRenderer.invoke(IPC.appReadClipboardImage),
getImageDataUrl: async (path: string): Promise<{ dataUrl: string }> =>
ipcRenderer.invoke(IPC.appGetImageDataUrl, { path }),
writeClipboardImage: async (path: string): Promise<void> =>
Expand Down Expand Up @@ -1798,6 +1800,8 @@ contextBridge.exposeInMainWorld("ade", {
ipcRenderer.invoke(IPC.iosSimulatorListLaunchTargets, args),
launch: async (args: IosSimulatorLaunchArgs = {}): Promise<IosSimulatorSession> =>
ipcRenderer.invoke(IPC.iosSimulatorLaunch, args),
attachToChatSession: async (args: { chatSessionId: string | null }): Promise<IosSimulatorSession | null> =>
ipcRenderer.invoke(IPC.iosSimulatorAttachToChatSession, args),
shutdown: async (args: IosSimulatorShutdownArgs = {}): Promise<IosSimulatorShutdownResult> =>
ipcRenderer.invoke(IPC.iosSimulatorShutdown, args),
screenshot: async (args: { deviceUdid?: string | null } = {}): Promise<IosSimulatorScreenshot> =>
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/browserMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2638,6 +2638,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) {
openExternal: resolvedArg(undefined),
revealPath: resolvedArg(undefined),
writeClipboardText: resolvedArg(undefined),
readClipboardImage: resolved(null),
getImageDataUrl: resolvedArg({ dataUrl: "" }),
writeClipboardImage: resolvedArg(undefined),
openPath: resolvedArg(undefined),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* @vitest-environment jsdom */

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, type RenderResult } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor, type RenderResult } from "@testing-library/react";
import type { ComponentProps } from "react";
import { AgentChatComposer } from "./AgentChatComposer";

Expand Down Expand Up @@ -63,7 +63,10 @@ beforeEach(() => {
installMatchMediaMock();
});

afterEach(cleanup);
afterEach(() => {
cleanup();
delete (window as any).ade;
});

function buildComposerProps(overrides: Partial<ComponentProps<typeof AgentChatComposer>> = {}) {
const props: ComponentProps<typeof AgentChatComposer> = {
Expand Down Expand Up @@ -478,6 +481,51 @@ describe("AgentChatComposer", () => {
expect((screen.getByLabelText("Upload file from disk") as HTMLButtonElement).disabled).toBe(false);
});

it("attaches a native clipboard image when macOS Cmd+V does not expose paste files", async () => {
const originalPlatform = navigator.platform;
Object.defineProperty(navigator, "platform", {
configurable: true,
value: "MacIntel",
});
const readClipboardImage = vi.fn().mockResolvedValue({
data: "abc123",
filename: "clipboard.png",
mimeType: "image/png",
});
const saveTempAttachment = vi.fn().mockResolvedValue({ path: "/tmp/ade-clipboard.png" });
(window as any).ade = {
app: { readClipboardImage },
agentChat: { saveTempAttachment },
};

try {
const props = renderComposer({
turnActive: false,
draft: "",
});

fireEvent.keyDown(screen.getByPlaceholderText("Type to vibecode..."), {
key: "v",
metaKey: true,
});

await waitFor(() => expect(readClipboardImage).toHaveBeenCalledTimes(1));
expect(saveTempAttachment).toHaveBeenCalledWith({
data: "abc123",
filename: "clipboard.png",
});
expect(props.onAddAttachment).toHaveBeenCalledWith({
path: "/tmp/ade-clipboard.png",
type: "image",
});
} finally {
Object.defineProperty(navigator, "platform", {
configurable: true,
value: originalPlatform,
});
}
});

it("hides native permission controls until a model is selected", () => {
const props = buildComposerProps({
modelId: "",
Expand Down
Loading
Loading