mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat: add "Copy image to clipboard" functionality in message context menus and improve logging with broken-pipe guards
This commit is contained in:
parent
5870af2db9
commit
514005e995
17 changed files with 383 additions and 8 deletions
|
|
@ -28,6 +28,7 @@ All notable changes to this project will be documented in this file.
|
|||
- Relay Chat room keys so hosts can require a key to join a room
|
||||
- Desktop privacy: Windows screen security to omit MeshChatX from screenshots, recording, and Recall
|
||||
- Android privacy options to block screenshots and clear the clipboard when backgrounded
|
||||
- Messages: **Copy image to clipboard** on the message and full-screen image context menus
|
||||
- Remote management allow-list for identities that may query this instance with rnstatus/rnpath
|
||||
- Post-install prompts for existing users after upgrades
|
||||
- Coolify-oriented Docker Compose with resource limits for deployments
|
||||
|
|
@ -56,6 +57,7 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
### Fixed
|
||||
|
||||
- Desktop AppImage: closed stdout no longer raises a main-process **write EPIPE** dialog when MeshChatX is backgrounded. Logging uses broken-pipe guards
|
||||
- Android: lxmfy packaging, flock soft-lock, splash/logo clipping, Landlock skipped on Android
|
||||
- Android RNode BLE/USB via Chaquopy
|
||||
- Startup check and disable unsupported interfaces
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ const {
|
|||
applyContentProtection,
|
||||
applyContentProtectionToWindows,
|
||||
} = require("./desktopPrivacySettings");
|
||||
const { installBrokenPipeGuards, safeConsoleLog } = require("./safeConsole");
|
||||
|
||||
installBrokenPipeGuards(process);
|
||||
|
||||
// remember main window
|
||||
var mainWindow = null;
|
||||
|
|
@ -84,7 +87,7 @@ try {
|
|||
const disableGpuFile = path.join(storageDir, "disable-gpu");
|
||||
if (fs.existsSync(disableGpuFile)) {
|
||||
app.disableHardwareAcceleration();
|
||||
console.log("Hardware acceleration disabled via storage flag.");
|
||||
safeConsoleLog("Hardware acceleration disabled via storage flag.");
|
||||
}
|
||||
} catch {
|
||||
// ignore errors reading storage dir this early
|
||||
|
|
@ -102,7 +105,7 @@ if (process.platform === "linux") {
|
|||
// Detect if running in Flatpak sandbox
|
||||
const isRunningInFlatpak = !!process.env.FLATPAK_ID;
|
||||
if (isRunningInFlatpak) {
|
||||
console.log(`Running in Flatpak sandbox: ${process.env.FLATPAK_ID}`);
|
||||
safeConsoleLog(`Running in Flatpak sandbox: ${process.env.FLATPAK_ID}`);
|
||||
}
|
||||
|
||||
// Protocol registration
|
||||
|
|
@ -629,6 +632,26 @@ function attachDefaultContextMenu(browserWindow) {
|
|||
});
|
||||
}
|
||||
|
||||
if (params.mediaType === "image" || params.hasImageContents) {
|
||||
if (template.length > 0) {
|
||||
template.push({ type: "separator" });
|
||||
}
|
||||
template.push({
|
||||
label: "Copy image",
|
||||
click: () => {
|
||||
webContents.copyImageAt(params.x, params.y);
|
||||
},
|
||||
});
|
||||
if (params.srcURL) {
|
||||
template.push({
|
||||
label: "Copy image address",
|
||||
click: () => {
|
||||
clipboard.writeText(params.srcURL);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (template.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -639,8 +662,8 @@ function attachDefaultContextMenu(browserWindow) {
|
|||
}
|
||||
|
||||
function log(message) {
|
||||
// log to stdout of this process
|
||||
console.log(message);
|
||||
// log to stdout of this process (AppImage may close the pipe later)
|
||||
safeConsoleLog(message);
|
||||
|
||||
// make sure main window exists
|
||||
if (!mainWindow) {
|
||||
|
|
|
|||
61
electron/safeConsole.js
Normal file
61
electron/safeConsole.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
/**
|
||||
* Broken-pipe (EPIPE) guards for Electron AppImage / launcher sessions where
|
||||
* stdout or stderr is closed while the main process still logs.
|
||||
*/
|
||||
|
||||
function isBrokenPipeError(err) {
|
||||
if (!err || typeof err !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (err.code === "EPIPE" || err.errno === "EPIPE") {
|
||||
return true;
|
||||
}
|
||||
const message = typeof err.message === "string" ? err.message : "";
|
||||
return /\bEPIPE\b/i.test(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach no-op error listeners so closed stdout/stderr do not become
|
||||
* uncaughtException dialogs when console.log writes after the pipe closes.
|
||||
* @param {{ stdout?: NodeJS.WritableStream, stderr?: NodeJS.WritableStream } | null | undefined} [proc]
|
||||
*/
|
||||
function installBrokenPipeGuards(proc = process) {
|
||||
const attach = (stream) => {
|
||||
if (!stream || typeof stream.on !== "function") {
|
||||
return;
|
||||
}
|
||||
if (stream.__meshchatxBrokenPipeGuarded) {
|
||||
return;
|
||||
}
|
||||
stream.__meshchatxBrokenPipeGuarded = true;
|
||||
stream.on("error", (err) => {
|
||||
if (isBrokenPipeError(err)) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
attach(proc?.stdout);
|
||||
attach(proc?.stderr);
|
||||
}
|
||||
|
||||
/**
|
||||
* console.log that never throws on a closed stdout pipe.
|
||||
* @param {...unknown} args
|
||||
*/
|
||||
function safeConsoleLog(...args) {
|
||||
try {
|
||||
console.log(...args);
|
||||
} catch (err) {
|
||||
if (!isBrokenPipeError(err)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isBrokenPipeError,
|
||||
installBrokenPipeGuards,
|
||||
safeConsoleLog,
|
||||
};
|
||||
BIN
meshchatx.rsm
BIN
meshchatx.rsm
Binary file not shown.
|
|
@ -968,6 +968,13 @@
|
|||
<MaterialDesignIcon icon-name="download" class="size-4 text-blue-500" />
|
||||
{{ $t("messages.save_image_to_device") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
v-if="messageContextMenu.chatItem?.lxmf_message?.fields?.image"
|
||||
@click="copyMessageImageToClipboard(messageContextMenu.chatItem)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="content-copy" class="size-4 text-blue-500" />
|
||||
{{ $t("messages.copy_image_to_clipboard") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
v-if="messageContextMenu.chatItem?.lxmf_message?.fields?.image"
|
||||
@click="saveMessageImageToStickers(messageContextMenu.chatItem)"
|
||||
|
|
@ -1266,6 +1273,10 @@
|
|||
<MaterialDesignIcon icon-name="download" class="size-4 text-blue-500" />
|
||||
{{ $t("messages.save_image_to_device") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="copyImageModalCurrentToClipboard">
|
||||
<MaterialDesignIcon icon-name="content-copy" class="size-4 text-blue-500" />
|
||||
{{ $t("messages.copy_image_to_clipboard") }}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuPanel>
|
||||
</Teleport>
|
||||
|
||||
|
|
@ -1646,7 +1657,7 @@
|
|||
|
||||
<script>
|
||||
import Utils from "../../js/Utils";
|
||||
import { copyTextToClipboard, readTextFromClipboard } from "../../js/clipboardUtils.js";
|
||||
import { copyTextToClipboard, copyImageBlobToClipboard, readTextFromClipboard } from "../../js/clipboardUtils.js";
|
||||
import { MESSAGE_BODY_MAX_DISPLAY_CHARS, isStringTooLargeForInlineDisplay } from "../../js/messageDisplayLimits.js";
|
||||
import {
|
||||
MAX_CODEC2_DECODED_RAW_BYTES,
|
||||
|
|
@ -4514,8 +4525,8 @@ export default {
|
|||
return;
|
||||
}
|
||||
this.imageModalContextMenu.show = true;
|
||||
const menuWidth = 220;
|
||||
const menuHeight = 48;
|
||||
const menuWidth = 240;
|
||||
const menuHeight = 88;
|
||||
let x = event.clientX;
|
||||
let y = event.clientY;
|
||||
if (x + menuWidth > window.innerWidth) {
|
||||
|
|
@ -4534,6 +4545,13 @@ export default {
|
|||
this.downloadMessageImage(chatItem);
|
||||
}
|
||||
},
|
||||
copyImageModalCurrentToClipboard() {
|
||||
const chatItem = this.imageModalActiveChatItem();
|
||||
this.imageModalContextMenu.show = false;
|
||||
if (chatItem) {
|
||||
void this.copyMessageImageToClipboard(chatItem);
|
||||
}
|
||||
},
|
||||
imageModalNavigate(delta) {
|
||||
if (!this.imageModalGallery || this.imageModalGallery.length < 2) return;
|
||||
const n = this.imageModalGallery.length;
|
||||
|
|
@ -5283,6 +5301,47 @@ export default {
|
|||
ToastUtils.error(this.$t("common.error"));
|
||||
}
|
||||
},
|
||||
async resolveMessageImageBlob(chatItem) {
|
||||
const msg = chatItem?.lxmf_message;
|
||||
const img = msg?.fields?.image;
|
||||
if (!msg?.hash || !img) {
|
||||
return null;
|
||||
}
|
||||
const rawType = String(img.image_type || "png")
|
||||
.replace(/^image\//, "")
|
||||
.toLowerCase();
|
||||
const mimeExt = rawType === "jpg" ? "jpeg" : rawType || "png";
|
||||
const mime = `image/${mimeExt}`;
|
||||
if (img.image_bytes) {
|
||||
const bytes = this.base64ToArrayBuffer(img.image_bytes);
|
||||
return new Blob([bytes], { type: mime });
|
||||
}
|
||||
const response = await window.api.get(`/api/v1/lxmf-messages/attachment/${msg.hash}/image`, {
|
||||
responseType: "arraybuffer",
|
||||
});
|
||||
const headerType = response?.headers?.["content-type"] || response?.headers?.["Content-Type"];
|
||||
const type =
|
||||
typeof headerType === "string" && headerType.startsWith("image/") ? headerType.split(";")[0] : mime;
|
||||
return new Blob([response.data], { type });
|
||||
},
|
||||
async copyMessageImageToClipboard(chatItem) {
|
||||
this.messageContextMenu.show = false;
|
||||
try {
|
||||
const blob = await this.resolveMessageImageBlob(chatItem);
|
||||
if (!blob) {
|
||||
return;
|
||||
}
|
||||
const ok = await copyImageBlobToClipboard(blob);
|
||||
if (!ok) {
|
||||
ToastUtils.error(this.$t("messages.clipboard_write_unavailable"));
|
||||
return;
|
||||
}
|
||||
ToastUtils.success(this.$t("messages.image_copied_to_clipboard"));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ToastUtils.error(this.$t("common.error"));
|
||||
}
|
||||
},
|
||||
async processAudioForSelectedPeerChatItems() {
|
||||
for (const chatItem of this.selectedPeerChatItems) {
|
||||
// skip if no audio, or if audio bytes are missing (must be downloaded manually)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,20 @@ export function canUseAsyncClipboardRead() {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether async clipboard image write is expected to work.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function canUseAsyncClipboardImageWrite() {
|
||||
return (
|
||||
typeof navigator !== "undefined" &&
|
||||
!!navigator.clipboard &&
|
||||
typeof navigator.clipboard.write === "function" &&
|
||||
typeof ClipboardItem !== "undefined" &&
|
||||
isWindowSecureContext()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {Promise<boolean>}
|
||||
|
|
@ -63,6 +77,94 @@ export async function copyTextToClipboard(text) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy an image Blob to the system clipboard (PNG preferred when conversion works).
|
||||
* @param {Blob} blob
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function copyImageBlobToClipboard(blob) {
|
||||
if (!(blob instanceof Blob) || blob.size <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (!canUseAsyncClipboardImageWrite()) {
|
||||
return false;
|
||||
}
|
||||
const type = blob.type && blob.type.startsWith("image/") ? blob.type : "image/png";
|
||||
try {
|
||||
await navigator.clipboard.write([new ClipboardItem({ [type]: blob })]);
|
||||
return true;
|
||||
} catch {
|
||||
// Many hosts only accept image/png on the clipboard.
|
||||
}
|
||||
if (type === "image/png") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const pngBlob = await convertImageBlobToPng(blob);
|
||||
if (!pngBlob) {
|
||||
return false;
|
||||
}
|
||||
await navigator.clipboard.write([new ClipboardItem({ "image/png": pngBlob })]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Blob} blob
|
||||
* @returns {Promise<Blob | null>}
|
||||
*/
|
||||
async function convertImageBlobToPng(blob) {
|
||||
if (typeof createImageBitmap === "function") {
|
||||
try {
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
bitmap.close?.();
|
||||
return null;
|
||||
}
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
bitmap.close?.();
|
||||
return await new Promise((resolve) => {
|
||||
canvas.toBlob((out) => resolve(out), "image/png");
|
||||
});
|
||||
} catch {
|
||||
// fall through to HTMLImageElement path
|
||||
}
|
||||
}
|
||||
if (typeof Image === "undefined" || typeof URL === "undefined") {
|
||||
return null;
|
||||
}
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
try {
|
||||
const img = await new Promise((resolve, reject) => {
|
||||
const el = new Image();
|
||||
el.onload = () => resolve(el);
|
||||
el.onerror = () => reject(new Error("image_load_failed"));
|
||||
el.src = objectUrl;
|
||||
});
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.naturalWidth || img.width;
|
||||
canvas.height = img.naturalHeight || img.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
return null;
|
||||
}
|
||||
ctx.drawImage(img, 0, 0);
|
||||
return await new Promise((resolve) => {
|
||||
canvas.toBlob((out) => resolve(out), "image/png");
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<{ ok: true, text: string } | { ok: false, code: string }>}
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1885,6 +1885,8 @@
|
|||
"forward_open_party_is_self": "Das ist Ihre eigene Adresse.",
|
||||
"message_actions": "Nachrichtenaktionen",
|
||||
"save_image_to_device": "Bild auf Gerät speichern",
|
||||
"copy_image_to_clipboard": "Bild in die Zwischenablage kopieren",
|
||||
"image_copied_to_clipboard": "Bild in die Zwischenablage kopiert",
|
||||
"cancel_send": "Senden abbrechen",
|
||||
"react": "Reagieren",
|
||||
"reaction_you": "Du",
|
||||
|
|
|
|||
|
|
@ -1837,6 +1837,8 @@
|
|||
"forward_open_party_is_self": "That is your own address.",
|
||||
"message_actions": "Message actions",
|
||||
"save_image_to_device": "Save image to device",
|
||||
"copy_image_to_clipboard": "Copy image to clipboard",
|
||||
"image_copied_to_clipboard": "Image copied to clipboard",
|
||||
"cancel_send": "Cancel send",
|
||||
"react": "React",
|
||||
"reaction_you": "You",
|
||||
|
|
|
|||
|
|
@ -1833,6 +1833,8 @@
|
|||
"forward_open_party_is_self": "Esa es tu propia dirección.",
|
||||
"message_actions": "Acciones de mensajes",
|
||||
"save_image_to_device": "Guardar imagen en el dispositivo",
|
||||
"copy_image_to_clipboard": "Copiar imagen al portapapeles",
|
||||
"image_copied_to_clipboard": "Imagen copiada al portapapeles",
|
||||
"cancel_send": "Cancelar envío",
|
||||
"react": "Reacción",
|
||||
"reaction_you": "Tú.",
|
||||
|
|
|
|||
|
|
@ -1833,6 +1833,8 @@
|
|||
"forward_open_party_is_self": "Tämä on oma osoitteesi",
|
||||
"message_actions": "Viestitoiminnot",
|
||||
"save_image_to_device": "Tallenna kuva laitteelle",
|
||||
"copy_image_to_clipboard": "Kopioi kuva leikepöydälle",
|
||||
"image_copied_to_clipboard": "Kuva kopioitu leikepöydälle",
|
||||
"cancel_send": "Peruuta lähetys",
|
||||
"react": "Reagoi",
|
||||
"reaction_you": "Sinä",
|
||||
|
|
|
|||
|
|
@ -1833,6 +1833,8 @@
|
|||
"forward_open_party_is_self": "Il s'agit de votre propre adresse.",
|
||||
"message_actions": "Actions du message",
|
||||
"save_image_to_device": "Enregistrer l'image sur l'appareil",
|
||||
"copy_image_to_clipboard": "Copier l'image dans le presse-papiers",
|
||||
"image_copied_to_clipboard": "Image copiée dans le presse-papiers",
|
||||
"cancel_send": "Annuler l'envoi",
|
||||
"react": "Réagir",
|
||||
"reaction_you": "Toi",
|
||||
|
|
|
|||
|
|
@ -1885,6 +1885,8 @@
|
|||
"forward_open_party_is_self": "Questo è il tuo stesso indirizzo.",
|
||||
"message_actions": "Azioni messaggio",
|
||||
"save_image_to_device": "Salva immagine sul dispositivo",
|
||||
"copy_image_to_clipboard": "Copia immagine negli appunti",
|
||||
"image_copied_to_clipboard": "Immagine copiata negli appunti",
|
||||
"cancel_send": "Annulla invio",
|
||||
"react": "Reagisci",
|
||||
"reaction_you": "Tu",
|
||||
|
|
|
|||
|
|
@ -1833,6 +1833,8 @@
|
|||
"forward_open_party_is_self": "Dat is uw eigen adres.",
|
||||
"message_actions": "Berichtacties",
|
||||
"save_image_to_device": "Afbeelding opslaan op apparaat",
|
||||
"copy_image_to_clipboard": "Afbeelding naar klembord kopiëren",
|
||||
"image_copied_to_clipboard": "Afbeelding naar klembord gekopieerd",
|
||||
"cancel_send": "Verzending annuleren",
|
||||
"react": "Reageren",
|
||||
"reaction_you": "Jij",
|
||||
|
|
|
|||
|
|
@ -1885,6 +1885,8 @@
|
|||
"forward_open_party_is_self": "Это ваш собственный адрес.",
|
||||
"message_actions": "Действия с сообщением",
|
||||
"save_image_to_device": "Сохранить изображение на устройство",
|
||||
"copy_image_to_clipboard": "Копировать изображение в буфер обмена",
|
||||
"image_copied_to_clipboard": "Изображение скопировано в буфер обмена",
|
||||
"cancel_send": "Отменить отправку",
|
||||
"react": "Реакции",
|
||||
"reaction_you": "Вы",
|
||||
|
|
|
|||
|
|
@ -1833,6 +1833,8 @@
|
|||
"forward_open_party_is_self": "那是您自己的地址。",
|
||||
"message_actions": "消息操作",
|
||||
"save_image_to_device": "保存图片到设备",
|
||||
"copy_image_to_clipboard": "复制图片到剪贴板",
|
||||
"image_copied_to_clipboard": "图片已复制到剪贴板",
|
||||
"cancel_send": "取消发送",
|
||||
"react": "反应",
|
||||
"reaction_you": "您",
|
||||
|
|
|
|||
57
tests/electron/safeConsole.test.js
Normal file
57
tests/electron/safeConsole.test.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
import { createRequire } from "module";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const {
|
||||
isBrokenPipeError,
|
||||
installBrokenPipeGuards,
|
||||
safeConsoleLog,
|
||||
} = require("../../electron/safeConsole");
|
||||
|
||||
describe("safeConsole", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("detects EPIPE by code and message", () => {
|
||||
expect(isBrokenPipeError({ code: "EPIPE" })).toBe(true);
|
||||
expect(isBrokenPipeError({ errno: "EPIPE" })).toBe(true);
|
||||
expect(isBrokenPipeError(new Error("write EPIPE"))).toBe(true);
|
||||
expect(isBrokenPipeError(new Error("other"))).toBe(false);
|
||||
expect(isBrokenPipeError(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("installBrokenPipeGuards ignores EPIPE stream errors", () => {
|
||||
const handlers = [];
|
||||
const stream = {
|
||||
on(event, handler) {
|
||||
handlers.push([event, handler]);
|
||||
},
|
||||
};
|
||||
installBrokenPipeGuards({ stdout: stream, stderr: null });
|
||||
expect(handlers).toHaveLength(1);
|
||||
expect(handlers[0][0]).toBe("error");
|
||||
expect(() => handlers[0][1]({ code: "EPIPE" })).not.toThrow();
|
||||
installBrokenPipeGuards({ stdout: stream, stderr: null });
|
||||
expect(handlers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("safeConsoleLog swallows EPIPE from console.log", () => {
|
||||
const spy = vi.spyOn(console, "log").mockImplementation(() => {
|
||||
const err = new Error("write EPIPE");
|
||||
err.code = "EPIPE";
|
||||
throw err;
|
||||
});
|
||||
expect(() => safeConsoleLog("backend line")).not.toThrow();
|
||||
expect(spy).toHaveBeenCalledWith("backend line");
|
||||
});
|
||||
|
||||
it("safeConsoleLog rethrows non-EPIPE errors", () => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
expect(() => safeConsoleLog("x")).toThrow("boom");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import {
|
||||
copyTextToClipboard,
|
||||
copyImageBlobToClipboard,
|
||||
canUseAsyncClipboardImageWrite,
|
||||
readTextFromClipboard,
|
||||
isWindowSecureContext,
|
||||
} from "../../meshchatx/src/frontend/js/clipboardUtils.js";
|
||||
|
|
@ -78,4 +80,55 @@ describe("clipboardUtils", () => {
|
|||
expect(isWindowSecureContext()).toBe(false);
|
||||
Object.defineProperty(window, "isSecureContext", { configurable: true, value: prev });
|
||||
});
|
||||
|
||||
it("copyImageBlobToClipboard writes ClipboardItem when API is available", async () => {
|
||||
const write = vi.fn(() => Promise.resolve());
|
||||
vi.stubGlobal("navigator", {
|
||||
...navigator,
|
||||
clipboard: { write },
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"ClipboardItem",
|
||||
class ClipboardItem {
|
||||
constructor(items) {
|
||||
this.items = items;
|
||||
}
|
||||
},
|
||||
);
|
||||
const prev = window.isSecureContext;
|
||||
Object.defineProperty(window, "isSecureContext", { configurable: true, value: true });
|
||||
try {
|
||||
expect(canUseAsyncClipboardImageWrite()).toBe(true);
|
||||
const blob = new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" });
|
||||
const ok = await copyImageBlobToClipboard(blob);
|
||||
expect(ok).toBe(true);
|
||||
expect(write).toHaveBeenCalledTimes(1);
|
||||
const arg = write.mock.calls[0][0];
|
||||
expect(Array.isArray(arg)).toBe(true);
|
||||
expect(arg[0]).toBeInstanceOf(ClipboardItem);
|
||||
} finally {
|
||||
Object.defineProperty(window, "isSecureContext", {
|
||||
configurable: true,
|
||||
value: prev,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("copyImageBlobToClipboard returns false without clipboard write API", async () => {
|
||||
vi.stubGlobal("navigator", {
|
||||
...navigator,
|
||||
clipboard: { writeText: vi.fn() },
|
||||
});
|
||||
const prev = window.isSecureContext;
|
||||
Object.defineProperty(window, "isSecureContext", { configurable: true, value: true });
|
||||
try {
|
||||
const blob = new Blob([new Uint8Array([1])], { type: "image/png" });
|
||||
expect(await copyImageBlobToClipboard(blob)).toBe(false);
|
||||
} finally {
|
||||
Object.defineProperty(window, "isSecureContext", {
|
||||
configurable: true,
|
||||
value: prev,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue