pi-image-tools 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/clipboard.ts CHANGED
@@ -5,13 +5,76 @@ import type { ClipboardImage, ClipboardModule } from "./types.js";
5
5
 
6
6
  const require = createRequire(import.meta.url);
7
7
 
8
+ const LIST_TYPES_TIMEOUT_MS = 1000;
9
+ const READ_TIMEOUT_MS = 5000;
10
+ const MAX_BUFFER_BYTES = 50 * 1024 * 1024;
11
+ const SUPPORTED_IMAGE_MIME_TYPES = [
12
+ "image/png",
13
+ "image/jpeg",
14
+ "image/webp",
15
+ "image/gif",
16
+ "image/bmp",
17
+ ] as const;
18
+
8
19
  let cachedClipboardModule: ClipboardModule | null | undefined;
9
20
 
10
- function loadClipboardModule(): ClipboardModule | null {
21
+ interface CommandResult {
22
+ ok: boolean;
23
+ stdout: Buffer;
24
+ missingCommand: boolean;
25
+ }
26
+
27
+ interface ClipboardReadResult {
28
+ available: boolean;
29
+ image: ClipboardImage | null;
30
+ }
31
+
32
+ function isErrnoException(error: Error): error is NodeJS.ErrnoException {
33
+ return "code" in error;
34
+ }
35
+
36
+ function hasGraphicalSession(platform: NodeJS.Platform, environment: NodeJS.ProcessEnv): boolean {
37
+ return platform !== "linux" || Boolean(environment.DISPLAY || environment.WAYLAND_DISPLAY);
38
+ }
39
+
40
+ function isWaylandSession(environment: NodeJS.ProcessEnv): boolean {
41
+ return Boolean(environment.WAYLAND_DISPLAY) || environment.XDG_SESSION_TYPE === "wayland";
42
+ }
43
+
44
+ function normalizeMimeType(mimeType: string): string {
45
+ return mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
46
+ }
47
+
48
+ function selectPreferredImageMimeType(mimeTypes: readonly string[]): string | null {
49
+ const normalized = mimeTypes
50
+ .map((mimeType) => mimeType.trim())
51
+ .filter((mimeType) => mimeType.length > 0)
52
+ .map((mimeType) => ({ raw: mimeType, normalized: normalizeMimeType(mimeType) }));
53
+
54
+ for (const preferredMimeType of SUPPORTED_IMAGE_MIME_TYPES) {
55
+ const match = normalized.find((mimeType) => mimeType.normalized === preferredMimeType);
56
+ if (match) {
57
+ return match.raw;
58
+ }
59
+ }
60
+
61
+ const firstImage = normalized.find((mimeType) => mimeType.normalized.startsWith("image/"));
62
+ return firstImage?.raw ?? null;
63
+ }
64
+
65
+ function loadClipboardModule(
66
+ platform: NodeJS.Platform = process.platform,
67
+ environment: NodeJS.ProcessEnv = process.env,
68
+ ): ClipboardModule | null {
11
69
  if (cachedClipboardModule !== undefined) {
12
70
  return cachedClipboardModule;
13
71
  }
14
72
 
73
+ if (environment.TERMUX_VERSION || !hasGraphicalSession(platform, environment)) {
74
+ cachedClipboardModule = null;
75
+ return cachedClipboardModule;
76
+ }
77
+
15
78
  try {
16
79
  cachedClipboardModule = require("@mariozechner/clipboard") as ClipboardModule;
17
80
  } catch {
@@ -21,26 +84,68 @@ function loadClipboardModule(): ClipboardModule | null {
21
84
  return cachedClipboardModule;
22
85
  }
23
86
 
24
- async function readClipboardImageViaNativeModule(): Promise<ClipboardImage | null> {
25
- const clipboard = loadClipboardModule();
26
- if (!clipboard || !clipboard.hasImage()) {
27
- return null;
87
+ async function readClipboardImageViaNativeModule(
88
+ platform: NodeJS.Platform,
89
+ environment: NodeJS.ProcessEnv,
90
+ ): Promise<ClipboardReadResult> {
91
+ const clipboard = loadClipboardModule(platform, environment);
92
+ if (!clipboard) {
93
+ return { available: false, image: null };
94
+ }
95
+
96
+ if (!clipboard.hasImage()) {
97
+ return { available: true, image: null };
28
98
  }
29
99
 
30
100
  const imageData = await clipboard.getImageBinary();
31
101
  if (!imageData || imageData.length === 0) {
32
- return null;
102
+ return { available: true, image: null };
33
103
  }
34
104
 
35
105
  const bytes = imageData instanceof Uint8Array ? imageData : Uint8Array.from(imageData);
36
- return { bytes, mimeType: "image/png" };
106
+ return {
107
+ available: true,
108
+ image: {
109
+ bytes,
110
+ mimeType: "image/png",
111
+ },
112
+ };
113
+ }
114
+
115
+ function runCommand(
116
+ command: string,
117
+ args: string[],
118
+ timeout: number,
119
+ ): CommandResult {
120
+ const result = spawnSync(command, args, {
121
+ timeout,
122
+ maxBuffer: MAX_BUFFER_BYTES,
123
+ });
124
+
125
+ if (result.error) {
126
+ return {
127
+ ok: false,
128
+ stdout: Buffer.alloc(0),
129
+ missingCommand: isErrnoException(result.error) && result.error.code === "ENOENT",
130
+ };
131
+ }
132
+
133
+ const stdout = Buffer.isBuffer(result.stdout)
134
+ ? result.stdout
135
+ : Buffer.from(result.stdout ?? "", typeof result.stdout === "string" ? "utf8" : undefined);
136
+
137
+ return {
138
+ ok: result.status === 0,
139
+ stdout,
140
+ missingCommand: false,
141
+ };
37
142
  }
38
143
 
39
144
  function encodePowerShell(script: string): string {
40
145
  return Buffer.from(script, "utf16le").toString("base64");
41
146
  }
42
147
 
43
- function readClipboardImageViaPowerShell(): ClipboardImage | null {
148
+ function readClipboardImageViaPowerShell(): ClipboardReadResult {
44
149
  const script = `
45
150
  $ErrorActionPreference = 'Stop'
46
151
  Add-Type -AssemblyName System.Windows.Forms
@@ -78,40 +183,203 @@ try {
78
183
  ],
79
184
  {
80
185
  encoding: "utf8",
81
- timeout: 6000,
82
- maxBuffer: 50 * 1024 * 1024,
186
+ timeout: READ_TIMEOUT_MS,
187
+ maxBuffer: MAX_BUFFER_BYTES,
83
188
  windowsHide: true,
84
189
  },
85
190
  );
86
191
 
87
- if (result.error || result.status !== 0) {
88
- return null;
192
+ if (result.error) {
193
+ return {
194
+ available: !isErrnoException(result.error) || result.error.code !== "ENOENT",
195
+ image: null,
196
+ };
197
+ }
198
+
199
+ if (result.status !== 0) {
200
+ return { available: true, image: null };
89
201
  }
90
202
 
91
203
  const base64 = result.stdout.trim();
92
204
  if (!base64) {
93
- return null;
205
+ return { available: true, image: null };
94
206
  }
95
207
 
96
208
  try {
97
209
  const bytes = Buffer.from(base64, "base64");
98
210
  if (bytes.length === 0) {
99
- return null;
211
+ return { available: true, image: null };
100
212
  }
101
- return { bytes: new Uint8Array(bytes), mimeType: "image/png" };
213
+
214
+ return {
215
+ available: true,
216
+ image: {
217
+ bytes: new Uint8Array(bytes),
218
+ mimeType: "image/png",
219
+ },
220
+ };
102
221
  } catch {
103
- return null;
222
+ return { available: true, image: null };
223
+ }
224
+ }
225
+
226
+ function readClipboardImageViaWlPaste(): ClipboardReadResult {
227
+ const listTypes = runCommand("wl-paste", ["--list-types"], LIST_TYPES_TIMEOUT_MS);
228
+ if (listTypes.missingCommand) {
229
+ return { available: false, image: null };
230
+ }
231
+
232
+ if (!listTypes.ok) {
233
+ return { available: true, image: null };
234
+ }
235
+
236
+ const mimeTypes = listTypes.stdout
237
+ .toString("utf8")
238
+ .split(/\r?\n/)
239
+ .map((mimeType) => mimeType.trim())
240
+ .filter((mimeType) => mimeType.length > 0);
241
+
242
+ const selectedMimeType = selectPreferredImageMimeType(mimeTypes);
243
+ if (!selectedMimeType) {
244
+ return { available: true, image: null };
245
+ }
246
+
247
+ const imageData = runCommand(
248
+ "wl-paste",
249
+ ["--type", selectedMimeType, "--no-newline"],
250
+ READ_TIMEOUT_MS,
251
+ );
252
+
253
+ if (!imageData.ok || imageData.stdout.length === 0) {
254
+ return { available: true, image: null };
255
+ }
256
+
257
+ return {
258
+ available: true,
259
+ image: {
260
+ bytes: new Uint8Array(imageData.stdout),
261
+ mimeType: normalizeMimeType(selectedMimeType),
262
+ },
263
+ };
264
+ }
265
+
266
+ function readClipboardImageViaXclip(): ClipboardReadResult {
267
+ const targets = runCommand(
268
+ "xclip",
269
+ ["-selection", "clipboard", "-t", "TARGETS", "-o"],
270
+ LIST_TYPES_TIMEOUT_MS,
271
+ );
272
+
273
+ if (targets.missingCommand) {
274
+ return { available: false, image: null };
275
+ }
276
+
277
+ const advertisedMimeTypes = targets.ok
278
+ ? targets.stdout
279
+ .toString("utf8")
280
+ .split(/\r?\n/)
281
+ .map((mimeType) => mimeType.trim())
282
+ .filter((mimeType) => mimeType.length > 0)
283
+ : [];
284
+
285
+ const preferredMimeType =
286
+ advertisedMimeTypes.length > 0 ? selectPreferredImageMimeType(advertisedMimeTypes) : null;
287
+ const mimeTypesToTry = preferredMimeType
288
+ ? [preferredMimeType, ...SUPPORTED_IMAGE_MIME_TYPES]
289
+ : [...SUPPORTED_IMAGE_MIME_TYPES];
290
+
291
+ for (const mimeType of mimeTypesToTry) {
292
+ const imageData = runCommand(
293
+ "xclip",
294
+ ["-selection", "clipboard", "-t", mimeType, "-o"],
295
+ READ_TIMEOUT_MS,
296
+ );
297
+
298
+ if (imageData.ok && imageData.stdout.length > 0) {
299
+ return {
300
+ available: true,
301
+ image: {
302
+ bytes: new Uint8Array(imageData.stdout),
303
+ mimeType: normalizeMimeType(mimeType),
304
+ },
305
+ };
306
+ }
307
+ }
308
+
309
+ return { available: true, image: null };
310
+ }
311
+
312
+ function getUnavailableReaderMessage(platform: NodeJS.Platform): string {
313
+ switch (platform) {
314
+ case "linux":
315
+ return "No Linux clipboard image reader is available. Install wl-clipboard or xclip, or ensure @mariozechner/clipboard is installed.";
316
+ case "darwin":
317
+ return "No macOS clipboard image reader is available. Ensure @mariozechner/clipboard is installed.";
318
+ case "win32":
319
+ return "No Windows clipboard image reader is available. Ensure PowerShell is available or @mariozechner/clipboard is installed.";
320
+ default:
321
+ return `Clipboard image paste is not supported on platform: ${platform}`;
104
322
  }
105
323
  }
106
324
 
107
- export async function readClipboardImage(platform: NodeJS.Platform = process.platform): Promise<ClipboardImage | null> {
108
- if (platform !== "win32") {
325
+ export async function readClipboardImage(options?: {
326
+ environment?: NodeJS.ProcessEnv;
327
+ platform?: NodeJS.Platform;
328
+ }): Promise<ClipboardImage | null> {
329
+ const environment = options?.environment ?? process.env;
330
+ const platform = options?.platform ?? process.platform;
331
+
332
+ if (environment.TERMUX_VERSION) {
109
333
  return null;
110
334
  }
111
335
 
112
- try {
113
- return (await readClipboardImageViaNativeModule()) ?? readClipboardImageViaPowerShell();
114
- } catch {
115
- return readClipboardImageViaPowerShell();
336
+ if (!hasGraphicalSession(platform, environment)) {
337
+ throw new Error("Clipboard image paste requires a graphical desktop session with DISPLAY or WAYLAND_DISPLAY.");
338
+ }
339
+
340
+ const readerResults: ClipboardReadResult[] = [];
341
+
342
+ const recordResult = (result: ClipboardReadResult): ClipboardImage | null => {
343
+ readerResults.push(result);
344
+ return result.image;
345
+ };
346
+
347
+ if (platform === "win32") {
348
+ const nativeImage = recordResult(await readClipboardImageViaNativeModule(platform, environment));
349
+ if (nativeImage) {
350
+ return nativeImage;
351
+ }
352
+
353
+ const powerShellImage = recordResult(readClipboardImageViaPowerShell());
354
+ if (powerShellImage) {
355
+ return powerShellImage;
356
+ }
357
+ } else if (platform === "linux") {
358
+ const sessionReaders = isWaylandSession(environment)
359
+ ? [readClipboardImageViaWlPaste, readClipboardImageViaXclip]
360
+ : [readClipboardImageViaXclip, readClipboardImageViaWlPaste];
361
+
362
+ for (const reader of sessionReaders) {
363
+ const image = recordResult(reader());
364
+ if (image) {
365
+ return image;
366
+ }
367
+ }
368
+
369
+ const nativeImage = recordResult(await readClipboardImageViaNativeModule(platform, environment));
370
+ if (nativeImage) {
371
+ return nativeImage;
372
+ }
373
+ } else {
374
+ const nativeImage = recordResult(await readClipboardImageViaNativeModule(platform, environment));
375
+ if (nativeImage) {
376
+ return nativeImage;
377
+ }
116
378
  }
379
+
380
+ if (readerResults.some((result) => result.available)) {
381
+ return null;
382
+ }
383
+
384
+ throw new Error(getUnavailableReaderMessage(platform));
117
385
  }