settings: gracefully handle github failures and paginate releases (#566)

Fixes #365
This commit is contained in:
Tyler Wilding 2024-09-22 13:33:54 -04:00 committed by GitHub
parent 543f937dfa
commit 7b9ade5def
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 212 additions and 172 deletions

View file

@ -141,6 +141,7 @@
"settings_versions_table_header_changes": "Changes",
"settings_versions_table_header_date": "Date",
"settings_versions_table_header_version": "Version",
"settings_versions_noReleasesFound": "No releases could be retrieved from GitHub!",
"setup_button_continue": "Continue",
"setup_button_getSupportPackage": "Get Support Package",
"setup_button_installViaISO": "Install via ISO",
@ -221,5 +222,7 @@
"toasts_modSourceUnreachable": "Mod source unreachable",
"toasts_couldNotRemoveModSource": "Unable to remove mod source",
"toasts_modSourceDuplicateName": "Mod source has the same display name as one you already have added",
"toasts_unableToRetrieveModDownloadURL": "Unable to retrieve mod download URL"
"toasts_unableToRetrieveModDownloadURL": "Unable to retrieve mod download URL",
"toasts_githubRateLimit": "Unable to hit GitHub's API, you are rate-limited",
"toasts_githubUnexpectedError": "Unexpected error when hitting GitHub's API"
}

View file

@ -75,7 +75,10 @@
$VersionStore.activeVersionType === "official"
) {
const latestToolingVersion = await getLatestOfficialRelease();
if ($VersionStore.activeVersionName !== latestToolingVersion.version) {
if (
latestToolingVersion !== undefined &&
$VersionStore.activeVersionName !== latestToolingVersion.version
) {
// Check that we havn't already downloaded it
let alreadyHaveRelease = false;
const downloadedOfficialVersions =
@ -127,11 +130,6 @@
{$VersionStore.activeVersionName === null
? "not set!"
: $VersionStore.activeVersionName}
{#if $VersionStore.activeVersionType === "unofficial"}
(unf)
{:else if $VersionStore.activeVersionType === "devel"}
(dev)
{/if}
</p>
</div>
<div

View file

@ -215,5 +215,7 @@ export async function initLocales(async: boolean) {
});
if (!async) {
await initPromise;
} else {
return initPromise;
}
}

View file

@ -1,6 +1,6 @@
import { invoke_rpc } from "./rpc";
export type VersionFolders = null | "official" | "unofficial" | "devel";
export type VersionFolders = null | "official";
export async function listDownloadedVersions(
versionFolder: VersionFolders,

View file

@ -1,12 +1,28 @@
import { afterEach, describe, expect, it, vi, type Mock } from "vitest";
import {
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
type Mock,
} from "vitest";
import { arch, platform } from "@tauri-apps/api/os";
import { listOfficialReleases } from "./github";
import { init } from "svelte-i18n";
import { initLocales } from "$lib/i18n/i18n";
vi.mock("@tauri-apps/api/os");
global.fetch = vi.fn();
function createFetchResponse(data: any) {
return { json: () => new Promise((resolve) => resolve(data)) };
return {
ok: true,
status: 200,
headers: new Map(),
json: () => new Promise((resolve) => resolve(data)),
};
}
function createFakeGithubReleaseAsset(assetName) {
@ -121,6 +137,10 @@ function createFakeGithubRelease(assetNames: string[]) {
};
}
beforeAll(async () => {
await initLocales(true);
});
describe("listOfficialReleases", () => {
afterEach(() => {
vi.clearAllMocks();

View file

@ -1,9 +1,11 @@
import { getVersion } from "@tauri-apps/api/app";
import { toastStore } from "$lib/stores/ToastStore";
import { arch, platform } from "@tauri-apps/api/os";
import semver from "semver";
import { unwrapFunctionStore, format } from "svelte-i18n";
const $format = unwrapFunctionStore(format);
export interface ReleaseInfo {
releaseType: "official" | "unofficial" | "devel";
releaseType: "official";
version: string;
date: string | undefined;
githubLink: string | undefined;
@ -26,17 +28,12 @@ function isIntelMacOsRelease(
);
}
// TODO - go back and fix old asset names so windows/linux can be simplified
function isWindowsRelease(
platform: string,
architecture: string,
assetName: string,
): boolean {
return (
platform === "win32" &&
(assetName.startsWith("opengoal-windows-v") ||
(assetName.startsWith("opengoal-v") && assetName.includes("windows")))
);
return platform === "win32" && assetName.startsWith("opengoal-windows-v");
}
function isLinuxRelease(
@ -44,11 +41,7 @@ function isLinuxRelease(
architecture: string,
assetName: string,
): boolean {
return (
platform === "linux" &&
(assetName.startsWith("opengoal-linux-v") ||
(assetName.startsWith("opengoal-v") && assetName.includes("linux")))
);
return platform === "linux" && assetName.startsWith("opengoal-linux-v");
}
async function getDownloadLinkForCurrentPlatform(
@ -99,35 +92,53 @@ async function parseGithubRelease(githubRelease: any): Promise<ReleaseInfo> {
}
export async function listOfficialReleases(): Promise<ReleaseInfo[]> {
return listReleases("official", "open-goal/jak-project");
}
export async function listReleases(
releaseType: string,
repo: string,
): Promise<ReleaseInfo[]> {
const nextUrlPattern = /(?<=<)([\S]*)(?=>; rel="Next")/i;
let releases = [];
// TODO - handle rate limiting
// TODO - long term - handle pagination (more than 100 releases)
const resp = await fetch(
"https://api.github.com/repos/open-goal/jak-project/releases?per_page=100",
);
// TODO - handle error
const githubReleases = await resp.json();
let urlToHit =
"https://api.github.com/repos/open-goal/jak-project/releases?per_page=100";
for (const release of githubReleases) {
releases.push(await parseGithubRelease(release));
while (urlToHit !== undefined) {
const resp = await fetch(urlToHit);
if (resp.status === 403 || resp.status === 429) {
toastStore.makeToast($format("toasts_githubRateLimit"), "error");
return [];
} else if (!resp.ok) {
toastStore.makeToast($format("toasts_githubUnexpectedError"), "error");
return [];
}
const githubReleases = await resp.json();
for (const release of githubReleases) {
releases.push(await parseGithubRelease(release));
}
if (
resp.headers.has("link") &&
resp.headers.get("link").includes(`rel=\"next\"`)
) {
// we must paginate!
urlToHit = resp.headers.get("link").match(nextUrlPattern)[0];
} else {
urlToHit = undefined;
}
}
return releases.sort((a, b) => b.date.localeCompare(a.date));
}
export async function getLatestOfficialRelease(): Promise<ReleaseInfo> {
// TODO - handle rate limiting
export async function getLatestOfficialRelease(): Promise<
ReleaseInfo | undefined
> {
const resp = await fetch(
"https://api.github.com/repos/open-goal/jak-project/releases/latest",
);
// TODO - handle error
if (resp.status === 403 || resp.status === 429) {
toastStore.makeToast($format("toasts_githubRateLimit"), "error");
return undefined;
} else if (!resp.ok) {
toastStore.makeToast($format("toasts_githubUnexpectedError"), "error");
return undefined;
}
const githubRelease = await resp.json();
return await parseGithubRelease(githubRelease);
}

View file

@ -50,7 +50,6 @@
},
];
}
// TODO - "no releases found"
// Merge that with the actual current releases on github
const githubReleases = await listOfficialReleases();
@ -111,8 +110,6 @@
if (success) {
$VersionStore.activeVersionType = "official";
$VersionStore.activeVersionName = $VersionStore.selectedVersions.official;
$VersionStore.selectedVersions.unofficial = null;
$VersionStore.selectedVersions.devel = null;
toastStore.makeToast($_("toasts_savedToolingVersion"), "info");
}
}

View file

@ -8,6 +8,7 @@
import IconDownload from "~icons/mdi/download";
import IconDeleteForever from "~icons/mdi/delete-forever";
import {
Alert,
Button,
Radio,
Spinner,
@ -60,144 +61,152 @@
</Button>
</div>
</div>
<Table>
<TableHead>
<TableHeadCell>
<span class="sr-only"></span>
</TableHeadCell>
<TableHeadCell>
<span class="sr-only"></span>
</TableHeadCell>
<TableHeadCell
>{$_("settings_versions_table_header_version")}</TableHeadCell
>
<TableHeadCell>{$_("settings_versions_table_header_date")}</TableHeadCell>
<TableHeadCell
>{$_("settings_versions_table_header_changes")}</TableHeadCell
>
</TableHead>
<TableBody tableBodyClass="divide-y">
{#each releaseList as release (release.version)}
<TableBodyRow>
<TableBodyCell class="px-6 py-2 whitespace-nowrap font-medium">
{#if release.isDownloaded}
<Radio
class="disabled:cursor-not-allowed p-0"
bind:group={$VersionStore.selectedVersions[releaseType]}
on:change={() => dispatch("versionChange")}
value={release.version}
disabled={!release.isDownloaded}
name={`${releaseType}-release`}
/>
{/if}
</TableBodyCell>
<TableBodyCell
class="px-6 py-2 whitespace-nowrap font-medium"
style="line-height: 0;"
>
<Button
class="py-0 dark:bg-transparent hover:dark:bg-transparent focus:ring-0 focus:ring-offset-0 disabled:opacity-50"
disabled={release.pendingAction ||
(!release.isDownloaded &&
release.downloadUrl !== undefined &&
release.invalid)}
on:click={async () => {
if (release.isDownloaded) {
dispatch("removeVersion", { version: release.version });
} else {
dispatch("downloadVersion", {
version: release.version,
downloadUrl: release.downloadUrl,
});
}
}}
>
{#if releaseList === undefined || releaseList.length <= 0}
<Alert color="red" class="dark:bg-slate-900 flex-grow">
{$_("settings_versions_noReleasesFound")}
</Alert>
{:else}
<Table>
<TableHead>
<TableHeadCell>
<span class="sr-only"></span>
</TableHeadCell>
<TableHeadCell>
<span class="sr-only"></span>
</TableHeadCell>
<TableHeadCell
>{$_("settings_versions_table_header_version")}</TableHeadCell
>
<TableHeadCell
>{$_("settings_versions_table_header_date")}</TableHeadCell
>
<TableHeadCell
>{$_("settings_versions_table_header_changes")}</TableHeadCell
>
</TableHead>
<TableBody tableBodyClass="divide-y">
{#each releaseList as release (release.version)}
<TableBodyRow>
<TableBodyCell class="px-6 py-2 whitespace-nowrap font-medium">
{#if release.isDownloaded}
<IconDeleteForever
class="text-xl"
color="red"
aria-label={$_(
"settings_versions_icon_removeVersion_altText",
)}
/>
{:else if release.downloadUrl === undefined}
<span>{$_("settings_versions_incompatibleVersion")}</span>
{:else if release.pendingAction}
<Spinner color="yellow" size={"6"} />
{:else if release.releaseType === "official" && release.downloadUrl !== undefined}
<IconDownload
class="text-xl"
color="#00d500"
aria-label={$_(
"settings_versions_icon_downloadVersion_altText",
)}
<Radio
class="disabled:cursor-not-allowed p-0"
bind:group={$VersionStore.selectedVersions[releaseType]}
on:change={() => dispatch("versionChange")}
value={release.version}
disabled={!release.isDownloaded}
name={`${releaseType}-release`}
/>
{/if}
</Button>
{#if release.invalid}
<Tooltip color="red">
{#if release.invalidationReasons.length > 0}
{$_("settings_versions_invalidReleaseGeneric")}
{#each release.invalidationReasons as reason}
<br />
- {reason}
{/each}
{:else}
{$_("settings_versions_invalidReleaseGeneric")}
{/if}
</Tooltip>
{/if}
{#if release.isDownloaded && release.releaseType == "official"}
</TableBodyCell>
<TableBodyCell
class="px-6 py-2 whitespace-nowrap font-medium"
style="line-height: 0;"
>
<Button
class="py-0 dark:bg-transparent hover:dark:bg-transparent focus:ring-0 focus:ring-offset-0 disabled:opacity-50"
disabled={release.pendingAction}
disabled={release.pendingAction ||
(!release.isDownloaded &&
release.downloadUrl !== undefined &&
release.invalid)}
on:click={async () => {
dispatch("redownloadVersion", {
version: release.version,
downloadUrl: release.downloadUrl,
});
if (release.isDownloaded) {
dispatch("removeVersion", { version: release.version });
} else {
dispatch("downloadVersion", {
version: release.version,
downloadUrl: release.downloadUrl,
});
}
}}
>
{#if release.pendingAction}
<Spinner color="yellow" size={"6"} />
{:else}
<IconRefresh
{#if release.isDownloaded}
<IconDeleteForever
class="text-xl"
color="red"
aria-label={$_(
"settings_versions_icon_redownloadVersion_altText",
"settings_versions_icon_removeVersion_altText",
)}
/>
{:else if release.downloadUrl === undefined}
<span>{$_("settings_versions_incompatibleVersion")}</span>
{:else if release.pendingAction}
<Spinner color="yellow" size={"6"} />
{:else if release.releaseType === "official" && release.downloadUrl !== undefined}
<IconDownload
class="text-xl"
color="#00d500"
aria-label={$_(
"settings_versions_icon_downloadVersion_altText",
)}
/>
{/if}
</Button>
{/if}
</TableBodyCell>
<TableBodyCell class="px-6 py-2 whitespace-nowrap font-medium"
>{release.version}</TableBodyCell
>
<TableBodyCell class="px-6 py-2 whitespace-nowrap font-medium">
{#if release.date}
{new Date(release.date).toLocaleDateString()}
{/if}
</TableBodyCell>
<TableBodyCell class="px-6 py-2 whitespace-nowrap font-medium">
{#if release.githubLink}
<a
class="inline-block"
href={release.githubLink}
target="_blank"
rel="noreferrer"
>
<IconGitHub
class="text-xl"
aria-label={$_(
"settings_versions_icon_githubRelease_altText",
)}
/>
</a>
{/if}
</TableBodyCell>
</TableBodyRow>
{/each}
</TableBody>
</Table>
{#if release.invalid}
<Tooltip color="red">
{#if release.invalidationReasons.length > 0}
{$_("settings_versions_invalidReleaseGeneric")}
{#each release.invalidationReasons as reason}
<br />
- {reason}
{/each}
{:else}
{$_("settings_versions_invalidReleaseGeneric")}
{/if}
</Tooltip>
{/if}
{#if release.isDownloaded && release.releaseType == "official"}
<Button
class="py-0 dark:bg-transparent hover:dark:bg-transparent focus:ring-0 focus:ring-offset-0 disabled:opacity-50"
disabled={release.pendingAction}
on:click={async () => {
dispatch("redownloadVersion", {
version: release.version,
downloadUrl: release.downloadUrl,
});
}}
>
{#if release.pendingAction}
<Spinner color="yellow" size={"6"} />
{:else}
<IconRefresh
class="text-xl"
aria-label={$_(
"settings_versions_icon_redownloadVersion_altText",
)}
/>
{/if}
</Button>
{/if}
</TableBodyCell>
<TableBodyCell class="px-6 py-2 whitespace-nowrap font-medium"
>{release.version}</TableBodyCell
>
<TableBodyCell class="px-6 py-2 whitespace-nowrap font-medium">
{#if release.date}
{new Date(release.date).toLocaleDateString()}
{/if}
</TableBodyCell>
<TableBodyCell class="px-6 py-2 whitespace-nowrap font-medium">
{#if release.githubLink}
<a
class="inline-block"
href={release.githubLink}
target="_blank"
rel="noreferrer"
>
<IconGitHub
class="text-xl"
aria-label={$_(
"settings_versions_icon_githubRelease_altText",
)}
/>
</a>
{/if}
</TableBodyCell>
</TableBodyRow>
{/each}
</TableBody>
</Table>
{/if}
{/if}