diff --git a/svc/nginx/nginx.conf.j2 b/svc/nginx/nginx.conf.j2 index ecec240..63f6ec2 100644 --- a/svc/nginx/nginx.conf.j2 +++ b/svc/nginx/nginx.conf.j2 @@ -123,6 +123,10 @@ http { proxy_pass https://updates.helium.computer/mac; } + location /updates/win { + proxy_pass https://updates.helium.computer/win; + } + location /dict { gzip_static always; root /dev/shm/dictionaries; diff --git a/util/winsparkler/.env.example b/util/winsparkler/.env.example new file mode 100644 index 0000000..72e7234 --- /dev/null +++ b/util/winsparkler/.env.example @@ -0,0 +1,5 @@ +GITHUB_REPO=imputnet/helium-windows +GITHUB_ACCESS_TOKEN= +OUTPUT_DIR=/appcast +ASSETS_DIR=/assets +SERVE_ASSETS_LOCALLY=1 diff --git a/util/winsparkler/Dockerfile b/util/winsparkler/Dockerfile new file mode 100644 index 0000000..373355c --- /dev/null +++ b/util/winsparkler/Dockerfile @@ -0,0 +1,10 @@ +FROM denoland/deno:alpine-2.5.1@sha256:904ba915c0b231c88f1309049ecfc1d72fc877afd7a44244f48503b15bad1720 AS base +USER deno + +FROM base AS builder + +WORKDIR /app +COPY . . +RUN deno cache main.ts + +ENTRYPOINT ["deno", "run", "-A", "main.ts"] diff --git a/util/winsparkler/README.md b/util/winsparkler/README.md new file mode 100644 index 0000000..656093e --- /dev/null +++ b/util/winsparkler/README.md @@ -0,0 +1,19 @@ +## winsparkler + +Generates WinSparkle appcasts for Helium Windows releases. + +The tool reads GitHub releases from `imputnet/helium-windows`, optionally +mirrors installer assets locally, and writes: + +- `appcast-x64.xml` +- `appcast-arm64.xml` + +### environment + +See [.env.example](.env.example). + +### usage + +```sh +deno run -A main.ts +``` diff --git a/util/winsparkler/deno.json b/util/winsparkler/deno.json new file mode 100644 index 0000000..8f0a44a --- /dev/null +++ b/util/winsparkler/deno.json @@ -0,0 +1,9 @@ +{ + "license": "AGPL-3.0", + "fmt": { + "indentWidth": 4, + "singleQuote": true, + "lineWidth": 100, + "trailingCommas": "onlyMultiLine" + } +} diff --git a/util/winsparkler/main.ts b/util/winsparkler/main.ts new file mode 100644 index 0000000..8b71fe5 --- /dev/null +++ b/util/winsparkler/main.ts @@ -0,0 +1,239 @@ +type GithubAsset = { + name: string; + browser_download_url: string; + size: number; + digest?: string; +}; + +type GithubRelease = { + draft: boolean; + prerelease: boolean; + tag_name: string; + html_url: string; + published_at: string; + assets: GithubAsset[]; +}; + +type Arch = 'x64' | 'arm64'; + +type Release = { + version: string; + releaseNotesUrl: string; + publishedAt: string; + assets: Record; +}; + +const strictGet = (name: string) => { + const value = Deno.env.get(name); + if (!value) { + throw new Error(`env ${name} is missing`); + } + return value; +}; + +const getBool = (name: string, fallback = false) => { + const value = Deno.env.get(name); + if (!value) { + return fallback; + } + return ['1', 'true', 'yes'].includes(value.toLowerCase()); +}; + +const env = { + githubRepo: Deno.env.get('GITHUB_REPO') ?? 'imputnet/helium-windows', + githubAccessToken: Deno.env.get('GITHUB_ACCESS_TOKEN'), + outputDir: strictGet('OUTPUT_DIR'), + assetsDir: strictGet('ASSETS_DIR'), + serveAssetsLocally: getBool('SERVE_ASSETS_LOCALLY', true), +}; + +const headers: Record = {}; +if (env.githubAccessToken) { + headers['authorization'] = `Bearer ${env.githubAccessToken}`; +} + +const appcastPathFor = (arch: Arch) => `${env.outputDir}/appcast-${arch}.xml`; +const assetPathFor = (asset: GithubAsset) => `${env.assetsDir}/${asset.name}`; +const githubRepoUrl = `https://github.com/${env.githubRepo}`; + +const escapeXml = (value: string) => value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + +const assetUrlFor = (asset: GithubAsset) => env.serveAssetsLocally + ? `assets/${asset.name}` + : asset.browser_download_url; + +const sha256 = async (data: Uint8Array) => { + const digest = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); +}; + +const isValidReleaseUrl = (url: string) => + url.startsWith(`${githubRepoUrl}/releases/`); + +const isValidAssetUrl = (url: string) => + url.startsWith(`${githubRepoUrl}/releases/download/`); + +const getArchFromName = (name: string): Arch | null => { + if (/_x64-installer\.exe$/i.test(name)) { + return 'x64'; + } + if (/_arm64-installer\.exe$/i.test(name)) { + return 'arm64'; + } + return null; +}; + +const fetchJson = async (url: string): Promise => { + const response = await fetch(url, { + headers: { + ...headers, + accept: 'application/vnd.github+json', + }, + }); + + if (!response.ok) { + throw new Error(`request failed (${response.status}): ${url}`); + } + + return await response.json() as T; +}; + +const getReleases = async (): Promise => { + const githubReleases = await fetchJson( + `https://api.github.com/repos/${env.githubRepo}/releases?per_page=20`, + ); + + return githubReleases + .filter((release) => !release.draft && !release.prerelease) + .map((release) => { + if (!isValidReleaseUrl(release.html_url)) { + throw new Error(`invalid release url: ${release.html_url}`); + } + + return { + version: release.tag_name, + releaseNotesUrl: release.html_url, + publishedAt: release.published_at, + assets: release.assets.reduce>((acc, asset) => { + if (!isValidAssetUrl(asset.browser_download_url)) { + return acc; + } + + const arch = getArchFromName(asset.name); + if (arch) { + acc[arch] = asset; + } + return acc; + }, { x64: null, arm64: null }), + }; + }) + .filter((release) => release.assets.x64 || release.assets.arm64); +}; + +const downloadAsset = async (asset: GithubAsset) => { + const response = await fetch(asset.browser_download_url, { headers }); + if (!response.ok) { + throw new Error(`asset download failed (${response.status}): ${asset.name}`); + } + + const data = new Uint8Array(await response.arrayBuffer()); + if (data.length !== asset.size) { + throw new Error(`size mismatch for ${asset.name}: expected ${asset.size}, got ${data.length}`); + } + + const expectedDigest = asset.digest?.replace('sha256:', ''); + if (expectedDigest) { + const actualDigest = await sha256(data); + if (actualDigest !== expectedDigest) { + throw new Error(`digest mismatch for ${asset.name}`); + } + } + + await Deno.mkdir(env.assetsDir, { recursive: true }); + await Deno.writeFile(assetPathFor(asset), data); +}; + +const ensureAssets = async (releases: Release[]) => { + if (!env.serveAssetsLocally) { + return; + } + + const requiredAssets = releases.flatMap((release) => + [release.assets.x64, release.assets.arm64].filter( + (asset): asset is GithubAsset => asset !== null, + )); + + const requiredNames = new Set(requiredAssets.map((asset) => asset.name)); + await Deno.mkdir(env.assetsDir, { recursive: true }); + + for await (const entry of Deno.readDir(env.assetsDir)) { + if (entry.isFile && !requiredNames.has(entry.name)) { + await Deno.remove(`${env.assetsDir}/${entry.name}`); + } + } + + for (const asset of requiredAssets) { + try { + await Deno.stat(assetPathFor(asset)); + } catch { + console.log(`downloading ${asset.name}`); + await downloadAsset(asset); + } + } +}; + +const makeItemXml = (release: Release, arch: Arch) => { + const asset = release.assets[arch]; + if (!asset) { + return null; + } + + return [ + ' ', + ` ${escapeXml(release.version)}`, + ` ${new Date(release.publishedAt).toUTCString()}`, + ` ${escapeXml(release.version)}`, + ` ${escapeXml(release.version)}`, + ' 10.0', + ` ${escapeXml(release.releaseNotesUrl)}`, + ` `, + ' ', + ].join('\n'); +}; + +const renderAppcast = (releases: Release[], arch: Arch) => { + const items = releases + .map((release) => makeItemXml(release, arch)) + .filter((item): item is string => item !== null) + .join('\n'); + + return [ + '', + '', + ' ', + ` Helium Windows (${arch})`, + ' Stable Helium for Windows updates', + ' en', + items, + ' ', + '', + '', + ].join('\n'); +}; + +if (import.meta.main) { + const releases = await getReleases(); + await ensureAssets(releases); + await Deno.mkdir(env.outputDir, { recursive: true }); + + for (const arch of ['x64', 'arm64'] as const) { + await Deno.writeTextFile(appcastPathFor(arch), renderAppcast(releases, arch)); + } +}