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/CHANGELOG.md +37 -22
- package/LICENSE +21 -21
- package/README.md +244 -202
- package/config/config.example.json +3 -3
- package/index.ts +3 -3
- package/package.json +9 -1
- package/src/clipboard.ts +290 -22
- package/src/image-preview.ts +469 -469
- package/src/index.ts +0 -8
- package/src/inline-user-preview.ts +10 -1
- package/src/keybindings.ts +8 -2
- package/src/recent-images.ts +89 -18
- package/src/types.ts +20 -20
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
|
-
|
|
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(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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 {
|
|
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():
|
|
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:
|
|
82
|
-
maxBuffer:
|
|
186
|
+
timeout: READ_TIMEOUT_MS,
|
|
187
|
+
maxBuffer: MAX_BUFFER_BYTES,
|
|
83
188
|
windowsHide: true,
|
|
84
189
|
},
|
|
85
190
|
);
|
|
86
191
|
|
|
87
|
-
if (result.error
|
|
88
|
-
return
|
|
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
|
-
|
|
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(
|
|
108
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
|
|
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
|
}
|