feat: add "Copy image to clipboard" functionality in message context menus and improve logging with broken-pipe guards

This commit is contained in:
Ivan 2026-07-24 04:52:23 -05:00
parent 5870af2db9
commit 514005e995
No known key found for this signature in database
17 changed files with 383 additions and 8 deletions

View file

@ -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

View file

@ -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
View 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,
};

Binary file not shown.

View file

@ -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)

View file

@ -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 }>}
*/

View file

@ -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",

View file

@ -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",

View file

@ -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ú.",

View file

@ -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ä",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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": "Вы",

View file

@ -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": "您",

View 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");
});
});

View file

@ -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,
});
}
});
});