mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(coding-agent,tui): paste Finder file paths instead of icons (#10136)
Read Finder file URLs before icon image data on macOS. Quote paths in bash mode and reject terminal control characters before rendering. Fixes #9999 Co-authored-by: 1dustycy <onedustycy@gmail.com>
This commit is contained in:
parent
3883fcb1f3
commit
fd889a2741
14 changed files with 272 additions and 14 deletions
|
|
@ -125,7 +125,7 @@ In fullscreen mode, these actions control the transcript and take precedence ove
|
||||||
| `app.exit` | `ctrl+d` | Exit (when editor empty) |
|
| `app.exit` | `ctrl+d` | Exit (when editor empty) |
|
||||||
| `app.suspend` | `ctrl+z` (None on Windows) | Suspend to background |
|
| `app.suspend` | `ctrl+z` (None on Windows) | Suspend to background |
|
||||||
| `app.editor.external` | `ctrl+g` | Open in external editor (`externalEditor`, `$VISUAL`, `$EDITOR`, Notepad on Windows, or `nano` elsewhere) |
|
| `app.editor.external` | `ctrl+g` | Open in external editor (`externalEditor`, `$VISUAL`, `$EDITOR`, Notepad on Windows, or `nano` elsewhere) |
|
||||||
| `app.clipboard.pasteImage` | `ctrl+v` (`alt+v` on Windows and WSL) | Paste image or text from clipboard |
|
| `app.clipboard.pasteImage` | `ctrl+v` (`alt+v` on Windows and WSL) | Paste files on macOS, images, or text from clipboard |
|
||||||
|
|
||||||
On native Windows, `app.suspend` has no default because Windows terminals do not support Unix job control. If you assign it manually, Pi shows a status message instead of suspending. WSL uses the normal `ctrl+z` and `fg` behavior.
|
On native Windows, `app.suspend` has no default because Windows terminals do not support Unix job control. If you assign it manually, Pi shows a status message instead of suspending. WSL uses the normal `ctrl+z` and `fg` behavior.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,7 @@ export const KEYBINDINGS = {
|
||||||
},
|
},
|
||||||
"app.clipboard.pasteImage": {
|
"app.clipboard.pasteImage": {
|
||||||
defaultKeys: windowsKeybindings ? "alt+v" : "ctrl+v",
|
defaultKeys: windowsKeybindings ? "alt+v" : "ctrl+v",
|
||||||
description: "Paste image from clipboard (text fallback)",
|
description: "Paste files on macOS, images, or text from clipboard",
|
||||||
},
|
},
|
||||||
"app.session.new": { defaultKeys: [], description: "Start a new session" },
|
"app.session.new": { defaultKeys: [], description: "Start a new session" },
|
||||||
"app.session.tree": { defaultKeys: [], description: "Open session tree" },
|
"app.session.tree": { defaultKeys: [], description: "Open session tree" },
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ import type { TruncationResult } from "../../core/tools/truncate.ts";
|
||||||
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts";
|
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts";
|
||||||
import { getUsageCostBreakdown } from "../../core/usage-totals.ts";
|
import { getUsageCostBreakdown } from "../../core/usage-totals.ts";
|
||||||
import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts";
|
import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts";
|
||||||
import { copyToClipboard, readClipboardText } from "../../utils/clipboard.ts";
|
import { copyToClipboard, readClipboardFilePaths, readClipboardText } from "../../utils/clipboard.ts";
|
||||||
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
|
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
|
||||||
import { parseGitUrl } from "../../utils/git.ts";
|
import { parseGitUrl } from "../../utils/git.ts";
|
||||||
import { getCwdRelativePath } from "../../utils/paths.ts";
|
import { getCwdRelativePath } from "../../utils/paths.ts";
|
||||||
|
|
@ -200,6 +200,10 @@ interface Expandable {
|
||||||
setExpanded(expanded: boolean): void;
|
setExpanded(expanded: boolean): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ActiveEditor extends EditorComponent {
|
||||||
|
getCursor?(): { line: number; col: number };
|
||||||
|
}
|
||||||
|
|
||||||
interface WorkingStatusEditor extends EditorComponent {
|
interface WorkingStatusEditor extends EditorComponent {
|
||||||
readonly embedWorkingStatus: boolean;
|
readonly embedWorkingStatus: boolean;
|
||||||
setWorkingStatusIndicator(indicator: StatusIndicator | undefined): void;
|
setWorkingStatusIndicator(indicator: StatusIndicator | undefined): void;
|
||||||
|
|
@ -428,7 +432,7 @@ export class InteractiveMode {
|
||||||
private pendingMessagesContainer: Container;
|
private pendingMessagesContainer: Container;
|
||||||
private statusContainer: Container;
|
private statusContainer: Container;
|
||||||
private defaultEditor: CustomEditor;
|
private defaultEditor: CustomEditor;
|
||||||
private editor: EditorComponent;
|
private editor: ActiveEditor;
|
||||||
private editorComponentFactory: EditorFactory | undefined;
|
private editorComponentFactory: EditorFactory | undefined;
|
||||||
private autocompleteProvider: AutocompleteProvider | undefined;
|
private autocompleteProvider: AutocompleteProvider | undefined;
|
||||||
private autocompleteProviderWrappers: AutocompleteProviderFactory[] = [];
|
private autocompleteProviderWrappers: AutocompleteProviderFactory[] = [];
|
||||||
|
|
@ -994,7 +998,7 @@ export class InteractiveMode {
|
||||||
rawKeyHint("!!", "to run bash (no context)"),
|
rawKeyHint("!!", "to run bash (no context)"),
|
||||||
hint("app.message.followUp", "to queue follow-up"),
|
hint("app.message.followUp", "to queue follow-up"),
|
||||||
hint("app.message.dequeue", "to edit all queued messages"),
|
hint("app.message.dequeue", "to edit all queued messages"),
|
||||||
hint("app.clipboard.pasteImage", "to paste image (with text fallback)"),
|
hint("app.clipboard.pasteImage", "to paste files on macOS, images, or text"),
|
||||||
rawKeyHint("drop files", "to attach"),
|
rawKeyHint("drop files", "to attach"),
|
||||||
].join("\n");
|
].join("\n");
|
||||||
const compactInstructions = () =>
|
const compactInstructions = () =>
|
||||||
|
|
@ -3017,8 +3021,8 @@ export class InteractiveMode {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle clipboard paste (triggered on Ctrl+V). Images are attached by path;
|
// Handle clipboard paste (triggered on Ctrl+V). Copied files use their original paths,
|
||||||
// otherwise, paste plain text from the system clipboard.
|
// images are attached via temporary files, and plain text is the final fallback.
|
||||||
this.defaultEditor.onPasteImage = () => {
|
this.defaultEditor.onPasteImage = () => {
|
||||||
void this.handleClipboardPaste();
|
void this.handleClipboardPaste();
|
||||||
};
|
};
|
||||||
|
|
@ -3040,6 +3044,23 @@ export class InteractiveMode {
|
||||||
|
|
||||||
private async handleClipboardPaste(): Promise<void> {
|
private async handleClipboardPaste(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
const filePaths = await readClipboardFilePaths();
|
||||||
|
if (filePaths) {
|
||||||
|
if (filePaths.some((filePath) => /\p{Cc}/u.test(filePath))) {
|
||||||
|
throw new Error("Clipboard file path contains control characters");
|
||||||
|
}
|
||||||
|
const paths = this.isBashMode ? filePaths.map(quoteIfNeeded).join(" ") : filePaths.join("\n");
|
||||||
|
const cursor = this.editor.getCursor?.();
|
||||||
|
const currentLine = cursor ? (this.editor.getText().split("\n")[cursor.line] ?? "") : "";
|
||||||
|
const characterBeforeCursor = cursor && cursor.col > 0 ? currentLine[cursor.col - 1] : "";
|
||||||
|
const characterAfterCursor = cursor ? currentLine[cursor.col] : "";
|
||||||
|
const leadingSpace = characterBeforeCursor && !/\s/.test(characterBeforeCursor) ? " " : "";
|
||||||
|
const trailingSpace = characterAfterCursor && !/\s/.test(characterAfterCursor) ? " " : "";
|
||||||
|
this.editor.insertTextAtCursor?.(`${leadingSpace}${paths}${trailingSpace}`);
|
||||||
|
this.ui.requestRender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const image = await readClipboardImage();
|
const image = await readClipboardImage();
|
||||||
if (image) {
|
if (image) {
|
||||||
const tmpDir = os.tmpdir();
|
const tmpDir = os.tmpdir();
|
||||||
|
|
@ -3058,8 +3079,8 @@ export class InteractiveMode {
|
||||||
this.editor.insertTextAtCursor?.(text);
|
this.editor.insertTextAtCursor?.(text);
|
||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
// Silently ignore clipboard errors (may not have permission, etc.)
|
this.showError(`Failed to paste from clipboard: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6636,7 +6657,7 @@ export class InteractiveMode {
|
||||||
| \`${copyMessage}\` | Copy selection or last assistant message |
|
| \`${copyMessage}\` | Copy selection or last assistant message |
|
||||||
| \`${followUp}\` | Queue follow-up message |
|
| \`${followUp}\` | Queue follow-up message |
|
||||||
| \`${dequeue}\` | Restore queued messages |
|
| \`${dequeue}\` | Restore queued messages |
|
||||||
| \`${pasteImage}\` | Paste image or text from clipboard |
|
| \`${pasteImage}\` | Paste files on macOS, images, or text from clipboard |
|
||||||
| \`/\` | Slash commands |
|
| \`/\` | Slash commands |
|
||||||
| \`!\` | Run bash command |
|
| \`!\` | Run bash command |
|
||||||
| \`!!\` | Run bash command (excluded from context) |
|
| \`!!\` | Run bash command (excluded from context) |
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,12 @@ export async function readClipboardText(): Promise<string | null> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read file paths, such as Finder file copies, from the native clipboard. */
|
||||||
|
export async function readClipboardFilePaths(): Promise<string[] | null> {
|
||||||
|
const paths = await getNativeClipboard()?.getFilePaths?.();
|
||||||
|
return paths?.length ? paths : null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function copyToClipboard(text: string): Promise<void> {
|
export async function copyToClipboard(text: string): Promise<void> {
|
||||||
const p = platform();
|
const p = platform();
|
||||||
const env = process.env;
|
const env = process.env;
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,11 @@ vi.mock("@earendil-works/pi-tui", async (importOriginal) => ({
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
vi.mock("../src/utils/clipboard.ts", () => ({ copyToClipboard: vi.fn(), readClipboardText }));
|
vi.mock("../src/utils/clipboard.ts", () => ({
|
||||||
|
copyToClipboard: vi.fn(),
|
||||||
|
readClipboardFilePaths: vi.fn(async () => null),
|
||||||
|
readClipboardText,
|
||||||
|
}));
|
||||||
vi.mock("../src/utils/clipboard-command.ts", () => ({ runClipboardCommand: vi.fn(async () => undefined) }));
|
vi.mock("../src/utils/clipboard-command.ts", () => ({ runClipboardCommand: vi.fn(async () => undefined) }));
|
||||||
afterEach(() => vi.unstubAllEnvs());
|
afterEach(() => vi.unstubAllEnvs());
|
||||||
|
|
||||||
|
|
@ -19,6 +23,7 @@ test("native image errors abort paste without reading text or changing the edito
|
||||||
vi.stubEnv("TERMUX_VERSION", "");
|
vi.stubEnv("TERMUX_VERSION", "");
|
||||||
const context = {
|
const context = {
|
||||||
editor: { insertTextAtCursor: vi.fn() },
|
editor: { insertTextAtCursor: vi.fn() },
|
||||||
|
showError: vi.fn(),
|
||||||
ui: { requestRender: vi.fn() },
|
ui: { requestRender: vi.fn() },
|
||||||
};
|
};
|
||||||
const prototype = InteractiveMode.prototype as unknown as {
|
const prototype = InteractiveMode.prototype as unknown as {
|
||||||
|
|
@ -28,4 +33,7 @@ test("native image errors abort paste without reading text or changing the edito
|
||||||
expect(readClipboardText).not.toHaveBeenCalled();
|
expect(readClipboardText).not.toHaveBeenCalled();
|
||||||
expect(context.editor.insertTextAtCursor).not.toHaveBeenCalled();
|
expect(context.editor.insertTextAtCursor).not.toHaveBeenCalled();
|
||||||
expect(context.ui.requestRender).not.toHaveBeenCalled();
|
expect(context.ui.requestRender).not.toHaveBeenCalled();
|
||||||
|
expect(context.showError).toHaveBeenCalledExactlyOnceWith(
|
||||||
|
"Failed to paste from clipboard: Native clipboard operation failed",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
141
packages/coding-agent/test/clipboard-paste-file-paths.test.ts
Normal file
141
packages/coding-agent/test/clipboard-paste-file-paths.test.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import { beforeEach, expect, test, vi } from "vitest";
|
||||||
|
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
readClipboardFilePaths: vi.fn<() => Promise<string[] | null>>(),
|
||||||
|
readClipboardImage: vi.fn<() => Promise<{ bytes: Uint8Array; mimeType: string } | null>>(),
|
||||||
|
readClipboardText: vi.fn<() => Promise<string | null>>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../src/utils/clipboard.ts", () => ({
|
||||||
|
copyToClipboard: vi.fn(),
|
||||||
|
readClipboardFilePaths: mocks.readClipboardFilePaths,
|
||||||
|
readClipboardText: mocks.readClipboardText,
|
||||||
|
}));
|
||||||
|
vi.mock("../src/utils/clipboard-image.ts", () => ({
|
||||||
|
extensionForImageMimeType: () => "png",
|
||||||
|
readClipboardImage: mocks.readClipboardImage,
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => vi.resetAllMocks());
|
||||||
|
|
||||||
|
test("Finder file paths take precedence over their icon image", async () => {
|
||||||
|
// Regression test for #9999.
|
||||||
|
const filePaths = ["/tmp/screenshot.png", "/tmp/My Photos/photo.png"];
|
||||||
|
mocks.readClipboardFilePaths.mockResolvedValue(filePaths);
|
||||||
|
mocks.readClipboardImage.mockResolvedValue({
|
||||||
|
bytes: new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
|
||||||
|
mimeType: "image/png",
|
||||||
|
});
|
||||||
|
mocks.readClipboardText.mockResolvedValue(null);
|
||||||
|
const insertTextAtCursor = vi.fn<(text: string) => void>();
|
||||||
|
const context = {
|
||||||
|
editor: { insertTextAtCursor },
|
||||||
|
showError: vi.fn(),
|
||||||
|
ui: { requestRender: vi.fn() },
|
||||||
|
};
|
||||||
|
const prototype = InteractiveMode.prototype as unknown as {
|
||||||
|
handleClipboardPaste(this: typeof context): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
await prototype.handleClipboardPaste.call(context);
|
||||||
|
expect(insertTextAtCursor).toHaveBeenCalledExactlyOnceWith(filePaths.join("\n"));
|
||||||
|
expect(mocks.readClipboardImage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clipboard file paths containing terminal control characters are rejected", async () => {
|
||||||
|
const insertTextAtCursor = vi.fn<(text: string) => void>();
|
||||||
|
const showError = vi.fn<(message: string) => void>();
|
||||||
|
mocks.readClipboardFilePaths.mockResolvedValue(["/tmp/photo\x1b]0;unsafe\x07.png"]);
|
||||||
|
const context = {
|
||||||
|
editor: { insertTextAtCursor },
|
||||||
|
showError,
|
||||||
|
ui: { requestRender: vi.fn() },
|
||||||
|
};
|
||||||
|
const prototype = InteractiveMode.prototype as unknown as {
|
||||||
|
handleClipboardPaste(this: typeof context): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
await prototype.handleClipboardPaste.call(context);
|
||||||
|
|
||||||
|
expect(insertTextAtCursor).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.readClipboardImage).not.toHaveBeenCalled();
|
||||||
|
expect(showError).toHaveBeenCalledExactlyOnceWith(
|
||||||
|
"Failed to paste from clipboard: Clipboard file path contains control characters",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("native file-path errors are shown without falling through to the icon image", async () => {
|
||||||
|
const showError = vi.fn<(message: string) => void>();
|
||||||
|
mocks.readClipboardFilePaths.mockRejectedValue(new Error("Native clipboard file read failed"));
|
||||||
|
const context = {
|
||||||
|
editor: { insertTextAtCursor: vi.fn() },
|
||||||
|
showError,
|
||||||
|
ui: { requestRender: vi.fn() },
|
||||||
|
};
|
||||||
|
const prototype = InteractiveMode.prototype as unknown as {
|
||||||
|
handleClipboardPaste(this: typeof context): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
await prototype.handleClipboardPaste.call(context);
|
||||||
|
|
||||||
|
expect(context.editor.insertTextAtCursor).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.readClipboardImage).not.toHaveBeenCalled();
|
||||||
|
expect(showError).toHaveBeenCalledExactlyOnceWith(
|
||||||
|
"Failed to paste from clipboard: Native clipboard file read failed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["punctuation", "Review:", 7],
|
||||||
|
["Unicode text", "確認", 2],
|
||||||
|
])("clipboard file paths are separated from preceding %s", async (_description, editorText, cursorCol) => {
|
||||||
|
const insertTextAtCursor = vi.fn<(text: string) => void>();
|
||||||
|
mocks.readClipboardFilePaths.mockResolvedValue(["/tmp/photo.png"]);
|
||||||
|
const context = {
|
||||||
|
editor: {
|
||||||
|
getCursor: () => ({ line: 0, col: cursorCol }),
|
||||||
|
getText: () => editorText,
|
||||||
|
insertTextAtCursor,
|
||||||
|
},
|
||||||
|
showError: vi.fn(),
|
||||||
|
ui: { requestRender: vi.fn() },
|
||||||
|
};
|
||||||
|
const prototype = InteractiveMode.prototype as unknown as {
|
||||||
|
handleClipboardPaste(this: typeof context): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
await prototype.handleClipboardPaste.call(context);
|
||||||
|
|
||||||
|
expect(insertTextAtCursor).toHaveBeenCalledExactlyOnceWith(" /tmp/photo.png");
|
||||||
|
expect(mocks.readClipboardImage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bash mode shell-quotes file paths and inserts them as arguments", async () => {
|
||||||
|
const insertTextAtCursor = vi.fn<(text: string) => void>();
|
||||||
|
mocks.readClipboardFilePaths.mockResolvedValue([
|
||||||
|
"/tmp/My Photos/photo.png",
|
||||||
|
"/tmp/$(touch hacked).png",
|
||||||
|
"/tmp/plain.png",
|
||||||
|
]);
|
||||||
|
const context = {
|
||||||
|
editor: {
|
||||||
|
getCursor: () => ({ line: 0, col: 3 }),
|
||||||
|
getText: () => "catDEST",
|
||||||
|
insertTextAtCursor,
|
||||||
|
},
|
||||||
|
isBashMode: true,
|
||||||
|
showError: vi.fn(),
|
||||||
|
ui: { requestRender: vi.fn() },
|
||||||
|
};
|
||||||
|
const prototype = InteractiveMode.prototype as unknown as {
|
||||||
|
handleClipboardPaste(this: typeof context): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
await prototype.handleClipboardPaste.call(context);
|
||||||
|
|
||||||
|
expect(insertTextAtCursor).toHaveBeenCalledExactlyOnceWith(
|
||||||
|
" '/tmp/My Photos/photo.png' '/tmp/$(touch hacked).png' /tmp/plain.png ",
|
||||||
|
);
|
||||||
|
expect(mocks.readClipboardImage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
@ -21,6 +21,7 @@ import { initTheme } from "../src/modes/interactive/theme/theme.ts";
|
||||||
|
|
||||||
const clipboardMocks = vi.hoisted(() => ({
|
const clipboardMocks = vi.hoisted(() => ({
|
||||||
copyToClipboard: vi.fn<(text: string) => Promise<void>>(),
|
copyToClipboard: vi.fn<(text: string) => Promise<void>>(),
|
||||||
|
readClipboardFilePaths: vi.fn<() => Promise<string[] | null>>(),
|
||||||
readClipboardText: vi.fn<() => Promise<string | null>>(),
|
readClipboardText: vi.fn<() => Promise<string | null>>(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,17 @@
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
typedef enum { CLIPBOARD_TEXT, CLIPBOARD_IMAGE, CLIPBOARD_WRITE } clipboard_operation;
|
typedef enum { CLIPBOARD_TEXT, CLIPBOARD_IMAGE, CLIPBOARD_WRITE, CLIPBOARD_FILES } clipboard_operation;
|
||||||
typedef enum { CLIPBOARD_UNAVAILABLE, CLIPBOARD_EMPTY, CLIPBOARD_UTF8, CLIPBOARD_LATIN1, CLIPBOARD_UTF16, CLIPBOARD_BUFFER } clipboard_format;
|
// CLIPBOARD_PATHS stores concatenated, NUL-terminated UTF-8 paths in clipboard_job.data.
|
||||||
|
typedef enum {
|
||||||
|
CLIPBOARD_UNAVAILABLE,
|
||||||
|
CLIPBOARD_EMPTY,
|
||||||
|
CLIPBOARD_UTF8,
|
||||||
|
CLIPBOARD_LATIN1,
|
||||||
|
CLIPBOARD_UTF16,
|
||||||
|
CLIPBOARD_BUFFER,
|
||||||
|
CLIPBOARD_PATHS
|
||||||
|
} clipboard_format;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
napi_async_work work;
|
napi_async_work work;
|
||||||
|
|
@ -63,6 +72,33 @@ static void PI_NAPI_CALL execute_clipboard_work(napi_env env, void* data) {
|
||||||
clipboard_execute(data);
|
clipboard_execute(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int create_clipboard_paths_value(
|
||||||
|
napi_env env,
|
||||||
|
const clipboard_job* job,
|
||||||
|
napi_create_string_utf8_fn create_string,
|
||||||
|
napi_value* result
|
||||||
|
) {
|
||||||
|
napi_create_array_fn create_array = (napi_create_array_fn)node_symbol("napi_create_array");
|
||||||
|
napi_set_element_fn set_element = (napi_set_element_fn)node_symbol("napi_set_element");
|
||||||
|
if (!create_array || !set_element || create_array(env, result) != 0) return 1;
|
||||||
|
|
||||||
|
const char* cursor = job->data;
|
||||||
|
const char* end = cursor + job->length;
|
||||||
|
for (uint32_t index = 0; cursor < end; index++) {
|
||||||
|
const char* terminator = cursor;
|
||||||
|
while (terminator < end && *terminator != '\0') terminator++;
|
||||||
|
if (terminator == end) return 1;
|
||||||
|
|
||||||
|
napi_value item = 0;
|
||||||
|
if (create_string(env, cursor, (size_t)(terminator - cursor), &item) != 0 ||
|
||||||
|
set_element(env, *result, index, item) != 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
cursor = terminator + 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
static void PI_NAPI_CALL complete_clipboard_work(napi_env env, int status, void* data) {
|
static void PI_NAPI_CALL complete_clipboard_work(napi_env env, int status, void* data) {
|
||||||
clipboard_job* job = data;
|
clipboard_job* job = data;
|
||||||
napi_create_string_utf8_fn create_string = (napi_create_string_utf8_fn)node_symbol("napi_create_string_utf8");
|
napi_create_string_utf8_fn create_string = (napi_create_string_utf8_fn)node_symbol("napi_create_string_utf8");
|
||||||
|
|
@ -71,6 +107,8 @@ static void PI_NAPI_CALL complete_clipboard_work(napi_env env, int status, void*
|
||||||
if (!job->error && job->operation != CLIPBOARD_WRITE) {
|
if (!job->error && job->operation != CLIPBOARD_WRITE) {
|
||||||
if (job->format == CLIPBOARD_EMPTY) {
|
if (job->format == CLIPBOARD_EMPTY) {
|
||||||
result = null_value(env);
|
result = null_value(env);
|
||||||
|
} else if (job->format == CLIPBOARD_PATHS) {
|
||||||
|
status = create_clipboard_paths_value(env, job, create_string, &result);
|
||||||
} else if (job->format == CLIPBOARD_BUFFER) {
|
} else if (job->format == CLIPBOARD_BUFFER) {
|
||||||
napi_create_buffer_copy_fn create_buffer = (napi_create_buffer_copy_fn)node_symbol("napi_create_buffer_copy");
|
napi_create_buffer_copy_fn create_buffer = (napi_create_buffer_copy_fn)node_symbol("napi_create_buffer_copy");
|
||||||
status = create_buffer(env, job->length, job->data, 0, &result);
|
status = create_buffer(env, job->length, job->data, 0, &result);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Darwin native prebuilds
|
# Darwin native prebuilds
|
||||||
|
|
||||||
Provides modifier-key state and asynchronous text/image clipboard access using AppKit.
|
Provides modifier-key state and asynchronous text, image, and file-path clipboard access using AppKit.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -66,6 +66,40 @@ static void clipboard_execute(clipboard_job* job) {
|
||||||
} else {
|
} else {
|
||||||
clipboard_copy(job, text.UTF8String, [text lengthOfBytesUsingEncoding:NSUTF8StringEncoding], CLIPBOARD_UTF8);
|
clipboard_copy(job, text.UTF8String, [text lengthOfBytesUsingEncoding:NSUTF8StringEncoding], CLIPBOARD_UTF8);
|
||||||
}
|
}
|
||||||
|
} else if (job->operation == CLIPBOARD_FILES) {
|
||||||
|
NSArray* urls = [pasteboard readObjectsForClasses:@[[NSURL class]]
|
||||||
|
options:@{NSPasteboardURLReadingFileURLsOnlyKey: @YES}];
|
||||||
|
size_t total = 0;
|
||||||
|
for (NSURL* url in urls) {
|
||||||
|
const char* path = url.fileSystemRepresentation;
|
||||||
|
if (!path) continue;
|
||||||
|
size_t length = strlen(path);
|
||||||
|
if (length == SIZE_MAX || total > SIZE_MAX - length - 1) {
|
||||||
|
job->error = "Clipboard file paths are too large";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
total += length + 1;
|
||||||
|
}
|
||||||
|
if (total == 0) {
|
||||||
|
job->format = CLIPBOARD_EMPTY;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
char* paths = clipboard_alloc(total);
|
||||||
|
if (!paths) {
|
||||||
|
job->error = "Out of memory";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
char* cursor = paths;
|
||||||
|
for (NSURL* url in urls) {
|
||||||
|
const char* path = url.fileSystemRepresentation;
|
||||||
|
if (!path) continue;
|
||||||
|
size_t length = strlen(path);
|
||||||
|
memcpy(cursor, path, length + 1);
|
||||||
|
cursor += length + 1;
|
||||||
|
}
|
||||||
|
job->data = paths;
|
||||||
|
job->length = total;
|
||||||
|
job->format = CLIPBOARD_PATHS;
|
||||||
} else {
|
} else {
|
||||||
if (![pasteboard availableTypeFromArray:@[ NSPasteboardTypePNG, NSPasteboardTypeTIFF ]]) {
|
if (![pasteboard availableTypeFromArray:@[ NSPasteboardTypePNG, NSPasteboardTypeTIFF ]]) {
|
||||||
job->format = CLIPBOARD_EMPTY;
|
job->format = CLIPBOARD_EMPTY;
|
||||||
|
|
@ -84,10 +118,15 @@ static void clipboard_execute(clipboard_job* job) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static napi_value PI_NAPI_CALL get_clipboard_file_paths(napi_env env, napi_callback_info info) {
|
||||||
|
return queue_clipboard(env, info, CLIPBOARD_FILES);
|
||||||
|
}
|
||||||
|
|
||||||
PI_NAPI_EXPORT napi_value napi_register_module_v1(napi_env env, napi_value exports) {
|
PI_NAPI_EXPORT napi_value napi_register_module_v1(napi_env env, napi_value exports) {
|
||||||
set_function_export(env, exports, "isModifierPressed", is_modifier_pressed);
|
set_function_export(env, exports, "isModifierPressed", is_modifier_pressed);
|
||||||
set_function_export(env, exports, "getText", get_clipboard_text);
|
set_function_export(env, exports, "getText", get_clipboard_text);
|
||||||
set_function_export(env, exports, "setText", set_clipboard_text);
|
set_function_export(env, exports, "setText", set_clipboard_text);
|
||||||
set_function_export(env, exports, "getImage", get_clipboard_image);
|
set_function_export(env, exports, "getImage", get_clipboard_image);
|
||||||
|
set_function_export(env, exports, "getFilePaths", get_clipboard_file_paths);
|
||||||
return exports;
|
return exports;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ typedef void* napi_deferred;
|
||||||
typedef void (PI_NAPI_CALL *napi_async_execute_callback)(napi_env, void*);
|
typedef void (PI_NAPI_CALL *napi_async_execute_callback)(napi_env, void*);
|
||||||
typedef void (PI_NAPI_CALL *napi_async_complete_callback)(napi_env, int, void*);
|
typedef void (PI_NAPI_CALL *napi_async_complete_callback)(napi_env, int, void*);
|
||||||
typedef int (PI_NAPI_CALL *napi_create_async_work_fn)(napi_env, napi_value, napi_value, napi_async_execute_callback, napi_async_complete_callback, void*, napi_async_work*);
|
typedef int (PI_NAPI_CALL *napi_create_async_work_fn)(napi_env, napi_value, napi_value, napi_async_execute_callback, napi_async_complete_callback, void*, napi_async_work*);
|
||||||
|
typedef int (PI_NAPI_CALL *napi_create_array_fn)(napi_env, napi_value*);
|
||||||
typedef int (PI_NAPI_CALL *napi_async_work_fn)(napi_env, napi_async_work);
|
typedef int (PI_NAPI_CALL *napi_async_work_fn)(napi_env, napi_async_work);
|
||||||
typedef int (PI_NAPI_CALL *napi_create_promise_fn)(napi_env, napi_deferred*, napi_value*);
|
typedef int (PI_NAPI_CALL *napi_create_promise_fn)(napi_env, napi_deferred*, napi_value*);
|
||||||
typedef int (PI_NAPI_CALL *napi_settle_deferred_fn)(napi_env, napi_deferred, napi_value);
|
typedef int (PI_NAPI_CALL *napi_settle_deferred_fn)(napi_env, napi_deferred, napi_value);
|
||||||
|
|
@ -41,6 +42,7 @@ typedef int (PI_NAPI_CALL *napi_get_value_fn)(napi_env, napi_value*);
|
||||||
typedef int (PI_NAPI_CALL *napi_get_value_string_utf8_fn)(napi_env, napi_value, char*, size_t, size_t*);
|
typedef int (PI_NAPI_CALL *napi_get_value_string_utf8_fn)(napi_env, napi_value, char*, size_t, size_t*);
|
||||||
typedef int (PI_NAPI_CALL *napi_get_value_string_utf16_fn)(napi_env, napi_value, uint16_t*, size_t, size_t*);
|
typedef int (PI_NAPI_CALL *napi_get_value_string_utf16_fn)(napi_env, napi_value, uint16_t*, size_t, size_t*);
|
||||||
typedef int (PI_NAPI_CALL *napi_set_named_property_fn)(napi_env, napi_value, const char*, napi_value);
|
typedef int (PI_NAPI_CALL *napi_set_named_property_fn)(napi_env, napi_value, const char*, napi_value);
|
||||||
|
typedef int (PI_NAPI_CALL *napi_set_element_fn)(napi_env, napi_value, uint32_t, napi_value);
|
||||||
typedef int (PI_NAPI_CALL *napi_throw_error_fn)(napi_env, const char*, const char*);
|
typedef int (PI_NAPI_CALL *napi_throw_error_fn)(napi_env, const char*, const char*);
|
||||||
|
|
||||||
static void* node_symbol(const char* name) {
|
static void* node_symbol(const char* name) {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ export interface NativeClipboard {
|
||||||
getText(): Promise<string | null | undefined>;
|
getText(): Promise<string | null | undefined>;
|
||||||
/** Undefined means unavailable, null means no image; transfer failures reject. */
|
/** Undefined means unavailable, null means no image; transfer failures reject. */
|
||||||
getImage(): Promise<Uint8Array | null | undefined>;
|
getImage(): Promise<Uint8Array | null | undefined>;
|
||||||
|
/** POSIX paths of file URLs on the clipboard. Undefined means unsupported, null means no files. */
|
||||||
|
getFilePaths?(): Promise<string[] | null | undefined>;
|
||||||
/** Linux uses command-line tools to retain clipboard ownership instead. */
|
/** Linux uses command-line tools to retain clipboard ownership instead. */
|
||||||
setText?(text: string): Promise<void>;
|
setText?(text: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue