fix: simplify widget package publishing and navigation

This commit is contained in:
ajnart committed 2026-09-12 19:28:16 +02:00
1 parent 84058544a4
commit 9ff1019a3d
21 files changed
+811 -396

No files matched your search

@@ -8,8 +8,8 @@ import { packagePreviewProcedures } from "./preview-procedures";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { eq } from "@homarr/db";
import { customWidgetInstallations, items } from "@homarr/db/schema";
import { eq, inArray } from "@homarr/db";
import { customWidgetArtifacts, customWidgetInstallations, items } from "@homarr/db/schema";
import { customWidgetPackageSchema } from "@homarr/custom-widgets/package";
import { createTRPCRouter } from "../../../trpc";
@@ -30,6 +30,7 @@ import { getOrBuildWidgetArtifact, packageTransferProcedures } from "./transfer"
import { withWidgetInstallationLock } from "./coordination";
import { publishWidgetPackageChange } from "./events";
import { packageWorkshopProcedures } from "./workshop";
import { getWidgetWorkshopUrl, readWidgetWorkshopOrigin } from "./workshop-client";
import { setWidgetPackageEnabled } from "./enabled";
import { packageWebhookProcedures } from "./webhook-procedures";
import { packageArtifactReviewProcedures } from "./artifact-review";
@@ -67,7 +68,7 @@ export const customWidgetPackageRouter = createTRPCRouter({
})
.query(async ({ ctx }) => {
const [rows, placements] = await Promise.all([
ctx.db.query.customWidgetInstallations.findMany({ columns: { draft: false, previousSnapshot: false } }),
ctx.db.query.customWidgetInstallations.findMany({ columns: { previousSnapshot: false } }),
ctx.db.query.items.findMany({ where: eq(items.kind, "customApi"), columns: { options: true } }),
]);
const counts = new Map<string, number>();
@@ -75,11 +76,30 @@ export const customWidgetPackageRouter = createTRPCRouter({
const options = parsePackagePlacement(placement.options);
if (options) counts.set(options.definitionId, (counts.get(options.definitionId) ?? 0) + 1);
}
return rows.map((row) => ({
...row,
bindings: parseBindings(row.bindings),
placementCount: counts.get(row.id) ?? 0,
}));
const artifactIds = rows.flatMap((row) => (row.activeArtifactId ? [row.activeArtifactId] : []));
const artifacts =
artifactIds.length > 0
? await ctx.db.query.customWidgetArtifacts.findMany({
where: inArray(customWidgetArtifacts.id, artifactIds),
columns: { id: true, version: true },
})
: [];
const versions = new Map(artifacts.map((artifact) => [artifact.id, artifact.version]));
return rows.map(({ draft, ...row }) => {
const source = customWidgetPackageSchema.safeParse(readPackageDraft(draft));
const origin = readWidgetWorkshopOrigin(row.origin);
return {
...row,
bindings: parseBindings(row.bindings),
placementCount: counts.get(row.id) ?? 0,
activeVersion: versions.get(row.activeArtifactId ?? "") ?? null,
draftVersion: source.data?.manifest.version ?? null,
description: source.data?.manifest.description ?? "",
author: origin?.authorName || source.data?.manifest.author || "",
workshop: origin,
workshopUrl: origin ? getWidgetWorkshopUrl(origin.submissionId) : null,
};
});
}),
get: admin
.meta({
@@ -95,6 +115,7 @@ export const customWidgetPackageRouter = createTRPCRouter({
const { previousSnapshot: _snapshot, draft: _draft, ...installation } = row;
return {
...installation,
workshop: readWidgetWorkshopOrigin(row.origin),
source: readPackageDraft(row.draft),
draftDigest: packageDigest(row.draft),
bindings: parseBindings(row.bindings),
@@ -48,16 +48,19 @@ export function preserveWidgetCollectionOrigin(raw: string | null, nextOrigin: o
export function readPortableWidgetOrigin(raw: string | null): WidgetPortableOrigin | undefined {
const value = readOrigin(raw);
if (!value) return undefined;
const { collection: _collection, ...origin } = value;
const { collection: _collection, authorName: _authorName, ...origin } = value;
const direct = widgetPortableOriginSchema.safeParse(origin);
if (direct.success) return direct.data;
if (typeof value.forkedFrom !== "string") return undefined;
if (value.kind !== undefined || typeof value.forkedFrom !== "string") return undefined;
const upstream = readPortableWidgetOrigin(typeof value.origin === "string" ? value.origin : null);
let workshopUpstream;
if (upstream?.kind === "workshop") workshopUpstream = upstream;
if (upstream?.kind === "fork") workshopUpstream = upstream.upstream;
const parsed = widgetPortableOriginSchema.safeParse({
kind: "fork",
packageId: value.forkedFrom,
artifactDigest: value.artifact ?? undefined,
upstream: upstream?.kind === "workshop" ? upstream : undefined,
upstream: workshopUpstream,
});
if (parsed.success) return parsed.data;
return undefined;
@@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { assertWidgetArtifactIntegrity } from "@homarr/custom-widgets/package/server";
import type { WorkshopPackageRelease } from "@homarr/workshop/package-releases";
import { parseWorkshopPackageRelease } from "@homarr/workshop/package-releases";
import { WorkshopBackend } from "@homarr/workshop/backend";
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
@@ -24,9 +25,13 @@ export const widgetWorkshopOriginSchema = z.object({
sourceDigest: z.string(),
artifactDigest: z.string(),
author: z.string(),
authorName: z.string().optional(),
forkedFrom: z.string(),
});
export const getWidgetWorkshopUrl = (submissionId: string) =>
`${urls.workshopWebUrl}/${encodeURIComponent(submissionId)}`;
export function readWidgetWorkshopOrigin(raw: string | null) {
if (!raw) return null;
try {
@@ -41,27 +46,31 @@ export function readWidgetWorkshopOrigin(raw: string | null) {
export async function getWidgetWorkshopRelease(releaseId: string) {
return withWorkshopError(async () => {
const release = await widgetWorkshop.packages.get(releaseId, AbortSignal.timeout(15_000));
const parsed = parseWorkshopPackageRelease(release);
if (
packageDigest(parsed.source) !== release.sourceDigest ||
parsed.source.manifest.id !== release.packageId ||
parsed.source.manifest.version !== release.version
) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Workshop release metadata does not match its package" });
}
if (parsed.artifact) {
assertWidgetArtifactIntegrity(parsed.artifact, parsed.source);
if (parsed.artifact.digest !== release.artifactDigest) throw new Error("Release artifact checksum mismatch");
}
return {
...parsed,
release,
origin: { kind: "workshop" as const, workshopApiUrl: urls.workshopApiUrl, ...parsed.origin },
workshopUrl: `${urls.workshopWebUrl}/${encodeURIComponent(release.submission)}`,
};
return describeWidgetWorkshopRelease(release);
});
}
export function describeWidgetWorkshopRelease(release: WorkshopPackageRelease) {
const parsed = parseWorkshopPackageRelease(release);
if (
packageDigest(parsed.source) !== release.sourceDigest ||
parsed.source.manifest.id !== release.packageId ||
parsed.source.manifest.version !== release.version
) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Workshop release metadata does not match its package" });
}
if (parsed.artifact) {
assertWidgetArtifactIntegrity(parsed.artifact, parsed.source);
if (parsed.artifact.digest !== release.artifactDigest) throw new Error("Release artifact checksum mismatch");
}
return {
...parsed,
release,
origin: { kind: "workshop" as const, workshopApiUrl: urls.workshopApiUrl, ...parsed.origin },
workshopUrl: getWidgetWorkshopUrl(release.submission),
};
}
export function authenticatedWidgetWorkshop(token: string) {
const backend = new WorkshopBackend(urls.workshopApiUrl);
backend.pocketBase.authStore.save(token);
@@ -3,18 +3,19 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { customWidgetPackageSchema } from "@homarr/custom-widgets/package";
import { customWidgetPackageSchema, widgetPackageManifestSchema } from "@homarr/custom-widgets/package";
import type { CustomWidgetArtifact } from "@homarr/custom-widgets/package";
import { and, eq } from "@homarr/db";
import { customWidgetInstallations } from "@homarr/db/schema";
import { preserveWidgetCollectionOrigin } from "./portable-collection-origin";
import { preserveWidgetCollectionOrigin, readPortableWidgetOrigin } from "./portable-collection-origin";
import { persistPackageArtifact } from "./preview-store";
import { withWidgetInstallationLock } from "./coordination";
import { withWidgetArtifactReferenceLock } from "./artifact-references";
import { getArtifact, getInstallation, packageDigest } from "./records";
import { getArtifact, getInstallation, packageDigest, readPackageDraft } from "./records";
import {
authenticatedWidgetWorkshop,
describeWidgetWorkshopRelease,
getWidgetWorkshopRelease,
readWidgetWorkshopOrigin,
widgetWorkshop,
@@ -91,7 +92,7 @@ export const packageWorkshopProcedures = {
widgetWorkshop.packages.list(origin.submissionId, AbortSignal.timeout(15_000)),
);
const latest = releases[0];
const draft = customWidgetPackageSchema.safeParse(JSON.parse(installation.draft));
const draft = customWidgetPackageSchema.safeParse(readPackageDraft(installation.draft));
let activeVersion: string | null = null;
if (installation.activeArtifactId)
activeVersion = (await getArtifact(ctx, installation.activeArtifactId)).source.manifest.version;
@@ -153,7 +154,11 @@ export const packageWorkshopProcedures = {
.input(
idInput.extend({
token: z.string().min(1).max(20_000),
mode: z.enum(["new", "update", "fork"]),
mode: z.enum(["auto", "new", "update", "fork"]).default("auto"),
title: z.string().trim().min(3).max(100).optional(),
description: z.string().max(2048).optional(),
version: widgetPackageManifestSchema.shape.version.optional(),
expectedDraftDigest: z.string().min(1).optional(),
submissionId: z.string().optional(),
expectedRevision: z.number().int().positive().optional(),
forkedFrom: z.string().optional(),
@@ -161,56 +166,89 @@ export const packageWorkshopProcedures = {
}),
)
.mutation(async ({ ctx, input }) => {
const installation = await getInstallation(ctx, input.id);
const source = customWidgetPackageSchema.parse(JSON.parse(installation.draft));
const origin = readWidgetWorkshopOrigin(installation.origin);
let submissionId: string | undefined;
let forkedFrom: string | undefined;
if (input.mode === "update") {
submissionId = input.submissionId ?? origin?.submissionId;
if (!submissionId || !input.expectedRevision)
throw new TRPCError({
code: "BAD_REQUEST",
message: "An update requires its Workshop submission and current revision",
});
} else if (input.mode === "fork") {
forkedFrom = input.forkedFrom ?? origin?.releaseId;
if (!forkedFrom)
throw new TRPCError({ code: "BAD_REQUEST", message: "Choose the original release to publish a fork" });
const original = await getWidgetWorkshopRelease(forkedFrom);
if (original.source.manifest.id === source.manifest.id)
throw new TRPCError({ code: "BAD_REQUEST", message: "A fork must have its own package identity" });
}
let artifact: CustomWidgetArtifact | undefined;
if (installation.activeArtifactId) {
const active = await getArtifact(ctx, installation.activeArtifactId);
if (packageDigest(active.source) === packageDigest(source)) artifact = active.artifact;
}
const release = await withWorkshopError(() =>
authenticatedWidgetWorkshop(input.token).packages.publish({
source,
artifact,
submissionId,
expectedRevision: input.expectedRevision,
forkedFrom,
changelog: input.changelog,
title: installation.name,
description: source.manifest.description,
}),
);
const published = await getWidgetWorkshopRelease(release.id);
await ctx.db
.update(customWidgetInstallations)
.set({
origin: JSON.stringify(preserveWidgetCollectionOrigin(installation.origin, published.origin)),
updatedAt: new Date(),
})
.where(eq(customWidgetInstallations.id, input.id));
return {
releaseId: release.id,
submissionId: release.submission,
version: release.version,
workshopUrl: published.workshopUrl,
};
return withWidgetInstallationLock(input.id, async () => {
const installation = await getInstallation(ctx, input.id);
if (input.expectedDraftDigest && packageDigest(installation.draft) !== input.expectedDraftDigest)
throw new TRPCError({ code: "CONFLICT", message: "The saved draft changed. Reload it before publishing." });
const source = customWidgetPackageSchema.parse(readPackageDraft(installation.draft));
const origin = readWidgetWorkshopOrigin(installation.origin);
const backend = authenticatedWidgetWorkshop(input.token);
const user = await withWorkshopError(() => backend.refreshAuth());
if (!user) throw new TRPCError({ code: "UNAUTHORIZED", message: "Sign in to Workshop before publishing." });
let submissionId: string | undefined;
let forkedFrom: string | undefined;
let expectedRevision = input.expectedRevision;
if (input.mode === "auto") {
if (!input.expectedDraftDigest)
throw new TRPCError({ code: "BAD_REQUEST", message: "Review the saved draft before publishing." });
const portable = readPortableWidgetOrigin(installation.origin);
if (origin?.author === user.id) {
const listing = await withWorkshopError(() => backend.get(origin.submissionId));
submissionId = listing.id;
expectedRevision = listing.revision;
} else if (origin) {
forkedFrom = origin.releaseId;
} else if (portable?.kind === "fork" && portable.upstream) {
const upstream = readWidgetWorkshopOrigin(JSON.stringify(portable.upstream));
forkedFrom = upstream?.releaseId;
}
if (forkedFrom) {
const original = await getWidgetWorkshopRelease(forkedFrom);
if (original.source.manifest.id === source.manifest.id) source.manifest.id = `widget-${createId()}`;
}
source.manifest.author = user.name;
source.manifest.name = input.title ?? installation.name;
source.manifest.description = input.description ?? source.manifest.description;
source.manifest.version = input.version ?? source.manifest.version;
} else if (input.mode === "update") {
submissionId = input.submissionId ?? origin?.submissionId;
if (!submissionId || !expectedRevision)
throw new TRPCError({
code: "BAD_REQUEST",
message: "An update requires its Workshop submission and current revision",
});
} else if (input.mode === "fork") {
forkedFrom = input.forkedFrom ?? origin?.releaseId;
if (!forkedFrom)
throw new TRPCError({ code: "BAD_REQUEST", message: "Choose the original release to publish a fork" });
const original = await getWidgetWorkshopRelease(forkedFrom);
if (original.source.manifest.id === source.manifest.id)
throw new TRPCError({ code: "BAD_REQUEST", message: "A fork must have its own package identity" });
}
let artifact: CustomWidgetArtifact | undefined;
if (installation.activeArtifactId) {
const active = await getArtifact(ctx, installation.activeArtifactId);
if (packageDigest(active.source) === packageDigest(source)) artifact = active.artifact;
}
const release = await withWorkshopError(() =>
backend.packages.publish({
source,
artifact,
submissionId,
expectedRevision,
forkedFrom,
changelog: input.changelog,
title: input.title ?? installation.name,
description: input.description ?? source.manifest.description,
}),
);
const published = describeWidgetWorkshopRelease(release);
await ctx.db
.update(customWidgetInstallations)
.set({
origin: JSON.stringify(
preserveWidgetCollectionOrigin(installation.origin, { ...published.origin, authorName: user.name }),
),
draft: JSON.stringify(source),
updatedAt: new Date(),
})
.where(eq(customWidgetInstallations.id, input.id));
return {
releaseId: release.id,
submissionId: release.submission,
version: release.version,
workshopUrl: published.workshopUrl,
};
});
}),
};