Merge pull request #465 from kinode-dao/bp/newapps

app_store UI: mirror checks and info
This commit is contained in:
bitful-pannul 2024-08-03 14:46:50 +03:00 committed by GitHub
commit 57cce90d6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 955 additions and 565 deletions

View File

@ -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(),
)), )),
} }
} }

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},
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:?}"
))
} }
} }

View File

@ -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

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

@ -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;
} }

View File

@ -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("/", "");

View File

@ -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({

View File

@ -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>

View File

@ -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;
} }

View File

@ -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>
); );
} }

View File

@ -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>
);
};

View File

@ -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) => {

View File

@ -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;

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;