From 9cccd0c6b383e90fe4b544425d95c5bbcd8c5f69 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sun, 11 Jan 2026 17:43:04 -0500 Subject: [PATCH] feat: auto-update signaler for Gaze --- src/routes/api/Gaze/appcast.xml.ts | 62 ++++++++++++++++++++ src/server/api/routers/downloads.ts | 87 ++++++++++++++++++++++------- 2 files changed, 130 insertions(+), 19 deletions(-) create mode 100644 src/routes/api/Gaze/appcast.xml.ts diff --git a/src/routes/api/Gaze/appcast.xml.ts b/src/routes/api/Gaze/appcast.xml.ts new file mode 100644 index 0000000..072c280 --- /dev/null +++ b/src/routes/api/Gaze/appcast.xml.ts @@ -0,0 +1,62 @@ +import type { APIEvent } from "@solidjs/start/server"; +import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; +import { env } from "~/env/server"; + +/** + * Serves the Gaze appcast.xml file from S3 + * This endpoint is used by Sparkle updater to check for new versions + * + * URL: https://freno.me/api/Gaze/appcast.xml + */ +export async function GET(event: APIEvent) { + const bucket = env.VITE_DOWNLOAD_BUCKET_STRING; + const key = "api/Gaze/appcast.xml"; + + const credentials = { + accessKeyId: env._AWS_ACCESS_KEY, + secretAccessKey: env._AWS_SECRET_KEY + }; + + try { + const client = new S3Client({ + region: env.AWS_REGION, + credentials: credentials + }); + + const command = new GetObjectCommand({ + Bucket: bucket, + Key: key + }); + + const response = await client.send(command); + + if (!response.Body) { + return new Response("Appcast not found", { + status: 404, + headers: { + "Content-Type": "text/plain" + } + }); + } + + // Stream the XML content from S3 + const body = await response.Body.transformToString(); + + return new Response(body, { + status: 200, + headers: { + "Content-Type": "application/xml; charset=utf-8", + "Cache-Control": "public, max-age=300", // Cache for 5 minutes + "Access-Control-Allow-Origin": "*" // Allow CORS for appcast + } + }); + } catch (error) { + console.error("Failed to fetch appcast:", error); + return new Response("Internal Server Error", { + status: 500, + headers: { + "Content-Type": "text/plain" + } + }); + } +} diff --git a/src/server/api/routers/downloads.ts b/src/server/api/routers/downloads.ts index 7acb7b4..a2208d8 100644 --- a/src/server/api/routers/downloads.ts +++ b/src/server/api/routers/downloads.ts @@ -1,55 +1,104 @@ import { createTRPCRouter, publicProcedure } from "../utils"; import { z } from "zod"; -import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; +import { S3Client, GetObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { env } from "~/env/server"; import { TRPCError } from "@trpc/server"; const assets: Record = { - gaze: "Gaze.dmg", lineage: "Life and Lineage.apk", cork: "Cork.zip", "shapes-with-abigail": "shapes-with-abigail.apk" }; +/** + * Get the latest Gaze DMG from S3 by finding the most recent file in downloads/ folder + */ +async function getLatestGazeDMG(client: S3Client, bucket: string): Promise { + try { + const listCommand = new ListObjectsV2Command({ + Bucket: bucket, + Prefix: "downloads/Gaze-", + MaxKeys: 100 + }); + + const response = await client.send(listCommand); + + if (!response.Contents || response.Contents.length === 0) { + throw new Error("No Gaze DMG files found in S3"); + } + + // Filter for .dmg files only and sort by LastModified (newest first) + const dmgFiles = response.Contents + .filter((obj) => obj.Key?.endsWith(".dmg")) + .sort((a, b) => { + const dateA = a.LastModified?.getTime() || 0; + const dateB = b.LastModified?.getTime() || 0; + return dateB - dateA; // Descending order (newest first) + }); + + if (dmgFiles.length === 0) { + throw new Error("No .dmg files found in downloads/Gaze-* prefix"); + } + + const latestFile = dmgFiles[0].Key!; + console.log(`Latest Gaze DMG: ${latestFile}`); + return latestFile; + } catch (error) { + console.error("Error finding latest Gaze DMG:", error); + throw error; + } +} + export const downloadsRouter = createTRPCRouter({ getDownloadUrl: publicProcedure .input(z.object({ asset_name: z.string() })) .query(async ({ input }) => { const bucket = env.VITE_DOWNLOAD_BUCKET_STRING; - const params = { - Bucket: bucket, - Key: assets[input.asset_name] - }; - - if (!assets[input.asset_name]) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Asset not found" - }); - } - + const credentials = { accessKeyId: env._AWS_ACCESS_KEY, secretAccessKey: env._AWS_SECRET_KEY }; + const client = new S3Client({ + region: env.AWS_REGION, + credentials: credentials + }); + try { - const client = new S3Client({ - region: env.AWS_REGION, - credentials: credentials + let fileKey: string; + + // Special handling for Gaze - find latest version automatically + if (input.asset_name === "gaze") { + fileKey = await getLatestGazeDMG(client, bucket); + } else { + // Use static mapping for other assets + fileKey = assets[input.asset_name]; + + if (!fileKey) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Asset not found" + }); + } + } + + const command = new GetObjectCommand({ + Bucket: bucket, + Key: fileKey }); - const command = new GetObjectCommand(params); const signedUrl = await getSignedUrl(client, command, { expiresIn: 120 }); + return { downloadURL: signedUrl }; } catch (error) { console.error(error); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: "Failed to generate download URL" + message: error instanceof Error ? error.message : "Failed to generate download URL" }); } })