app_store: fix uninstall removing listing, separate backend api, add progress update to FT

This commit is contained in:
bitful-pannul 2024-08-01 13:47:16 +03:00
parent 14d119f55b
commit b8811180e2
7 changed files with 179 additions and 208 deletions

View File

@ -19,6 +19,8 @@ pub fn init_frontend(our: &Address) {
for path in [ for path in [
"/apps", "/apps",
"/apps/:id", "/apps/:id",
"/apps/:id/download",
"/apps/:id/install",
"/apps/:id/caps", "/apps/:id/caps",
"/apps/:id/mirror", "/apps/:id/mirror",
"/apps/:id/auto-update", "/apps/:id/auto-update",
@ -27,14 +29,8 @@ pub fn init_frontend(our: &Address) {
] { ] {
bind_http_path(path, true, false).expect("failed to bind http path"); bind_http_path(path, true, false).expect("failed to bind http path");
} }
serve_ui( serve_ui(&our, "ui", true, false, vec!["/", "/app/:id", "/publish"])
&our, .expect("failed to serve static UI");
"ui",
true,
false,
vec!["/", "/my-apps", "/app/:id", "/publish"],
)
.expect("failed to serve static UI");
bind_ws_path("/", true, true).expect("failed to bind ws path"); bind_ws_path("/", true, true).expect("failed to bind ws path");
@ -176,9 +172,10 @@ fn make_widget() -> String {
/// - get capabilities for a specific downloaded app: GET /apps/:id/caps /// - get capabilities for a specific downloaded app: GET /apps/:id/caps
/// ///
/// - get online/offline mirrors for a listed app: GET /mirrorcheck/:node /// - get online/offline mirrors for a listed app: GET /mirrorcheck/:node
/// - install a downloaded app, download a listed app: POST /apps/:id /// - download a listed app: POST /apps/:id/download
/// - install a downloaded app: POST /apps/:id/install
/// - uninstall/delete a downloaded app: DELETE /apps/:id /// - uninstall/delete a downloaded app: DELETE /apps/:id
/// - update a downloaded app: PUT /apps/:id /// - update a downloaded app: PUT /apps/:id FIX
/// - approve capabilities for a downloaded app: POST /apps/:id/caps /// - approve capabilities for a downloaded app: POST /apps/:id/caps
/// - start mirroring a downloaded app: PUT /apps/:id/mirror /// - start mirroring a downloaded app: PUT /apps/:id/mirror
/// - stop mirroring a downloaded app: DELETE /apps/:id/mirror /// - stop mirroring a downloaded app: DELETE /apps/:id/mirror
@ -312,9 +309,7 @@ fn serve_paths(
} }
} }
// GET detail about a specific app // GET detail about a specific app
// install an app: POST
// update a downloaded app: PUT // update a downloaded app: PUT
// uninstall an app: DELETE
"/apps/:id" => { "/apps/:id" => {
let Ok(package_id) = get_package_id(url_params) else { let Ok(package_id) = get_package_id(url_params) else {
return Ok(( return Ok((
@ -341,194 +336,6 @@ fn serve_paths(
.into_bytes(), .into_bytes(),
)) ))
} }
Method::POST => {
let Some(listing) = state.packages.get(&package_id) else {
return Ok((
StatusCode::NOT_FOUND,
None,
format!("App not found: {package_id}").into_bytes(),
));
};
if listing.state.is_some() {
// install a downloaded app
crate::handle_install(state, &package_id)?;
Ok((StatusCode::CREATED, None, format!("Installed").into_bytes()))
} else {
// download a listed app
let pkg_listing: &PackageListing = state
.packages
.get(&package_id)
.ok_or(anyhow::anyhow!("No package"))?;
// from POST body, look for download_from field and use that as the mirror
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 mirrors: &Vec<NodeId> = pkg_listing
.metadata
.as_ref()
.expect("Package does not have metadata")
.properties
.mirrors
.as_ref();
let download_from = body_json
.get("download_from")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| mirrors.first().map(|mirror| mirror.to_string()));
// if no specific mirror specified, loop through and ping them.
if let Some(download_from) = download_from {
// TODO choose more on frontend.
let mirror = false;
let auto_update = false;
let desired_version_hash = None;
match crate::start_download(
state,
package_id,
download_from,
mirror,
auto_update,
desired_version_hash,
) {
DownloadResponse::Started => Ok((
StatusCode::CREATED,
None,
format!("Downloading").into_bytes(),
)),
other => Ok((
StatusCode::SERVICE_UNAVAILABLE,
None,
format!("Failed to download: {other:?}").into_bytes(),
)),
}
} else {
let online_mirrors: Vec<NodeId> = mirrors
.iter()
.filter_map(|mirror| {
let target = Address::new(
mirror,
ProcessId::new(Some("net"), "distro", "sys"),
);
let request = Request::new().target(target).body(vec![]).send();
match request {
Ok(_) => Some(mirror.clone()),
Err(_) => None,
}
})
.collect();
println!("all mirrors: {:?}", mirrors);
println!("online mirrors: {:?}", online_mirrors);
let mut failed_mirrors = Vec::new();
for online_mirror in &online_mirrors {
let mirror = true;
let auto_update = false;
let desired_version_hash = None;
match crate::start_download(
state,
package_id.clone(),
online_mirror.to_string(),
mirror,
auto_update,
desired_version_hash,
) {
DownloadResponse::Started => {
return Ok((
StatusCode::CREATED,
None,
format!(
"Download started from mirror: {}",
online_mirror
)
.into_bytes(),
));
}
_ => {
failed_mirrors.push(online_mirror.to_string());
continue;
}
}
}
let mut failed_mirrors = Vec::new();
for online_mirror in &online_mirrors {
let mirror = true;
let auto_update = false;
let desired_version_hash = None;
match crate::start_download(
state,
package_id.clone(),
online_mirror.to_string(),
mirror,
auto_update,
desired_version_hash,
) {
DownloadResponse::Started => {
return Ok((
StatusCode::CREATED,
None,
format!(
"Download started from mirror: {}",
online_mirror
)
.into_bytes(),
));
}
_ => {
failed_mirrors.push(online_mirror.to_string());
continue;
}
}
}
Ok((
StatusCode::SERVICE_UNAVAILABLE,
None,
format!(
"Failed to start download from any mirrors. Failed mirrors: {:?}",
failed_mirrors
).into_bytes(),
))
}
}
}
Method::PUT => {
// update a downloaded app
let listing: &PackageListing = state
.packages
.get(&package_id)
.ok_or(anyhow::anyhow!("No package listing"))?;
let Some(ref pkg_state) = listing.state else {
return Err(anyhow::anyhow!("No package state"));
};
let download_from = pkg_state
.mirrored_from
.as_ref()
.ok_or(anyhow::anyhow!("No mirror for package {package_id}"))?
.to_string();
match crate::start_download(
state,
package_id,
download_from,
pkg_state.mirroring,
pkg_state.auto_update,
None,
) {
DownloadResponse::Started => Ok((
StatusCode::CREATED,
None,
format!("Downloading").into_bytes(),
)),
_ => Ok((
StatusCode::SERVICE_UNAVAILABLE,
None,
format!("Failed to download").into_bytes(),
)),
}
}
Method::DELETE => { Method::DELETE => {
// uninstall an app // uninstall an app
state.uninstall(&package_id)?; state.uninstall(&package_id)?;
@ -545,6 +352,85 @@ fn serve_paths(
)), )),
} }
} }
// PUT /apps/:id/download
// download a listed app from a mirror
"/apps/:id/download" => {
let Ok(package_id) = get_package_id(url_params) else {
return Ok((
StatusCode::BAD_REQUEST,
None,
format!("Missing id").into_bytes(),
));
};
// download a listed app
let pkg_listing: &PackageListing = state
.packages
.get(&package_id)
.ok_or(anyhow::anyhow!("No package"))?;
// from POST body, look for download_from field and use that as the mirror
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 mirrors: &Vec<NodeId> = pkg_listing
.metadata
.as_ref()
.expect("Package does not have metadata")
.properties
.mirrors
.as_ref();
let download_from = body_json
.get("download_from")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| mirrors.first().map(|mirror| mirror.to_string()))
.ok_or_else(|| anyhow::anyhow!("No download_from specified!"))?;
let mirror = false;
let auto_update = false;
// TODO choose on frontend?
let desired_version_hash = None;
match crate::start_download(
state,
package_id,
download_from,
mirror,
auto_update,
desired_version_hash,
) {
DownloadResponse::Started => Ok((
StatusCode::CREATED,
None,
format!("Downloading").into_bytes(),
)),
other => Ok((
StatusCode::SERVICE_UNAVAILABLE,
None,
format!("Failed to download: {other:?}").into_bytes(),
)),
}
}
// POST /apps/:id/install
// install a downloaded app
"/apps/:id/install" => {
let Ok(package_id) = get_package_id(url_params) else {
return Ok((
StatusCode::BAD_REQUEST,
None,
format!("Missing id").into_bytes(),
));
};
match crate::handle_install(state, &package_id) {
Ok(_) => Ok((StatusCode::CREATED, None, vec![])),
Err(e) => Ok((
StatusCode::SERVICE_UNAVAILABLE,
None,
e.to_string().into_bytes(),
)),
}
}
// GET caps for a specific downloaded app // GET caps for a specific downloaded app
// approve capabilities for a downloaded app: POST // approve capabilities for a downloaded app: POST
"/apps/:id/caps" => { "/apps/:id/caps" => {

View File

@ -1,7 +1,7 @@
#![feature(let_chains)] #![feature(let_chains)]
//! App Store: //! App Store:
//! acts as both a local package manager and a protocol to share packages across the network. //! acts as both a local package manager and a protocol to share packages across the network.
//! packages are apps; apps are packages. we use an onchain app listing contract to determine //! packages are apps; apps are packages. we use the kimap contract to determine
//! what apps are available to download and what node(s) to download them from. //! what apps are available to download and what node(s) to download them from.
//! //!
//! once we know that list, we can request a package from a node and download it locally. //! once we know that list, we can request a package from a node and download it locally.
@ -22,8 +22,9 @@ use ft_worker_lib::{
spawn_receive_transfer, spawn_transfer, FTWorkerCommand, FTWorkerResult, FileTransferContext, spawn_receive_transfer, spawn_transfer, FTWorkerCommand, FTWorkerResult, FileTransferContext,
}; };
use kinode_process_lib::{ use kinode_process_lib::{
await_message, call_init, eth, get_blob, http, kimap, println, vfs, Address, LazyLoadBlob, await_message, call_init, eth, get_blob,
Message, PackageId, Request, Response, http::{self, WsMessageType},
println, vfs, Address, LazyLoadBlob, Message, PackageId, Request, Response,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use state::{AppStoreLogError, PackageState, RequestedPackage, State}; use state::{AppStoreLogError, PackageState, RequestedPackage, State};
@ -53,7 +54,7 @@ pub const APP_SHARE_TIMEOUT: u64 = 120; // 120s
#[cfg(not(feature = "simulation-mode"))] #[cfg(not(feature = "simulation-mode"))]
const KIMAP_ADDRESS: &str = kimap::KIMAP_ADDRESS; const KIMAP_ADDRESS: &str = kimap::KIMAP_ADDRESS;
#[cfg(feature = "simulation-mode")] #[cfg(feature = "simulation-mode")]
const KIMAP_ADDRESS: &str = "0x0165878A594ca255338adfa4d48449f69242Eb8F"; // note temp kimap address! const KIMAP_ADDRESS: &str = "0x0165878A594ca255338adfa4d48449f69242Eb8F";
#[cfg(not(feature = "simulation-mode"))] #[cfg(not(feature = "simulation-mode"))]
const KIMAP_FIRST_BLOCK: u64 = kimap::KIMAP_FIRST_BLOCK; const KIMAP_FIRST_BLOCK: u64 = kimap::KIMAP_FIRST_BLOCK;
@ -135,11 +136,33 @@ fn handle_message(state: &mut State, message: &Message) -> anyhow::Result<()> {
let resp = handle_remote_request(state, message.source(), remote_request); let resp = handle_remote_request(state, message.source(), remote_request);
Response::new().body(serde_json::to_vec(&resp)?).send()?; Response::new().body(serde_json::to_vec(&resp)?).send()?;
} }
Req::FTWorkerCommand(_) => {
spawn_receive_transfer(&state.our, message.body())?;
}
Req::FTWorkerResult(FTWorkerResult::ReceiveSuccess(name)) => { Req::FTWorkerResult(FTWorkerResult::ReceiveSuccess(name)) => {
handle_receive_download(state, &name)?; handle_receive_download(state, &name)?;
} }
Req::FTWorkerCommand(_) => { Req::FTWorkerResult(FTWorkerResult::ProgressUpdate {
spawn_receive_transfer(&state.our, message.body())?; file_name,
chunks_received,
total_chunks,
}) => {
// forward progress to UI
let ws_blob = LazyLoadBlob {
mime: Some("application/json".to_string()),
bytes: serde_json::json!({
"kind": "progress",
"data": {
"file_name": file_name,
"chunks_received": chunks_received,
"total_chunks": total_chunks,
}
})
.to_string()
.as_bytes()
.to_vec(),
};
http::send_ws_push(6969, WsMessageType::Text, ws_blob);
} }
Req::FTWorkerResult(r) => { Req::FTWorkerResult(r) => {
println!("got weird ft_worker result: {r:?}"); println!("got weird ft_worker result: {r:?}");
@ -566,8 +589,12 @@ fn handle_ft_worker_result(ft_worker_result: FTWorkerResult, context: &[u8]) ->
.as_secs_f64(), .as_secs_f64(),
); );
Ok(()) Ok(())
} else if let FTWorkerResult::Err(e) = ft_worker_result {
Err(anyhow::anyhow!("failed to share app: {e:?}"))
} else { } else {
Err(anyhow::anyhow!("failed to share app")) Err(anyhow::anyhow!(
"failed to share app: unknown FTWorkerResult {ft_worker_result:?}"
))
} }
} }

View File

@ -353,7 +353,10 @@ impl State {
pub fn uninstall(&mut self, package_id: &PackageId) -> anyhow::Result<()> { pub fn uninstall(&mut self, package_id: &PackageId) -> anyhow::Result<()> {
utils::uninstall(package_id)?; utils::uninstall(package_id)?;
self.packages.remove(package_id); let Some(listing) = self.packages.get_mut(package_id) else {
return Err(anyhow::anyhow!("package not found"));
};
listing.state = None;
// kinode_process_lib::set_state(&serde_json::to_vec(self)?); // kinode_process_lib::set_state(&serde_json::to_vec(self)?);
println!("uninstalled {package_id}"); println!("uninstalled {package_id}");
Ok(()) Ok(())

View File

@ -34,6 +34,11 @@ pub enum FTWorkerResult {
SendSuccess, SendSuccess,
/// string is name of file. bytes in blob /// string is name of file. bytes in blob
ReceiveSuccess(String), ReceiveSuccess(String),
ProgressUpdate {
file_name: String,
chunks_received: u64,
total_chunks: u64,
},
Err(TransferError), Err(TransferError),
} }

View File

@ -155,6 +155,18 @@ fn handle_receive(
}; };
chunks_received += 1; chunks_received += 1;
file_bytes.extend(blob.bytes); file_bytes.extend(blob.bytes);
// send progress update to parent
Request::to(parent_process.clone())
.body(
serde_json::to_vec(&FTWorkerResult::ProgressUpdate {
file_name: file_name.to_string(),
chunks_received,
total_chunks,
})
.unwrap(),
)
.send()
.unwrap();
if chunks_received == total_chunks { if chunks_received == total_chunks {
break; break;
} }

View File

@ -3,11 +3,15 @@ import { persist } from 'zustand/middleware'
import { AppInfo, MirrorCheckFile, PackageManifest } from '../types/Apps' import { AppInfo, MirrorCheckFile, PackageManifest } from '../types/Apps'
import { HTTP_STATUS } from '../constants/http' import { HTTP_STATUS } from '../constants/http'
import { appId } from '../utils/app' import { appId } from '../utils/app'
import KinodeClientApi from "@kinode/client-api";
import { WEBSOCKET_URL } from '../utils/ws'
const BASE_URL = '/main:app_store:sys' const BASE_URL = '/main:app_store:sys'
interface AppsStore { interface AppsStore {
apps: AppInfo[] apps: AppInfo[]
ws: KinodeClientApi
downloads: Map<string, [number, number]>
getApps: () => Promise<void> getApps: () => Promise<void>
getApp: (id: string) => Promise<AppInfo> getApp: (id: string) => Promise<AppInfo>
checkMirror: (node: string) => Promise<MirrorCheckFile> checkMirror: (node: string) => Promise<MirrorCheckFile>
@ -27,6 +31,29 @@ const useAppsStore = create<AppsStore>()(
(set, get) => ({ (set, get) => ({
apps: [], apps: [],
downloads: new Map(),
ws: new KinodeClientApi({
uri: WEBSOCKET_URL,
nodeId: window.our?.node,
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 };
});
}
},
onOpen: (_e) => {
console.log('open')
},
}),
getApps: async () => { getApps: async () => {
const res = await fetch(`${BASE_URL}/apps`) const res = await fetch(`${BASE_URL}/apps`)
if (res.status === HTTP_STATUS.OK) { if (res.status === HTTP_STATUS.OK) {

View File

@ -0,0 +1,11 @@
// TODO: remove as much as possible of this..
const BASE_URL = "/main:app_store:sys/";
if (window.our) window.our.process = BASE_URL?.replace("/", "");
export const PROXY_TARGET = `${(import.meta.env.VITE_NODE_URL || `http://localhost:8080`)}${BASE_URL}`;
// This env also has BASE_URL which should match the process + package name
export const WEBSOCKET_URL = import.meta.env.DEV
? `${PROXY_TARGET.replace('http', 'ws')}`
: undefined;