fix: simplify widget package publishing and navigation
This commit is contained in:
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,
|
||||
};
|
||||
});
|
||||
}),
|
||||
};
|
||||
Reference in new issue
Block a user