mirror of
https://github.com/uqbar-dao/nectar.git
synced 2024-11-26 11:53:31 +03:00
Merge pull request #465 from kinode-dao/bp/newapps
app_store UI: mirror checks and info
This commit is contained in:
commit
57cce90d6f
@ -1,4 +1,4 @@
|
|||||||
use crate::state::{PackageListing, State};
|
use crate::state::{MirrorCheckFile, PackageListing, State};
|
||||||
use crate::DownloadResponse;
|
use crate::DownloadResponse;
|
||||||
use kinode_process_lib::{
|
use kinode_process_lib::{
|
||||||
http::{
|
http::{
|
||||||
@ -7,6 +7,7 @@ use kinode_process_lib::{
|
|||||||
},
|
},
|
||||||
println, Address, NodeId, PackageId, ProcessId, Request,
|
println, Address, NodeId, PackageId, ProcessId, Request,
|
||||||
};
|
};
|
||||||
|
use kinode_process_lib::{SendError, SendErrorKind};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@ -18,20 +19,17 @@ 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",
|
||||||
"/apps/rebuild-index",
|
"/apps/rebuild-index",
|
||||||
|
"/mirrorcheck/:node",
|
||||||
] {
|
] {
|
||||||
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,
|
|
||||||
"ui",
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
vec!["/", "/my-apps", "/app/:id", "/publish"],
|
|
||||||
)
|
|
||||||
.expect("failed to serve static UI");
|
.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");
|
||||||
@ -173,9 +171,11 @@ fn make_widget() -> String {
|
|||||||
/// - get detail about a specific app: GET /apps/:id
|
/// - get detail about a specific app: GET /apps/:id
|
||||||
/// - get capabilities for a specific downloaded app: GET /apps/:id/caps
|
/// - get capabilities for a specific downloaded app: GET /apps/:id/caps
|
||||||
///
|
///
|
||||||
/// - install a downloaded app, download a listed app: POST /apps/:id
|
/// - get online/offline mirrors for a listed app: GET /mirrorcheck/:node
|
||||||
|
/// - 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
|
||||||
@ -218,6 +218,7 @@ fn gen_package_info(id: &PackageId, listing: &PackageListing) -> serde_json::Val
|
|||||||
None => false,
|
None => false,
|
||||||
},
|
},
|
||||||
"metadata_hash": listing.metadata_hash,
|
"metadata_hash": listing.metadata_hash,
|
||||||
|
"metadata_uri": listing.metadata_uri,
|
||||||
"metadata": listing.metadata,
|
"metadata": listing.metadata,
|
||||||
"state": match &listing.state {
|
"state": match &listing.state {
|
||||||
Some(state) => json!({
|
Some(state) => json!({
|
||||||
@ -259,10 +260,56 @@ fn serve_paths(
|
|||||||
.collect();
|
.collect();
|
||||||
return Ok((StatusCode::OK, None, serde_json::to_vec(&all)?));
|
return Ok((StatusCode::OK, None, serde_json::to_vec(&all)?));
|
||||||
}
|
}
|
||||||
|
// GET online/offline mirrors for a listed app
|
||||||
|
"/mirrorcheck/:node" => {
|
||||||
|
if method != Method::GET {
|
||||||
|
return Ok((
|
||||||
|
StatusCode::METHOD_NOT_ALLOWED,
|
||||||
|
None,
|
||||||
|
format!("Invalid method {method} for {bound_path}").into_bytes(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let Some(node) = url_params.get("node") else {
|
||||||
|
return Ok((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
None,
|
||||||
|
format!("Missing node").into_bytes(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if let Err(SendError { kind, .. }) = Request::to((node, "net", "distro", "sys"))
|
||||||
|
.body(b"checking your mirror status...")
|
||||||
|
.send_and_await_response(3)
|
||||||
|
.unwrap()
|
||||||
|
{
|
||||||
|
match kind {
|
||||||
|
SendErrorKind::Timeout => {
|
||||||
|
let check_reponse = MirrorCheckFile {
|
||||||
|
node: node.to_string(),
|
||||||
|
is_online: false,
|
||||||
|
error: Some(format!("node {} timed out", node).to_string()),
|
||||||
|
};
|
||||||
|
return Ok((StatusCode::OK, None, serde_json::to_vec(&check_reponse)?));
|
||||||
|
}
|
||||||
|
SendErrorKind::Offline => {
|
||||||
|
let check_reponse = MirrorCheckFile {
|
||||||
|
node: node.to_string(),
|
||||||
|
is_online: false,
|
||||||
|
error: Some(format!("node {} is offline", node).to_string()),
|
||||||
|
};
|
||||||
|
return Ok((StatusCode::OK, None, serde_json::to_vec(&check_reponse)?));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let check_reponse = MirrorCheckFile {
|
||||||
|
node: node.to_string(),
|
||||||
|
is_online: true,
|
||||||
|
error: None,
|
||||||
|
};
|
||||||
|
return Ok((StatusCode::OK, None, serde_json::to_vec(&check_reponse)?));
|
||||||
|
}
|
||||||
|
}
|
||||||
// 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((
|
||||||
@ -289,19 +336,32 @@ fn serve_paths(
|
|||||||
.into_bytes(),
|
.into_bytes(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
Method::POST => {
|
Method::DELETE => {
|
||||||
let Some(listing) = state.packages.get(&package_id) else {
|
// uninstall an app
|
||||||
return Ok((
|
state.uninstall(&package_id)?;
|
||||||
StatusCode::NOT_FOUND,
|
Ok((
|
||||||
|
StatusCode::NO_CONTENT,
|
||||||
None,
|
None,
|
||||||
format!("App not found: {package_id}").into_bytes(),
|
format!("Uninstalled").into_bytes(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ => Ok((
|
||||||
|
StatusCode::METHOD_NOT_ALLOWED,
|
||||||
|
None,
|
||||||
|
format!("Invalid method {method} for {bound_path}").into_bytes(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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(),
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
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
|
// download a listed app
|
||||||
let pkg_listing: &PackageListing = state
|
let pkg_listing: &PackageListing = state
|
||||||
.packages
|
.packages
|
||||||
@ -311,8 +371,7 @@ fn serve_paths(
|
|||||||
let body = crate::get_blob()
|
let body = crate::get_blob()
|
||||||
.ok_or(anyhow::anyhow!("missing blob"))?
|
.ok_or(anyhow::anyhow!("missing blob"))?
|
||||||
.bytes;
|
.bytes;
|
||||||
let body_json: serde_json::Value =
|
let body_json: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||||
serde_json::from_slice(&body).unwrap_or_default();
|
|
||||||
let mirrors: &Vec<NodeId> = pkg_listing
|
let mirrors: &Vec<NodeId> = pkg_listing
|
||||||
.metadata
|
.metadata
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@ -325,13 +384,12 @@ fn serve_paths(
|
|||||||
.get("download_from")
|
.get("download_from")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.or_else(|| mirrors.first().map(|mirror| mirror.to_string()));
|
.or_else(|| mirrors.first().map(|mirror| mirror.to_string()))
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No download_from specified!"))?;
|
||||||
|
|
||||||
// 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 mirror = false;
|
||||||
let auto_update = false;
|
let auto_update = false;
|
||||||
|
// TODO choose on frontend?
|
||||||
let desired_version_hash = None;
|
let desired_version_hash = None;
|
||||||
match crate::start_download(
|
match crate::start_download(
|
||||||
state,
|
state,
|
||||||
@ -352,144 +410,24 @@ fn serve_paths(
|
|||||||
format!("Failed to download: {other:?}").into_bytes(),
|
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,
|
|
||||||
}
|
}
|
||||||
})
|
// POST /apps/:id/install
|
||||||
.collect();
|
// install a downloaded app
|
||||||
|
"/apps/:id/install" => {
|
||||||
println!("all mirrors: {:?}", mirrors);
|
let Ok(package_id) = get_package_id(url_params) else {
|
||||||
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((
|
return Ok((
|
||||||
StatusCode::CREATED,
|
StatusCode::BAD_REQUEST,
|
||||||
None,
|
None,
|
||||||
format!(
|
format!("Missing id").into_bytes(),
|
||||||
"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
|
match crate::handle_install(state, &package_id) {
|
||||||
.as_ref()
|
Ok(_) => Ok((StatusCode::CREATED, None, vec![])),
|
||||||
.ok_or(anyhow::anyhow!("No mirror for package {package_id}"))?
|
Err(e) => Ok((
|
||||||
.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,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
None,
|
None,
|
||||||
format!("Failed to download").into_bytes(),
|
e.to_string().into_bytes(),
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Method::DELETE => {
|
|
||||||
// uninstall an app
|
|
||||||
state.uninstall(&package_id)?;
|
|
||||||
Ok((
|
|
||||||
StatusCode::NO_CONTENT,
|
|
||||||
None,
|
|
||||||
format!("Uninstalled").into_bytes(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
_ => Ok((
|
|
||||||
StatusCode::METHOD_NOT_ALLOWED,
|
|
||||||
None,
|
|
||||||
format!("Invalid method {method} for {bound_path}").into_bytes(),
|
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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},
|
||||||
|
kimap, 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,35 @@ 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(),
|
||||||
|
};
|
||||||
|
for channel_id in state.ui_ws_channels.iter() {
|
||||||
|
http::send_ws_push(*channel_id, WsMessageType::Text, ws_blob.clone());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Req::FTWorkerResult(r) => {
|
Req::FTWorkerResult(r) => {
|
||||||
println!("got weird ft_worker result: {r:?}");
|
println!("got weird ft_worker result: {r:?}");
|
||||||
@ -169,6 +194,10 @@ fn handle_message(state: &mut State, message: &Message) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
if let http::HttpServerRequest::Http(req) = incoming {
|
if let http::HttpServerRequest::Http(req) = incoming {
|
||||||
http_api::handle_http_request(state, &req)?;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -566,8 +595,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:?}"
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,6 +48,13 @@ pub struct MirroringFile {
|
|||||||
pub auto_update: bool,
|
pub auto_update: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct MirrorCheckFile {
|
||||||
|
pub node: NodeId,
|
||||||
|
pub is_online: bool,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub struct RequestedPackage {
|
pub struct RequestedPackage {
|
||||||
pub from: NodeId,
|
pub from: NodeId,
|
||||||
@ -109,6 +116,8 @@ pub struct State {
|
|||||||
pub requested_packages: HashMap<PackageId, RequestedPackage>,
|
pub requested_packages: HashMap<PackageId, RequestedPackage>,
|
||||||
/// the APIs we have outstanding requests to download (not persisted)
|
/// the APIs we have outstanding requests to download (not persisted)
|
||||||
pub requested_apis: HashMap<PackageId, RequestedPackage>,
|
pub requested_apis: HashMap<PackageId, RequestedPackage>,
|
||||||
|
/// UI websocket connected channel_IDs
|
||||||
|
pub ui_ws_channels: HashSet<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@ -144,6 +153,7 @@ impl State {
|
|||||||
downloaded_apis: s.downloaded_apis,
|
downloaded_apis: s.downloaded_apis,
|
||||||
requested_packages: HashMap::new(),
|
requested_packages: HashMap::new(),
|
||||||
requested_apis: HashMap::new(),
|
requested_apis: HashMap::new(),
|
||||||
|
ui_ws_channels: HashSet::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,6 +168,7 @@ impl State {
|
|||||||
downloaded_apis: HashSet::new(),
|
downloaded_apis: HashSet::new(),
|
||||||
requested_packages: HashMap::new(),
|
requested_packages: HashMap::new(),
|
||||||
requested_apis: HashMap::new(),
|
requested_apis: HashMap::new(),
|
||||||
|
ui_ws_channels: HashSet::new(),
|
||||||
};
|
};
|
||||||
state.populate_packages_from_filesystem()?;
|
state.populate_packages_from_filesystem()?;
|
||||||
Ok(state)
|
Ok(state)
|
||||||
@ -202,9 +213,11 @@ impl State {
|
|||||||
mirroring: package_state.mirroring,
|
mirroring: package_state.mirroring,
|
||||||
auto_update: package_state.auto_update,
|
auto_update: package_state.auto_update,
|
||||||
})?)?;
|
})?)?;
|
||||||
if utils::extract_api(package_id)? {
|
if let Ok(extracted) = utils::extract_api(package_id) {
|
||||||
|
if extracted {
|
||||||
self.downloaded_apis.insert(package_id.to_owned());
|
self.downloaded_apis.insert(package_id.to_owned());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
listing.state = Some(package_state);
|
listing.state = Some(package_state);
|
||||||
// kinode_process_lib::set_state(&serde_json::to_vec(self)?);
|
// kinode_process_lib::set_state(&serde_json::to_vec(self)?);
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -345,7 +358,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(())
|
||||||
@ -364,7 +380,7 @@ impl State {
|
|||||||
let block_number: u64 = log.block_number.ok_or(AppStoreLogError::NoBlockNumber)?;
|
let block_number: u64 = log.block_number.ok_or(AppStoreLogError::NoBlockNumber)?;
|
||||||
|
|
||||||
let note: kimap::Note =
|
let note: kimap::Note =
|
||||||
kimap::decode_note_log(&log).map_err(AppStoreLogError::DecodeLogError)?;
|
kimap::decode_note_log(&log).map_err(|_| AppStoreLogError::DecodeLogError)?;
|
||||||
|
|
||||||
let package_id = note
|
let package_id = note
|
||||||
.parent_path
|
.parent_path
|
||||||
|
@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,7 +64,7 @@ fn handle_send(our: &Address, target: &Address, file_name: &str, timeout: u64) -
|
|||||||
let file_bytes = blob.bytes;
|
let file_bytes = blob.bytes;
|
||||||
let mut file_size = file_bytes.len() as u64;
|
let mut file_size = file_bytes.len() as u64;
|
||||||
let mut offset: u64 = 0;
|
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;
|
let total_chunks = (file_size as f64 / chunk_size as f64).ceil() as u64;
|
||||||
// send a file to another worker
|
// send a file to another worker
|
||||||
// start by telling target to expect a file,
|
// start by telling target to expect a 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;
|
||||||
}
|
}
|
||||||
|
@ -3,9 +3,10 @@ import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
|
|||||||
|
|
||||||
import StorePage from "./pages/StorePage";
|
import StorePage from "./pages/StorePage";
|
||||||
import AppPage from "./pages/AppPage";
|
import AppPage from "./pages/AppPage";
|
||||||
import { APP_DETAILS_PATH, PUBLISH_PATH, STORE_PATH } from "./constants/path";
|
|
||||||
import PublishPage from "./pages/PublishPage";
|
import PublishPage from "./pages/PublishPage";
|
||||||
import Header from "./components/Header";
|
import Header from "./components/Header";
|
||||||
|
import { APP_DETAILS_PATH, PUBLISH_PATH, STORE_PATH } from "./constants/path";
|
||||||
|
|
||||||
|
|
||||||
const BASE_URL = import.meta.env.BASE_URL;
|
const BASE_URL = import.meta.env.BASE_URL;
|
||||||
if (window.our) window.our.process = BASE_URL?.replace("/", "");
|
if (window.our) window.our.process = BASE_URL?.replace("/", "");
|
||||||
|
@ -2,15 +2,6 @@ import { multicallAbi, kinomapAbi, mechAbi, KINOMAP, MULTICALL, KINO_ACCOUNT_IMP
|
|||||||
import { encodeFunctionData, encodePacked, stringToHex } from "viem";
|
import { encodeFunctionData, encodePacked, stringToHex } from "viem";
|
||||||
|
|
||||||
export function encodeMulticalls(metadataUri: string, metadataHash: string) {
|
export function encodeMulticalls(metadataUri: string, metadataHash: string) {
|
||||||
const metadataUriCall = encodeFunctionData({
|
|
||||||
abi: kinomapAbi,
|
|
||||||
functionName: 'note',
|
|
||||||
args: [
|
|
||||||
encodePacked(["bytes"], [stringToHex("~metadata-uri")]),
|
|
||||||
encodePacked(["bytes"], [stringToHex(metadataUri)]),
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
const metadataHashCall = encodeFunctionData({
|
const metadataHashCall = encodeFunctionData({
|
||||||
abi: kinomapAbi,
|
abi: kinomapAbi,
|
||||||
functionName: 'note',
|
functionName: 'note',
|
||||||
@ -20,9 +11,18 @@ export function encodeMulticalls(metadataUri: string, metadataHash: string) {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const metadataUriCall = encodeFunctionData({
|
||||||
|
abi: kinomapAbi,
|
||||||
|
functionName: 'note',
|
||||||
|
args: [
|
||||||
|
encodePacked(["bytes"], [stringToHex("~metadata-uri")]),
|
||||||
|
encodePacked(["bytes"], [stringToHex(metadataUri)]),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
const calls = [
|
const calls = [
|
||||||
|
{ target: KINOMAP, callData: metadataHashCall },
|
||||||
{ target: KINOMAP, callData: metadataUriCall },
|
{ target: KINOMAP, callData: metadataUriCall },
|
||||||
{ target: KINOMAP, callData: metadataHashCall }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const multicall = encodeFunctionData({
|
const multicall = encodeFunctionData({
|
||||||
|
@ -1,16 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { STORE_PATH, PUBLISH_PATH } from '../constants/path';
|
import { STORE_PATH, PUBLISH_PATH } from '../constants/path';
|
||||||
import { ConnectButton } from '@rainbow-me/rainbowkit';
|
import { ConnectButton } from '@rainbow-me/rainbowkit';
|
||||||
|
import { FaHome } from "react-icons/fa";
|
||||||
|
|
||||||
const Header: React.FC = () => {
|
const Header: React.FC = () => {
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<div className="header-left">
|
<div className="header-left">
|
||||||
<nav>
|
<nav>
|
||||||
<Link to={STORE_PATH} className={location.pathname === STORE_PATH ? 'active' : ''}>Home</Link>
|
<button onClick={() => window.location.href = '/'}>
|
||||||
|
<FaHome />
|
||||||
|
</button>
|
||||||
|
<Link to={STORE_PATH} className={location.pathname === STORE_PATH ? 'active' : ''}>Apps</Link>
|
||||||
<Link to={PUBLISH_PATH} className={location.pathname === PUBLISH_PATH ? 'active' : ''}>Publish</Link>
|
<Link to={PUBLISH_PATH} className={location.pathname === PUBLISH_PATH ? 'active' : ''}>Publish</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
@ -17,6 +17,7 @@
|
|||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
margin-right: 2rem;
|
margin-right: 2rem;
|
||||||
|
color: var(--orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left nav {
|
.header-left nav {
|
||||||
@ -44,9 +45,36 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-content {
|
.app-content {
|
||||||
padding: 2rem;
|
display: flex;
|
||||||
flex-grow: 1;
|
gap: 2rem;
|
||||||
overflow-y: auto;
|
}
|
||||||
|
|
||||||
|
.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 {
|
.special-appstore-background {
|
||||||
@ -58,14 +86,63 @@
|
|||||||
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
|
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 Styles */
|
||||||
.store-page {
|
.store-page {
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.store-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: stretch;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.search-bar {
|
.search-bar {
|
||||||
margin-bottom: 1rem;
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-bar input {
|
.search-bar input {
|
||||||
@ -74,47 +151,119 @@
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
border: 1px solid var(--gray);
|
border: 1px solid var(--gray);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
height: 38px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-grid {
|
.filter-button,
|
||||||
display: grid;
|
.store-header button {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
height: 38px;
|
||||||
gap: 1rem;
|
padding: 0 1rem;
|
||||||
margin-top: 2rem;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-card {
|
.store-header>* {
|
||||||
background-color: light-dark(var(--white), var(--off-black));
|
margin: 0;
|
||||||
border: 1px solid var(--gray);
|
}
|
||||||
border-radius: 8px;
|
|
||||||
|
.store-header button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-list table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-list th,
|
||||||
|
.app-list td {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--gray);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-card:hover {
|
.app-list th {
|
||||||
transform: translateY(-5px);
|
font-weight: bold;
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-card h3 {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
color: var(--orange);
|
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;
|
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 */
|
/* App Page Styles */
|
||||||
.app-page {
|
.app-page {
|
||||||
max-width: 800px;
|
max-width: 1000px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-icon {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
margin-right: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-id {
|
||||||
|
font-family: monospace;
|
||||||
|
color: light-dark(var(--gray), var(--off-white));
|
||||||
|
margin-top: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-description {
|
.app-description {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 20px;
|
||||||
color: light-dark(var(--gray), var(--off-white));
|
color: light-dark(var(--gray), var(--off-white));
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-details {
|
.app-details {
|
||||||
@ -127,66 +276,109 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-details-list {
|
.info-section {
|
||||||
list-style-type: none;
|
margin-bottom: 20px;
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-details-list li {
|
.info-section h3 {
|
||||||
|
color: var(--orange);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: light-dark(var(--tan), var(--maroon));
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-list li {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.5rem 0;
|
padding: 10px 0;
|
||||||
border-bottom: 1px solid light-dark(var(--gray), var(--maroon));
|
border-bottom: 1px solid light-dark(var(--gray-light), var(--gray));
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-details-list li:last-child {
|
.detail-list li:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-icon {
|
.status-icon {
|
||||||
font-size: 1rem;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: light-dark(var(--gray-light), var(--gray));
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-icon.installed,
|
.installed,
|
||||||
.status-icon.mirroring,
|
.verified,
|
||||||
.status-icon.auto-update {
|
.approved,
|
||||||
|
.mirroring,
|
||||||
|
.auto-update {
|
||||||
color: var(--orange);
|
color: var(--orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-icon.not-installed,
|
.not-installed,
|
||||||
.status-icon.not-mirroring,
|
.not-verified,
|
||||||
.status-icon.no-auto-update {
|
.not-approved,
|
||||||
|
.not-mirroring,
|
||||||
|
.no-auto-update {
|
||||||
color: var(--ansi-red);
|
color: var(--ansi-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hash {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
word-break: break-all;
|
||||||
|
color: light-dark(var(--gray), var(--off-white));
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
background-color: light-dark(var(--gray-light), var(--gray));
|
||||||
|
color: light-dark(var(--off-black), var(--off-white));
|
||||||
|
border: none;
|
||||||
|
border-radius: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-button.active {
|
||||||
|
background-color: var(--orange);
|
||||||
|
color: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-button svg {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.app-actions {
|
.app-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 10px;
|
||||||
|
min-width: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-actions button {
|
.screenshot-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
gap: 10px;
|
||||||
gap: 0.5rem;
|
margin-top: 10px;
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-screenshots {
|
|
||||||
display: flex;
|
|
||||||
overflow-x: auto;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1rem 0;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-screenshot {
|
.app-screenshot {
|
||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
border-radius: 4px;
|
max-height: 200px;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
border-radius: 8px;
|
||||||
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* My Apps Page Styles */
|
/* My Apps Page Styles */
|
||||||
@ -324,7 +516,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message.success {
|
.message.success {
|
||||||
background-color: #4CAF50;
|
background-color: var(--green);
|
||||||
color: var(--white);
|
color: var(--white);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -363,133 +555,7 @@
|
|||||||
background-color: #c62828;
|
background-color: #c62828;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Store Page Styles */
|
/* Mirrors Dropdown 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 {
|
.mirrors-dropdown {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@ -500,34 +566,130 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: var(--off-black);
|
color: light-dark(var(--off-black), var(--off-white));
|
||||||
}
|
}
|
||||||
|
|
||||||
.mirrors-list {
|
.mirrors-list {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mirror-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mirror-address {
|
||||||
|
flex-grow: 1;
|
||||||
|
margin-right: 10px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px;
|
||||||
|
margin-right: 10px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-button svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: light-dark(var(--off-black), var(--off-white));
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinning {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mirror-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline {
|
||||||
|
color: var(--ansi-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
margin-left: 5px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
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;
|
position: absolute;
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
left: 0;
|
||||||
background-color: white;
|
right: 0;
|
||||||
border: 1px solid var(--gray-light);
|
top: 0;
|
||||||
border-radius: 4px;
|
bottom: 0;
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
display: flex;
|
||||||
list-style-type: none;
|
align-items: center;
|
||||||
padding: 0;
|
justify-content: center;
|
||||||
margin: 0;
|
color: var(--white);
|
||||||
z-index: 10;
|
font-size: 14px;
|
||||||
max-height: 200px;
|
font-weight: bold;
|
||||||
overflow-y: auto;
|
text-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mirrors-list li {
|
.capabilities-section {
|
||||||
padding: 8px 12px;
|
margin-top: 20px;
|
||||||
border-bottom: 1px solid var(--gray-light);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mirrors-list li:last-child {
|
.capabilities-section h3 {
|
||||||
border-bottom: none;
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: var(--gray-light);
|
||||||
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mirrors-list li:hover {
|
.capabilities {
|
||||||
background-color: var(--gray-lighter);
|
background-color: var(--off-white);
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
@ -1,72 +1,312 @@
|
|||||||
import React from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { FaDownload, FaSync, FaTrash, FaMagnet, FaCog, FaCheck, FaTimes } from "react-icons/fa";
|
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 useAppsStore from "../store";
|
||||||
import { appId } from "../utils/app";
|
import { appId } from "../utils/app";
|
||||||
|
import { MirrorCheckFile } from "../types/Apps";
|
||||||
|
|
||||||
export default function AppPage() {
|
export default function AppPage() {
|
||||||
const { installApp, updateApp, uninstallApp, setMirroring, setAutoUpdate, apps } = useAppsStore();
|
const { installApp, updateApp, uninstallApp, approveCaps, setMirroring, setAutoUpdate, checkMirror, apps, downloadApp, getCaps, getApp } = useAppsStore();
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
|
||||||
const app = apps.find(a => appId(a) === id);
|
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<string | null>(null);
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
const [isInstalling, setIsInstalling] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [caps, setCaps] = useState<any>(null);
|
||||||
|
const [showCaps, setShowCaps] = useState(false);
|
||||||
|
const [localProgress, setLocalProgress] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (app) {
|
||||||
|
checkMirrors();
|
||||||
|
fetchCaps();
|
||||||
|
}
|
||||||
|
}, [app]);
|
||||||
|
|
||||||
if (!app) {
|
if (!app) {
|
||||||
return <div className="app-page"><h4>App details not found for {id}</h4></div>;
|
return <div className="app-page"><h4>App details not found for {id}</h4></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleInstall = () => app && installApp(app);
|
const checkMirrors = async () => {
|
||||||
const handleUpdate = () => app && updateApp(app);
|
const mirrors = [app.publisher, ...(app.metadata?.properties?.mirrors || [])];
|
||||||
const handleUninstall = () => app && uninstallApp(app);
|
const statuses: { [mirror: string]: MirrorCheckFile | null } = {};
|
||||||
const handleMirror = () => app && setMirroring(app, !app.state?.mirroring);
|
for (const mirror of mirrors) {
|
||||||
const handleAutoUpdate = () => app && setAutoUpdate(app, !app.state?.auto_update);
|
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.package}:${app.publisher}`, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDownloaded = app.state !== null;
|
||||||
|
const isInstalled = app.installed;
|
||||||
|
|
||||||
|
const progressPercentage = localProgress !== null
|
||||||
|
? localProgress
|
||||||
|
: isDownloaded ? 100 : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="app-page">
|
<section className="app-page">
|
||||||
|
<div className="app-header">
|
||||||
|
{app.metadata?.image && (
|
||||||
|
<img src={app.metadata.image} alt={app.metadata?.name || app.package} className="app-icon" />
|
||||||
|
)}
|
||||||
|
<div className="app-title">
|
||||||
<h2>{app.metadata?.name || app.package}</h2>
|
<h2>{app.metadata?.name || app.package}</h2>
|
||||||
<p className="app-description">{app.metadata?.description || "No description available"}</p>
|
<p className="app-id">{`${app.package}.${app.publisher}`}</p>
|
||||||
<div className="app-details">
|
</div>
|
||||||
<div className="app-info">
|
</div>
|
||||||
<ul className="app-details-list">
|
|
||||||
|
<div className="app-description">{app.metadata?.description || "No description available"}</div>
|
||||||
|
|
||||||
|
<div className="app-content">
|
||||||
|
<div className="app-info-column">
|
||||||
|
<div className="info-section">
|
||||||
|
<h3 onClick={() => setShowMetadata(!showMetadata)}>
|
||||||
|
Metadata {showMetadata ? <FaChevronUp /> : <FaChevronDown />}
|
||||||
|
</h3>
|
||||||
|
{showMetadata && (
|
||||||
|
<ul className="detail-list">
|
||||||
<li><span>Version:</span> <span>{app.metadata?.properties?.current_version || "Unknown"}</span></li>
|
<li><span>Version:</span> <span>{app.metadata?.properties?.current_version || "Unknown"}</span></li>
|
||||||
<li><span>Developer:</span> <span>{app.publisher}</span></li>
|
<li><span>~metadata-uri</span> <span className="hash">{app.metadata_uri}</span></li>
|
||||||
<li><span>Mirrors:</span> <span>{app.metadata?.properties?.mirrors?.length || 0}</span></li>
|
<li><span>~metadata-hash</span> <span className="hash">{app.metadata_hash}</span></li>
|
||||||
|
<li className="mirrors-list">
|
||||||
|
<span>Mirrors:</span>
|
||||||
|
<ul>
|
||||||
|
{Object.entries(mirrorStatuses).map(([mirror, status]) => (
|
||||||
|
<li key={mirror} className="mirror-item">
|
||||||
|
<span className="mirror-address">{mirror}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => checkMirror(mirror)}
|
||||||
|
className="check-button"
|
||||||
|
title="Check if mirror is online"
|
||||||
|
>
|
||||||
|
<FaSync className={status === null ? 'spinning' : ''} />
|
||||||
|
</button>
|
||||||
|
{status && (
|
||||||
|
<span className="mirror-status">
|
||||||
|
{status.is_online ? (
|
||||||
|
<><FaCheck className="online" /> <span className="online">Online</span></>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FaTimes className="offline" />
|
||||||
|
<span className="offline">Offline</span>
|
||||||
|
{status.error && (
|
||||||
|
<span className="error-message">
|
||||||
|
({status.error})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="info-section">
|
||||||
|
<h3 onClick={() => setShowLocalInfo(!showLocalInfo)}>
|
||||||
|
Local Information {showLocalInfo ? <FaChevronUp /> : <FaChevronDown />}
|
||||||
|
</h3>
|
||||||
|
{showLocalInfo && (
|
||||||
|
<ul className="detail-list">
|
||||||
<li>
|
<li>
|
||||||
<span>Installed:</span>
|
<span>Installed:</span>
|
||||||
{app.installed ? <FaCheck className="status-icon installed" /> : <FaTimes className="status-icon not-installed" />}
|
<span className="status-icon">{isInstalled ? <FaCheck className="installed" /> : <FaTimes className="not-installed" />}</span>
|
||||||
|
</li>
|
||||||
|
<li><span>Installed Version:</span> <span>{app.state?.our_version || "Not installed"}</span></li>
|
||||||
|
<li>
|
||||||
|
<span>Verified:</span>
|
||||||
|
<span className="status-icon">{app.state?.verified ? <FaCheck className="verified" /> : <FaTimes className="not-verified" />}</span>
|
||||||
|
</li>
|
||||||
|
<li><span>License:</span> <span>{app.metadata?.properties?.license || "Not specified"}</span></li>
|
||||||
|
<li>
|
||||||
|
<span>Capabilities Approved:</span>
|
||||||
|
<button onClick={() => approveCaps(app)} className={`toggle-button ${app.state?.caps_approved ? 'active' : ''}`}>
|
||||||
|
{app.state?.caps_approved ? <FaCheck /> : <FaShieldAlt />}
|
||||||
|
{app.state?.caps_approved ? "Approved" : "Approve Caps"}
|
||||||
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span>Mirroring:</span>
|
<span>Mirroring:</span>
|
||||||
{app.state?.mirroring ? <FaCheck className="status-icon mirroring" /> : <FaTimes className="status-icon not-mirroring" />}
|
<button onClick={handleMirror} className={`toggle-button ${app.state?.mirroring ? 'active' : ''}`}>
|
||||||
|
<FaMagnet />
|
||||||
|
{app.state?.mirroring ? "Mirroring" : "Start Mirroring"}
|
||||||
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span>Auto-Update:</span>
|
<span>Auto-Update:</span>
|
||||||
{app.state?.auto_update ? <FaCheck className="status-icon auto-update" /> : <FaTimes className="status-icon no-auto-update" />}
|
<button onClick={handleAutoUpdate} className={`toggle-button ${app.state?.auto_update ? 'active' : ''}`}>
|
||||||
|
<FaCog />
|
||||||
|
{app.state?.auto_update ? "Auto-Update On" : "Enable Auto-Update"}
|
||||||
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li><span>Manifest Hash:</span> <span className="hash">{app.state?.manifest_hash || "N/A"}</span></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="app-actions-column">
|
||||||
<div className="app-actions">
|
<div className="app-actions">
|
||||||
{app.installed ? (
|
{isInstalled ? (
|
||||||
<>
|
<>
|
||||||
|
<button onClick={handleLaunch} className="primary"><FaPlay /> Launch</button>
|
||||||
<button onClick={handleUpdate} className="secondary"><FaSync /> Update</button>
|
<button onClick={handleUpdate} className="secondary"><FaSync /> Update</button>
|
||||||
<button onClick={handleUninstall} className="secondary"><FaTrash /> Uninstall</button>
|
<button onClick={handleUninstall} className="secondary"><FaTrash /> Uninstall</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={handleInstall}><FaDownload /> Install</button>
|
<>
|
||||||
|
<div className="mirror-selection">
|
||||||
|
<select
|
||||||
|
value={selectedMirror || ''}
|
||||||
|
onChange={(e) => setSelectedMirror(e.target.value)}
|
||||||
|
disabled={isDownloading}
|
||||||
|
>
|
||||||
|
<option value="" disabled>Select Mirror</option>
|
||||||
|
{Object.entries(mirrorStatuses).map(([mirror, status]) => (
|
||||||
|
<option key={mirror} value={mirror} disabled={!status?.is_online}>
|
||||||
|
{mirror} {status?.is_online ? '(Online)' : '(Offline)'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="download-button"
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={!selectedMirror || isDownloading}
|
||||||
|
>
|
||||||
|
{isDownloading ? (
|
||||||
|
<>
|
||||||
|
<FaSpinner className="fa-spin" /> Downloading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FaDownload /> {isDownloaded ? 'Re-download' : 'Download'}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<button onClick={handleMirror} className="secondary">
|
|
||||||
<FaMagnet /> {app.state?.mirroring ? "Stop Mirroring" : "Start Mirroring"}
|
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleAutoUpdate} className="secondary">
|
<button
|
||||||
<FaCog /> {app.state?.auto_update ? "Disable Auto-Update" : "Enable Auto-Update"}
|
className="install-button"
|
||||||
|
onClick={handleInstall}
|
||||||
|
disabled={!isDownloaded || isInstalling}
|
||||||
|
>
|
||||||
|
<FaRocket /> {isInstalling ? 'Installing...' : 'Install'}
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{app.metadata?.external_url && (
|
||||||
|
<a href={app.metadata.external_url} target="_blank" rel="noopener noreferrer" className="external-link">
|
||||||
|
<FaLink /> External Link
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(isDownloading || isDownloaded) && (
|
||||||
|
<div className="progress-container">
|
||||||
|
<div className="progress-bar">
|
||||||
|
<div
|
||||||
|
className="progress"
|
||||||
|
style={{ width: `${progressPercentage}%` }}
|
||||||
|
></div>
|
||||||
|
<div className="progress-percentage">{progressPercentage}%</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="error-message">
|
||||||
|
<FaExclamationTriangle /> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="capabilities-section">
|
||||||
|
<h3 onClick={() => setShowCaps(!showCaps)}>
|
||||||
|
Requested Capabilities {showCaps ? <FaChevronUp /> : <FaChevronDown />}
|
||||||
|
</h3>
|
||||||
|
{showCaps && caps && (
|
||||||
|
<pre className="capabilities">{JSON.stringify(caps, null, 2)}</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{app.metadata?.properties?.screenshots && (
|
{app.metadata?.properties?.screenshots && (
|
||||||
<div className="app-screenshots">
|
<div className="app-screenshots">
|
||||||
|
<h3>Screenshots</h3>
|
||||||
|
<div className="screenshot-container">
|
||||||
{app.metadata.properties.screenshots.map((screenshot, index) => (
|
{app.metadata.properties.screenshots.map((screenshot, index) => (
|
||||||
<img key={index} src={screenshot} alt={`Screenshot ${index + 1}`} className="app-screenshot" />
|
<img key={index} src={screenshot} alt={`Screenshot ${index + 1}`} className="app-screenshot" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
@ -1,76 +0,0 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { FaUpload } from "react-icons/fa";
|
|
||||||
import { useNavigate, Link } from "react-router-dom";
|
|
||||||
|
|
||||||
import { AppInfo } from "../types/Apps";
|
|
||||||
import useAppsStore from "../store";
|
|
||||||
import { PUBLISH_PATH } from "../constants/path";
|
|
||||||
import { appId } from "../utils/app";
|
|
||||||
|
|
||||||
export default function MyAppsPage() {
|
|
||||||
const { apps, getApps } = useAppsStore();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getApps();
|
|
||||||
}, [getApps]);
|
|
||||||
|
|
||||||
const filteredApps = apps.filter((app) =>
|
|
||||||
app.package.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
app.metadata?.description?.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
const categorizedApps = {
|
|
||||||
installed: filteredApps.filter(app => app.installed),
|
|
||||||
downloaded: filteredApps.filter(app => !app.installed && app.state),
|
|
||||||
available: filteredApps.filter(app => !app.state)
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="my-apps-page">
|
|
||||||
<div className="my-apps-header">
|
|
||||||
<h1>My Packages</h1>
|
|
||||||
<button className="publish-button" onClick={() => navigate(PUBLISH_PATH)}>
|
|
||||||
<FaUpload className="mr-2" />
|
|
||||||
Publish Package
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search packages..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="search-input"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="apps-list">
|
|
||||||
{Object.entries(categorizedApps).map(([category, apps]) => (
|
|
||||||
apps.length > 0 && (
|
|
||||||
<div key={category} className="app-category">
|
|
||||||
<h2>{category.charAt(0).toUpperCase() + category.slice(1)}</h2>
|
|
||||||
{apps.map((app) => (
|
|
||||||
<AppEntry key={app.package} app={app} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AppEntryProps {
|
|
||||||
app: AppInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AppEntry: React.FC<AppEntryProps> = ({ app }) => {
|
|
||||||
return (
|
|
||||||
<Link to={`/app/${appId(app)}`} className="app-entry">
|
|
||||||
<h3>{app.metadata?.name || app.package}</h3>
|
|
||||||
<p>{app.metadata?.description || "No description available"}</p>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,15 +1,20 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
import { persist } from 'zustand/middleware'
|
||||||
import { AppInfo, 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: Record<string, [number, number]>
|
||||||
getApps: () => Promise<void>
|
getApps: () => Promise<void>
|
||||||
getApp: (id: string) => Promise<AppInfo>
|
getApp: (id: string) => Promise<AppInfo>
|
||||||
|
checkMirror: (node: string) => Promise<MirrorCheckFile>
|
||||||
installApp: (app: AppInfo) => Promise<void>
|
installApp: (app: AppInfo) => Promise<void>
|
||||||
updateApp: (app: AppInfo) => Promise<void>
|
updateApp: (app: AppInfo) => Promise<void>
|
||||||
uninstallApp: (app: AppInfo) => Promise<void>
|
uninstallApp: (app: AppInfo) => Promise<void>
|
||||||
@ -26,6 +31,34 @@ const useAppsStore = create<AppsStore>()(
|
|||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
apps: [],
|
apps: [],
|
||||||
|
|
||||||
|
downloads: {},
|
||||||
|
|
||||||
|
ws: new KinodeClientApi({
|
||||||
|
uri: WEBSOCKET_URL,
|
||||||
|
nodeId: window.our?.node,
|
||||||
|
processId: "main:app_store:sys",
|
||||||
|
onMessage: (message) => {
|
||||||
|
const data = JSON.parse(message);
|
||||||
|
if (data.kind === 'progress') {
|
||||||
|
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) => {
|
||||||
|
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) {
|
||||||
@ -44,8 +77,16 @@ const useAppsStore = create<AppsStore>()(
|
|||||||
throw new Error(`Failed to get app: ${id}`)
|
throw new Error(`Failed to get app: ${id}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
checkMirror: async (node: string) => {
|
||||||
|
const res = await fetch(`${BASE_URL}/mirrorcheck/${node}`)
|
||||||
|
if (res.status === HTTP_STATUS.OK) {
|
||||||
|
return await res.json()
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to check mirror status for node: ${node}`)
|
||||||
|
},
|
||||||
|
|
||||||
installApp: async (app: AppInfo) => {
|
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) {
|
if (res.status !== HTTP_STATUS.CREATED) {
|
||||||
throw new Error(`Failed to install app: ${appId(app)}`)
|
throw new Error(`Failed to install app: ${appId(app)}`)
|
||||||
}
|
}
|
||||||
@ -53,11 +94,8 @@ const useAppsStore = create<AppsStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateApp: async (app: AppInfo) => {
|
updateApp: async (app: AppInfo) => {
|
||||||
const res = await fetch(`${BASE_URL}/apps/${appId(app)}`, { method: 'PUT' })
|
// Note: The backend doesn't have a specific update endpoint, so we might need to implement this differently
|
||||||
if (res.status !== HTTP_STATUS.CREATED) {
|
throw new Error('Update functionality not implemented')
|
||||||
throw new Error(`Failed to update app: ${appId(app)}`)
|
|
||||||
}
|
|
||||||
await get().getApp(appId(app))
|
|
||||||
},
|
},
|
||||||
|
|
||||||
uninstallApp: async (app: AppInfo) => {
|
uninstallApp: async (app: AppInfo) => {
|
||||||
@ -69,8 +107,8 @@ const useAppsStore = create<AppsStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
downloadApp: async (app: AppInfo, downloadFrom: string) => {
|
downloadApp: async (app: AppInfo, downloadFrom: string) => {
|
||||||
const res = await fetch(`${BASE_URL}/apps/${appId(app)}`, {
|
const res = await fetch(`${BASE_URL}/apps/${appId(app)}/download`, {
|
||||||
method: 'POST',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ download_from: downloadFrom }),
|
body: JSON.stringify({ download_from: downloadFrom }),
|
||||||
})
|
})
|
||||||
if (res.status !== HTTP_STATUS.CREATED) {
|
if (res.status !== HTTP_STATUS.CREATED) {
|
||||||
@ -91,6 +129,7 @@ const useAppsStore = create<AppsStore>()(
|
|||||||
if (res.status !== HTTP_STATUS.OK) {
|
if (res.status !== HTTP_STATUS.OK) {
|
||||||
throw new Error(`Failed to approve caps for app: ${appId(app)}`)
|
throw new Error(`Failed to approve caps for app: ${appId(app)}`)
|
||||||
}
|
}
|
||||||
|
await get().getApp(appId(app))
|
||||||
},
|
},
|
||||||
|
|
||||||
setMirroring: async (app: AppInfo, mirroring: boolean) => {
|
setMirroring: async (app: AppInfo, mirroring: boolean) => {
|
||||||
|
@ -10,11 +10,18 @@ export interface AppListing {
|
|||||||
package: string
|
package: string
|
||||||
publisher: string
|
publisher: string
|
||||||
metadata_hash: string
|
metadata_hash: string
|
||||||
|
metadata_uri: string
|
||||||
metadata?: OnchainPackageMetadata
|
metadata?: OnchainPackageMetadata
|
||||||
installed: boolean
|
installed: boolean
|
||||||
state?: PackageState
|
state?: PackageState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MirrorCheckFile {
|
||||||
|
node: string;
|
||||||
|
is_online: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Erc721Properties {
|
export interface Erc721Properties {
|
||||||
package_name: string;
|
package_name: string;
|
||||||
publisher: string;
|
publisher: string;
|
||||||
|
11
kinode/packages/app_store/ui/src/utils/ws.ts
Normal file
11
kinode/packages/app_store/ui/src/utils/ws.ts
Normal 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;
|
Loading…
Reference in New Issue
Block a user