feat(desktop): upload images through a remote PicList

Allow a saved PicGo or PicList address beyond loopback, and send the
optional server key on both the connection check and the upload.
This commit is contained in:
tianma-if committed 2026-10-24 11:03:24 +08:00
1 parent f7f10e64ee
commit cf3435de43
17 files changed
+297 -67

No files matched your search

+3 -2
View File
@@ -1870,9 +1870,10 @@ const startApplication = async () => {
await saveDesktopSessionToken("");
return { stored: false };
});
ipcMain.handle("desktop:picgo-test", (event, endpoint) => {
ipcMain.handle("desktop:picgo-test", (event, input) => {
if (event.sender !== mainWindow?.webContents) throw new Error("PicGo requests must come from the main window");
return testPicGoConnection(endpoint);
const target = input && typeof input === "object" ? input : { endpoint: input, serverKey: "" };
return testPicGoConnection(target.endpoint, { serverKey: target.serverKey });
});
// Serialize uploads and error dialogs across rapid pastes and multi-image batches.
const queuePicGoUpload = createPicGoUploadQueue({
+112 -17
View File
@@ -1,18 +1,108 @@
// PicGo owns uploader credentials. This bridge only sends image bytes to its
// local HTTP server; it never reads the clipboard or writes temporary files.
export const normalizePicGoEndpoint = (value) => {
const url = new URL(value);
if (url.protocol !== "http:" || !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)
|| url.username || url.password || url.hash || url.search || url.pathname !== "/upload") {
throw new Error("Use a local PicGo address such as http://127.0.0.1:36677/upload");
// PicGo and PicList own the image-host credentials. This bridge only posts
// image bytes to the server address the user saved. It never reads the
// clipboard or writes temporary files.
import { lookup } from "node:dns/promises";
import { BlockList, isIP } from "node:net";
const ADDRESS_ERROR = "Use a PicGo or PicList upload address such as http://127.0.0.1:36677/upload";
const BLOCKED_HOSTNAMES = new Set(["metadata.google.internal", "metadata.goog"]);
const blockedAddresses = new BlockList();
blockedAddresses.addSubnet("0.0.0.0", 8, "ipv4");
blockedAddresses.addSubnet("169.254.0.0", 16, "ipv4");
blockedAddresses.addSubnet("224.0.0.0", 4, "ipv4");
blockedAddresses.addSubnet("240.0.0.0", 4, "ipv4");
blockedAddresses.addAddress("100.100.100.200", "ipv4");
blockedAddresses.addSubnet("fe80::", 10, "ipv6");
blockedAddresses.addSubnet("ff00::", 8, "ipv6");
blockedAddresses.addAddress("::", "ipv6");
blockedAddresses.addAddress("fd00:ec2::254", "ipv6");
const canonicalHost = (hostname) => {
let host = String(hostname).toLowerCase();
if (host.endsWith(".")) host = host.slice(0, -1);
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
return host;
};
const isLiteralHost = (host) => host.includes(":") || /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
// Link-local and cloud metadata addresses are rejected. LAN and public
// addresses stay available because a remote PicList usually lives on a NAS.
export const isBlockedPicGoAddress = (address) => {
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(address);
const candidate = mapped ? mapped[1] : address;
const family = isIP(candidate);
if (family === 4) {
const canonical = candidate.split(".").map((part) => String(Number(part))).join(".");
if (canonical !== candidate) return true;
return blockedAddresses.check(candidate, "ipv4");
}
if (family === 6) return blockedAddresses.check(candidate, "ipv6");
return true;
};
const assertHostShape = (hostname) => {
const host = canonicalHost(hostname);
if (!host || BLOCKED_HOSTNAMES.has(host) || /^\d+$/.test(host)) throw new Error(ADDRESS_ERROR);
if (isLiteralHost(host) && isBlockedPicGoAddress(host)) throw new Error(ADDRESS_ERROR);
};
export const normalizePicGoServerKey = (value) => {
if (value == null || value === "") return "";
if (typeof value !== "string") throw new Error("PicGo server key is invalid");
const key = value.trim();
if (!key) return "";
if (key.length > 512 || /[\u0000-\u0020\u007f]/.test(key)) throw new Error("PicGo server key is invalid");
return key;
};
export const normalizePicGoEndpoint = (value) => {
let url;
try { url = new URL(value); }
catch { throw new Error(ADDRESS_ERROR); }
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.hash || url.search || url.pathname !== "/upload") {
throw new Error(ADDRESS_ERROR);
}
assertHostShape(url.hostname);
return url;
};
const buildPicGoUrl = (endpoint, serverKey, pathname) => {
const url = normalizePicGoEndpoint(endpoint);
url.pathname = pathname;
const key = normalizePicGoServerKey(serverKey);
if (key) url.searchParams.set("key", key);
return url;
};
const assertPicGoHostAllowed = async (hostname, lookupImpl) => {
assertHostShape(hostname);
const host = canonicalHost(hostname);
if (isLiteralHost(host)) return;
let records;
try {
records = await lookupImpl(host, { all: true, verbatim: true });
} catch {
throw new Error("PicGo host could not be resolved");
}
if (!Array.isArray(records) || records.length === 0) throw new Error("PicGo host could not be resolved");
for (const record of records) {
if (!record || typeof record.address !== "string" || isBlockedPicGoAddress(record.address)) throw new Error(ADDRESS_ERROR);
}
};
const request = async (url, body, fetchImpl, timeoutMs) => {
const response = await fetchImpl(url, {
method: "POST", body, redirect: "error", signal: AbortSignal.timeout(timeoutMs),
});
let response;
try {
response = await fetchImpl(url, {
method: "POST", body, redirect: "error", signal: AbortSignal.timeout(timeoutMs),
});
} catch (error) {
const name = error && typeof error === "object" ? error.name : "";
if (name === "TimeoutError" || name === "AbortError") throw new Error("PicGo did not respond in time");
throw new Error("PicGo could not be reached");
}
if (!response.ok) throw new Error(`PicGo HTTP ${response.status}`);
const reader = response.body?.getReader();
if (!reader) throw new Error("PicGo returned an empty response");
@@ -33,19 +123,24 @@ const request = async (url, body, fetchImpl, timeoutMs) => {
const bytes = new Uint8Array(length);
let offset = 0;
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
return JSON.parse(new TextDecoder().decode(bytes));
try {
return JSON.parse(new TextDecoder().decode(bytes));
} catch {
throw new Error("PicGo returned an unreadable response");
}
};
export const testPicGoConnection = async (endpoint, { fetchImpl = fetch, timeoutMs = 5000 } = {}) => {
const url = normalizePicGoEndpoint(endpoint);
url.pathname = "/heartbeat";
export const testPicGoConnection = async (endpoint, { fetchImpl = fetch, timeoutMs = 5000, serverKey = "", lookupImpl = lookup } = {}) => {
const url = buildPicGoUrl(endpoint, serverKey, "/heartbeat");
await assertPicGoHostAllowed(url.hostname, lookupImpl);
const data = await request(url, undefined, fetchImpl, timeoutMs);
if (data?.success !== true || data.result !== "alive") throw new Error("The local service did not identify itself as PicGo");
if (data?.success !== true || data.result !== "alive") throw new Error("The service did not identify itself as PicGo or PicList");
return true;
};
export const uploadPicGoImage = async (input, { fetchImpl = fetch, timeoutMs = 60000 } = {}) => {
const url = normalizePicGoEndpoint(input.endpoint);
export const uploadPicGoImage = async (input, { fetchImpl = fetch, timeoutMs = 60000, lookupImpl = lookup } = {}) => {
const url = buildPicGoUrl(input.endpoint, input.serverKey, "/upload");
await assertPicGoHostAllowed(url.hostname, lookupImpl);
const bytes = input.bytes;
if (!(bytes instanceof Uint8Array) || bytes.byteLength === 0 || bytes.byteLength > 50 * 1024 * 1024
|| typeof input.mimeType !== "string" || !input.mimeType.startsWith("image/")) {
+80 -4
View File
@@ -1,5 +1,5 @@
import { afterEach, expect, test } from "bun:test";
import { createPicGoUploadQueue, normalizePicGoEndpoint, testPicGoConnection, uploadPicGoImage } from "./picgo.mjs";
import { createPicGoUploadQueue, isBlockedPicGoAddress, normalizePicGoEndpoint, normalizePicGoServerKey, testPicGoConnection, uploadPicGoImage } from "./picgo.mjs";
const servers = [];
afterEach(() => { for (const server of servers.splice(0)) server.stop(true); });
@@ -47,11 +47,87 @@ test("rejects wrong services, failed uploads and unsafe or ambiguous URLs", asyn
}
});
test("only accepts local upload endpoints and image bytes", async () => {
for (const endpoint of ["https://example.com/upload", "http://192.168.0.1/upload", "http://127.0.0.1/other", "http://user:pass@localhost/upload", "http://localhost/upload?secret=foo", "http://localhost/upload#foo"]) {
test("accepts a remote PicList address and keeps its server key on heartbeat and upload", async () => {
expect(normalizePicGoEndpoint("https://piclist.example.com/upload").origin).toBe("https://piclist.example.com");
expect(normalizePicGoEndpoint("http://192.168.1.20:36677/upload").hostname).toBe("192.168.1.20");
expect(normalizePicGoEndpoint("http://[::1]:36677/upload").hostname).toBe("[::1]");
expect(normalizePicGoServerKey(" nas-key ")).toBe("nas-key");
const requests = [];
const endpoint = serve(async (request) => {
const url = new URL(request.url);
requests.push([url.pathname, url.searchParams.get("key"), request.method]);
if (url.pathname === "/heartbeat") return Response.json({ success: true, result: "alive" });
return Response.json({ success: true, result: ["https://images.example.com/picture.png"] });
});
expect(await testPicGoConnection(endpoint, { serverKey: "nas-key" })).toBe(true);
expect(await uploadPicGoImage({ ...image(endpoint), serverKey: "nas-key" })).toEqual({ url: "https://images.example.com/picture.png" });
expect(requests).toEqual([["/heartbeat", "nas-key", "POST"], ["/upload", "nas-key", "POST"]]);
});
test("resolves a remote name before connecting and refuses link-local targets", async () => {
const seen = [];
await testPicGoConnection("https://piclist.example.com:8443/upload", {
serverKey: "secret",
lookupImpl: async () => [{ address: "203.0.113.10", family: 4 }],
fetchImpl: async (url, init) => {
const parsed = new URL(url);
seen.push([parsed.origin, parsed.pathname, parsed.searchParams.get("key"), init.redirect]);
return Response.json({ success: true, result: "alive" });
},
});
expect(seen).toEqual([["https://piclist.example.com:8443", "/heartbeat", "secret", "error"]]);
let fetched = false;
await expect(testPicGoConnection("https://piclist.example.com/upload", {
serverKey: "secret",
lookupImpl: async () => [{ address: "169.254.169.254", family: 4 }],
fetchImpl: () => { fetched = true; throw new Error("must not fetch"); },
})).rejects.toThrow();
expect(fetched).toBe(false);
await expect(uploadPicGoImage({ ...image("http://169.254.169.254/upload"), serverKey: "secret" })).rejects.toThrow();
});
test("rejects unsafe addresses and does not echo the server key", async () => {
for (const endpoint of [
"file:///upload",
"http://169.254.169.254/upload",
"http://[::ffff:169.254.169.254]/upload",
"http://0.0.0.0/upload",
"http://[fe80::1]/upload",
"http://127.0.0.1/other",
"http://user:pass@localhost/upload",
"http://localhost/upload?secret=foo",
"http://localhost/upload?key=hidden",
"http://localhost/upload#foo",
"http://2852039166/upload",
"http://0xa9fea9fe/upload",
"http://[::ffff:a9fe:a9fe]/upload",
"https://metadata.google.internal/upload",
]) {
expect(() => normalizePicGoEndpoint(endpoint)).toThrow();
}
expect(normalizePicGoEndpoint("http://[::1]:36677/upload").hostname).toBe("[::1]");
expect(normalizePicGoEndpoint("http://2130706433/upload").hostname).toBe("127.0.0.1");
expect(normalizePicGoEndpoint("http://127.0.0.01/upload").hostname).toBe("127.0.0.1");
expect(normalizePicGoEndpoint("http://[::ffff:c0a8:114]/upload").hostname).toBe("[::ffff:c0a8:114]");
expect(isBlockedPicGoAddress("192.168.1.20")).toBe(false);
expect(isBlockedPicGoAddress("10.1.2.3")).toBe(false);
expect(isBlockedPicGoAddress("::ffff:c0a8:114")).toBe(false);
expect(() => normalizePicGoServerKey("bad key")).toThrow();
expect(() => normalizePicGoServerKey("x".repeat(513))).toThrow();
let failure = "";
try {
await testPicGoConnection("http://127.0.0.1:9/upload", {
serverKey: "nas-key",
fetchImpl: async (url) => { throw new Error(`connect ECONNREFUSED ${url}`); },
});
} catch (error) {
failure = String(error);
}
expect(failure).toContain("could not be reached");
expect(failure.includes("nas-key")).toBe(false);
});
test("only accepts image bytes", async () => {
const endpoint = serve(() => { throw new Error("Should not send non-images"); });
await expect(uploadPicGoImage({ ...image(endpoint), mimeType: "application/pdf" })).rejects.toThrow();
await expect(uploadPicGoImage({ ...image(endpoint), bytes: new Uint8Array() })).rejects.toThrow();
+1 -1
View File
@@ -54,7 +54,7 @@ contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({
},
setSessionToken: (value) => ipcRenderer.invoke("desktop:set-session-token", value),
clearSessionToken: () => ipcRenderer.invoke("desktop:clear-session-token"),
testPicGoConnection: (endpoint) => ipcRenderer.invoke("desktop:picgo-test", endpoint),
testPicGoConnection: (input) => ipcRenderer.invoke("desktop:picgo-test", input),
uploadPicGoImage: (input) => ipcRenderer.invoke("desktop:picgo-upload", input),
publicNetworkFetch: (requestId, input) => ipcRenderer.invoke("desktop:public-network-fetch", requestId, input),
cancelPublicNetworkFetch: async (requestId) => { ipcRenderer.send("desktop:cancel-public-network-fetch", requestId); },
@@ -4,27 +4,29 @@ import { Image } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { hasPicGoBridge, readPicGoPreference, writePicGoPreference, type PicGoPreference } from "@/lib/picgo";
import { hasPicGoBridge, readPicGoPreference, takePicGoServerKey, writePicGoPreference, type PicGoPreference } from "@/lib/picgo";
import { SETTINGS_ITEM_DESCRIPTION_CLASSNAME, SETTINGS_ITEM_TITLE_CLASSNAME } from "./settings-ui";
const picGoTargetId = (value: PicGoPreference) => `${value.endpoint}\0${value.serverKey}`;
export const PicGoSettingsItem = () => {
const { t } = useTranslation();
const [saved, setSaved] = useState(readPicGoPreference);
const [draft, setDraft] = useState(saved);
const [testedEndpoint, setTestedEndpoint] = useState<string | null>(null);
const [testedTarget, setTestedTarget] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState("");
const desktopAvailable = hasPicGoBridge();
const dirty = draft.provider !== saved.provider || draft.endpoint !== saved.endpoint;
const canSave = dirty && (draft.provider === "edgeever" || (desktopAvailable && testedEndpoint === draft.endpoint));
const dirty = draft.provider !== saved.provider || draft.endpoint !== saved.endpoint || draft.serverKey !== saved.serverKey;
const canSave = dirty && (draft.provider === "edgeever" || (desktopAvailable && testedTarget === picGoTargetId(draft)));
const testConnection = async () => {
setBusy(true);
setTestedEndpoint(null);
setTestedTarget(null);
setStatus("");
try {
await window.edgeeverDesktop!.testPicGoConnection(draft.endpoint);
setTestedEndpoint(draft.endpoint);
await window.edgeeverDesktop!.testPicGoConnection({ endpoint: draft.endpoint, serverKey: draft.serverKey });
setTestedTarget(picGoTargetId(draft));
setStatus(t("settings.picgo.connected"));
} catch {
setStatus(t("settings.picgo.connectionFailed"));
@@ -62,10 +64,18 @@ export const PicGoSettingsItem = () => {
<p className={SETTINGS_ITEM_DESCRIPTION_CLASSNAME}>{t("settings.picgo.description")}</p>
<label htmlFor="picgo-endpoint" className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.picgo.endpoint")}</label>
<Input id="picgo-endpoint" value={draft.endpoint} disabled={busy} onChange={(event) => {
setDraft({ ...draft, endpoint: event.target.value.trim() });
setTestedEndpoint(null);
const next = takePicGoServerKey(event.target.value);
setDraft({ ...draft, endpoint: next.endpoint, serverKey: next.serverKey ?? draft.serverKey });
setTestedTarget(null);
setStatus("");
}} spellCheck={false} />
}} spellCheck={false} inputMode="url" autoComplete="off" />
<label htmlFor="picgo-server-key" className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.picgo.serverKey")}</label>
<Input id="picgo-server-key" name="picgo-server-key" type="password" value={draft.serverKey} disabled={busy} onChange={(event) => {
setDraft({ ...draft, serverKey: event.target.value.trim() });
setTestedTarget(null);
setStatus("");
}} spellCheck={false} autoComplete="new-password" maxLength={512} />
<p className={SETTINGS_ITEM_DESCRIPTION_CLASSNAME}>{t("settings.picgo.serverKeyDescription")}</p>
<p className={SETTINGS_ITEM_DESCRIPTION_CLASSNAME}>{t("settings.picgo.externalImages")}</p>
</div>
) : null}
+25 -6
View File
@@ -1,5 +1,5 @@
import { afterEach, expect, test } from "bun:test";
import { PICGO_STORAGE_KEY, DEFAULT_PICGO_ENDPOINT, readPicGoPreference, shouldUploadWithPicGo, uploadImageWithPicGo, writePicGoPreference } from "./picgo.ts";
import { PICGO_STORAGE_KEY, DEFAULT_PICGO_ENDPOINT, readPicGoPreference, shouldUploadWithPicGo, takePicGoServerKey, uploadImageWithPicGo, writePicGoPreference } from "./picgo.ts";
const previousWindow = globalThis.window;
afterEach(() => {
@@ -19,17 +19,17 @@ const file = () => new File([new Uint8Array([137, 80, 78, 71])], "image.png", {
test("default, corrupt settings and ordinary Web runtime retain attachment uploads", async () => {
const storage = setup();
expect(readPicGoPreference()).toEqual({ provider: "edgeever", endpoint: DEFAULT_PICGO_ENDPOINT });
expect(readPicGoPreference()).toEqual({ provider: "edgeever", endpoint: DEFAULT_PICGO_ENDPOINT, serverKey: "" });
storage.set(PICGO_STORAGE_KEY, "corrupt");
expect(shouldUploadWithPicGo()).toBe(false);
writePicGoPreference({ provider: "picgo", endpoint: DEFAULT_PICGO_ENDPOINT });
writePicGoPreference({ provider: "picgo", endpoint: DEFAULT_PICGO_ENDPOINT, serverKey: "" });
expect(shouldUploadWithPicGo()).toBe(false);
expect(await uploadImageWithPicGo(file(), copy)).toBeNull();
});
test("configured but disabled PicGo never sends an image", async () => {
setup(() => { throw new Error("Should not upload"); });
writePicGoPreference({ provider: "edgeever", endpoint: DEFAULT_PICGO_ENDPOINT });
writePicGoPreference({ provider: "edgeever", endpoint: DEFAULT_PICGO_ENDPOINT, serverKey: "" });
expect(shouldUploadWithPicGo()).toBe(false);
expect(await uploadImageWithPicGo(file(), copy)).toBeNull();
});
@@ -37,11 +37,12 @@ test("configured but disabled PicGo never sends an image", async () => {
test("enabled PicGo passes actual bytes and returns an external image for either editor mode", async () => {
setup(async (input) => {
expect(input.endpoint).toBe(DEFAULT_PICGO_ENDPOINT);
expect(input.serverKey).toBe("");
expect(input.bytes).toEqual(new Uint8Array([137, 80, 78, 71]));
expect(input.copy).toEqual(copy);
return { url: "https://images.example.com/image.png" };
});
writePicGoPreference({ provider: "picgo", endpoint: DEFAULT_PICGO_ENDPOINT });
writePicGoPreference({ provider: "picgo", endpoint: DEFAULT_PICGO_ENDPOINT, serverKey: "" });
expect(shouldUploadWithPicGo()).toBe(true);
expect(await uploadImageWithPicGo(file(), copy)).toEqual({
kind: "image", url: "https://images.example.com/image.png", filename: "image.png", mimeType: "image/png", byteSize: 4,
@@ -50,8 +51,26 @@ test("enabled PicGo passes actual bytes and returns an external image for either
test("only explicit fallback returns to attachment storage; cancellation stays a failure", async () => {
setup(async () => ({ fallback: true }));
writePicGoPreference({ provider: "picgo", endpoint: DEFAULT_PICGO_ENDPOINT });
writePicGoPreference({ provider: "picgo", endpoint: DEFAULT_PICGO_ENDPOINT, serverKey: "" });
expect(await uploadImageWithPicGo(file(), copy)).toBeNull();
window.edgeeverDesktop.uploadPicGoImage = async () => { throw new Error("Cancelled"); };
await expect(uploadImageWithPicGo(file(), copy)).rejects.toThrow("Cancelled");
});
test("keeps an older saved address and lifts a pasted PicList key out of the address", () => {
const storage = setup();
storage.set(PICGO_STORAGE_KEY, JSON.stringify({ provider: "picgo", endpoint: "https://piclist.example.com/upload" }));
expect(readPicGoPreference()).toEqual({ provider: "picgo", endpoint: "https://piclist.example.com/upload", serverKey: "" });
expect(takePicGoServerKey("https://piclist.example.com/upload?key=nas-key")).toEqual({
endpoint: "https://piclist.example.com/upload", serverKey: "nas-key",
});
expect(takePicGoServerKey("https://piclist.example.com/upload?key=nas-key&picbed=s3")).toEqual({
endpoint: "https://piclist.example.com/upload?key=nas-key&picbed=s3",
});
setup(async (input) => {
expect(input.serverKey).toBe("nas-key");
return { url: "https://images.example.com/image.png" };
});
writePicGoPreference({ provider: "picgo", endpoint: "https://piclist.example.com/upload", serverKey: "nas-key" });
return uploadImageWithPicGo(file(), copy);
});
+25 -4
View File
@@ -1,16 +1,37 @@
export const PICGO_STORAGE_KEY = "edgeever.desktop.picgo.v1";
export const DEFAULT_PICGO_ENDPOINT = "http://127.0.0.1:36677/upload";
export type PicGoPreference = { provider: "edgeever" | "picgo"; endpoint: string };
export type PicGoPreference = { provider: "edgeever" | "picgo"; endpoint: string; serverKey: string };
const emptyPicGoPreference = (): PicGoPreference => ({ provider: "edgeever", endpoint: DEFAULT_PICGO_ENDPOINT, serverKey: "" });
export const readPicGoPreference = (): PicGoPreference => {
const fallback: PicGoPreference = { provider: "edgeever", endpoint: DEFAULT_PICGO_ENDPOINT };
const fallback = emptyPicGoPreference();
try {
const value = JSON.parse(window.localStorage.getItem(PICGO_STORAGE_KEY) ?? "null");
if (!value || typeof value.endpoint !== "string") return fallback;
return { provider: value.provider === "picgo" ? "picgo" : "edgeever", endpoint: value.endpoint };
return {
provider: value.provider === "picgo" ? "picgo" : "edgeever",
endpoint: value.endpoint,
serverKey: typeof value.serverKey === "string" ? value.serverKey : "",
};
} catch { return fallback; }
};
// A pasted PicList address may carry ?key=. Keep that secret out of the address field.
export const takePicGoServerKey = (value: string): { endpoint: string; serverKey?: string } => {
const endpoint = value.trim();
try {
const url = new URL(endpoint);
const names = [...url.searchParams.keys()];
if (names.length === 1 && names[0] === "key" && url.searchParams.getAll("key").length === 1 && !url.hash) {
const serverKey = url.searchParams.get("key")?.trim() ?? "";
url.search = "";
return { endpoint: url.href, serverKey };
}
} catch { /* The field may be an incomplete address while the user is typing. */ }
return { endpoint };
};
export const writePicGoPreference = (value: PicGoPreference) => {
// Fail visibly when persistence is unavailable; never claim a provider changed.
window.localStorage.setItem(PICGO_STORAGE_KEY, JSON.stringify(value));
@@ -26,7 +47,7 @@ export const uploadImageWithPicGo = async (file: File, copy: PicGoFailureCopy) =
const preference = readPicGoPreference();
if (preference.provider !== "picgo" || !hasPicGoBridge()) return null;
const result = await window.edgeeverDesktop!.uploadPicGoImage({
endpoint: preference.endpoint, bytes: new Uint8Array(await file.arrayBuffer()),
endpoint: preference.endpoint, serverKey: preference.serverKey, bytes: new Uint8Array(await file.arrayBuffer()),
filename: file.name, mimeType: file.type, copy,
});
return "url" in result ? {
+2 -2
View File
@@ -49,9 +49,9 @@ interface EdgeEverDesktopBridge {
readClipboard?(): Promise<{ html: string; text: string; images: Uint8Array[] }>;
setSessionToken(value: string): Promise<{ stored: boolean }>;
clearSessionToken(): Promise<{ stored: false }>;
testPicGoConnection(endpoint: string): Promise<boolean>;
testPicGoConnection(input: { endpoint: string; serverKey?: string }): Promise<boolean>;
uploadPicGoImage(input: {
endpoint: string; bytes: Uint8Array; filename: string; mimeType: string;
endpoint: string; serverKey?: string; bytes: Uint8Array; filename: string; mimeType: string;
copy: import("@/lib/picgo").PicGoFailureCopy;
}): Promise<{ url: string } | { fallback: true }>;
publicNetworkFetch(requestId: string, input: import("@edgeever/shared").PluginPublicFetchRequest): Promise<import("@edgeever/shared").PluginPublicFetchResponse>;