feat(desktop-client): fall back to authenticated desktop asset endpoint

When a public asset URL fails (e.g. stale signed token), retry via the
new /api/desktop/content/assets endpoint with the desktop Bearer token
so editor images keep rendering across token rotations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 12:21:02 +08:00
parent acf7738bdd
commit b4cfc6f992
3 changed files with 70 additions and 6 deletions
@@ -18,6 +18,7 @@ vi.mock("electron", () => ({
}));
vi.mock("../transport/api-client", () => ({
fetchDesktopApiURL: vi.fn(),
resolveDesktopApiURL: (path: string) => `http://127.0.0.1:8080${path}`,
}));
@@ -27,10 +28,12 @@ import {
} from "./media-image";
describe("media image helpers", () => {
it("resolves API asset URLs through the desktop API base and requests png output", () => {
expect(buildAssetURLCandidates("/api/public/assets/12").at(0)).toBe(
it("resolves public asset URLs through public and authenticated desktop candidates", () => {
expect(buildAssetURLCandidates("/api/public/assets/12")).toEqual([
"http://127.0.0.1:8080/api/public/assets/12?format=png",
);
"http://127.0.0.1:8080/api/desktop/content/assets/12?format=png",
"http://127.0.0.1:8080/api/public/assets/12",
]);
});
it("centers crop rectangles at the requested ratio", () => {
@@ -2,7 +2,7 @@ import { Buffer } from "node:buffer";
import { nativeImage } from "electron";
import { resolveDesktopApiURL } from "../transport/api-client";
import { fetchDesktopApiURL, resolveDesktopApiURL } from "../transport/api-client";
export interface ImageAssetBlob {
blob: Blob;
@@ -20,6 +20,24 @@ export interface CropRect {
const passthroughImageTypes = new Set(["image/png", "image/jpeg", "image/jpg", "image/gif"]);
function buildDesktopAssetFallbackURL(publicAssetUrl: URL): string | null {
if (!publicAssetUrl.pathname.startsWith("/api/public/assets/")) {
return null;
}
const token = publicAssetUrl.pathname.slice("/api/public/assets/".length);
if (!token) {
return null;
}
const fallbackURL = new URL(resolveDesktopApiURL(`/api/desktop/content/assets/${token}`));
publicAssetUrl.searchParams.forEach((value, key) => {
fallbackURL.searchParams.set(key, value);
});
fallbackURL.searchParams.set("format", "png");
return fallbackURL.toString();
}
export function buildAssetURLCandidates(sourceUrl: string): string[] {
const trimmed = sourceUrl.trim();
if (!trimmed) {
@@ -46,6 +64,11 @@ export function buildAssetURLCandidates(sourceUrl: string): string[] {
apiAssetUrl.searchParams.set("format", "png");
}
pushCandidate(apiAssetUrl.toString());
const desktopAssetFallbackURL = buildDesktopAssetFallbackURL(apiAssetUrl);
if (desktopAssetFallbackURL) {
pushCandidate(desktopAssetFallbackURL);
}
}
pushCandidate(parsed.toString());
@@ -56,9 +79,29 @@ export function buildAssetURLCandidates(sourceUrl: string): string[] {
return candidates;
}
function shouldUseDesktopAssetFetch(candidate: string): boolean {
try {
return new URL(candidate).pathname.startsWith("/api/desktop/content/assets/");
} catch {
return false;
}
}
function fileNameForImageType(sourceType: string): string {
if (sourceType === "image/gif") {
return "image.gif";
}
if (sourceType === "image/jpeg" || sourceType === "image/jpg") {
return "image.jpg";
}
return "image.png";
}
export async function fetchImageAssetBlob(sourceUrl: string): Promise<ImageAssetBlob | null> {
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
const response = await fetch(candidate).catch(() => null);
const response = await (
shouldUseDesktopAssetFetch(candidate) ? fetchDesktopApiURL(candidate) : fetch(candidate)
).catch(() => null);
if (!response?.ok) {
continue;
}
@@ -80,7 +123,7 @@ export async function fetchImageAssetBlob(sourceUrl: string): Promise<ImageAsset
blob: sourceBlob,
width: size.width,
height: size.height,
fileName: sourceType === "image/gif" ? "image.gif" : sourceType === "image/jpeg" || sourceType === "image/jpg" ? "image.jpg" : "image.png",
fileName: fileNameForImageType(sourceType),
};
}
@@ -147,6 +147,24 @@ export function resolveDesktopApiURL(path: string): string {
return resolveDesktopURL(path);
}
export async function fetchDesktopApiURL(input: string | URL, init: RequestInit = {}): Promise<Response> {
const headers = new Headers(init.headers);
if (transportSession.clientToken && !headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${transportSession.clientToken}`);
}
const url =
input instanceof URL
? input.toString()
: /^https?:\/\//i.test(input)
? input
: resolveDesktopURL(input);
return fetch(url, {
...init,
headers,
});
}
function resolveObservedRequestURL(path: string | undefined, baseURL: string): string {
if (!path) {
return baseURL;