Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 5 additions & 0 deletions .changeset/desktop-paste-image.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"aicodeman": patch
---

**Mac app: pasting an image works.** ⌘V with a copied picture in a session's terminal did nothing in the Mac app, while the same server worked in a browser. Codeman's paste asks the browser for the clipboard first, and inside the app WebKit answers that with a hidden "Paste" permission menu that held up the real paste. The app now declines that request the way Chrome does, so the picture uploads and its path lands in the prompt, the same as in a browser. Screenshots, images copied from an app, files copied in Finder and plain text all paste with one ⌘V, also from servers on older Codeman versions.
24 changes: 24 additions & 0 deletions packages/desktop/src/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ pub fn respond(request: Request<Vec<u8>>) -> Response<Cow<'static, [u8]>> {
/// server restarted, and nothing in the page asks for the login again.
/// - defines `__codemanDesktopRefused(id)` (not enumerable) for
/// [`refused_script`].
/// - answers `document.execCommand('paste')` with false, the way Chromium
/// does, without asking WebKit. Codeman's ⌘V handler calls it on its hidden
/// paste trap (`_handleImagePaste` in src/web/public/image-input.js). From a
/// WKWebView, WebKit answers that call by opening its "Paste" access menu at
/// the last click and holding the page until it is picked or the clipboard
/// changes, so the key's own paste, the one that carries a copied image, got
/// stuck behind it and a pasted picture never arrived. With this answer each
/// ⌘V delivers exactly one paste event to the trap, measured on macOS 27.2
/// for image data, a screenshot, a file copied in Finder, and text.
pub const HOST_SHIM: &str = r#"(function () {
'use strict';
if (!/^https?:$/.test(location.protocol) || window.CodemanHost) return;
Expand Down Expand Up @@ -113,6 +122,19 @@ pub const HOST_SHIM: &str = r#"(function () {
}
});

// Cmd+V: Codeman's paste trap calls document.execCommand('paste'). WebKit
// answers that from a WKWebView with its "Paste" access menu and holds the
// page until someone picks it, and the key's own paste (the one carrying a
// copied image) is stuck behind it. Refuse it as Chromium does; every other
// command reaches WebKit unchanged.
var nativeExec = typeof Document === 'function' ? Document.prototype.execCommand : null;
if (typeof nativeExec === 'function') {
Document.prototype.execCommand = function execCommand(command) {
if (typeof command === 'string' && command.toLowerCase() === 'paste') return false;
return nativeExec.apply(this, arguments);
};
}

var markDownload = function (ev) {
if (ev.button > 1) return;
var a = link(ev);
Expand Down Expand Up @@ -278,6 +300,8 @@ mod tests {
"'auxclick'",
"MAX_URL = 4096",
"typeof id === 'string' && id !== ''",
"Document.prototype.execCommand = function execCommand(command)",
"command.toLowerCase() === 'paste') return false",
] {
assert!(HOST_SHIM.contains(needle), "{needle}");
}
Expand Down
68 changes: 68 additions & 0 deletions test/desktop-host-shim-paste.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* @fileoverview The Mac app's host shim refuses `document.execCommand('paste')`.
*
* Codeman's Cmd+V handler (`_handleImagePaste`, src/web/public/image-input.js)
* calls `document.execCommand('paste')` on its hidden paste trap. In a
* WKWebView WebKit answers that call with its "Paste" access menu and holds the
* page until it is picked, so the key's own paste, which carries a copied
* image, never reached the trap. The shim the app injects into every server
* page (`HOST_SHIM`, packages/desktop/src/assets.rs) answers the command with
* false, as Chromium does, and leaves every other command to WebKit.
*
* Runs the shim's real text, cut out of assets.rs, in a vm with a stub
* Document, so a change to the Rust string is what these tests see.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';

const assetsRs = readFileSync(resolve(import.meta.dirname, '../packages/desktop/src/assets.rs'), 'utf8');
const shim = /pub const HOST_SHIM: &str = r#"([\s\S]*?)"#;/.exec(assetsRs)?.[1];

function load(protocol = 'https:') {
if (!shim) throw new Error('HOST_SHIM not found in assets.rs');
const nativeExec = vi.fn(function (this: unknown) {
return true;
});
function Document() {}
Document.prototype.execCommand = nativeExec;
const document = Object.create(Document.prototype) as { execCommand: (...args: unknown[]) => boolean };
const sandbox: Record<string, unknown> = {
Document,
document,
URL,
location: { protocol, href: `${protocol}//atlas.example/`, origin: `${protocol}//atlas.example` },
addEventListener: vi.fn(),
open: vi.fn(),
fetch: vi.fn(),
ipc: { postMessage: vi.fn() },
};
sandbox.window = sandbox;
vm.runInNewContext(shim, sandbox);
return { document, nativeExec, sandbox };
}

describe('desktop host shim: execCommand paste', () => {
it('answers paste with false and never asks WebKit', () => {
const { document, nativeExec } = load();
expect(document.execCommand('paste')).toBe(false);
expect(document.execCommand('Paste')).toBe(false);
expect(nativeExec).not.toHaveBeenCalled();
});

it('hands every other command to WebKit with its arguments and document', () => {
const { document, nativeExec } = load();
expect(document.execCommand('copy')).toBe(true);
expect(document.execCommand('insertText', false, 'hi')).toBe(true);
expect(nativeExec).toHaveBeenNthCalledWith(1, 'copy');
expect(nativeExec).toHaveBeenNthCalledWith(2, 'insertText', false, 'hi');
expect(nativeExec.mock.contexts[0]).toBe(document);
});

it('leaves a page that is not http(s) alone', () => {
const { document, nativeExec } = load('file:');
expect(document.execCommand('paste')).toBe(true);
expect(nativeExec).toHaveBeenCalledWith('paste');
});
});
Loading