gui/modules/desktop/src/libs/stores/pkgs.ts
Neil 53224e21b9
New image format (#675)
* use new image loading strategy: infer from gui.tea.xyz/stage/pkgFullname/dimension.webp
2023-06-22 14:08:45 +08:00

414 lines
12 KiB
TypeScript

import { derived, writable } from "svelte/store";
import type { GUIPackage, InstalledPackage, Packages } from "../types";
import { PackageStates } from "../types";
import {
getPackage,
getDistPackages,
getInstalledPackages,
installPackage,
deletePackage,
setBadgeCount,
loadPackageCache,
writePackageCache,
syncPantry,
cacheImageURL,
listenToChannel,
getInstalledVersionsForPackage,
monitorTeaDir,
stopMonitoringTeaDir,
isDev
} from "@native";
import { getReadme, getContributors, getRepoAsPackage } from "$libs/github";
import { NotificationType } from "@tea/ui/types";
import { trackInstall, trackInstallFailed } from "$libs/analytics";
import { addInstalledVersion, isInstalling, packageWasUpdated } from "$libs/packages/pkg-utils";
import withDebounce from "$libs/utils/debounce";
import { trimGithubSlug } from "$libs/github";
import { notificationStore } from "$libs/stores";
import withRetry from "$libs/utils/retry";
import log from "$libs/logger";
import { isPackageUpToDate } from "../packages/pkg-utils";
import { indexPackages, searchPackages } from "$libs/search-index";
const packageRefreshInterval = 1000 * 60 * 60; // 1 hour
let initialized = false;
let isDestroyed = false;
let refreshTimeoutId: ReturnType<typeof setTimeout> | null = null;
const dev = isDev();
const packageMap = writable<Packages>({ version: "0", packages: {} });
const packageList = derived(packageMap, ($packages) =>
Object.values($packages.packages).sort((a, b) => {
// implement default sort by last_modified > descending
const aDate = new Date(a.last_modified);
const bDate = new Date(b.last_modified);
return +bDate - +aDate;
})
);
const updateAllPackages = (guiPkgs: GUIPackage[]) => {
packageMap.update((pkgs) => {
guiPkgs.forEach((pkg) => {
const oldPkg = pkgs.packages[pkg.full_name];
pkgs.packages[pkg.full_name] = { ...oldPkg, ...pkg };
});
setBadgeCountFromPkgs(pkgs);
return pkgs;
});
};
const updatePackage = (full_name: string, props: Partial<GUIPackage>, newVersion?: string) => {
packageMap.update((pkgs) => {
const pkg = pkgs.packages[full_name];
if (pkg) {
const updatedPkg = { ...pkg, ...props };
if (newVersion) {
updatedPkg.installed_versions = addInstalledVersion(
updatedPkg.installed_versions,
newVersion
);
}
updatedPkg.state = getPackageState(updatedPkg);
pkgs.packages[full_name] = updatedPkg;
setBadgeCountFromPkgs(pkgs);
}
return pkgs;
});
};
// getPackage state centralizes the logic for determining the state of the package based on the other properties
const getPackageState = (pkg: GUIPackage): PackageStates => {
if (pkg.isUninstalling) {
//TODO: maybe there should be an uninstalling state too? Although that needs UI/UX changes
return PackageStates.AVAILABLE;
}
const isUpToDate = isPackageUpToDate(pkg);
if (isInstalling(pkg)) {
const hasNoVersions = !pkg.installed_versions?.length;
if (hasNoVersions || isUpToDate) {
return PackageStates.INSTALLING;
}
return PackageStates.UPDATING;
}
if (!pkg.installed_versions?.length) {
return PackageStates.AVAILABLE;
}
return isUpToDate ? PackageStates.INSTALLED : PackageStates.NEEDS_UPDATE;
};
const syncPackageData = async (guiPkg: Partial<GUIPackage> | undefined) => {
if (!guiPkg) return;
const pkg = await getPackage(guiPkg.full_name!); // ATM: pkg only bottles and github:string
const readmeMd = `# ${guiPkg.full_name} #
To read more about this package go to [${guiPkg.homepage}](${guiPkg.homepage}).
`;
const updatedPackage: Partial<GUIPackage> = {
bottles: pkg?.bottles || [],
readme: {
data: readmeMd,
type: "md"
},
synced: true,
github_url: pkg.github_url
? trimGithubSlug(pkg.github_url)
: pkg.full_name?.includes("github.com")
? trimGithubSlug(pkg.full_name.split("github.com/")[1])
: ""
};
if (updatedPackage.github_url) {
const [owner, repo] = updatedPackage.github_url.split("/");
const [readme, contributors, repoData] = await Promise.all([
getReadme(owner, repo),
getContributors(owner, repo),
getRepoAsPackage(owner, repo)
]);
if (readme) {
updatedPackage.readme = readme;
}
updatedPackage.contributors = contributors;
updatedPackage.license = repoData.license;
}
updatePackage(guiPkg.full_name!, updatedPackage);
};
const init = async function () {
log.info("packages store: try initialize");
if (!initialized) {
const cachedPkgs: Packages = await loadPackageCache();
log.info(`Loaded ${Object.keys(cachedPkgs.packages).length} packages from cache`);
packageMap.set(cachedPkgs);
await refreshPackages();
await monitorTeaDir();
initialized = true;
}
log.info("packages store: initialized!");
};
const refreshPackages = async () => {
if (isDestroyed) return;
log.info("packages store: refreshing...");
const isDev = await dev;
const pkgs = await getDistPackages();
const guiPkgs: GUIPackage[] = pkgs.map((p) => {
const prefix = `https://gui.tea.xyz/${isDev ? "dev" : "prod"}/${p.full_name}`;
return {
...p,
state: PackageStates.AVAILABLE,
...(p.image_added_at
? {
image_512_url: `${prefix}/512x512.webp`,
image_128_url: `${prefix}/128x128.webp`
}
: {})
};
});
if (!initialized) {
// set packages data so that i can render something in the UI already
updateAllPackages(guiPkgs);
log.info("initialized packages store with ", guiPkgs.length);
}
// initialize Fuse index for fuzzy search
indexPackages(guiPkgs);
try {
const installedPkgs: InstalledPackage[] = await getInstalledPackages();
log.info("updating state of packages");
for (const pkg of guiPkgs) {
const iPkg = installedPkgs.find((p) => p.full_name === pkg.full_name);
if (iPkg) {
pkg.installed_versions = iPkg.installed_versions;
updatePackage(pkg.full_name, {
installed_versions: iPkg.installed_versions
});
}
}
} catch (error) {
log.error(error);
}
try {
await withRetry(syncPantry);
} catch (err) {
log.error(err);
}
refreshTimeoutId = setTimeout(() => refreshPackages(), packageRefreshInterval); // refresh every hour
};
const refreshSinglePackage = async (fullName: string) => {
log.info(`refreshing single package: ${fullName}`);
const result = await getInstalledVersionsForPackage(fullName);
log.info(`package: ${fullName} has installed versions:`, result.installed_versions);
updatePackage(fullName, { installed_versions: result.installed_versions });
};
// Destructor for the package store
const destroy = async () => {
isDestroyed = true;
if (refreshTimeoutId) {
clearTimeout(refreshTimeoutId);
}
await stopMonitoringTeaDir();
log.info("packages store: destroyed");
};
const installPkg = async (pkg: GUIPackage, version?: string) => {
const versionToInstall = version || pkg.version;
try {
updatePackage(pkg.full_name, { install_progress_percentage: 0.01 });
await installPackage(pkg, versionToInstall);
trackInstall(pkg.full_name);
// If the package was AVAILABLE previously then it was just installed, otherwise it was updated
const state = pkg.state === PackageStates.AVAILABLE ? "INSTALLED" : "UPDATED";
updatePackage(pkg.full_name, { displayState: { state, version: versionToInstall } });
await refreshSinglePackage(pkg.full_name);
} catch (error) {
log.error(error);
let message = "Unknown Error";
if (error instanceof Error) message = error.message;
trackInstallFailed(pkg.full_name, message || "unknown");
updatePackage(pkg.full_name, {
displayState: { state: "ERROR", errorMessage: message, version: versionToInstall }
});
} finally {
updatePackage(pkg.full_name, { install_progress_percentage: 100 });
}
};
const uninstallPkg = async (pkg: GUIPackage) => {
let fakeTimer: NodeJS.Timer | null = null;
try {
fakeTimer = withFakeLoader(pkg, (progress) => {
updatePackage(pkg.full_name, {
install_progress_percentage: progress,
isUninstalling: true
});
});
for (const v of pkg.installed_versions || []) {
await deletePkg(pkg, v);
}
await refreshSinglePackage(pkg.full_name);
} catch (error) {
log.error(error);
notificationStore.add({
message: `Package ${pkg.full_name} failed to uninstall.`,
type: NotificationType.ERROR
});
} finally {
fakeTimer && clearTimeout(fakeTimer);
updatePackage(pkg.full_name, { install_progress_percentage: 0, isUninstalling: false });
}
};
const deletePkg = async (pkg: GUIPackage, version: string) => {
log.info("deleting package: ", pkg.full_name, " version: ", version);
await deletePackage({ fullName: pkg.full_name, version });
updatePackage(pkg.full_name, {
installed_versions: pkg.installed_versions?.filter((v) => v !== version)
});
};
const writePackageCacheWithDebounce = withDebounce(writePackageCache);
packageMap.subscribe(async (pkgs) => {
writePackageCacheWithDebounce(pkgs);
});
const cachePkgImage = async (pkg: GUIPackage): Promise<string> => {
let cacheFileURL = "";
updatePackage(pkg.full_name, { cached_image_url: "" });
if (pkg.image_added_at && pkg.image_512_url) {
const result = await cacheImageURL(pkg.image_512_url);
if (result) {
cacheFileURL = result;
updatePackage(pkg.full_name, { cached_image_url: cacheFileURL });
}
}
return cacheFileURL;
};
export const getPackageImageURL = async (
pkg: GUIPackage,
size: 512 | 1024 | 128
): Promise<string> => {
if (!pkg.image_added_at) return "";
const isDev = await dev;
return `https://gui.tea.xyz/${isDev ? "dev" : "prod"}/${pkg.full_name}/${size}x${size}.webp`;
};
listenToChannel("install-progress", ({ full_name, progress }: any) => {
if (!full_name) {
return;
}
updatePackage(full_name, { install_progress_percentage: progress });
});
// TODO: perhaps this can be combined with pkg-modified?
listenToChannel("pkg-installed", ({ full_name, version }: any) => {
if (!full_name) {
return;
}
updatePackage(full_name, {}, version);
});
listenToChannel("pkg-modified", ({ full_name }: any) => {
if (!full_name) {
return;
}
refreshSinglePackage(full_name);
});
// This is only used for uninstall now
export const withFakeLoader = (
pkg: GUIPackage,
callback: (progress: number) => void
): NodeJS.Timer => {
let fakeLoadingProgress = 1;
const ms = 100;
const assumedDlSpeedMb = 1024 * 1024 * 3; // 3mbps
const size = pkg?.bottles?.length ? pkg.bottles[0].bytes : assumedDlSpeedMb * 10;
const eta = size / assumedDlSpeedMb;
const increment = 1 / eta / 10;
const fakeTimer = setInterval(() => {
const progressLeft = 100 - fakeLoadingProgress;
const addProgress = progressLeft * increment;
fakeLoadingProgress = fakeLoadingProgress + addProgress;
callback(fakeLoadingProgress);
}, ms);
return fakeTimer;
};
const setBadgeCountFromPkgs = (pkgs: Packages) => {
try {
const needsUpdateCount = Object.values(pkgs.packages).filter(
(p) => p.state === PackageStates.NEEDS_UPDATE
).length;
setBadgeCount(needsUpdateCount);
} catch (error) {
log.error(error);
}
};
const resetPackageDisplayState = (pkg: GUIPackage) => {
updatePackage(pkg.full_name, {
displayState: null
});
};
const resetAllPackagesUpdatedState = () => {
packageMap.update((pkgs) => {
Object.values(pkgs.packages).forEach((pkg) => {
// only reset the display state if the package was updated,
// installed and error display states should not be reset here
if (packageWasUpdated(pkg)) {
pkg.displayState = null;
}
});
return pkgs;
});
};
export default {
packageList,
search: searchPackages,
init,
installPkg,
uninstallPkg,
syncPackageData,
deletePkg,
destroy,
cachePkgImage,
resetPackageDisplayState,
resetAllPackagesUpdatedState,
getPackageImageURL
};