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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user