commit 4ab217897eaa63554c231fdcd19dff1a46fb1c90 Author: render Date: Wed Sep 23 16:09:14 2026 +0800 chore: publish clean Curio source diff --git a/.gitea/actions/curio-source/action.yml b/.gitea/actions/curio-source/action.yml new file mode 100644 index 0000000..36051de --- /dev/null +++ b/.gitea/actions/curio-source/action.yml @@ -0,0 +1,23 @@ +name: Curio source +description: Validate a Curio source PR or propose an official release +inputs: + mode: + description: validate or propose + required: true + repository: + description: Published Curio repository + required: false + tag: + description: Published release tag + required: false + catalog: + description: Catalog repository owner/name + required: false + default: trove/curios + trusted_owner: + description: Repository owner whose first releases may merge automatically + required: false + default: trove-curios +runs: + using: node20 + main: main.mjs diff --git a/.gitea/actions/curio-source/main.mjs b/.gitea/actions/curio-source/main.mjs new file mode 100644 index 0000000..76e76ae --- /dev/null +++ b/.gitea/actions/curio-source/main.mjs @@ -0,0 +1,690 @@ +// tools/curio-source/action.mjs +import { appendFile, readFile } from "node:fs/promises"; +import { createHash as createHash2 } from "node:crypto"; + +// tools/curio-source/catalog.mjs +import { createHash } from "node:crypto"; + +// tools/curio-source/semver.mjs +var SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; +function parseSemver(value) { + if (typeof value !== "string") throw new Error("Version must be a string"); + const match = SEMVER.exec(value); + if (!match) throw new Error(`Invalid semantic version: ${value}`); + return { + major: BigInt(match[1]), + minor: BigInt(match[2]), + patch: BigInt(match[3]), + prerelease: match[4]?.split(".") ?? [] + }; +} +function compareIdentifier(left, right) { + const leftNumeric = /^\d+$/.test(left); + const rightNumeric = /^\d+$/.test(right); + if (leftNumeric && rightNumeric) return Number(BigInt(left) - BigInt(right)); + if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1; + return left.localeCompare(right); +} +function compareSemver(left, right) { + const a = parseSemver(left); + const b = parseSemver(right); + for (const key of ["major", "minor", "patch"]) { + if (a[key] !== b[key]) return a[key] > b[key] ? 1 : -1; + } + if (!a.prerelease.length || !b.prerelease.length) { + return a.prerelease.length === b.prerelease.length ? 0 : a.prerelease.length ? -1 : 1; + } + for (let index = 0; index < Math.max(a.prerelease.length, b.prerelease.length); index++) { + if (a.prerelease[index] === void 0) return -1; + if (b.prerelease[index] === void 0) return 1; + const compared = compareIdentifier(a.prerelease[index], b.prerelease[index]); + if (compared) return compared; + } + return 0; +} +function tagMatchesVersion(tag, version) { + return tag === version || tag === `v${version}`; +} + +// tools/curio-source/catalog.mjs +var TARGETS = /* @__PURE__ */ new Set(["any", "aarch64-apple-darwin", "x86_64-apple-darwin"]); +var ID = /^[a-z0-9][a-z0-9.-]*[a-z0-9]$/; +var SHA256 = /^[a-f0-9]{64}$/; +function fail(message) { + throw new Error(message); +} +function exactKeys(value, allowed, label) { + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) fail(`${label} contains unknown field ${key}`); + } +} +function text(value, label, maximum = 1e3) { + if (typeof value !== "string" || !value.trim() || value.length > maximum) { + fail(`${label} must be a non-empty string of at most ${maximum} characters`); + } + return value; +} +function httpUrl(value, label, { repository = false } = {}) { + text(value, label, 4096); + let url; + try { + url = new URL(value); + } catch { + fail(`${label} must be an absolute HTTP(S) URL`); + } + if (!url || !["http:", "https:"].includes(url.protocol) || url.username || url.password) { + fail(`${label} must be an HTTP(S) URL without embedded credentials`); + } + if (repository && (url.search || url.hash || url.pathname === "/")) { + fail(`${label} must identify a repository without a query or fragment`); + } + return url; +} +function repositorySlug(repository) { + const url = httpUrl(repository, "Curio repository", { repository: true }); + const parts = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "").split("/"); + if (parts.length !== 2 || parts.some((part) => !/^[A-Za-z0-9_.-]+$/.test(part))) { + fail("Curio repository URL must end in owner/repository"); + } + return parts.join("/"); +} +function repositoryOwner(repository) { + return repositorySlug(repository).split("/")[0]; +} +function releaseAssetUrl(value, repository, tag, label) { + const url = httpUrl(value, label); + const repositoryUrl = httpUrl(repository, "Curio repository"); + const repositoryPath2 = repositoryUrl.pathname.replace(/\/+$/, "").replace(/\.git$/, ""); + const releasePath = `${repositoryPath2}/releases/download/${tag}/`; + let pathname; + try { + pathname = decodeURIComponent(url.pathname); + } catch { + fail(`${label} has an invalid encoded path`); + } + if (url.origin !== repositoryUrl.origin || !pathname.startsWith(releasePath)) { + fail(`${label} must belong to the bound repository Release`); + } + return url; +} +function date(value, label) { + text(value, label, 100); + if (!Number.isFinite(Date.parse(value))) fail(`${label} must be an RFC 3339 date-time`); +} +function same(left, right) { + return JSON.stringify(left) === JSON.stringify(right); +} +function mutablePrerelease(version) { + return parseSemver(version).prerelease.length > 0; +} +function normalizedName(value) { + return value.normalize("NFKC").toLocaleLowerCase().replace(/[\s._-]+/g, ""); +} +function validateCatalog(index) { + if (!index || typeof index !== "object" || Array.isArray(index)) fail("Source index must be an object"); + exactKeys(index, ["schemaVersion", "id", "name", "icon", "generatedAt", "curios"], "Source index"); + if (index.schemaVersion !== 2) fail("The public catalog requires Source Index schemaVersion 2"); + text(index.id, "Source ID", 100); + text(index.name, "Source name", 100); + if (index.icon !== void 0) httpUrl(index.icon, "Source icon"); + date(index.generatedAt, "generatedAt"); + if (!Array.isArray(index.curios)) fail("curios must be an array"); + const ids = /* @__PURE__ */ new Set(); + const names = /* @__PURE__ */ new Map(); + let previousId = ""; + for (const curio of index.curios) { + if (!curio || typeof curio !== "object" || Array.isArray(curio)) fail("Each Curio must be an object"); + exactKeys(curio, ["id", "name", "description", "repository", "icon", "versions"], "Curio"); + if (typeof curio.id !== "string" || curio.id.length > 160 || !ID.test(curio.id)) fail(`Invalid Curio ID: ${curio.id}`); + if (ids.has(curio.id)) fail(`Duplicate Curio ID: ${curio.id}`); + if (previousId && previousId.localeCompare(curio.id) >= 0) fail("Curios must be sorted by ID"); + previousId = curio.id; + ids.add(curio.id); + text(curio.name, `${curio.id} name`, 100); + if (curio.description !== void 0 && (typeof curio.description !== "string" || curio.description.length > 1e3)) fail(`${curio.id} has an invalid description`); + repositorySlug(curio.repository); + if (curio.icon !== void 0) httpUrl(curio.icon, `${curio.id} icon`); + const name = normalizedName(curio.name); + if (names.has(name)) fail(`${curio.name} conflicts with ${names.get(name)}`); + names.set(name, curio.name); + if (!Array.isArray(curio.versions) || !curio.versions.length) fail(`${curio.id} has no versions`); + const versions = /* @__PURE__ */ new Set(); + let previousVersion; + for (const version of curio.versions) { + if (!version || typeof version !== "object" || Array.isArray(version)) fail(`${curio.id} has an invalid version`); + exactKeys(version, ["version", "engine", "releasedAt", "descriptor", "artifacts"], `${curio.id} version`); + parseSemver(version.version); + if (versions.has(version.version)) fail(`${curio.id} has duplicate version ${version.version}`); + if (previousVersion && compareSemver(previousVersion, version.version) <= 0) fail(`${curio.id} versions must be newest first`); + previousVersion = version.version; + versions.add(version.version); + if (!version.engine || typeof version.engine !== "object" || Array.isArray(version.engine)) fail(`${curio.id}@${version.version} has no Trove compatibility range`); + exactKeys(version.engine, ["trove"], `${curio.id}@${version.version} engine`); + if (typeof version.engine.trove !== "string" || !version.engine.trove.trim()) fail(`${curio.id}@${version.version} has no Trove compatibility range`); + date(version.releasedAt, `${curio.id}@${version.version} releasedAt`); + httpUrl(version.descriptor, `${curio.id}@${version.version} descriptor`); + if (!Array.isArray(version.artifacts) || !version.artifacts.length || version.artifacts.length > 8) fail(`${curio.id}@${version.version} has invalid artifacts`); + const targets = /* @__PURE__ */ new Set(); + let previousTarget = ""; + for (const artifact of version.artifacts) { + if (!artifact || typeof artifact !== "object" || Array.isArray(artifact)) fail(`${curio.id}@${version.version} has an invalid artifact`); + exactKeys(artifact, ["target", "url", "sha256", "size"], `${curio.id}@${version.version} artifact`); + if (!TARGETS.has(artifact.target) || targets.has(artifact.target)) fail(`${curio.id}@${version.version} has an invalid or duplicate target`); + if (previousTarget && previousTarget.localeCompare(artifact.target) >= 0) fail(`${curio.id}@${version.version} artifacts must be sorted by target`); + previousTarget = artifact.target; + targets.add(artifact.target); + httpUrl(artifact.url, `${curio.id}@${version.version}/${artifact.target} URL`); + if (!SHA256.test(artifact.sha256)) fail(`${curio.id}@${version.version}/${artifact.target} has an invalid SHA-256`); + if (!Number.isSafeInteger(artifact.size) || artifact.size < 1 || artifact.size > 512 * 1024 * 1024) fail(`${curio.id}@${version.version}/${artifact.target} has an invalid size`); + } + } + } + return index; +} +function validateDescriptor(descriptor, registry) { + if (!descriptor || descriptor.schemaVersion !== 2) fail("Release descriptor must use schemaVersion 2"); + exactKeys(descriptor, ["schemaVersion", "repository", "tag", "commit", "registries", "plugins"], "Release descriptor"); + text(descriptor.repository, "Release repository", 300); + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(descriptor.repository)) fail("Release repository must be owner/name"); + text(descriptor.tag, "Release tag", 200); + if (!/^[a-f0-9]{40,64}$/.test(descriptor.commit)) fail("Release descriptor has an invalid commit"); + if (!Array.isArray(descriptor.registries) || descriptor.registries.some((item) => typeof item !== "string") || !descriptor.registries.includes(registry)) fail(`Release does not opt in to ${registry}`); + if (!Array.isArray(descriptor.plugins) || !descriptor.plugins.length) fail("Release descriptor has no Curios"); + const plugins = /* @__PURE__ */ new Set(); + for (const plugin of descriptor.plugins) { + if (!plugin || typeof plugin !== "object" || Array.isArray(plugin)) fail("Release descriptor has an invalid Curio"); + exactKeys(plugin, ["id", "name", "description", "icon", "version", "engine", "artifacts"], "Release Curio"); + if (!ID.test(plugin.id)) fail(`Release descriptor has an invalid Curio ID: ${plugin.id}`); + parseSemver(plugin.version); + if (plugins.has(`${plugin.id}@${plugin.version}`)) fail(`Release descriptor repeats ${plugin.id}@${plugin.version}`); + plugins.add(`${plugin.id}@${plugin.version}`); + if (!tagMatchesVersion(descriptor.tag, plugin.version)) fail(`${plugin.id} version does not match release tag ${descriptor.tag}`); + text(plugin.name, `${plugin.id} name`, 100); + if (plugin.description !== void 0 && (typeof plugin.description !== "string" || plugin.description.length > 1e3)) fail(`${plugin.id} has an invalid description`); + if (plugin.icon !== void 0) httpUrl(plugin.icon, `${plugin.id} icon`); + if (!plugin.engine || typeof plugin.engine !== "object" || Array.isArray(plugin.engine)) fail(`${plugin.id} has no Trove compatibility range`); + exactKeys(plugin.engine, ["trove"], `${plugin.id} engine`); + if (typeof plugin.engine.trove !== "string" || !plugin.engine.trove.trim()) fail(`${plugin.id} has no Trove compatibility range`); + if (!Array.isArray(plugin.artifacts) || !plugin.artifacts.length) fail(`${plugin.id} has no release artifacts`); + const targets = /* @__PURE__ */ new Set(); + for (const artifact of plugin.artifacts) { + if (!artifact || typeof artifact !== "object" || Array.isArray(artifact)) fail(`${plugin.id} has an invalid release artifact`); + exactKeys(artifact, ["target", "asset", "sha256", "size"], `${plugin.id} release artifact`); + if (!TARGETS.has(artifact.target) || targets.has(artifact.target)) fail(`${plugin.id} has an invalid or duplicate target`); + targets.add(artifact.target); + if (!/^[A-Za-z0-9._+-]+\.curio$/.test(artifact.asset)) fail(`${plugin.id} has an invalid asset name`); + if (!SHA256.test(artifact.sha256) || !Number.isSafeInteger(artifact.size) || artifact.size < 1 || artifact.size > 512 * 1024 * 1024) fail(`${plugin.id}/${artifact.target} has invalid artifact metadata`); + } + } + return descriptor; +} +function applyDescriptor(index, descriptor, release, registry) { + validateCatalog(index); + validateDescriptor(descriptor, registry); + if (repositorySlug(release.repositoryUrl) !== descriptor.repository) fail("Release repository differs from its descriptor"); + const next = structuredClone(index); + for (const plugin of descriptor.plugins) { + let curio = next.curios.find((item) => item.id === plugin.id); + if (curio && curio.repository !== release.repositoryUrl) fail(`${plugin.id} is owned by ${curio.repository}`); + if (!curio) { + curio = { + id: plugin.id, + name: plugin.name, + description: plugin.description ?? "", + repository: release.repositoryUrl, + ...plugin.icon ? { icon: plugin.icon } : {}, + versions: [] + }; + next.curios.push(curio); + } + const artifacts = plugin.artifacts.map((artifact) => { + const url = release.assets.get(artifact.asset); + if (!url) fail(`Release is missing ${artifact.asset}`); + return { target: artifact.target, url, sha256: artifact.sha256, size: artifact.size }; + }).sort((a, b) => a.target.localeCompare(b.target)); + const candidate = { + version: plugin.version, + engine: structuredClone(plugin.engine), + releasedAt: release.publishedAt, + descriptor: release.descriptorUrl, + artifacts + }; + const existing = curio.versions.find((item) => item.version === plugin.version); + if (existing) { + if (same(existing, candidate)) continue; + if (!mutablePrerelease(plugin.version)) fail(`${plugin.id}@${plugin.version} is immutable; publish a new version`); + Object.assign(existing, candidate); + curio.name = plugin.name; + curio.description = plugin.description ?? ""; + if (plugin.icon) curio.icon = plugin.icon; + else delete curio.icon; + continue; + } + const newest = curio.versions.every((item) => compareSemver(plugin.version, item.version) > 0); + curio.versions.push(candidate); + curio.versions.sort((a, b) => compareSemver(b.version, a.version)); + if (newest) { + curio.name = plugin.name; + curio.description = plugin.description ?? ""; + if (plugin.icon) curio.icon = plugin.icon; + } + } + next.curios.sort((a, b) => a.id.localeCompare(b.id)); + if (Date.parse(release.publishedAt) > Date.parse(next.generatedAt)) next.generatedAt = release.publishedAt; + return validateCatalog(next); +} +function planCatalogChange(base, head) { + validateCatalog(base); + validateCatalog(head); + if (base.id !== head.id || base.name !== head.name || (base.icon ?? null) !== (head.icon ?? null)) fail("A catalog PR cannot change source identity"); + if (Date.parse(head.generatedAt) < Date.parse(base.generatedAt)) fail("generatedAt cannot move backwards"); + const additions = []; + const headById = new Map(head.curios.map((curio) => [curio.id, curio])); + for (const oldCurio of base.curios) { + const curio = headById.get(oldCurio.id); + if (!curio) fail(`A catalog PR cannot remove ${oldCurio.id}`); + if (curio.repository !== oldCurio.repository) fail(`${curio.id} repository ownership cannot change in a release PR`); + const versions = new Map(curio.versions.map((version) => [version.version, version])); + const replaced = []; + for (const oldVersion of oldCurio.versions) { + const version = versions.get(oldVersion.version); + if (!version) fail(`${curio.id}@${oldVersion.version} cannot be removed`); + if (!same(version, oldVersion)) { + if (!mutablePrerelease(oldVersion.version)) fail(`${curio.id}@${oldVersion.version} is immutable`); + replaced.push(version); + } + } + const added = curio.versions.filter((version) => !oldCurio.versions.some((item) => item.version === version.version)); + const releases = [...added, ...replaced]; + const metadataChanged = !same( + { name: oldCurio.name, description: oldCurio.description ?? "", icon: oldCurio.icon ?? null }, + { name: curio.name, description: curio.description ?? "", icon: curio.icon ?? null } + ); + if (metadataChanged && !releases.length) fail(`${curio.id} metadata changes require a release`); + additions.push(...releases.map((version) => ({ curio, version, isNewCurio: false, metadataChanged }))); + } + for (const curio of head.curios) { + if (!base.curios.some((item) => item.id === curio.id)) { + additions.push(...curio.versions.map((version) => ({ curio, version, isNewCurio: true, metadataChanged: true }))); + } + } + if (!additions.length && !same(base, head)) fail("Catalog data changed without adding a release"); + return { additions, newCurios: [...new Set(additions.filter((item) => item.isNewCurio).map((item) => item.curio.id))] }; +} +async function verifyReleaseAddition(addition, { registry, fetchJson: fetchJson2, fetchBytes: fetchBytes2 }) { + const descriptor = validateDescriptor(await fetchJson2(addition.version.descriptor, 1024 * 1024), registry); + if (repositorySlug(addition.curio.repository) !== descriptor.repository) fail(`${addition.curio.id} descriptor belongs to another repository`); + const descriptorUrl = releaseAssetUrl( + addition.version.descriptor, + addition.curio.repository, + descriptor.tag, + "Release descriptor URL" + ); + if (!descriptorUrl.pathname.endsWith("/trove-release.json")) fail(`${addition.curio.id} descriptor is not hosted by its repository`); + const plugin = descriptor.plugins.find((item) => item.id === addition.curio.id && item.version === addition.version.version); + if (!plugin) fail(`${addition.curio.id}@${addition.version.version} is missing from its release descriptor`); + if (plugin.name !== addition.curio.name || (plugin.description ?? "") !== (addition.curio.description ?? "") || (plugin.icon ?? null) !== (addition.curio.icon ?? null)) fail(`${addition.curio.id} metadata differs from its release descriptor`); + if (!same(plugin.engine, addition.version.engine)) fail(`${addition.curio.id}@${plugin.version} compatibility differs from its release descriptor`); + if (plugin.artifacts.length !== addition.version.artifacts.length) fail(`${addition.curio.id}@${plugin.version} artifact count differs from its release descriptor`); + for (const artifact of addition.version.artifacts) { + const expected = plugin.artifacts.find((item) => item.target === artifact.target); + if (!expected || expected.sha256 !== artifact.sha256 || expected.size !== artifact.size) fail(`${addition.curio.id}@${plugin.version}/${artifact.target} differs from its release descriptor`); + const artifactUrl = releaseAssetUrl( + artifact.url, + addition.curio.repository, + descriptor.tag, + `${addition.curio.id}@${plugin.version}/${artifact.target} URL` + ); + const name = decodeURIComponent(artifactUrl.pathname.split("/").at(-1) ?? ""); + if (name !== expected.asset) fail(`${addition.curio.id}@${plugin.version}/${artifact.target} URL names the wrong asset`); + const bytes = await fetchBytes2(artifact.url, artifact.size); + if (bytes.length !== artifact.size || createHash("sha256").update(bytes).digest("hex") !== artifact.sha256) fail(`${addition.curio.id}@${plugin.version}/${artifact.target} checksum mismatch`); + } + return descriptor; +} +function canonicalCatalog(index) { + validateCatalog(index); + return `${JSON.stringify(index, null, 2)} +`; +} + +// tools/curio-source/network.mjs +import dns from "node:dns/promises"; +import net from "node:net"; +function privateIpv4(address) { + const parts = address.split(".").map(Number); + const [a, b] = parts; + return a === 0 || a === 10 || a === 127 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 198 && (b === 18 || b === 19) || a >= 224; +} +function privateAddress(address) { + if (net.isIPv4(address)) return privateIpv4(address); + const value = address.toLowerCase(); + return value === "::" || value === "::1" || value.startsWith("fc") || value.startsWith("fd") || /^fe[89ab]/.test(value) || value.startsWith("::ffff:") && privateIpv4(value.slice(7)); +} +async function publicUrl(value) { + const url = new URL(value); + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw new Error("Remote URL must use HTTP(S) without credentials"); + const addresses = await dns.lookup(url.hostname, { all: true, verbatim: true }); + if (!addresses.length || addresses.some(({ address }) => privateAddress(address))) throw new Error(`Remote URL does not resolve exclusively to public addresses: ${url.hostname}`); + return url; +} +async function response(value) { + let url = await publicUrl(value); + for (let redirects = 0; redirects <= 5; redirects++) { + const result = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(12e4) }); + if ([301, 302, 303, 307, 308].includes(result.status)) { + const location = result.headers.get("location"); + await result.body?.cancel(); + if (!location) throw new Error("Remote redirect has no location"); + url = await publicUrl(new URL(location, url).href); + continue; + } + if (!result.ok) throw new Error(`Remote download returned HTTP ${result.status}`); + return result; + } + throw new Error("Remote download has too many redirects"); +} +async function fetchBytes(url, limit) { + const result = await response(url); + const chunks = []; + let size = 0; + for await (const chunk of result.body) { + size += chunk.length; + if (size > limit) throw new Error("Remote download exceeds its size limit"); + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} +async function fetchJson(url, limit) { + const bytes = await fetchBytes(url, limit); + try { + return JSON.parse(bytes); + } catch { + throw new Error("Remote release descriptor is not valid JSON"); + } +} + +// tools/curio-source/gitea.mjs +function repositoryPath(repository) { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) throw new Error(`Invalid repository: ${repository}`); + return repository.split("/").map(encodeURIComponent).join("/"); +} +var Gitea = class { + constructor({ server = process.env.GITEA_SERVER_URL, token = process.env.GITEA_TOKEN || process.env.GITHUB_TOKEN } = {}) { + const origin = new URL(server || "https://git.render.ink"); + if (!["http:", "https:"].includes(origin.protocol) || origin.username || origin.password || origin.pathname !== "/") throw new Error("Gitea server must be an HTTP(S) origin without credentials"); + this.server = origin.origin; + this.token = token; + } + async api(route, { method = "GET", body, missing = false } = {}) { + const response2 = await fetch(`${this.server}/api/v1${route}`, { + method, + redirect: "error", + signal: AbortSignal.timeout(12e4), + headers: { + ...this.token ? { Authorization: `token ${this.token}` } : {}, + ...body && !(body instanceof FormData) ? { "Content-Type": "application/json" } : {} + }, + body: body instanceof FormData ? body : body === void 0 ? void 0 : JSON.stringify(body) + }); + if (missing && response2.status === 404) return null; + if (!response2.ok) { + const error = new Error(`Gitea ${method} ${route.split("?")[0]} returned HTTP ${response2.status}`); + error.status = response2.status; + throw error; + } + if (response2.status === 204 || response2.headers.get("content-length") === "0") return null; + return response2.json(); + } + async download(url, limit = 512 * 1024 * 1024) { + let current = new URL(url); + for (let redirects = 0; redirects <= 5; redirects++) { + if (!["http:", "https:"].includes(current.protocol) || current.username || current.password) throw new Error("Invalid release download URL"); + const response2 = await fetch(current, { + redirect: "manual", + signal: AbortSignal.timeout(12e4), + headers: current.origin === this.server && this.token ? { Authorization: `token ${this.token}` } : {} + }); + if ([301, 302, 303, 307, 308].includes(response2.status)) { + const location = response2.headers.get("location"); + await response2.body?.cancel(); + if (!location) throw new Error("Release redirect has no location"); + current = new URL(location, current); + continue; + } + if (!response2.ok) throw new Error(`Release download returned HTTP ${response2.status}`); + const chunks = []; + let size = 0; + for await (const chunk of response2.body) { + size += chunk.length; + if (size > limit) throw new Error("Release download exceeds its size limit"); + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks); + } + throw new Error("Release download has too many redirects"); + } + async json(url, limit = 1024 * 1024) { + try { + return JSON.parse(await this.download(url, limit)); + } catch (error) { + if (error instanceof SyntaxError) throw new Error("Release descriptor is not valid JSON"); + throw error; + } + } +}; + +// tools/curio-source/action.mjs +var input = (name, fallback) => process.env[`INPUT_${name.toUpperCase()}`] || fallback; +async function output(name, value) { + const file = process.env.GITHUB_OUTPUT || process.env.GITEA_OUTPUT; + if (file) await appendFile(file, `${name}=${String(value).replaceAll("\n", "%0A")} +`); +} +function event() { + const file = process.env.GITEA_EVENT_PATH || process.env.GITHUB_EVENT_PATH; + if (!file) throw new Error("Action event payload is missing"); + return readFile(file, "utf8").then(JSON.parse); +} +function decodeContent(file) { + if (!file?.content) throw new Error("Catalog index is missing"); + return JSON.parse(Buffer.from(file.content, "base64").toString("utf8")); +} +function decodeBytes(file) { + if (!file?.content || file.type !== "file") throw new Error("Catalog asset is missing"); + return Buffer.from(file.content, "base64"); +} +function png(bytes) { + return bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); +} +function webp(bytes) { + return bytes.length >= 12 && bytes.subarray(0, 4).toString() === "RIFF" && bytes.subarray(8, 12).toString() === "WEBP"; +} +async function validateChangedIcons(paths, catalog, { client, route, ref }) { + const icons = paths.filter((path) => path.startsWith("source/icons/")); + const referenced = new Set(catalog.curios.map((curio) => { + if (!curio.icon) return null; + try { + const url = new URL(curio.icon); + const marker = "/raw/branch/main/"; + return url.pathname.includes(marker) ? url.pathname.split(marker)[1] : null; + } catch { + return null; + } + }).filter(Boolean)); + for (const path of icons) { + if (!/^source\/icons\/[a-z0-9][a-z0-9.-]*[a-z0-9]\.(png|webp)$/.test(path)) throw new Error(`Invalid catalog icon path: ${path}`); + const encodedPath = path.split("/").map(encodeURIComponent).join("/"); + const file = await client.api(`${route}/contents/${encodedPath}?ref=${encodeURIComponent(ref)}`); + const bytes = decodeBytes(file); + if (bytes.length < 1 || bytes.length > 256 * 1024) throw new Error(`${path} must be a non-empty image no larger than 256 KiB`); + if (!png(bytes) && !webp(bytes)) throw new Error(`${path} must contain PNG or WebP data`); + if (!referenced.has(path)) throw new Error(`${path} is not referenced by a Curio entry`); + } +} +async function validatePullRequest() { + const payload = await event(); + const pull = payload.pull_request; + if (!pull) throw new Error("catalog validation only runs for pull requests"); + const catalogRepository = input("catalog", process.env.GITEA_REPOSITORY); + const trustedOwner = input("trusted_owner", "trove-curios"); + const client = new Gitea(); + const route = `/repos/${repositoryPath(catalogRepository)}`; + const headRepository = pull.head?.repo?.full_name || catalogRepository; + const headRoute = `/repos/${repositoryPath(headRepository)}`; + const base = decodeContent(await client.api(`${route}/contents/source/index.json?ref=${encodeURIComponent(pull.base.sha)}`)); + const pullNumber = pull.number ?? pull.index; + if (Number(pull.changed_files) > 100) { + throw new Error("Catalog PRs may change at most 100 files"); + } + const files = await client.api(`${route}/pulls/${pullNumber}/files?limit=100&page=1`); + if (files.length === 100) { + const overflow = await client.api(`${route}/pulls/${pullNumber}/files?limit=100&page=2`); + if (overflow.length) throw new Error("Catalog PRs may change at most 100 files"); + } + const paths = files.map((file) => file.filename); + if (!paths.includes("source/index.json")) { + console.log("No catalog data changed; catalog validation is not required for this PR."); + await output("review_required", true); + return; + } + const unexpected = paths.filter((path) => path !== "source/index.json" && !path.startsWith("source/icons/")); + if (unexpected.length) throw new Error(`A catalog data PR cannot change other files: ${unexpected.join(", ")}`); + const head = decodeContent(await client.api(`${headRoute}/contents/source/index.json?ref=${encodeURIComponent(pull.head.sha)}`)); + const plan = planCatalogChange(base, head); + if (plan.additions.length > 20) throw new Error("A catalog PR may add at most 20 releases"); + const downloadBytes = plan.additions.reduce( + (total, addition) => total + addition.version.artifacts.reduce((sum, artifact) => sum + artifact.size, 0), + 0 + ); + if (downloadBytes > 1024 * 1024 * 1024) { + throw new Error("A catalog PR may verify at most 1 GiB of release assets"); + } + await validateChangedIcons(paths, head, { client, route: headRoute, ref: pull.head.sha }); + for (const addition of plan.additions) { + await verifyReleaseAddition(addition, { registry: catalogRepository, fetchJson, fetchBytes }); + } + const reviewRequired = plan.newCurios.some((id) => { + const curio = head.curios.find((item) => item.id === id); + return repositoryOwner(curio.repository) !== trustedOwner || new URL(curio.repository).origin !== client.server; + }); + console.log(`Validated ${plan.additions.length} release(s).${reviewRequired ? " First-time third-party publication requires review." : " Provenance permits automatic merge."}`); + await output("review_required", reviewRequired); + await output("release_count", plan.additions.length); + if (!reviewRequired && plan.additions.length) { + await client.api(`${route}/pulls/${pullNumber}/merge`, { + method: "POST", + body: { do: "merge", merge_when_checks_succeed: true, delete_branch_after_merge: true } + }); + console.log("Scheduled merge after required checks succeed."); + } +} +async function releaseCommit(client, repository, tag) { + const refs = await client.api(`/repos/${repositoryPath(repository)}/git/refs/tags/${encodeURIComponent(tag)}`); + let object = (Array.isArray(refs) ? refs.find((ref) => ref.ref === `refs/tags/${tag}`) : refs)?.object; + for (let depth = 0; object?.type === "tag" && depth < 5; depth++) { + object = (await client.api(`/repos/${repositoryPath(repository)}/git/tags/${object.sha}`)).object; + } + if (object?.type !== "commit") throw new Error("Release tag does not resolve to a commit"); + return object.sha; +} +async function verifyPublishedRelease(client, repository, tag, registry) { + const release = await client.api(`/repos/${repositoryPath(repository)}/releases/tags/${encodeURIComponent(tag)}`); + if (release.draft) throw new Error("Draft releases cannot enter the catalog"); + const descriptorAsset = release.assets.find((asset) => asset.name === "trove-release.json"); + if (!descriptorAsset) throw new Error("Release has no trove-release.json attachment"); + const descriptor = validateDescriptor(await client.json(descriptorAsset.browser_download_url), registry); + if (descriptor.repository !== repository || descriptor.tag !== tag) throw new Error("Release identity differs from its descriptor"); + if (descriptor.commit !== await releaseCommit(client, repository, tag)) throw new Error("Release tag differs from the descriptor commit"); + const assets = /* @__PURE__ */ new Map(); + for (const plugin of descriptor.plugins) { + for (const artifact of plugin.artifacts) { + const asset = release.assets.find((item) => item.name === artifact.asset); + if (!asset || asset.size !== artifact.size) throw new Error(`Release is missing ${artifact.asset}`); + const bytes = await client.download(asset.browser_download_url, artifact.size); + if (bytes.length !== artifact.size || createHash2("sha256").update(bytes).digest("hex") !== artifact.sha256) throw new Error(`Release checksum mismatch: ${plugin.id}/${artifact.target}`); + assets.set(artifact.asset, asset.browser_download_url); + } + } + return { release, descriptor, descriptorAsset, assets }; +} +async function proposeRelease() { + const repository = input("repository", process.env.GITEA_REPOSITORY); + const tag = input("tag"); + const catalogRepository = input("catalog", "trove/curios"); + const trustedOwner = input("trusted_owner", "trove-curios"); + if (!tag) throw new Error("Release tag is required"); + if (repository.split("/")[0] !== trustedOwner) throw new Error(`${repository} is not an official Curio repository`); + const client = new Gitea(); + const published = await verifyPublishedRelease(client, repository, tag, catalogRepository); + const route = `/repos/${repositoryPath(catalogRepository)}`; + const file = await client.api(`${route}/contents/source/index.json?ref=main`); + const base = decodeContent(file); + const repositoryUrl = `${client.server}/${repository}`; + const next = applyDescriptor(base, published.descriptor, { + repositoryUrl, + descriptorUrl: published.descriptorAsset.browser_download_url, + publishedAt: published.release.published_at || published.release.created_at, + assets: published.assets + }, catalogRepository); + const content = canonicalCatalog(next); + const before = Buffer.from(file.content, "base64").toString("utf8"); + if (content === before) { + console.log("Release is already present in the catalog."); + return; + } + const plugin = published.descriptor.plugins[0]; + const branch = `release/${plugin.id}/${plugin.version}`; + const open = await client.api(`${route}/pulls?state=open&limit=100`); + let pull = open.find((item) => item.head?.ref === branch); + const existingBranch = await client.api( + `${route}/branches/${encodeURIComponent(branch)}`, + { missing: true } + ); + const branchFile = existingBranch ? await client.api(`${route}/contents/source/index.json?ref=${encodeURIComponent(branch)}`) : null; + const branchContent = branchFile ? Buffer.from(branchFile.content, "base64").toString("utf8") : null; + if (branchContent !== content) { + await client.api(`${route}/contents/source/index.json`, { + method: "PUT", + body: { + branch: existingBranch ? branch : "main", + ...existingBranch ? {} : { new_branch: branch }, + sha: branchFile?.sha || file.sha, + content: Buffer.from(content).toString("base64"), + message: `release(${plugin.id}): ${plugin.version}` + } + }); + } + if (!pull) { + pull = await client.api(`${route}/pulls`, { + method: "POST", + body: { + base: "main", + head: branch, + title: `release(${plugin.id}): ${plugin.version}`, + body: [ + `Curio: \`${plugin.id}\``, + `Version: \`${plugin.version}\``, + `Repository: ${repositoryUrl}`, + `Release: ${published.release.html_url}`, + "", + "This proposal was generated from the published release descriptor." + ].join("\n") + } + }); + } + console.log(`Release proposal: ${pull.html_url}`); + await output("pull_request", pull.html_url); +} +async function runAction() { + const mode = input("mode"); + if (mode === "validate") return validatePullRequest(); + if (mode === "propose") return proposeRelease(); + throw new Error(`Unknown Curio source action mode: ${mode}`); +} + +// .gitea/actions/curio-source/main.mjs +runAction().catch((error) => { + console.error(error instanceof Error ? error.stack : error); + process.exitCode = 1; +}); diff --git a/.gitea/workflows/validate-pr.yml b/.gitea/workflows/validate-pr.yml new file mode 100644 index 0000000..d681523 --- /dev/null +++ b/.gitea/workflows/validate-pr.yml @@ -0,0 +1,28 @@ +name: Validate catalog pull request + +# The validator reads candidate files through the API and never executes PR code. +on: + pull_request_target: + branches: [main] + +permissions: + contents: read + pull-requests: write + +jobs: + catalog-validate: + runs-on: ubuntu-latest + steps: + - name: Checkout trusted catalog automation + uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ gitea.event.pull_request.base.sha }} + persist-credentials: false + - name: Validate catalog provenance + uses: ./.gitea/actions/curio-source + with: + mode: validate + catalog: trove/curios + trusted_owner: trove-curios + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..646ac51 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +node_modules/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..30c5a75 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,129 @@ +# 向 Trove 官方软件源投稿 / Contributing to the Trove Curio Source + + + +官方软件源是公开索引,不接收 Curio 源码或安装包。外部作者与官方仓库使用同一条发布路径:先在 Curio 仓库发布 Release,再通过 Pull Request 更新本仓库。 + +### 1. 发布 Curio Release + +Release 必须公开提供: + +- 一个或多个 `.curio` 安装包; +- 一个名为 `trove-release.json` 的描述文件; +- 与 Curio Manifest 相同的通用 SemVer 版本。Tag 可以是版本本身,也可以只多一个 `v` 前缀。 + +描述文件使用 `schemaVersion: 2`,并显式选择本软件源: + +```json +{ + "schemaVersion": 2, + "repository": "owner/repository", + "tag": "v1.2.3", + "commit": "完整的 Git commit SHA", + "registries": ["trove/curios"], + "plugins": [ + { + "id": "dev.example.curio", + "name": "Example Curio", + "description": "一句面向使用者的说明。", + "icon": "https://example.com/curio.png", + "version": "1.2.3", + "engine": { "trove": ">=0.0.1-alpha.1 <0.0.2" }, + "artifacts": [ + { + "target": "aarch64-apple-darwin", + "asset": "dev.example.curio-1.2.3-aarch64-apple-darwin.curio", + "sha256": "64 位小写十六进制 SHA-256", + "size": 123456 + } + ] + } + ] +} +``` + +版本只按通用 SemVer 校验,不要求使用某条特定预发布版本线。开发阶段的预发布版本允许重建并替换同名安装包;稳定版本发布后不可覆盖,需要修正时发布新版本。 + +### 2. 更新索引 + +PR 只修改 [`source/index.json`](https://git.render.ink/trove/curios/src/branch/main/source/index.json),以及可选的单个 Curio 图标: + +- `repository` 填写公开的 HTTP(S) 仓库地址; +- `descriptor` 指向该 Release 的 `trove-release.json`; +- `url` 指向公开可下载的 Release 安装包; +- `size` 和 `sha256` 必须由最终安装包计算; +- Curio 按 ID 排序,版本按 SemVer 从新到旧排序,产物按 target 排序。 + +图标属于 Curio,不属于版本。可以使用公开的 HTTP(S) 图片地址;也可以提交一次 `source/icons/.png` 或 `.webp`,大小不超过 256 KiB,并在所有版本中复用同一个 URL。不要提交 Base64 图标。 + +### 3. 身份与审核 + +首次收录会人工确认 Curio 的名称、用途、仓库和发布者意图。通过后,Curio ID 会与该仓库永久绑定;稳定版本的 `(id, version, target)` 不可覆盖,预发布版本允许由同一绑定仓库重建。仓库迁移需要单独审核,不能夹在版本 PR 中。 + +后续版本仍会从上游 Release 重新读取描述文件和安装包,核对仓库、版本、显式软件源授权、大小与 SHA-256。校验全部通过的已绑定仓库版本可以自动合并;校验失败的 PR 不能合并。 + + + + + +The official source is a public index; it does not host Curio source code or packages. Third-party and official repositories follow the same path: publish a Release in the Curio repository, then update this repository through a pull request. + +### 1. Publish a Curio Release + +The public Release must contain: + +- one or more `.curio` packages; +- a descriptor named `trove-release.json`; +- a generic SemVer matching the Curio manifest. The tag may be the version or the version with one leading `v`. + +The descriptor uses `schemaVersion: 2` and explicitly opts in to this source: + +```json +{ + "schemaVersion": 2, + "repository": "owner/repository", + "tag": "v1.2.3", + "commit": "full Git commit SHA", + "registries": ["trove/curios"], + "plugins": [ + { + "id": "dev.example.curio", + "name": "Example Curio", + "description": "A short user-facing description.", + "icon": "https://example.com/curio.png", + "version": "1.2.3", + "engine": { "trove": ">=0.0.1-alpha.1 <0.0.2" }, + "artifacts": [ + { + "target": "aarch64-apple-darwin", + "asset": "dev.example.curio-1.2.3-aarch64-apple-darwin.curio", + "sha256": "64 lowercase hexadecimal SHA-256 characters", + "size": 123456 + } + ] + } + ] +} +``` + +Versions are checked as generic SemVer and are not tied to a particular prerelease line. Prerelease assets may be rebuilt in place during development; stable Release assets are immutable and require a new version for corrections. + +### 2. Update the index + +A PR changes only [`source/index.json`](https://git.render.ink/trove/curios/src/branch/main/source/index.json) and, optionally, one stable Curio icon: + +- `repository` is the public HTTP(S) repository URL; +- `descriptor` points to that Release's `trove-release.json`; +- `url` points to a publicly downloadable Release asset; +- `size` and `sha256` are calculated from the final package; +- Curios are sorted by ID, versions by descending SemVer, and artifacts by target. + +An icon belongs to a Curio, not a version. Use a public HTTP(S) image URL, or add one `source/icons/.png` or `.webp` file up to 256 KiB and reuse its URL across releases. Do not embed Base64 icons. + +### 3. Identity and review + +The first submission is manually reviewed for its name, purpose, repository, and publisher intent. Once accepted, the Curio ID is permanently bound to that repository. Stable `(id, version, target)` entries are immutable; prerelease entries may be rebuilt only by the bound repository. Repository transfers require a separate review. + +Later versions are verified again from the upstream Release: repository, version, explicit registry opt-in, byte size, and SHA-256 must all match. A verified update from a bound repository may merge automatically; a failing PR cannot merge. + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..a15c5ab --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +![Trove 官方软件源:工具从软件源来到桌面](assets/trove-official-source-v1.png) + +# Trove 官方软件源 + +Trove 官方软件源图标 + +这是 [Trove](https://trove.render.ink/zh) 的公开 Curio 索引。本仓库只保存索引、稳定图标和投稿规则;每个 Curio 的源码、安装包与 Release 都留在它自己的仓库。 + +## 添加软件源 + +在 Trove 的「软件源」页面添加 Git 仓库: + +| 字段 | 值 | +| --- | --- | +| 名称 | Trove 官方软件源 | +| ID | `trove-official` | +| 仓库 | `https://git.render.ink/trove/curios.git` | + +添加后刷新即可浏览 [source/index.json](source/index.json) 中的 Curio。 + +## 投稿 + +外部 Curio 与官方 Curio 都通过 Pull Request 进入索引。发布者先在自己的仓库发布安装包和 `trove-release.json`,再按 [CONTRIBUTING.md](CONTRIBUTING.md) 提交索引变更。 + +每个 PR 都会校验版本、仓库归属、Release 描述文件、安装包大小和 SHA-256。新 Curio 首次收录需要人工审核;仓库绑定后的新版本可在校验通过后自动合并。 + +## 已收录 Curio + +| Curio | 仓库 | +| --- | --- | +| Harmony Assistant | [trove-curios/harmony-assistant](https://git.render.ink/trove-curios/harmony-assistant) | +| Harmony Mirror | [trove-curios/harmony-mirror](https://git.render.ink/trove-curios/harmony-mirror) | +| Sing-box Manager | [trove-curios/sing-box-manager](https://git.render.ink/trove-curios/sing-box-manager) | + +Curio 开发、打包和软件源格式见 [Trove 文档](https://trove.render.ink/zh/docs)。 diff --git a/assets/source-icon.png b/assets/source-icon.png new file mode 100644 index 0000000..6f03b84 Binary files /dev/null and b/assets/source-icon.png differ diff --git a/assets/trove-official-source-v1.png b/assets/trove-official-source-v1.png new file mode 100644 index 0000000..fc69f5b Binary files /dev/null and b/assets/trove-official-source-v1.png differ diff --git a/source/icons/ink.render.trove.harmonyassistant.png b/source/icons/ink.render.trove.harmonyassistant.png new file mode 100644 index 0000000..239b96d Binary files /dev/null and b/source/icons/ink.render.trove.harmonyassistant.png differ diff --git a/source/icons/ink.render.trove.harmonymirror.png b/source/icons/ink.render.trove.harmonymirror.png new file mode 100644 index 0000000..9b56c75 Binary files /dev/null and b/source/icons/ink.render.trove.harmonymirror.png differ diff --git a/source/icons/ink.render.trove.singbox.png b/source/icons/ink.render.trove.singbox.png new file mode 100644 index 0000000..56587db Binary files /dev/null and b/source/icons/ink.render.trove.singbox.png differ diff --git a/source/index.json b/source/index.json new file mode 100644 index 0000000..013e7df --- /dev/null +++ b/source/index.json @@ -0,0 +1,84 @@ +{ + "schemaVersion": 2, + "id": "trove-official", + "name": "Trove 官方软件源", + "icon": "https://git.render.ink/trove/curios/raw/branch/main/assets/source-icon.png", + "generatedAt": "2026-09-23T15:35:09+08:00", + "curios": [ + { + "id": "ink.render.trove.harmonyassistant", + "name": "Harmony Assistant", + "description": "在 Mac 上浏览和互传鸿蒙设备的文件与媒体、安装卸载应用,并使用高级开发工具。", + "repository": "https://git.render.ink/trove-curios/harmony-assistant", + "icon": "https://git.render.ink/trove/curios/raw/branch/main/source/icons/ink.render.trove.harmonyassistant.png", + "versions": [ + { + "version": "0.0.1-alpha.1", + "engine": { + "trove": ">=0.0.1-alpha.1 <0.0.2" + }, + "releasedAt": "2026-09-23T14:44:14+08:00", + "descriptor": "https://git.render.ink/trove-curios/harmony-assistant/releases/download/v0.0.1-alpha.1/trove-release.json", + "artifacts": [ + { + "target": "aarch64-apple-darwin", + "url": "https://git.render.ink/trove-curios/harmony-assistant/releases/download/v0.0.1-alpha.1/ink.render.trove.harmonyassistant-0.0.1-alpha.1-aarch64-apple-darwin.curio", + "sha256": "7a21d24510449cd339671a133c6d82ffa3c979f6164b1761d8ea80b6799eb10d", + "size": 6297472 + } + ] + } + ] + }, + { + "id": "ink.render.trove.harmonymirror", + "name": "Harmony Mirror", + "description": "在 Mac 的独立窗口中呈现鸿蒙设备画面并进行键鼠操作。", + "repository": "https://git.render.ink/trove-curios/harmony-mirror", + "icon": "https://git.render.ink/trove/curios/raw/branch/main/source/icons/ink.render.trove.harmonymirror.png", + "versions": [ + { + "version": "0.0.1-alpha.1", + "engine": { + "trove": ">=0.0.1-alpha.1 <0.0.2" + }, + "releasedAt": "2026-09-23T14:44:14+08:00", + "descriptor": "https://git.render.ink/trove-curios/harmony-mirror/releases/download/v0.0.1-alpha.1/trove-release.json", + "artifacts": [ + { + "target": "aarch64-apple-darwin", + "url": "https://git.render.ink/trove-curios/harmony-mirror/releases/download/v0.0.1-alpha.1/ink.render.trove.harmonymirror-0.0.1-alpha.1-aarch64-apple-darwin.curio", + "sha256": "dff6f4a66bb4bd1d0750c398c3a1a8542e8ab2474b4c699afdb6d3b19f71f083", + "size": 5193107 + } + ] + } + ] + }, + { + "id": "ink.render.trove.singbox", + "name": "Sing-box Manager", + "description": "通过 SSH 管理 VPS 上的 sing-box、Cloudflare DNS、证书、流量统计与节点质量。", + "repository": "https://git.render.ink/trove-curios/sing-box-manager", + "icon": "https://git.render.ink/trove/curios/raw/branch/main/source/icons/ink.render.trove.singbox.png", + "versions": [ + { + "version": "0.0.1-alpha.1", + "engine": { + "trove": ">=0.0.1-alpha.1 <0.0.2" + }, + "releasedAt": "2026-09-23T15:35:09+08:00", + "descriptor": "https://git.render.ink/trove-curios/sing-box-manager/releases/download/v0.0.1-alpha.1/trove-release.json", + "artifacts": [ + { + "target": "aarch64-apple-darwin", + "url": "https://git.render.ink/trove-curios/sing-box-manager/releases/download/v0.0.1-alpha.1/ink.render.trove.singbox-0.0.1-alpha.1-aarch64-apple-darwin.curio", + "sha256": "798aca45630c4fdba85a469db179df488efd93c3d2ca71539e0bfdfed683bd91", + "size": 2494298 + } + ] + } + ] + } + ] +}