From eb20ab83094f094ce14e8ca7d8b793743ab8079b Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sun, 11 Oct 2026 14:13:44 +0300 Subject: [PATCH] feat(install): the desktop app downloads from a link that never changes /download/windows and /download/linux redirect to a one-minute signed URL for the current desktop app, from the same private bucket as the host agent, so a download page can link to them once and every download still passes through the API. Which version they hand out is a constant bumped by a commit after a release is published, as the host installer's is. /desktop.sh installs it on Linux for the calling user: one binary in ~/.local/bin and a launcher entry, no root. --- apps/api/app/routes/install.ts | 88 ++++++++++++++++++++++------- apps/api/install/desktop-version.ts | 6 ++ apps/api/install/desktop.sh | 36 ++++++++++++ apps/api/test/download.test.ts | 22 ++++++++ 4 files changed, 133 insertions(+), 19 deletions(-) create mode 100644 apps/api/install/desktop-version.ts create mode 100644 apps/api/install/desktop.sh create mode 100644 apps/api/test/download.test.ts diff --git a/apps/api/app/routes/install.ts b/apps/api/app/routes/install.ts index 935bc3d0..1d2bdb03 100644 --- a/apps/api/app/routes/install.ts +++ b/apps/api/app/routes/install.ts @@ -5,6 +5,8 @@ import { describeRoute } from 'hono-openapi'; // The installer, embedded at build time so the script and the API that redeems // its token always ship as one version. import script from '../../install/install.sh' with { type: 'text' }; +import desktopScript from '../../install/desktop.sh' with { type: 'text' }; +import { DESKTOP_VERSION } from '../../install/desktop-version'; import { presignGet } from '../utils/presign'; /** @@ -16,12 +18,37 @@ import { presignGet } from '../utils/presign'; */ export namespace InstallApi { /** What may be downloaded: one component, versions and asset names by shape. */ - const COMPONENTS = new Set(['host']); + const COMPONENTS = new Set(['host', 'desktop']); const VERSION = /^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/; const ASSET = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; const SIGNED_SECONDS = 60; + /** The desktop app's asset for each platform a person can download it for. */ + const DESKTOP_ASSETS: Record = { + windows: 'Nestri.exe', + linux: 'nestri-linux-x86_64' + }; + + function releases() { + const env = Env.get(); + if ( + !env.RELEASES_BUCKET || + !env.RELEASES_ENDPOINT || + !env.RELEASES_ACCESS_KEY_ID || + !env.RELEASES_SECRET_ACCESS_KEY + ) { + return null; + } + return { + endpoint: env.RELEASES_ENDPOINT, + bucket: env.RELEASES_BUCKET, + region: env.RELEASES_REGION, + accessKeyId: env.RELEASES_ACCESS_KEY_ID, + secretAccessKey: env.RELEASES_SECRET_ACCESS_KEY + }; + } + export const route = new Hono() .get( '/install.sh', @@ -55,26 +82,49 @@ export namespace InstallApi { if (!COMPONENTS.has(component) || !VERSION.test(version) || !ASSET.test(asset)) { return c.notFound(); } - const env = Env.get(); - if ( - !env.RELEASES_BUCKET || - !env.RELEASES_ENDPOINT || - !env.RELEASES_ACCESS_KEY_ID || - !env.RELEASES_SECRET_ACCESS_KEY - ) { + const bucket = releases(); + if (!bucket) { return c.json({ message: 'Downloads are not configured on this deployment.' }, 503); } - const url = await presignGet( - { - endpoint: env.RELEASES_ENDPOINT, - bucket: env.RELEASES_BUCKET, - region: env.RELEASES_REGION, - accessKeyId: env.RELEASES_ACCESS_KEY_ID, - secretAccessKey: env.RELEASES_SECRET_ACCESS_KEY - }, - `${component}/${version}/${asset}`, - SIGNED_SECONDS - ); + const url = await presignGet(bucket, `${component}/${version}/${asset}`, SIGNED_SECONDS); + return c.redirect(url, 302); + } + ) + .get( + '/desktop.sh', + describeRoute({ + tags: ['Install'], + summary: 'The desktop app installer for Linux', + description: + 'A POSIX shell script that installs the desktop app for the calling user, in ~/.local/bin with a launcher entry. Pipe it to `sh`.', + responses: { 200: { description: 'The script' } } + }), + (c) => + c.body(desktopScript, 200, { + 'content-type': 'text/x-shellscript; charset=utf-8', + 'cache-control': 'no-cache' + }) + ) + .get( + '/download/:platform', + describeRoute({ + tags: ['Install'], + summary: 'Download the desktop app', + description: + 'Redirects to a short-lived signed URL for the current desktop app on `windows` or `linux`. This link does not change between versions.', + responses: { + 302: { description: 'Where to download it' }, + 404: { description: 'Not available for that platform' } + } + }), + async (c) => { + const asset = DESKTOP_ASSETS[c.req.param('platform')]; + if (!asset) return c.notFound(); + const bucket = releases(); + if (!bucket) { + return c.json({ message: 'Downloads are not configured on this deployment.' }, 503); + } + const url = await presignGet(bucket, `desktop/${DESKTOP_VERSION}/${asset}`, SIGNED_SECONDS); return c.redirect(url, 302); } ); diff --git a/apps/api/install/desktop-version.ts b/apps/api/install/desktop-version.ts new file mode 100644 index 00000000..ed4cfd70 --- /dev/null +++ b/apps/api/install/desktop-version.ts @@ -0,0 +1,6 @@ +/** + * The desktop app every download link hands out. Bumped by a commit once a + * release of that version is published, the same way the host installer's + * default is, so what a person downloads changes only through review. + */ +export const DESKTOP_VERSION = '0.1.0'; diff --git a/apps/api/install/desktop.sh b/apps/api/install/desktop.sh new file mode 100644 index 00000000..84125756 --- /dev/null +++ b/apps/api/install/desktop.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# Installs the Nestri desktop app for the calling user: one binary in +# ~/.local/bin and a launcher entry. No root, nothing outside $HOME. +# +# curl -fsSL https://api.nestri.io/desktop.sh | sh +set -eu + +API="${NESTRI_API:-https://api.nestri.io}" +BIN="${XDG_BIN_HOME:-$HOME/.local/bin}" +APPS="${XDG_DATA_HOME:-$HOME/.local/share}/applications" + +case "$(uname -s)-$(uname -m)" in + Linux-x86_64) ;; + *) echo "Nestri runs on x86_64 Linux and Windows for now." >&2; exit 1 ;; +esac + +mkdir -p "$BIN" "$APPS" +tmp="$BIN/.nestri.$$" +trap 'rm -f "$tmp"' EXIT +echo "Downloading Nestri..." +curl -fSL --progress-bar "$API/download/linux" -o "$tmp" +chmod 755 "$tmp" +mv "$tmp" "$BIN/nestri" + +cat > "$APPS/nestri.desktop" < { + test('the Linux installer is served as a script', async () => { + const res = await app.request('/desktop.sh'); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('shellscript'); + expect(await res.text()).toContain('/download/linux'); + }); + + test('a platform that is not offered is not found', async () => { + expect((await app.request('/download/macos')).status).toBe(404); + }); + + test('a desktop asset is a known component', async () => { + // No bucket in tests: the route got past its allow-list to the config check. + const res = await app.request('/install/desktop/0.1.0/Nestri.exe'); + expect([302, 503]).toContain(res.status); + }); +});