From c47e41a87b98e5775ffee88a451f58ca838ad0bb Mon Sep 17 00:00:00 2001 From: bitful-pannul Date: Thu, 1 Aug 2024 17:48:21 +0300 Subject: [PATCH] app_store: new download flow --- .../packages/app_store/app_store/src/lib.rs | 10 +- .../packages/app_store/app_store/src/state.rs | 10 +- .../packages/app_store/ft_worker/src/lib.rs | 2 +- kinode/packages/app_store/ui/src/App.tsx | 3 +- kinode/packages/app_store/ui/src/index.css | 404 +++++++++--------- .../app_store/ui/src/pages/AppPage.tsx | 224 ++++++++-- .../packages/app_store/ui/src/store/index.ts | 36 +- 7 files changed, 424 insertions(+), 265 deletions(-) diff --git a/kinode/packages/app_store/app_store/src/lib.rs b/kinode/packages/app_store/app_store/src/lib.rs index 016c7493..4f75a4e5 100644 --- a/kinode/packages/app_store/app_store/src/lib.rs +++ b/kinode/packages/app_store/app_store/src/lib.rs @@ -24,7 +24,7 @@ use ft_worker_lib::{ use kinode_process_lib::{ await_message, call_init, eth, get_blob, http::{self, WsMessageType}, - kimap, vfs, Address, LazyLoadBlob, Message, PackageId, Request, Response, + kimap, println, vfs, Address, LazyLoadBlob, Message, PackageId, Request, Response, }; use serde::{Deserialize, Serialize}; use state::{AppStoreLogError, PackageState, RequestedPackage, State}; @@ -162,7 +162,9 @@ fn handle_message(state: &mut State, message: &Message) -> anyhow::Result<()> { .as_bytes() .to_vec(), }; - http::send_ws_push(6969, WsMessageType::Text, ws_blob); + for channel_id in state.ui_ws_channels.iter() { + http::send_ws_push(*channel_id, WsMessageType::Text, ws_blob.clone()); + } } Req::FTWorkerResult(r) => { println!("got weird ft_worker result: {r:?}"); @@ -192,6 +194,10 @@ fn handle_message(state: &mut State, message: &Message) -> anyhow::Result<()> { } if let http::HttpServerRequest::Http(req) = incoming { http_api::handle_http_request(state, &req)?; + } else if let http::HttpServerRequest::WebSocketOpen { channel_id, .. } = incoming { + state.ui_ws_channels.insert(channel_id); + } else if let http::HttpServerRequest::WebSocketClose { 0: channel_id } = incoming { + state.ui_ws_channels.remove(&channel_id); } } } diff --git a/kinode/packages/app_store/app_store/src/state.rs b/kinode/packages/app_store/app_store/src/state.rs index 891b2899..0c750520 100644 --- a/kinode/packages/app_store/app_store/src/state.rs +++ b/kinode/packages/app_store/app_store/src/state.rs @@ -117,6 +117,8 @@ pub struct State { pub requested_packages: HashMap, /// the APIs we have outstanding requests to download (not persisted) pub requested_apis: HashMap, + /// UI websocket connected channel_IDs + pub ui_ws_channels: HashSet, } #[derive(Deserialize)] @@ -152,6 +154,7 @@ impl State { downloaded_apis: s.downloaded_apis, requested_packages: HashMap::new(), requested_apis: HashMap::new(), + ui_ws_channels: HashSet::new(), } } @@ -166,6 +169,7 @@ impl State { downloaded_apis: HashSet::new(), requested_packages: HashMap::new(), requested_apis: HashMap::new(), + ui_ws_channels: HashSet::new(), }; state.populate_packages_from_filesystem()?; Ok(state) @@ -210,8 +214,10 @@ impl State { mirroring: package_state.mirroring, auto_update: package_state.auto_update, })?)?; - if utils::extract_api(package_id)? { - self.downloaded_apis.insert(package_id.to_owned()); + if let Ok(extracted) = utils::extract_api(package_id) { + if extracted { + self.downloaded_apis.insert(package_id.to_owned()); + } } listing.state = Some(package_state); // kinode_process_lib::set_state(&serde_json::to_vec(self)?); diff --git a/kinode/packages/app_store/ft_worker/src/lib.rs b/kinode/packages/app_store/ft_worker/src/lib.rs index 2bbdf378..480b5b05 100644 --- a/kinode/packages/app_store/ft_worker/src/lib.rs +++ b/kinode/packages/app_store/ft_worker/src/lib.rs @@ -64,7 +64,7 @@ fn handle_send(our: &Address, target: &Address, file_name: &str, timeout: u64) - let file_bytes = blob.bytes; let mut file_size = file_bytes.len() as u64; let mut offset: u64 = 0; - let chunk_size: u64 = 1048576; // 1MB, can be changed + let chunk_size: u64 = 262144; // 256KB let total_chunks = (file_size as f64 / chunk_size as f64).ceil() as u64; // send a file to another worker // start by telling target to expect a file, diff --git a/kinode/packages/app_store/ui/src/App.tsx b/kinode/packages/app_store/ui/src/App.tsx index aa14c28f..453cd584 100644 --- a/kinode/packages/app_store/ui/src/App.tsx +++ b/kinode/packages/app_store/ui/src/App.tsx @@ -3,9 +3,10 @@ import { BrowserRouter as Router, Route, Routes } from "react-router-dom"; import StorePage from "./pages/StorePage"; import AppPage from "./pages/AppPage"; -import { APP_DETAILS_PATH, PUBLISH_PATH, STORE_PATH } from "./constants/path"; import PublishPage from "./pages/PublishPage"; import Header from "./components/Header"; +import { APP_DETAILS_PATH, PUBLISH_PATH, STORE_PATH } from "./constants/path"; + const BASE_URL = import.meta.env.BASE_URL; if (window.our) window.our.process = BASE_URL?.replace("/", ""); diff --git a/kinode/packages/app_store/ui/src/index.css b/kinode/packages/app_store/ui/src/index.css index 2a38deb9..af8a7390 100644 --- a/kinode/packages/app_store/ui/src/index.css +++ b/kinode/packages/app_store/ui/src/index.css @@ -17,6 +17,7 @@ font-size: 1.5rem; margin: 0; margin-right: 2rem; + color: var(--orange); } .header-left nav { @@ -44,9 +45,36 @@ } .app-content { - padding: 2rem; - flex-grow: 1; - overflow-y: auto; + display: flex; + gap: 2rem; +} + +.app-info-column { + flex: 2; +} + +.app-actions-column { + flex: 1; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.app-actions { + display: flex; + flex-direction: column; + gap: 1rem; +} + +@media (max-width: 768px) { + .app-content { + flex-direction: column; + } + + .app-info-column, + .app-actions-column { + flex: 1; + } } .special-appstore-background { @@ -58,14 +86,63 @@ background-position: 0 0, 0 10px, 10px -10px, -10px 0px; } +/* Common Styles */ +button, +.external-link { + padding: 10px 15px; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + text-decoration: none; + transition: background-color 0.3s ease; +} + +button:hover, +.external-link:hover { + opacity: 0.9; +} + +button svg, +.external-link svg { + margin-right: 5px; +} + +.primary { + background-color: var(--orange); + color: var(--white); +} + +.secondary { + background-color: var(--gray); + color: var(--white); +} + +.external-link { + background-color: var(--blue); + color: var(--white); +} /* Store Page Styles */ .store-page { padding: 2rem; } +.store-header { + display: flex; + justify-content: space-between; + align-items: stretch; + margin-bottom: 2rem; + gap: 1rem; +} + .search-bar { - margin-bottom: 1rem; + flex-grow: 1; + display: flex; + align-items: stretch; } .search-bar input { @@ -74,36 +151,77 @@ font-size: 1rem; border: 1px solid var(--gray); border-radius: 4px; + height: 38px; } -.app-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); - gap: 1rem; - margin-top: 2rem; +.filter-button, +.store-header button { + height: 38px; + padding: 0 1rem; + display: flex; + align-items: center; + justify-content: center; + white-space: nowrap; + align-self: stretch; } -.app-card { - background-color: light-dark(var(--white), var(--off-black)); - border: 1px solid var(--gray); - border-radius: 8px; +.store-header>* { + margin: 0; +} + +.store-header button { + flex-shrink: 0; +} + +.app-list table { + width: 100%; + border-collapse: collapse; +} + +.app-list th, +.app-list td { padding: 1rem; - transition: transform 0.3s ease, box-shadow 0.3s ease; + text-align: left; + border-bottom: 1px solid var(--gray); } -.app-card:hover { - transform: translateY(-5px); - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); -} - -.app-card h3 { - margin-bottom: 0.5rem; +.app-list th { + font-weight: bold; color: var(--orange); } -.app-card p { +.app-row:hover { + background-color: light-dark(var(--tan), var(--maroon)); +} + +.app-name { + font-weight: bold; + color: var(--blue); + text-decoration: none; +} + +.publisher, +.version, +.mirrors { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.status { + padding: 0.25rem 0.5rem; + border-radius: 4px; font-size: 0.9rem; - color: light-dark(var(--gray), var(--off-white)); +} + +.status.installed { + background-color: var(--off-black); + color: var(--white); +} + +.status.not-installed { + background-color: var(--gray); + color: var(--white); } /* App Page Styles */ @@ -263,45 +381,6 @@ object-fit: cover; } -button, -.external-link { - padding: 10px 15px; - border: none; - border-radius: 5px; - cursor: pointer; - font-size: 14px; - display: flex; - align-items: center; - justify-content: center; - text-decoration: none; - transition: background-color 0.3s ease; -} - -button:hover, -.external-link:hover { - opacity: 0.9; -} - -button svg, -.external-link svg { - margin-right: 5px; -} - -.primary { - background-color: var(--orange); - color: var(--white); -} - -.secondary { - background-color: var(--gray); - color: var(--white); -} - -.external-link { - background-color: var(--blue); - color: var(--white); -} - /* My Apps Page Styles */ .my-apps-page { padding: 2rem; @@ -437,7 +516,7 @@ button svg, } .message.success { - background-color: #4CAF50; + background-color: var(--green); color: var(--white); } @@ -476,133 +555,7 @@ button svg, background-color: #c62828; } -/* Store Page Styles */ -.store-page { - padding: 2rem; -} - -.store-header { - display: flex; - justify-content: space-between; - align-items: stretch; - margin-bottom: 2rem; - gap: 1rem; -} - -.search-bar { - flex-grow: 1; - display: flex; - align-items: stretch; -} - -.search-bar input { - width: 100%; - padding: 0.5rem; - font-size: 1rem; - border: 1px solid var(--gray); - border-radius: 4px; - height: 38px; -} - -.filter-button, -.store-header button { - height: 38px; - padding: 0 1rem; - display: flex; - align-items: center; - justify-content: center; - white-space: nowrap; - align-self: stretch; -} - -/* Add these new styles */ -.store-header>* { - margin: 0; -} - -.store-header button { - flex-shrink: 0; -} - -.app-list table { - width: 100%; - border-collapse: collapse; -} - -.app-list th, -.app-list td { - padding: 1rem; - text-align: left; - border-bottom: 1px solid var(--gray); -} - -.app-list th { - font-weight: bold; - color: var(--orange); -} - -.app-row:hover { - background-color: light-dark(var(--tan), var(--maroon)); -} - -.app-name { - font-weight: bold; - color: var(--blue); - text-decoration: none; -} - -.publisher, -.version, -.mirrors { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.status { - padding: 0.25rem 0.5rem; - border-radius: 4px; - font-size: 0.9rem; -} - -.status.installed { - background-color: var(--off-black); - color: var(--white); -} - -.status.not-installed { - background-color: var(--gray); - color: var(--white); -} - -.app-info-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 1rem; - margin-bottom: 1rem; -} - -.app-info-item { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; -} - -.app-info-item>span:first-child { - font-weight: bold; - margin-bottom: 0.5rem; -} - -.app-info-item a { - color: var(--blue); - text-decoration: none; -} - -.app-info-item a:hover { - text-decoration: underline; -} - +/* Mirrors Dropdown Styles */ .mirrors-dropdown { position: relative; } @@ -613,7 +566,7 @@ button svg, cursor: pointer; display: flex; align-items: center; - color: var(--off-black); + color: light-dark(var(--off-black), var(--off-white)); } .mirrors-list { @@ -638,8 +591,7 @@ button svg, cursor: pointer; padding: 5px; margin-right: 10px; - display: inline-flex !important; - /* Force display */ + display: inline-flex; align-items: center; justify-content: center; width: 24px; @@ -649,8 +601,7 @@ button svg, .check-button svg { width: 16px; height: 16px; - color: var(--off-black); - /* Ensure visibility */ + color: light-dark(var(--off-black), var(--off-white)); } .spinning { @@ -673,15 +624,72 @@ button svg, } .online { - color: green; + color: var(--green); } .offline { - color: red; + color: var(--ansi-red); } .error-message { margin-left: 5px; font-size: 0.8em; - color: #888; + color: var(--gray); +} + +.progress-container { + margin-top: 20px; +} + +.progress-bar { + width: 100%; + height: 24px; + background-color: var(--gray-light); + border-radius: 12px; + overflow: hidden; + position: relative; +} + +.progress { + height: 100%; + background-color: var(--blue); + transition: width 0.3s ease; +} + +.progress-percentage { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--white); + font-size: 14px; + font-weight: bold; + text-shadow: 0 0 2px rgba(0, 0, 0, 0.5); +} + +.capabilities-section { + margin-top: 20px; +} + +.capabilities-section h3 { + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + background-color: var(--gray-light); + border-radius: 5px; +} + +.capabilities { + background-color: var(--off-white); + padding: 10px; + border-radius: 5px; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; } \ 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 04dddc6e..89b44741 100644 --- a/kinode/packages/app_store/ui/src/pages/AppPage.tsx +++ b/kinode/packages/app_store/ui/src/pages/AppPage.tsx @@ -1,43 +1,111 @@ -import React, { useState } from "react"; -import { useParams } from "react-router-dom"; -import { FaDownload, FaSync, FaTrash, FaMagnet, FaCog, FaCheck, FaTimes, FaRocket, FaLink, FaChevronDown, FaChevronUp, FaShieldAlt } from "react-icons/fa"; +import React, { useState, useEffect } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { FaDownload, FaSync, FaTrash, FaMagnet, FaCog, FaCheck, FaTimes, FaRocket, FaLink, FaChevronDown, FaChevronUp, FaShieldAlt, FaSpinner, FaPlay, FaExclamationTriangle } from "react-icons/fa"; import useAppsStore from "../store"; import { appId } from "../utils/app"; import { MirrorCheckFile } from "../types/Apps"; export default function AppPage() { - const { installApp, updateApp, uninstallApp, approveCaps, setMirroring, setAutoUpdate, checkMirror, apps } = useAppsStore(); + const { installApp, updateApp, uninstallApp, approveCaps, setMirroring, setAutoUpdate, checkMirror, apps, downloadApp, getCaps, getApp } = useAppsStore(); const { id } = useParams(); + const app = apps.find(a => appId(a) === id); const [showMetadata, setShowMetadata] = useState(true); const [showLocalInfo, setShowLocalInfo] = useState(true); const [mirrorStatuses, setMirrorStatuses] = useState<{ [mirror: string]: MirrorCheckFile | null }>({}); + const [selectedMirror, setSelectedMirror] = useState(null); + const [isDownloading, setIsDownloading] = useState(false); + const [isInstalling, setIsInstalling] = useState(false); + const [error, setError] = useState(null); + const [caps, setCaps] = useState(null); + const [showCaps, setShowCaps] = useState(false); + const [localProgress, setLocalProgress] = useState(null); + + useEffect(() => { + if (app) { + checkMirrors(); + fetchCaps(); + } + }, [app]); if (!app) { return

App details not found for {id}

; } - const handleInstall = () => app && installApp(app); - const handleUpdate = () => app && updateApp(app); - const handleUninstall = () => app && uninstallApp(app); - const handleMirror = () => app && setMirroring(app, !app.state?.mirroring); - const handleApproveCaps = () => app && approveCaps(app); - const handleAutoUpdate = () => app && setAutoUpdate(app, !app.state?.auto_update); + const checkMirrors = async () => { + const mirrors = [app.publisher, ...(app.metadata?.properties?.mirrors || [])]; + const statuses: { [mirror: string]: MirrorCheckFile | null } = {}; + for (const mirror of mirrors) { + const status = await checkMirror(mirror); + statuses[mirror] = status; + } + setMirrorStatuses(statuses); + setSelectedMirror(statuses[app.publisher]?.is_online ? app.publisher : mirrors.find(m => statuses[m]?.is_online) || null); + }; + + const fetchCaps = async () => { + try { + const appCaps = await getCaps(app); + setCaps(appCaps); + } catch (error) { + console.error('Failed to fetch capabilities:', error); + setError(`Failed to fetch capabilities: ${error instanceof Error ? error.message : String(error)}`); + } + }; + + const handleDownload = async () => { + if (selectedMirror) { + setError(null); + setIsDownloading(true); + setLocalProgress(0); + try { + await downloadApp(app, selectedMirror); + setLocalProgress(100); + setTimeout(() => { + setIsDownloading(false); + setLocalProgress(null); + }, 3000); + } catch (error) { + console.error('Download failed:', error); + setError(`Download failed: ${error instanceof Error ? error.message : String(error)}`); + setIsDownloading(false); + setLocalProgress(null); + } + } + }; + + const handleInstall = async () => { + setIsInstalling(true); + setError(null); + try { + if (!caps?.approved) { + await approveCaps(app); + } + await installApp(app); + await getApp(app.package); + } catch (error) { + console.error('Installation failed:', error); + setError(`Installation failed: ${error instanceof Error ? error.message : String(error)}`); + } finally { + setIsInstalling(false); + } + }; + + const handleUpdate = () => updateApp(app); + const handleUninstall = () => uninstallApp(app); + const handleMirror = () => setMirroring(app, !app.state?.mirroring); + const handleAutoUpdate = () => setAutoUpdate(app, !app.state?.auto_update); const handleLaunch = () => { console.log("Launching app:", app.package); - window.open(`/${app.package}:${app.publisher}`, '_blank'); + window.open(`/${app.package}${app.package}:${app.publisher}`, '_blank'); }; + const isDownloaded = app.state !== undefined; + const isInstalled = app.installed; - const handleCheckMirror = (mirror: string) => { - setMirrorStatuses(prev => ({ ...prev, [mirror]: null })); // Set to loading - checkMirror(mirror) - .then(status => setMirrorStatuses(prev => ({ ...prev, [mirror]: status }))) - .catch(error => { - console.error(`Failed to check mirror ${mirror}:`, error); - setMirrorStatuses(prev => ({ ...prev, [mirror]: { node: mirror, is_online: false, error: "Request failed" } })); - }); - }; + const progressPercentage = localProgress !== null + ? localProgress + : isDownloaded ? 100 : 0; return (
@@ -53,8 +121,8 @@ export default function AppPage() {
{app.metadata?.description || "No description available"}
-
-
+
+

setShowMetadata(!showMetadata)}> Metadata {showMetadata ? : } @@ -67,27 +135,27 @@ export default function AppPage() {
  • Mirrors:
      - {app.metadata?.properties?.mirrors?.map((mirror) => ( + {Object.entries(mirrorStatuses).map(([mirror, status]) => (
    • {mirror} - {mirrorStatuses[mirror] && ( + {status && ( - {mirrorStatuses[mirror]?.is_online ? ( + {status.is_online ? ( <> Online ) : ( <> Offline - {mirrorStatuses[mirror]?.error && ( + {status.error && ( - ({mirrorStatuses[mirror]?.error}) + ({status.error}) )} @@ -109,7 +177,7 @@ export default function AppPage() {
      • Installed: - {app.installed ? : } + {isInstalled ? : }
      • Installed Version: {app.state?.our_version || "Not installed"}
      • @@ -119,7 +187,7 @@ export default function AppPage() {
      • License: {app.metadata?.properties?.license || "Not specified"}
      • Capabilities Approved: - @@ -143,21 +211,88 @@ export default function AppPage() { )}
  • -
    - {app.installed ? ( - <> - - - - - ) : ( - + +
    +
    + {isInstalled ? ( + <> + + + + + ) : ( + <> +
    + +
    + + + + )} + {app.metadata?.external_url && ( + + External Link + + )} +
    + + {(isDownloading || isDownloaded) && ( +
    +
    +
    +
    {progressPercentage}%
    +
    +
    )} - {app.metadata?.external_url && ( - - External Link - + + {error && ( +
    + {error} +
    )} + +
    +

    setShowCaps(!showCaps)}> + Requested Capabilities {showCaps ? : } +

    + {showCaps && caps && ( +
    {JSON.stringify(caps, null, 2)}
    + )} +
    @@ -173,4 +308,5 @@ export default function AppPage() { )}
    ); + } \ No newline at end of file diff --git a/kinode/packages/app_store/ui/src/store/index.ts b/kinode/packages/app_store/ui/src/store/index.ts index 8f63ad82..8e7482b7 100644 --- a/kinode/packages/app_store/ui/src/store/index.ts +++ b/kinode/packages/app_store/ui/src/store/index.ts @@ -11,7 +11,7 @@ const BASE_URL = '/main:app_store:sys' interface AppsStore { apps: AppInfo[] ws: KinodeClientApi - downloads: Map + downloads: Record getApps: () => Promise getApp: (id: string) => Promise checkMirror: (node: string) => Promise @@ -31,7 +31,7 @@ const useAppsStore = create()( (set, get) => ({ apps: [], - downloads: new Map(), + downloads: {}, ws: new KinodeClientApi({ uri: WEBSOCKET_URL, @@ -39,14 +39,19 @@ const useAppsStore = create()( processId: "main:app_store:sys", onMessage: (message) => { const data = JSON.parse(message); - console.log('we got a json message', data) if (data.kind === 'progress') { - const appId = data.data.name.split('/').pop().split('.').shift(); - set((state) => { - const newDownloads = new Map(state.downloads); - newDownloads.set(appId, [data.data.chunks_received, data.data.total_chunks]); - return { downloads: newDownloads }; - }); + const appId = data.data.file_name.slice(1).replace('.zip', ''); + console.log('got app id with progress: ', appId, data.data.chunks_received, data.data.total_chunks) + set((state) => ({ + downloads: { + ...state.downloads, + [appId]: [data.data.chunks_received, data.data.total_chunks] + } + })); + + if (data.data.chunks_received === data.data.total_chunks) { + get().getApp(appId); + } } }, onOpen: (_e) => { @@ -81,7 +86,7 @@ const useAppsStore = create()( }, installApp: async (app: AppInfo) => { - const res = await fetch(`${BASE_URL}/apps/${appId(app)}`, { method: 'POST' }) + const res = await fetch(`${BASE_URL}/apps/${appId(app)}/install`, { method: 'POST' }) if (res.status !== HTTP_STATUS.CREATED) { throw new Error(`Failed to install app: ${appId(app)}`) } @@ -89,11 +94,8 @@ const useAppsStore = create()( }, updateApp: async (app: AppInfo) => { - const res = await fetch(`${BASE_URL}/apps/${appId(app)}`, { method: 'PUT' }) - if (res.status !== HTTP_STATUS.CREATED) { - throw new Error(`Failed to update app: ${appId(app)}`) - } - await get().getApp(appId(app)) + // Note: The backend doesn't have a specific update endpoint, so we might need to implement this differently + throw new Error('Update functionality not implemented') }, uninstallApp: async (app: AppInfo) => { @@ -105,8 +107,8 @@ const useAppsStore = create()( }, downloadApp: async (app: AppInfo, downloadFrom: string) => { - const res = await fetch(`${BASE_URL}/apps/${appId(app)}`, { - method: 'POST', + const res = await fetch(`${BASE_URL}/apps/${appId(app)}/download`, { + method: 'PUT', body: JSON.stringify({ download_from: downloadFrom }), }) if (res.status !== HTTP_STATUS.CREATED) {