diff --git a/kinode/packages/app_store/api/app_store:sys-v1.wit b/kinode/packages/app_store/api/app_store:sys-v1.wit index 352af4ce..360d7cfe 100644 --- a/kinode/packages/app_store/api/app_store:sys-v1.wit +++ b/kinode/packages/app_store/api/app_store:sys-v1.wit @@ -184,6 +184,8 @@ interface downloads { auto-update(auto-update-request), /// Notify that a download is complete download-complete(download-complete-request), + /// Auto-update-download complete + auto-download-complete(auto-download-complete-request), /// Get files for a package get-files(option), /// Remove a file @@ -243,6 +245,12 @@ interface downloads { err: option, } + /// Request for an auto-download complete + record auto-download-complete-request { + download-info: download-complete-request, + manifest-hash: string, + } + /// Represents a hash mismatch error record hash-mismatch { desired: string, diff --git a/kinode/packages/app_store/app_store/src/lib.rs b/kinode/packages/app_store/app_store/src/lib.rs index f3b5731b..e7a9d433 100644 --- a/kinode/packages/app_store/app_store/src/lib.rs +++ b/kinode/packages/app_store/app_store/src/lib.rs @@ -30,7 +30,7 @@ //! It delegates these responsibilities to the downloads and chain processes respectively. //! use crate::kinode::process::downloads::{ - DownloadCompleteRequest, DownloadResponses, ProgressUpdate, + AutoDownloadCompleteRequest, DownloadCompleteRequest, DownloadResponses, ProgressUpdate, }; use crate::kinode::process::main::{ ApisResponse, GetApiResponse, InstallPackageRequest, InstallResponse, LocalRequest, @@ -65,6 +65,7 @@ pub enum Req { LocalRequest(LocalRequest), Progress(ProgressUpdate), DownloadComplete(DownloadCompleteRequest), + AutoDownloadComplete(AutoDownloadCompleteRequest), Http(http::server::HttpServerRequest), } @@ -161,6 +162,40 @@ fn handle_message( }, ); } + Req::AutoDownloadComplete(req) => { + if !message.is_local(&our) { + return Err(anyhow::anyhow!( + "auto download complete from non-local node" + )); + } + // auto_install case: + // the downloads process has given us the new package manifest's + // capability hashes, and the old package's capability hashes. + // we can use these to determine if the new package has the same + // capabilities as the old one, and if so, auto-install it. + + let manifest_hash = req.manifest_hash; + let package_id = req.download_info.package_id; + let version_hash = req.download_info.version_hash; + + if let Some(package) = state.packages.get(&package_id.clone().to_process_lib()) { + if package.manifest_hash == Some(manifest_hash) { + print_to_terminal(1, "auto_install:main, manifest_hash match"); + if let Err(e) = + utils::install(&package_id, None, &version_hash, state, &our.node) + { + print_to_terminal(1, &format!("error auto_installing package: {e}")); + } else { + println!( + "auto_installed update for package: {:?}", + &package_id.to_process_lib() + ); + } + } else { + print_to_terminal(1, "auto_install:main, manifest_hash do not match"); + } + } + } Req::DownloadComplete(req) => { if !message.is_local(&our) { return Err(anyhow::anyhow!("download complete from non-local node")); @@ -182,41 +217,6 @@ fn handle_message( .unwrap(), }, ); - - // auto_install case: - // the downloads process has given us the new package manifest's - // capability hashes, and the old package's capability hashes. - // we can use these to determine if the new package has the same - // capabilities as the old one, and if so, auto-install it. - if let Some(context) = message.context() { - let manifest_hash = String::from_utf8(context.to_vec())?; - if let Some(package) = - state.packages.get(&req.package_id.clone().to_process_lib()) - { - if package.manifest_hash == Some(manifest_hash) { - print_to_terminal(1, "auto_install:main, manifest_hash match"); - if let Err(e) = utils::install( - &req.package_id, - None, - &req.version_hash, - state, - &our.node, - ) { - print_to_terminal( - 1, - &format!("error auto_installing package: {e}"), - ); - } else { - println!( - "auto_installed update for package: {:?}", - &req.package_id.to_process_lib() - ); - } - } else { - print_to_terminal(1, "auto_install:main, manifest_hash do not match"); - } - } - } } } } else { diff --git a/kinode/packages/app_store/downloads/src/lib.rs b/kinode/packages/app_store/downloads/src/lib.rs index 8a8bfc31..31c91f13 100644 --- a/kinode/packages/app_store/downloads/src/lib.rs +++ b/kinode/packages/app_store/downloads/src/lib.rs @@ -42,9 +42,9 @@ //! mechanism is implemented in the FT worker for improved modularity and performance. //! use crate::kinode::process::downloads::{ - AutoUpdateRequest, DirEntry, DownloadCompleteRequest, DownloadError, DownloadRequests, - DownloadResponses, Entry, FileEntry, HashMismatch, LocalDownloadRequest, RemoteDownloadRequest, - RemoveFileRequest, + AutoDownloadCompleteRequest, AutoUpdateRequest, DirEntry, DownloadCompleteRequest, + DownloadError, DownloadRequests, DownloadResponses, Entry, FileEntry, HashMismatch, + LocalDownloadRequest, RemoteDownloadRequest, RemoveFileRequest, }; use std::{collections::HashSet, io::Read, str::FromStr}; @@ -245,7 +245,7 @@ fn handle_message( // if we have a pending auto_install, forward that context to the main process. // it will check if the caps_hashes match (no change in capabilities), and auto_install if it does. - let context = if auto_updates.remove(&( + let manifest_hash = if auto_updates.remove(&( req.package_id.clone().to_process_lib(), req.version_hash.clone(), )) { @@ -253,7 +253,7 @@ fn handle_message( req.package_id.clone().to_process_lib(), req.version_hash.clone(), ) { - Ok(manifest_hash) => Some(manifest_hash.as_bytes().to_vec()), + Ok(manifest_hash) => Some(manifest_hash), Err(e) => { print_to_terminal( 1, @@ -267,13 +267,26 @@ fn handle_message( }; // pushed to UI via websockets - let mut request = Request::to(("our", "main", "app_store", "sys")) - .body(serde_json::to_vec(&req)?); + Request::to(("our", "main", "app_store", "sys")) + .body(serde_json::to_vec(&req)?) + .send()?; - if let Some(ctx) = context { - request = request.context(ctx); + // trigger auto-update install trigger to main:app_store:sys + if let Some(manifest_hash) = manifest_hash { + let auto_download_complete_req = AutoDownloadCompleteRequest { + download_info: req.clone(), + manifest_hash, + }; + print_to_terminal( + 1, + &format!( + "auto_update download complete: triggering install on main:app_store:sys" + ), + ); + Request::to(("our", "main", "app_store", "sys")) + .body(serde_json::to_vec(&auto_download_complete_req)?) + .send()?; } - request.send()?; } DownloadRequests::GetFiles(maybe_id) => { // if not local, throw to the boonies. diff --git a/kinode/packages/app_store/ui/src/components/PackageSelector.tsx b/kinode/packages/app_store/ui/src/components/PackageSelector.tsx index 7fc66743..ae28fffd 100644 --- a/kinode/packages/app_store/ui/src/components/PackageSelector.tsx +++ b/kinode/packages/app_store/ui/src/components/PackageSelector.tsx @@ -6,7 +6,7 @@ interface PackageSelectorProps { } const PackageSelector: React.FC = ({ onPackageSelect }) => { - const { installed } = useAppsStore(); + const { installed, fetchInstalled } = useAppsStore(); const [selectedPackage, setSelectedPackage] = useState(""); const [customPackage, setCustomPackage] = useState(""); const [isCustomPackageSelected, setIsCustomPackageSelected] = useState(false); @@ -18,6 +18,10 @@ const PackageSelector: React.FC = ({ onPackageSelect }) => } }, [selectedPackage, onPackageSelect]); + useEffect(() => { + fetchInstalled(); + }, []); + const handlePackageChange = (e: React.ChangeEvent) => { const value = e.target.value; if (value === "custom") { diff --git a/kinode/packages/app_store/ui/src/pages/DownloadPage.tsx b/kinode/packages/app_store/ui/src/pages/DownloadPage.tsx index cd90c6be..c933d3a7 100644 --- a/kinode/packages/app_store/ui/src/pages/DownloadPage.tsx +++ b/kinode/packages/app_store/ui/src/pages/DownloadPage.tsx @@ -1,11 +1,10 @@ import React, { useState, useEffect, useCallback, useMemo } from "react"; -import { useParams, useNavigate } from "react-router-dom"; +import { useParams } from "react-router-dom"; import { FaDownload, FaSpinner, FaChevronDown, FaChevronUp, FaRocket, FaTrash, FaPlay } from "react-icons/fa"; import useAppsStore from "../store"; import { MirrorSelector } from '../components'; export default function DownloadPage() { - const navigate = useNavigate(); const { id } = useParams<{ id: string }>(); const { listings, @@ -28,6 +27,9 @@ export default function DownloadPage() { const [isMirrorOnline, setIsMirrorOnline] = useState(null); const [showCapApproval, setShowCapApproval] = useState(false); const [manifest, setManifest] = useState(null); + const [isInstalling, setIsInstalling] = useState(false); + const [isCheckingLaunch, setIsCheckingLaunch] = useState(false); + const [launchPath, setLaunchPath] = useState(null); const app = useMemo(() => listings[id || ""], [listings, id]); const appDownloads = useMemo(() => downloads[id || ""] || [], [downloads, id]); @@ -101,6 +103,36 @@ export default function DownloadPage() { return versionData ? installedApp.our_version_hash === versionData.hash : false; }, [app, selectedVersion, installedApp, sortedVersions]); + const checkLaunchPath = useCallback(() => { + if (!app) return; + setIsCheckingLaunch(true); + const appId = `${app.package_id.package_name}:${app.package_id.publisher_node}`; + fetchHomepageApps().then(() => { + const path = getLaunchUrl(appId); + setLaunchPath(path); + setIsCheckingLaunch(false); + if (path) { + setIsInstalling(false); + } + }); + }, [app, fetchHomepageApps, getLaunchUrl]); + + useEffect(() => { + if (isInstalling) { + const checkInterval = setInterval(checkLaunchPath, 500); + const timeout = setTimeout(() => { + clearInterval(checkInterval); + setIsInstalling(false); + setIsCheckingLaunch(false); + }, 5000); + + return () => { + clearInterval(checkInterval); + clearTimeout(timeout); + }; + } + }, [isInstalling, checkLaunchPath]); + const handleDownload = useCallback(() => { if (!id || !selectedMirror || !app || !selectedVersion) return; const versionData = sortedVersions.find(v => v.version === selectedVersion); @@ -130,36 +162,87 @@ export default function DownloadPage() { } }, [id, app, appDownloads]); - const canDownload = useMemo(() => { - return selectedMirror && (isMirrorOnline === true || selectedMirror.startsWith('http')) && !isDownloading && !isDownloaded; - }, [selectedMirror, isMirrorOnline, isDownloading, isDownloaded]); - const confirmInstall = useCallback(() => { if (!id || !selectedVersion) return; const versionData = sortedVersions.find(v => v.version === selectedVersion); if (versionData) { + setIsInstalling(true); + setLaunchPath(null); installApp(id, versionData.hash).then(() => { - fetchData(id); setShowCapApproval(false); setManifest(null); + fetchData(id); }); } }, [id, selectedVersion, sortedVersions, installApp, fetchData]); const handleLaunch = useCallback(() => { - if (app) { - const launchUrl = getLaunchUrl(`${app.package_id.package_name}:${app.package_id.publisher_node}`); - if (launchUrl) { - window.location.href = launchUrl; - } + if (launchPath) { + window.location.href = launchPath; } - }, [app, getLaunchUrl]); + }, [launchPath]); const canLaunch = useMemo(() => { if (!app) return false; return !!getLaunchUrl(`${app.package_id.package_name}:${app.package_id.publisher_node}`); }, [app, getLaunchUrl]); + const canDownload = useMemo(() => { + return selectedMirror && (isMirrorOnline === true || selectedMirror.startsWith('http')) && !isDownloading && !isDownloaded; + }, [selectedMirror, isMirrorOnline, isDownloading, isDownloaded]); + + const renderActionButton = () => { + if (isCurrentVersionInstalled || launchPath) { + return ( + + ); + } + + if (isInstalling || isCheckingLaunch) { + return ( + + ); + } + + if (isDownloaded) { + return ( + + ); + } + + return ( + + ); + }; + if (!app) { return

Loading app details...

; } @@ -176,15 +259,22 @@ export default function DownloadPage() {

{`${app.package_id.package_name}.${app.package_id.publisher_node}`}

- {installedApp && ( + {launchPath ? ( - )} + ) : isInstalling || isCheckingLaunch ? ( + + ) : installedApp ? ( + + ) : null}

{app.metadata?.description}

@@ -207,39 +297,7 @@ export default function DownloadPage() { onMirrorSelect={handleMirrorSelect} /> - {isCurrentVersionInstalled ? ( - - ) : isDownloaded ? ( - - ) : ( - - )} + {renderActionButton()}
diff --git a/kinode/packages/app_store/ui/src/pages/PublishPage.tsx b/kinode/packages/app_store/ui/src/pages/PublishPage.tsx index c72d0eec..e9c16c44 100644 --- a/kinode/packages/app_store/ui/src/pages/PublishPage.tsx +++ b/kinode/packages/app_store/ui/src/pages/PublishPage.tsx @@ -12,7 +12,7 @@ const NAME_INVALID = "Package name must contain only valid characters (a-z, 0-9, export default function PublishPage() { const { openConnectModal } = useConnectModal(); - const { ourApps, fetchOurApps, installed, downloads } = useAppsStore(); + const { ourApps, fetchOurApps, downloads } = useAppsStore(); const publicClient = usePublicClient(); const { address, isConnected, isConnecting } = useAccount(); diff --git a/kinode/packages/app_store/ui/src/store/index.ts b/kinode/packages/app_store/ui/src/store/index.ts index c87e23e8..c6c0deec 100644 --- a/kinode/packages/app_store/ui/src/store/index.ts +++ b/kinode/packages/app_store/ui/src/store/index.ts @@ -218,12 +218,6 @@ const useAppsStore = create()((set, get) => ({ }); if (res.status === HTTP_STATUS.CREATED) { await get().fetchInstalled(); - - // hacky: a small delay (500ms) before fetching homepage apps - // to give the app time to add itself to the homepage - // might make sense to add more state and do retry logic instead. - await new Promise(resolve => setTimeout(resolve, 500)); - await get().fetchHomepageApps(); } } catch (error) {