mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-03 22:22:39 +02:00
feat(install): the desktop app downloads from a link that never changes (#394)
`/download/windows` and `/download/linux` redirect to a one-minute signed URL for the current desktop app (`desktop/<version>/` in the releases bucket). `/desktop.sh` installs it on Linux for the calling user, without sudo. The version handed out is a constant bumped by a commit after each release. Tests: 180 pass.
This commit is contained in:
@@ -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<string, string> = {
|
||||
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);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
@@ -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" <<DESKTOP
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Nestri
|
||||
Comment=Play your games on our GPUs
|
||||
Exec=$BIN/nestri
|
||||
Terminal=false
|
||||
Categories=Game;
|
||||
DESKTOP
|
||||
|
||||
echo "Installed. Open Nestri from your app launcher, or run: $BIN/nestri"
|
||||
case ":$PATH:" in *":$BIN:"*) ;; *) echo "($BIN is not on your PATH.)" ;; esac
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { app } from '../app/index';
|
||||
|
||||
describe('the desktop app download', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user