diff --git a/kinode/packages/app_store/app_store/src/http_api.rs b/kinode/packages/app_store/app_store/src/http_api.rs index 74337a18..a0b9c75b 100644 --- a/kinode/packages/app_store/app_store/src/http_api.rs +++ b/kinode/packages/app_store/app_store/src/http_api.rs @@ -21,6 +21,7 @@ pub fn init_frontend(our: &Address) { "/apps/:id", "/apps/:id/download", "/apps/:id/install", + "/apps/:id/update", "/apps/:id/caps", "/apps/:id/mirror", "/apps/:id/auto-update", @@ -411,6 +412,75 @@ fn serve_paths( )), } } + // POST /apps/:id/update + // update a downloaded app + "/apps/:id/update" => { + let Ok(package_id) = get_package_id(url_params) else { + return Ok(( + StatusCode::BAD_REQUEST, + None, + format!("Missing id").into_bytes(), + )); + }; + + match method { + Method::POST => { + let pkg_listing: &PackageListing = state + .packages + .get(&package_id) + .ok_or(anyhow::anyhow!("No package"))?; + + let body = crate::get_blob() + .ok_or(anyhow::anyhow!("missing blob"))? + .bytes; + let body_json: serde_json::Value = + serde_json::from_slice(&body).unwrap_or_default(); + + let download_from = body_json + .get("download_from") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| { + pkg_listing + .metadata + .as_ref()? + .properties + .mirrors + .first() + .map(|m| m.to_string()) + }) + .ok_or_else(|| anyhow::anyhow!("No download_from specified!"))?; + + let desired_version_hash = body_json + .get("version") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + match crate::start_download( + state, + package_id, + download_from, + false, // Don't mirror during update + pkg_listing.state.as_ref().map_or(false, |s| s.auto_update), + desired_version_hash, + ) { + DownloadResponse::Started => { + Ok((StatusCode::ACCEPTED, None, format!("Updating").into_bytes())) + } + other => Ok(( + StatusCode::SERVICE_UNAVAILABLE, + None, + format!("Failed to update: {other:?}").into_bytes(), + )), + } + } + _ => Ok(( + StatusCode::METHOD_NOT_ALLOWED, + None, + format!("Invalid method {method} for {bound_path}").into_bytes(), + )), + } + } // POST /apps/:id/install // install a downloaded app "/apps/:id/install" => { diff --git a/kinode/packages/app_store/ui/src/index.css b/kinode/packages/app_store/ui/src/index.css index 4ec35fdf..5e6ea004 100644 --- a/kinode/packages/app_store/ui/src/index.css +++ b/kinode/packages/app_store/ui/src/index.css @@ -63,7 +63,39 @@ .app-actions { display: flex; flex-direction: column; - gap: 1rem; + gap: 10px; + min-width: 200px; +} + +.app-actions-buttons { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.app-actions-buttons button { + flex: 1 0 auto; + min-width: 120px; + /* Ensure buttons have a minimum width */ +} + +.mirror-selection { + width: 100%; + margin-bottom: 10px; +} + +.mirror-selection select { + width: 100%; + padding: 8px; + border-radius: 4px; + border: 1px solid var(--gray); + background-color: light-dark(var(--off-white), var(--off-black)); + color: light-dark(var(--off-black), var(--off-white)); +} + +.external-link { + display: inline-block; + margin-top: 10px; } @media (max-width: 768px) { @@ -735,4 +767,96 @@ button svg, padding: 10px; margin: 0; color: light-dark(var(--off-black), var(--off-white)); +} + +/* Add these new styles */ +.app-actions button { + transition: all 0.3s ease; +} + +.app-actions button:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.app-actions button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.updating { + background-color: var(--yellow); + color: var(--off-black); +} + +.progress-container { + margin-top: 1rem; +} + +.progress-bar { + height: 10px; + background-color: var(--off-white); + border-radius: 5px; + overflow: hidden; +} + +.progress { + height: 100%; + background-color: var(--blue); + transition: width 0.3s ease; +} + +.progress-percentage { + text-align: center; + font-size: 0.8rem; + margin-top: 0.25rem; +} + +.expandable-item { + background-color: light-dark(var(--tan), var(--maroon)); + border-radius: 4px; + overflow: hidden; + transition: all 0.3s ease; +} + +.expandable-item:hover { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.item-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + cursor: pointer; +} + +.dropdown-toggle { + background: none; + border: none; + cursor: pointer; + color: var(--blue); + padding: 5px; + transition: transform 0.3s ease; +} + +.dropdown-toggle:hover { + transform: scale(1.1); +} + +.json-container { + max-height: 300px; + overflow-y: auto; + background-color: light-dark(var(--off-white), var(--off-black)); + padding: 10px; + border-top: 1px solid var(--gray); +} + +.json-display { + font-family: monospace; + font-size: 12px; + white-space: pre-wrap; + word-wrap: break-word; + margin: 0; + color: light-dark(var(--off-black), var(--off-white)); } \ No newline at end of file diff --git a/kinode/packages/app_store/ui/src/pages/AppPage.tsx b/kinode/packages/app_store/ui/src/pages/AppPage.tsx index c23ed606..32632ff9 100644 --- a/kinode/packages/app_store/ui/src/pages/AppPage.tsx +++ b/kinode/packages/app_store/ui/src/pages/AppPage.tsx @@ -29,6 +29,9 @@ export default function AppPage() { const [metadataUriContent, setMetadataUriContent] = useState(null); const [showAllVersions, setShowAllVersions] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [updateProgress, setUpdateProgress] = useState(null); + useEffect(() => { if (app) { @@ -48,10 +51,12 @@ export default function AppPage() { useEffect(() => { if (app && app.metadata?.properties?.code_hashes) { - const versions = Object.keys(app.metadata.properties.code_hashes); - const latest = versions[versions.length - 1]; - setLatestVersion(latest); - setUpdateAvailable(app.state?.our_version !== latest); + const versions = Object.entries(app.metadata.properties.code_hashes); + if (versions.length > 0) { + const [latestVersion, latestHash] = versions[versions.length - 1]; + setLatestVersion(latestVersion); + setUpdateAvailable(app.state?.our_version !== latestHash); + } } }, [app]); @@ -101,6 +106,28 @@ export default function AppPage() { } }; + const handleUpdate = async () => { + if (selectedMirror) { + setError(null); + setIsUpdating(true); + setUpdateProgress(0); + try { + await updateApp(app, selectedMirror); + setUpdateProgress(100); + setTimeout(() => { + setIsUpdating(false); + setUpdateProgress(null); + getApp(app.package); // Refresh app data after update + }, 3000); + } catch (error) { + console.error('Update failed:', error); + setError(`Update failed: ${error instanceof Error ? error.message : String(error)}`); + setIsUpdating(false); + setUpdateProgress(null); + } + } + }; + const handleInstall = async () => { setIsInstalling(true); setError(null); @@ -118,7 +145,6 @@ export default function AppPage() { } }; - const handleUpdate = () => updateApp(app); const handleUninstall = () => uninstallApp(app); const handleMirror = () => setMirroring(app, !app.state?.mirroring); const handleAutoUpdate = () => setAutoUpdate(app, !app.state?.auto_update); @@ -130,9 +156,11 @@ export default function AppPage() { const isDownloaded = app.state !== null; const isInstalled = app.installed; - const progressPercentage = localProgress !== null - ? localProgress - : isDownloaded ? 100 : 0; + const progressPercentage = updateProgress !== null + ? updateProgress + : localProgress !== null + ? localProgress + : isDownloaded ? 100 : 0; return (
@@ -178,7 +206,7 @@ export default function AppPage() { )} -
  • Update Available: {updateAvailable ? "Yes" : "No"}
  • + {/*
  • Update Available: {updateAvailable ? "Yes" : "No"}
  • */}
  • ~metadata-uri @@ -285,7 +313,23 @@ export default function AppPage() { {isInstalled ? ( <> - + {updateAvailable && ( + + )} ) : ( @@ -294,7 +338,7 @@ export default function AppPage() {