mirror of
https://github.com/smartfrigde/armcord.git
synced 2026-08-19 14:23:23 +00:00
Compare commits
3 commits
022676cf65
...
dc7be47cbc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc7be47cbc |
||
|
|
69b8d4202e |
||
|
|
74231ca190 |
13 changed files with 615 additions and 9 deletions
28
.github/workflows/flatpak-node.yml
vendored
Normal file
28
.github/workflows/flatpak-node.yml
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
name: Upload generated-sources.json to release for Flatpak building
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types:
|
||||||
|
- published
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
upload:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install flatpak-node-generator
|
||||||
|
run: pipx install git+https://github.com/flatpak/flatpak-builder-tools.git#subdirectory=node
|
||||||
|
|
||||||
|
- name: Create generated-sources.json
|
||||||
|
run: /root/.local/bin/flatpak-node-generator pnpm pnpm-lock.yaml --node-sdk-extension org.freedesktop.Sdk.Extension.node26 --electron-node-headers
|
||||||
|
|
||||||
|
- name: Upload generated-sources.json to release
|
||||||
|
run: |
|
||||||
|
gh release upload ${{ github.event.release.tag_name }} generated-sources.json
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
82
docs/examples/clipboard-fallback-plugin/README.md
Normal file
82
docs/examples/clipboard-fallback-plugin/README.md
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
# Legcord Clipboard Fallback Plugin
|
||||||
|
|
||||||
|
Fixes Discord in-page copy actions in Legcord, including:
|
||||||
|
|
||||||
|
- Copy User ID
|
||||||
|
- Copy Message ID
|
||||||
|
- Copy Message Link
|
||||||
|
- Other Discord menu actions that call `navigator.clipboard.writeText(...)`
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
In affected Legcord/Electron environments, Discord's web UI calls:
|
||||||
|
|
||||||
|
```js
|
||||||
|
navigator.clipboard.writeText(text)
|
||||||
|
```
|
||||||
|
|
||||||
|
but Chromium rejects it, commonly with errors like:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NotAllowedError: Failed to execute 'writeText' on 'Clipboard': Document is not focused.
|
||||||
|
```
|
||||||
|
|
||||||
|
or Legcord logs:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Unable to determine render window for element [object HTMLDocument]
|
||||||
|
```
|
||||||
|
|
||||||
|
This plugin patches `navigator.clipboard.writeText` in the Discord page and falls back to a selection-based `document.execCommand("copy")` copy path.
|
||||||
|
|
||||||
|
## Known limitation
|
||||||
|
|
||||||
|
Legcord's native **Copy Image** context-menu action does not go through `navigator.clipboard.writeText` or `navigator.clipboard.write` in the page. It is handled by Electron's main-process context menu (`webContents.copyImageAt(...)`), so a renderer/custom-bundle plugin cannot reliably fix image copying. That needs a Legcord main-process fix or a filesystem plugin with main/preload access on newer Legcord versions.
|
||||||
|
|
||||||
|
## Install on Legcord versions with filesystem plugins
|
||||||
|
|
||||||
|
1. Open the Legcord plugins folder:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/Library/Application Support/legcord/plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create this folder:
|
||||||
|
|
||||||
|
```text
|
||||||
|
clipboard-fallback
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Copy these files into it:
|
||||||
|
|
||||||
|
```text
|
||||||
|
manifest.json
|
||||||
|
renderer.js
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Restart Legcord.
|
||||||
|
5. Enable **Clipboard Fallback** in Legcord's plugin settings.
|
||||||
|
|
||||||
|
## Older Legcord workaround: custom bundle
|
||||||
|
|
||||||
|
If your Legcord version does not have filesystem plugins yet, copy `custom-bundle.js` into:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/Library/Application Support/legcord/custom.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Do **not** use `renderer.js` as `custom.js`; `renderer.js` is the filesystem-plugin entry and expects Legcord's plugin loader to provide `module.exports`.
|
||||||
|
|
||||||
|
and add `"custom"` to the `mods` array in:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/Library/Application Support/legcord/storage/settings.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"mods": ["equicord", "custom"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart Legcord.
|
||||||
216
docs/examples/clipboard-fallback-plugin/custom-bundle.js
Normal file
216
docs/examples/clipboard-fallback-plugin/custom-bundle.js
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
(() => {
|
||||||
|
const module = { exports: {} };
|
||||||
|
const api = {
|
||||||
|
logger: {
|
||||||
|
log: (...args) => console.log("[ClipboardFallback]", ...args),
|
||||||
|
warn: (...args) => console.warn("[ClipboardFallback]", ...args),
|
||||||
|
error: (...args) => console.error("[ClipboardFallback]", ...args),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legcord Clipboard Fallback
|
||||||
|
*
|
||||||
|
* Discord's web UI uses navigator.clipboard.writeText for actions such as
|
||||||
|
* "Copy User ID" and "Copy Message Link", and navigator.clipboard.write for
|
||||||
|
* richer clipboard payloads such as images. In some Legcord/Electron/macOS
|
||||||
|
* combinations Chromium rejects those calls because the document is not focused
|
||||||
|
* or the clipboard permission is not granted, leaving the clipboard unchanged.
|
||||||
|
*
|
||||||
|
* This renderer plugin replaces those APIs with selection-based copy fallbacks
|
||||||
|
* that run inside the original click gesture.
|
||||||
|
*/
|
||||||
|
module.exports.activate = (api) => {
|
||||||
|
const PATCH_KEY = Symbol.for("legcord.clipboardFallback.installed");
|
||||||
|
|
||||||
|
function install() {
|
||||||
|
try {
|
||||||
|
if (!navigator.clipboard) {
|
||||||
|
api.logger.warn("navigator.clipboard is unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigator.clipboard[PATCH_KEY]) return;
|
||||||
|
|
||||||
|
const originalWriteText = navigator.clipboard.writeText?.bind(navigator.clipboard);
|
||||||
|
const originalWrite = navigator.clipboard.write?.bind(navigator.clipboard);
|
||||||
|
|
||||||
|
async function blobToDataUrl(blob) {
|
||||||
|
if (typeof FileReader !== "undefined") {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result));
|
||||||
|
reader.onerror = () => reject(reader.error ?? new Error("Failed to read clipboard image"));
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test/runtime fallback for environments with Blob but no FileReader.
|
||||||
|
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||||
|
const base64 = typeof btoa === "function" ? btoa(binary) : Buffer.from(bytes).toString("base64");
|
||||||
|
return `data:${blob.type || "application/octet-stream"};base64,${base64}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectElementForCopy(element) {
|
||||||
|
const previousActiveElement = document.activeElement;
|
||||||
|
const selection = window.getSelection?.() ?? globalThis.getSelection?.();
|
||||||
|
const range = document.createRange();
|
||||||
|
|
||||||
|
element.focus?.();
|
||||||
|
range.selectNodeContents(element);
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
|
||||||
|
const copied = document.execCommand("copy");
|
||||||
|
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
if (previousActiveElement && typeof previousActiveElement.focus === "function") {
|
||||||
|
try {
|
||||||
|
previousActiveElement.focus();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!copied) throw new Error("document.execCommand('copy') returned false");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fallbackCopyText(text) {
|
||||||
|
const value = String(text);
|
||||||
|
const parent = document.body || document.documentElement;
|
||||||
|
if (!parent) throw new Error("No document body available for clipboard fallback");
|
||||||
|
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = value;
|
||||||
|
textarea.setAttribute("readonly", "");
|
||||||
|
textarea.setAttribute("aria-hidden", "true");
|
||||||
|
textarea.style.position = "fixed";
|
||||||
|
textarea.style.left = "-9999px";
|
||||||
|
textarea.style.top = "0";
|
||||||
|
textarea.style.width = "1px";
|
||||||
|
textarea.style.height = "1px";
|
||||||
|
textarea.style.opacity = "0";
|
||||||
|
textarea.style.pointerEvents = "none";
|
||||||
|
|
||||||
|
parent.appendChild(textarea);
|
||||||
|
|
||||||
|
const previousActiveElement = document.activeElement;
|
||||||
|
textarea.focus();
|
||||||
|
textarea.select();
|
||||||
|
textarea.setSelectionRange(0, value.length);
|
||||||
|
|
||||||
|
const copied = document.execCommand("copy");
|
||||||
|
textarea.remove();
|
||||||
|
|
||||||
|
if (previousActiveElement && typeof previousActiveElement.focus === "function") {
|
||||||
|
try {
|
||||||
|
previousActiveElement.focus();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!copied) throw new Error("document.execCommand('copy') returned false");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getClipboardItemType(item, type) {
|
||||||
|
if (!item?.types?.includes(type) || typeof item.getType !== "function") return null;
|
||||||
|
return await item.getType(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fallbackCopyItems(items) {
|
||||||
|
const parent = document.body || document.documentElement;
|
||||||
|
if (!parent) throw new Error("No document body available for clipboard fallback");
|
||||||
|
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.contentEditable = "true";
|
||||||
|
container.setAttribute("aria-hidden", "true");
|
||||||
|
container.style.position = "fixed";
|
||||||
|
container.style.left = "-9999px";
|
||||||
|
container.style.top = "0";
|
||||||
|
container.style.width = "1px";
|
||||||
|
container.style.height = "1px";
|
||||||
|
container.style.overflow = "hidden";
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const htmlBlob = await getClipboardItemType(item, "text/html");
|
||||||
|
if (htmlBlob) {
|
||||||
|
container.innerHTML += await htmlBlob.text();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textBlob = await getClipboardItemType(item, "text/plain");
|
||||||
|
if (textBlob) {
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.textContent = await textBlob.text();
|
||||||
|
container.appendChild(span);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageType = item?.types?.find((type) => type.startsWith("image/"));
|
||||||
|
if (imageType && typeof item.getType === "function") {
|
||||||
|
const imageBlob = await item.getType(imageType);
|
||||||
|
const image = document.createElement("img");
|
||||||
|
image.src = await blobToDataUrl(imageBlob);
|
||||||
|
image.alt = "";
|
||||||
|
container.appendChild(image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!container.innerHTML && !container.textContent && !container.children?.length) {
|
||||||
|
throw new Error("No supported clipboard item types found");
|
||||||
|
}
|
||||||
|
|
||||||
|
parent.appendChild(container);
|
||||||
|
selectElementForCopy(container);
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalWriteText) {
|
||||||
|
Object.defineProperty(navigator.clipboard, "writeText", {
|
||||||
|
configurable: true,
|
||||||
|
value: async (text) => {
|
||||||
|
try {
|
||||||
|
await fallbackCopyText(text);
|
||||||
|
api.logger.log("copied text via fallback", text);
|
||||||
|
} catch (fallbackError) {
|
||||||
|
api.logger.warn("text fallback failed; trying original writeText", fallbackError);
|
||||||
|
return originalWriteText(text);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalWrite) {
|
||||||
|
Object.defineProperty(navigator.clipboard, "write", {
|
||||||
|
configurable: true,
|
||||||
|
value: async (items) => {
|
||||||
|
try {
|
||||||
|
await fallbackCopyItems(items);
|
||||||
|
api.logger.log("copied rich clipboard payload via fallback");
|
||||||
|
} catch (fallbackError) {
|
||||||
|
api.logger.warn("rich clipboard fallback failed; trying original write", fallbackError);
|
||||||
|
return originalWrite(items);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(navigator.clipboard, PATCH_KEY, {
|
||||||
|
configurable: false,
|
||||||
|
enumerable: false,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
api.logger.log("installed");
|
||||||
|
} catch (error) {
|
||||||
|
api.logger.error("install failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
install();
|
||||||
|
window.addEventListener("DOMContentLoaded", install, { once: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module.exports.activate === "function") {
|
||||||
|
module.exports.activate(api);
|
||||||
|
}
|
||||||
|
})();
|
||||||
9
docs/examples/clipboard-fallback-plugin/manifest.json
Normal file
9
docs/examples/clipboard-fallback-plugin/manifest.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"id": "clipboard-fallback",
|
||||||
|
"name": "Clipboard Fallback",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"description": "Fixes Discord in-page text copy actions in Legcord by falling back to document.execCommand('copy') when navigator.clipboard.writeText is blocked.",
|
||||||
|
"author": "Nigel Thornberry",
|
||||||
|
"compatibleVersions": ["*"],
|
||||||
|
"renderer": "renderer.js"
|
||||||
|
}
|
||||||
201
docs/examples/clipboard-fallback-plugin/renderer.js
Normal file
201
docs/examples/clipboard-fallback-plugin/renderer.js
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
/**
|
||||||
|
* Legcord Clipboard Fallback
|
||||||
|
*
|
||||||
|
* Discord's web UI uses navigator.clipboard.writeText for actions such as
|
||||||
|
* "Copy User ID" and "Copy Message Link", and navigator.clipboard.write for
|
||||||
|
* richer clipboard payloads such as images. In some Legcord/Electron/macOS
|
||||||
|
* combinations Chromium rejects those calls because the document is not focused
|
||||||
|
* or the clipboard permission is not granted, leaving the clipboard unchanged.
|
||||||
|
*
|
||||||
|
* This renderer plugin replaces those APIs with selection-based copy fallbacks
|
||||||
|
* that run inside the original click gesture.
|
||||||
|
*/
|
||||||
|
module.exports.activate = (api) => {
|
||||||
|
const PATCH_KEY = Symbol.for("legcord.clipboardFallback.installed");
|
||||||
|
|
||||||
|
function install() {
|
||||||
|
try {
|
||||||
|
if (!navigator.clipboard) {
|
||||||
|
api.logger.warn("navigator.clipboard is unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigator.clipboard[PATCH_KEY]) return;
|
||||||
|
|
||||||
|
const originalWriteText = navigator.clipboard.writeText?.bind(navigator.clipboard);
|
||||||
|
const originalWrite = navigator.clipboard.write?.bind(navigator.clipboard);
|
||||||
|
|
||||||
|
async function blobToDataUrl(blob) {
|
||||||
|
if (typeof FileReader !== "undefined") {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result));
|
||||||
|
reader.onerror = () => reject(reader.error ?? new Error("Failed to read clipboard image"));
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test/runtime fallback for environments with Blob but no FileReader.
|
||||||
|
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||||
|
const base64 = typeof btoa === "function" ? btoa(binary) : Buffer.from(bytes).toString("base64");
|
||||||
|
return `data:${blob.type || "application/octet-stream"};base64,${base64}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectElementForCopy(element) {
|
||||||
|
const previousActiveElement = document.activeElement;
|
||||||
|
const selection = window.getSelection?.() ?? globalThis.getSelection?.();
|
||||||
|
const range = document.createRange();
|
||||||
|
|
||||||
|
element.focus?.();
|
||||||
|
range.selectNodeContents(element);
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
|
||||||
|
const copied = document.execCommand("copy");
|
||||||
|
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
if (previousActiveElement && typeof previousActiveElement.focus === "function") {
|
||||||
|
try {
|
||||||
|
previousActiveElement.focus();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!copied) throw new Error("document.execCommand('copy') returned false");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fallbackCopyText(text) {
|
||||||
|
const value = String(text);
|
||||||
|
const parent = document.body || document.documentElement;
|
||||||
|
if (!parent) throw new Error("No document body available for clipboard fallback");
|
||||||
|
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = value;
|
||||||
|
textarea.setAttribute("readonly", "");
|
||||||
|
textarea.setAttribute("aria-hidden", "true");
|
||||||
|
textarea.style.position = "fixed";
|
||||||
|
textarea.style.left = "-9999px";
|
||||||
|
textarea.style.top = "0";
|
||||||
|
textarea.style.width = "1px";
|
||||||
|
textarea.style.height = "1px";
|
||||||
|
textarea.style.opacity = "0";
|
||||||
|
textarea.style.pointerEvents = "none";
|
||||||
|
|
||||||
|
parent.appendChild(textarea);
|
||||||
|
|
||||||
|
const previousActiveElement = document.activeElement;
|
||||||
|
textarea.focus();
|
||||||
|
textarea.select();
|
||||||
|
textarea.setSelectionRange(0, value.length);
|
||||||
|
|
||||||
|
const copied = document.execCommand("copy");
|
||||||
|
textarea.remove();
|
||||||
|
|
||||||
|
if (previousActiveElement && typeof previousActiveElement.focus === "function") {
|
||||||
|
try {
|
||||||
|
previousActiveElement.focus();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!copied) throw new Error("document.execCommand('copy') returned false");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getClipboardItemType(item, type) {
|
||||||
|
if (!item?.types?.includes(type) || typeof item.getType !== "function") return null;
|
||||||
|
return await item.getType(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fallbackCopyItems(items) {
|
||||||
|
const parent = document.body || document.documentElement;
|
||||||
|
if (!parent) throw new Error("No document body available for clipboard fallback");
|
||||||
|
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.contentEditable = "true";
|
||||||
|
container.setAttribute("aria-hidden", "true");
|
||||||
|
container.style.position = "fixed";
|
||||||
|
container.style.left = "-9999px";
|
||||||
|
container.style.top = "0";
|
||||||
|
container.style.width = "1px";
|
||||||
|
container.style.height = "1px";
|
||||||
|
container.style.overflow = "hidden";
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const htmlBlob = await getClipboardItemType(item, "text/html");
|
||||||
|
if (htmlBlob) {
|
||||||
|
container.innerHTML += await htmlBlob.text();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textBlob = await getClipboardItemType(item, "text/plain");
|
||||||
|
if (textBlob) {
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.textContent = await textBlob.text();
|
||||||
|
container.appendChild(span);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageType = item?.types?.find((type) => type.startsWith("image/"));
|
||||||
|
if (imageType && typeof item.getType === "function") {
|
||||||
|
const imageBlob = await item.getType(imageType);
|
||||||
|
const image = document.createElement("img");
|
||||||
|
image.src = await blobToDataUrl(imageBlob);
|
||||||
|
image.alt = "";
|
||||||
|
container.appendChild(image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!container.innerHTML && !container.textContent && !container.children?.length) {
|
||||||
|
throw new Error("No supported clipboard item types found");
|
||||||
|
}
|
||||||
|
|
||||||
|
parent.appendChild(container);
|
||||||
|
selectElementForCopy(container);
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalWriteText) {
|
||||||
|
Object.defineProperty(navigator.clipboard, "writeText", {
|
||||||
|
configurable: true,
|
||||||
|
value: async (text) => {
|
||||||
|
try {
|
||||||
|
await fallbackCopyText(text);
|
||||||
|
api.logger.log("copied text via fallback", text);
|
||||||
|
} catch (fallbackError) {
|
||||||
|
api.logger.warn("text fallback failed; trying original writeText", fallbackError);
|
||||||
|
return originalWriteText(text);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalWrite) {
|
||||||
|
Object.defineProperty(navigator.clipboard, "write", {
|
||||||
|
configurable: true,
|
||||||
|
value: async (items) => {
|
||||||
|
try {
|
||||||
|
await fallbackCopyItems(items);
|
||||||
|
api.logger.log("copied rich clipboard payload via fallback");
|
||||||
|
} catch (fallbackError) {
|
||||||
|
api.logger.warn("rich clipboard fallback failed; trying original write", fallbackError);
|
||||||
|
return originalWrite(items);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(navigator.clipboard, PATCH_KEY, {
|
||||||
|
configurable: false,
|
||||||
|
enumerable: false,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
api.logger.log("installed");
|
||||||
|
} catch (error) {
|
||||||
|
api.logger.error("install failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
install();
|
||||||
|
window.addEventListener("DOMContentLoaded", install, { once: true });
|
||||||
|
};
|
||||||
|
|
@ -115,6 +115,11 @@ A complete example is available at:
|
||||||
- `docs/examples/hello-plugin/preload.js`
|
- `docs/examples/hello-plugin/preload.js`
|
||||||
- `docs/examples/hello-plugin/renderer.js`
|
- `docs/examples/hello-plugin/renderer.js`
|
||||||
|
|
||||||
|
A practical renderer-only workaround plugin is also available at:
|
||||||
|
|
||||||
|
- `docs/examples/clipboard-fallback-plugin/manifest.json`
|
||||||
|
- `docs/examples/clipboard-fallback-plugin/renderer.js`
|
||||||
|
|
||||||
To test it:
|
To test it:
|
||||||
|
|
||||||
1. Copy `docs/examples/hello-plugin` into your runtime plugins directory:
|
1. Copy `docs/examples/hello-plugin` into your runtime plugins directory:
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { applyAppImageSandboxFix } from "./scripts/build/sandboxFix.mjs";
|
||||||
export const config: Configuration = {
|
export const config: Configuration = {
|
||||||
appId: "app.legcord.Legcord",
|
appId: "app.legcord.Legcord",
|
||||||
productName: "Legcord",
|
productName: "Legcord",
|
||||||
|
// Biome treats electron-builder macro placeholders as template syntax.
|
||||||
|
// biome-ignore lint/suspicious/noTemplateCurlyInString: electron-builder expands these placeholders.
|
||||||
artifactName: "Legcord-${version}-${os}-${arch}.${ext}",
|
artifactName: "Legcord-${version}-${os}-${arch}.${ext}",
|
||||||
beforePack: applyAppImageSandboxFix,
|
beforePack: applyAppImageSandboxFix,
|
||||||
protocols: [
|
protocols: [
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@
|
||||||
"node": ">=26"
|
"node": ">=26"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:dev": "rollup -c --environment BUILD:dev && node scripts/copyVenmic.ts",
|
"build:dev": "rollup -c --environment BUILD:dev && node --experimental-strip-types scripts/copyVenmic.ts",
|
||||||
"build:plugins": "lune ci --repoSubDir src/shelter --to ts-out/plugins",
|
"build:plugins": "lune ci --repoSubDir src/shelter --to ts-out/plugins",
|
||||||
"build": "pnpm build:plugins && rolldown -c rolldown.config.ts && node scripts/copyVenmic.ts",
|
"build": "pnpm build:plugins && rolldown -c rolldown.config.ts && node --experimental-strip-types scripts/copyVenmic.ts",
|
||||||
"start": "pnpm run build && electron --trace-warnings --ozone-platform-hint=auto ./ts-out/main.js",
|
"start": "pnpm run build && electron --trace-warnings --ozone-platform-hint=auto ./ts-out/main.js",
|
||||||
"startThemeManager": "pnpm run build:dev && electron ./ts-out/main.js themes",
|
"startThemeManager": "pnpm run build:dev && electron ./ts-out/main.js themes",
|
||||||
"package": "pnpm run build && electron-builder",
|
"package": "pnpm run build && electron-builder",
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
"lint:fix": "biome check --write",
|
"lint:fix": "biome check --write",
|
||||||
"postinstall": "electron-builder install-app-deps",
|
"postinstall": "electron-builder install-app-deps",
|
||||||
"CIbuild": "pnpm run build && electron-builder --linux zip && electron-builder --windows zip && electron-builder --macos zip",
|
"CIbuild": "pnpm run build && electron-builder --linux zip && electron-builder --windows zip && electron-builder --macos zip",
|
||||||
"updateMeta": "node scripts/utils/updateMeta.ts"
|
"updateMeta": "node --experimental-strip-types scripts/utils/updateMeta.ts"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export function openCssEditor(file: string) {
|
||||||
preload: path.join(import.meta.dirname, "cssEditor", "preload.mjs"),
|
preload: path.join(import.meta.dirname, "cssEditor", "preload.mjs"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
cssWindow.loadURL(`file://${import.meta.dirname}/html/editor.html`);
|
cssWindow.loadURL("legcord://html/editor.html");
|
||||||
|
|
||||||
ipcMain.on("editor-setCSS", (_event, css: string) => {
|
ipcMain.on("editor-setCSS", (_event, css: string) => {
|
||||||
fs.writeFileSync(file, css);
|
fs.writeFileSync(file, css);
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,11 @@ import {
|
||||||
app,
|
app,
|
||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
type BrowserWindowConstructorOptions,
|
type BrowserWindowConstructorOptions,
|
||||||
|
clipboard,
|
||||||
dialog,
|
dialog,
|
||||||
type MessageBoxOptions,
|
type MessageBoxOptions,
|
||||||
nativeImage,
|
nativeImage,
|
||||||
|
net,
|
||||||
screen,
|
screen,
|
||||||
shell,
|
shell,
|
||||||
} from "electron";
|
} from "electron";
|
||||||
|
|
@ -75,10 +77,42 @@ function saveWindowState(win: BrowserWindow): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyImageFromContext(
|
||||||
|
parameters: { srcURL: string; x: number; y: number },
|
||||||
|
win?: BrowserWindow,
|
||||||
|
): Promise<void> {
|
||||||
|
if (parameters.srcURL) {
|
||||||
|
try {
|
||||||
|
const response = await net.fetch(parameters.srcURL);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
||||||
|
|
||||||
|
const image = nativeImage.createFromBuffer(Buffer.from(await response.arrayBuffer()));
|
||||||
|
if (!image.isEmpty()) {
|
||||||
|
clipboard.writeImage(image);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[ContextMenu] Failed to copy image from URL, falling back to copyImageAt:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
win?.webContents.copyImageAt(parameters.x, parameters.y);
|
||||||
|
}
|
||||||
|
|
||||||
contextMenu({
|
contextMenu({
|
||||||
showSaveImageAs: true,
|
showSaveImageAs: true,
|
||||||
|
showCopyImage: false,
|
||||||
showCopyImageAddress: true,
|
showCopyImageAddress: true,
|
||||||
showSearchWithGoogle: false,
|
showSearchWithGoogle: false,
|
||||||
|
append: (_defaultActions, parameters, win) => [
|
||||||
|
{
|
||||||
|
label: "Copy Image",
|
||||||
|
visible: parameters.mediaType === "image",
|
||||||
|
click: () => {
|
||||||
|
void copyImageFromContext(parameters, win as BrowserWindow | undefined);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
prepend: (_defaultActions, parameters) => [
|
prepend: (_defaultActions, parameters) => [
|
||||||
{
|
{
|
||||||
label: getLang("contextMenu-searchGoogle"),
|
label: getLang("contextMenu-searchGoogle"),
|
||||||
|
|
@ -423,7 +457,7 @@ function doAfterDefiningTheWindow(passedWindow: BrowserWindow): void {
|
||||||
lastPolledBounds = { x, y, width, height };
|
lastPolledBounds = { x, y, width, height };
|
||||||
saveWindowState(passedWindow);
|
saveWindowState(passedWindow);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
// ignore transient errors
|
// ignore transient errors
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
@ -520,7 +554,7 @@ export function createWindow() {
|
||||||
mainWindow.setPosition(storedBounds.x, storedBounds.y);
|
mainWindow.setPosition(storedBounds.x, storedBounds.y);
|
||||||
mainWindow.setSize(storedBounds.width, storedBounds.height);
|
mainWindow.setSize(storedBounds.width, storedBounds.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
mainWindows.push(mainWindow);
|
mainWindows.push(mainWindow);
|
||||||
doAfterDefiningTheWindow(mainWindow);
|
doAfterDefiningTheWindow(mainWindow);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,26 @@ void app.whenReady().then(() => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return net.fetch(Url.pathToFileURL(filePath).toString());
|
return net.fetch(Url.pathToFileURL(filePath).toString());
|
||||||
|
} else if (req.url.startsWith("legcord://html/")) {
|
||||||
|
const file = req.url.replace("legcord://html/", "");
|
||||||
|
const filePath = path.join(import.meta.dirname, "html", `${file}`);
|
||||||
|
if (filePath.includes("..")) {
|
||||||
|
return new Response("bad", {
|
||||||
|
status: 400,
|
||||||
|
headers: { "content-type": "text/html" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return net.fetch(Url.pathToFileURL(filePath).toString());
|
||||||
|
} else if (req.url.startsWith("legcord://js/")) {
|
||||||
|
const file = req.url.replace("legcord://js/", "");
|
||||||
|
const filePath = path.join(import.meta.dirname, "js", `${file}`);
|
||||||
|
if (filePath.includes("..")) {
|
||||||
|
return new Response("bad", {
|
||||||
|
status: 400,
|
||||||
|
headers: { "content-type": "text/html" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return net.fetch(Url.pathToFileURL(filePath).toString());
|
||||||
} else if (req.url.startsWith("legcord://assets/")) {
|
} else if (req.url.startsWith("legcord://assets/")) {
|
||||||
const file = req.url.replace("legcord://assets/", "");
|
const file = req.url.replace("legcord://assets/", "");
|
||||||
const filePath = path.join(import.meta.dirname, "assets", "app", `${file}`);
|
const filePath = path.join(import.meta.dirname, "assets", "app", `${file}`);
|
||||||
|
|
@ -39,6 +59,16 @@ void app.whenReady().then(() => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return net.fetch(Url.pathToFileURL(filePath).toString());
|
return net.fetch(Url.pathToFileURL(filePath).toString());
|
||||||
|
} else if (req.url.startsWith("legcord://css/")) {
|
||||||
|
const file = req.url.replace("legcord://css/", "");
|
||||||
|
const filePath = path.join(import.meta.dirname, "css", `${file}`);
|
||||||
|
if (filePath.includes("..")) {
|
||||||
|
return new Response("bad", {
|
||||||
|
status: 400,
|
||||||
|
headers: { "content-type": "text/html" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return net.fetch(Url.pathToFileURL(filePath).toString());
|
||||||
} else if (req.url.startsWith("legcord://local/")) {
|
} else if (req.url.startsWith("legcord://local/")) {
|
||||||
const file = req.url.replace("legcord://local/", "");
|
const file = req.url.replace("legcord://local/", "");
|
||||||
const userDataPath = path.join(app.getPath("userData"), "userAssets");
|
const userDataPath = path.join(app.getPath("userData"), "userAssets");
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ export async function createSetupWindow(): Promise<void> {
|
||||||
maximizable: false,
|
maximizable: false,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
sandbox: true,
|
|
||||||
spellcheck: false,
|
spellcheck: false,
|
||||||
preload: path.join(import.meta.dirname, "setup", "preload.mjs"),
|
preload: path.join(import.meta.dirname, "setup", "preload.mjs"),
|
||||||
},
|
},
|
||||||
|
|
@ -70,6 +69,6 @@ export async function createSetupWindow(): Promise<void> {
|
||||||
// workaround electron trying to relaunch from squashfs
|
// workaround electron trying to relaunch from squashfs
|
||||||
handleRestart();
|
handleRestart();
|
||||||
});
|
});
|
||||||
void setupWindow.loadFile(path.join(import.meta.dirname, "/html/setup.html"));
|
void setupWindow.loadURL("legcord://html/setup.html");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,5 +31,5 @@ export async function createSplashWindow(): Promise<void> {
|
||||||
ipcMain.on("splash-clientmod", (event) => {
|
ipcMain.on("splash-clientmod", (event) => {
|
||||||
event.returnValue = getConfig("mods");
|
event.returnValue = getConfig("mods");
|
||||||
});
|
});
|
||||||
await splashWindow.loadFile(path.join(import.meta.dirname, "html", "splash.html"));
|
await splashWindow.loadURL("legcord://html/splash.html");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue